mobile download
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
loadStrategies,
|
||||
placePaperOrder,
|
||||
resetPaperAccount,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
type StrategyDto,
|
||||
} from '../../lib/shared';
|
||||
import { useVirtualIndicatorSnapshots } from '@frontend/hooks/useVirtualIndicatorSnapshots';
|
||||
import { useVirtualAutoTrade } from '@frontend/hooks/useVirtualAutoTrade';
|
||||
import { useVirtualTargetLiveStatus } from '@frontend/hooks/useVirtualTargetLiveStatus';
|
||||
import {
|
||||
loadVirtualSession,
|
||||
loadVirtualTargets,
|
||||
saveVirtualSession,
|
||||
saveVirtualTargets,
|
||||
loadVirtualCardViewMode,
|
||||
saveVirtualCardViewMode,
|
||||
type VirtualSessionConfig,
|
||||
type VirtualTargetItem,
|
||||
type VirtualCardViewMode,
|
||||
resolveTargetCandleType,
|
||||
} from '@frontend/utils/virtualTradingStorage';
|
||||
import {
|
||||
syncVirtualTargetsToBackend,
|
||||
stopVirtualLiveOnBackend,
|
||||
} from '@frontend/utils/virtualLiveStrategySync';
|
||||
import { resolveVirtualTargetStrategyId } from '@frontend/utils/virtualTargetStrategy';
|
||||
import { virtualTargetLimitMessage, isVirtualTargetAddAllowed } from '@frontend/utils/virtualTargetLimits';
|
||||
import { persistVirtualTargetPinned } from '@frontend/utils/virtualTargetMutations';
|
||||
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
|
||||
import { useNavigation } from '../../contexts/NavigationContext';
|
||||
import BottomSheet from '../../components/BottomSheet';
|
||||
import SegmentedControl from '../../components/SegmentedControl';
|
||||
import VirtualTargetCardMobile from './VirtualTargetCardMobile';
|
||||
import VirtualFocusScreen from './VirtualFocusScreen';
|
||||
import VirtualTradePanel from './VirtualTradePanel';
|
||||
|
||||
type RightTab = 'trade' | 'history';
|
||||
|
||||
export default function VirtualTradingScreen() {
|
||||
const { focusMarket, clearVirtualFocus, openVirtualFocus } = useNavigation();
|
||||
const { settings } = useAppSettings();
|
||||
const defaults = resolveAppDefaults(settings);
|
||||
|
||||
const [targets, setTargets] = useState<VirtualTargetItem[]>(() => loadVirtualTargets());
|
||||
const [session, setSession] = useState<VirtualSessionConfig>(() => loadVirtualSession());
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMarket, setSelectedMarket] = useState('KRW-BTC');
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
const [rightTab, setRightTab] = useState<RightTab>('trade');
|
||||
const [newMarket, setNewMarket] = useState('');
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
const [s, t] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
|
||||
setSummary(s);
|
||||
setTrades(t ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([
|
||||
loadStrategies().then(setStrategies).catch(() => []),
|
||||
refreshSummary(),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [refreshSummary]);
|
||||
|
||||
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
|
||||
useEffect(() => { saveVirtualSession(session); }, [session]);
|
||||
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
|
||||
|
||||
const targetRefs = useMemo(
|
||||
() => targets.map(t => ({
|
||||
market: t.market,
|
||||
strategyId: resolveVirtualTargetStrategyId(t, session.globalStrategyId),
|
||||
})),
|
||||
[targets, session.globalStrategyId],
|
||||
);
|
||||
|
||||
const snapshots = useVirtualIndicatorSnapshots(
|
||||
targetRefs,
|
||||
strategies as Parameters<typeof useVirtualIndicatorSnapshots>[1],
|
||||
session.running,
|
||||
);
|
||||
|
||||
const liveStatus = useVirtualTargetLiveStatus(targetRefs, session.running);
|
||||
|
||||
const liveConnected = useMemo(
|
||||
() => Object.values(liveStatus.statusByMarket).some(s => s === 'live' || s === 'connecting'),
|
||||
[liveStatus.statusByMarket],
|
||||
);
|
||||
|
||||
useVirtualAutoTrade({
|
||||
targets,
|
||||
session,
|
||||
snapshots,
|
||||
enabled: defaults.paperAutoTradeEnabled && defaults.paperTradingEnabled,
|
||||
paperAutoTradeBudgetPct: defaults.paperAutoTradeBudgetPct ?? 95,
|
||||
positions: summary?.positions,
|
||||
onFilled: refreshSummary,
|
||||
});
|
||||
|
||||
const toggleRunning = useCallback(async () => {
|
||||
const next = { ...session, running: !session.running };
|
||||
setSession(next);
|
||||
if (next.running) {
|
||||
await syncVirtualTargetsToBackend(targets, next, true);
|
||||
} else {
|
||||
await stopVirtualLiveOnBackend(targets, next);
|
||||
}
|
||||
}, [session, targets]);
|
||||
|
||||
const handleAddTarget = useCallback(() => {
|
||||
const market = newMarket.trim().toUpperCase();
|
||||
if (!market) return;
|
||||
if (targets.some(t => t.market === market)) return;
|
||||
if (!isVirtualTargetAddAllowed(targets.length, defaults.virtualTargetMaxCount)) {
|
||||
alert(virtualTargetLimitMessage(defaults.virtualTargetMaxCount));
|
||||
return;
|
||||
}
|
||||
setTargets(prev => [...prev, { market, strategyId: null }]);
|
||||
setNewMarket('');
|
||||
setAddSheetOpen(false);
|
||||
}, [newMarket, targets, defaults.virtualTargetMaxCount]);
|
||||
|
||||
const handleRemoveTarget = useCallback((market: string) => {
|
||||
const t = targets.find(x => x.market === market);
|
||||
if (t?.pinned) return;
|
||||
setTargets(prev => prev.filter(x => x.market !== market));
|
||||
}, [targets]);
|
||||
|
||||
const handleTogglePin = useCallback(async (market: string) => {
|
||||
const t = targets.find(x => x.market === market);
|
||||
if (!t) return;
|
||||
const pinned = !t.pinned;
|
||||
setTargets(prev => prev.map(x => (x.market === market ? { ...x, pinned } : x)));
|
||||
await persistVirtualTargetPinned(market, pinned);
|
||||
}, [targets]);
|
||||
|
||||
const pnlPct = summary?.totalReturnPct ?? 0;
|
||||
|
||||
if (focusMarket) {
|
||||
const target = targets.find(t => t.market === focusMarket);
|
||||
if (target) {
|
||||
return (
|
||||
<VirtualFocusScreen
|
||||
target={target}
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
snapshot={(() => {
|
||||
const sid = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||
return snapshots[`${target.market}:${sid ?? ''}`] ?? snapshots[target.market];
|
||||
})()}
|
||||
liveConnected={Object.values(liveStatus.statusByMarket).some(s => s === 'live')}
|
||||
onBack={clearVirtualFocus}
|
||||
onOpenTrade={() => {
|
||||
setSelectedMarket(focusMarket);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
clearVirtualFocus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screen virtual-screen">
|
||||
<header className="screen-header">
|
||||
<h1 className="screen-title">가상매매</h1>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-primary session-btn${session.running ? ' running' : ''}`}
|
||||
onClick={() => void toggleRunning()}
|
||||
>
|
||||
{session.running ? '■ 중지' : '▶ 시작'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="virtual-summary card" style={{ margin: '0 16px 12px' }}>
|
||||
<div className="virtual-summary-row">
|
||||
<div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>총 자산</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
₩{(summary?.totalAsset ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>수익률</div>
|
||||
<div className={`${pnlPct >= 0 ? 'text-green' : 'text-red'}`} style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!liveConnected && session.running && (
|
||||
<div className="connection-banner" style={{ marginTop: 8, borderRadius: 8 }}>실시간 연결 끊김</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chip-row">
|
||||
<select
|
||||
className="chip"
|
||||
value={session.globalStrategyId ?? ''}
|
||||
onChange={e => setSession(s => ({ ...s, globalStrategyId: e.target.value ? Number(e.target.value) : null }))}
|
||||
style={{ maxWidth: 160 }}
|
||||
>
|
||||
<option value="">전략 선택</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'CANDLE_CLOSE' as const, label: '봉마감' },
|
||||
{ value: 'REALTIME_TICK' as const, label: '실시간' },
|
||||
]}
|
||||
value={session.executionType}
|
||||
onChange={v => setSession(s => ({ ...s, executionType: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chip-row">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'LONG_ONLY' as const, label: '롱온리' },
|
||||
{ value: 'SIGNAL_ONLY' as const, label: '시그널' },
|
||||
]}
|
||||
value={session.positionMode}
|
||||
onChange={v => setSession(s => ({ ...s, positionMode: v }))}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'summary' as const, label: '요약' },
|
||||
{ value: 'detail' as const, label: '상세' },
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={(v: VirtualCardViewMode) => setViewMode(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center">로딩 중…</div>
|
||||
) : targets.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>투자 대상 없음</h3>
|
||||
<p>+ 버튼으로 종목을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="virtual-cards" style={{ padding: '0 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{targets.map(target => {
|
||||
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId);
|
||||
const snapKey = `${target.market}:${strategyId ?? ''}`;
|
||||
const snap = snapshots[snapKey] ?? snapshots[target.market];
|
||||
return (
|
||||
<VirtualTargetCardMobile
|
||||
key={target.market}
|
||||
target={target}
|
||||
session={session}
|
||||
strategies={strategies}
|
||||
snapshot={snap}
|
||||
viewMode={viewMode}
|
||||
liveFlash={!!liveStatus.lastTickAtByMarket[target.market]}
|
||||
liveStatus={liveStatus.statusByMarket[target.market]}
|
||||
onFocus={() => openVirtualFocus(target.market)}
|
||||
onTrade={() => { setSelectedMarket(target.market); setSheetOpen(true); }}
|
||||
onRemove={() => handleRemoveTarget(target.market)}
|
||||
onTogglePin={() => void handleTogglePin(target.market)}
|
||||
onStrategyChange={sid => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, strategyId: sid } : t))}
|
||||
onCandleTypeChange={ct => setTargets(prev => prev.map(t => t.market === target.market ? { ...t, candleType: ct } : t))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="fab" aria-label="종목 추가" onClick={() => setAddSheetOpen(true)}>+</button>
|
||||
|
||||
<BottomSheet open={addSheetOpen} title="종목 추가" onClose={() => setAddSheetOpen(false)}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="KRW-BTC"
|
||||
value={newMarket}
|
||||
onChange={e => setNewMarket(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--gc-border)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn-primary" style={{ width: '100%' }} onClick={handleAddTarget}>추가</button>
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={sheetOpen} title={selectedMarket} onClose={() => setSheetOpen(false)} height="half">
|
||||
<div className="chip-row" style={{ padding: '0 0 12px' }}>
|
||||
<button type="button" className={`chip${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
|
||||
<button type="button" className={`chip${rightTab === 'history' ? ' active' : ''}`} onClick={() => setRightTab('history')}>내역</button>
|
||||
</div>
|
||||
{rightTab === 'trade' ? (
|
||||
<VirtualTradePanel
|
||||
market={selectedMarket}
|
||||
summary={summary}
|
||||
onOrder={async (side, qty, price) => {
|
||||
await placePaperOrder({ market: selectedMarket, side, price, quantity: qty });
|
||||
await refreshSummary();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{trades.filter(t => t.symbol === selectedMarket).slice(0, 30).map(t => (
|
||||
<div key={t.id} className="card" style={{ padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span className={t.side === 'BUY' ? 'text-green' : 'text-red'}>{t.side}</span>
|
||||
<span>{t.quantity} @ ₩{t.price?.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>{t.createdAt ?? ''}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</BottomSheet>
|
||||
|
||||
<style>{`
|
||||
.session-btn.running { background: var(--gc-red); }
|
||||
.virtual-summary-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user