/** * 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링 */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { loadPaperSummary, loadPaperTrades, loadStrategies, saveLiveStrategySettings, type PaperSummaryDto, type PaperTradeDto, type StrategyDto, } from '../utils/backendApi'; import type { Theme, TradeOrderFillRequest } from '../types'; import type { TickerData } from '../hooks/useMarketTicker'; import TradeOrderPanel from './TradeOrderPanel'; import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; import PaperSplitPanel from './paper/PaperSplitPanel'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; import BuilderPageShell from './layout/BuilderPageShell'; import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel'; import VirtualTargetGrid from './virtual/VirtualTargetGrid'; import { VirtualSessionHeaderControls, VirtualViewHeaderControls, } from './virtual/VirtualGridUnifiedHeader'; import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard'; import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots'; import { useVirtualAutoTrade } from '../hooks/useVirtualAutoTrade'; import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus'; import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData'; import { loadVirtualSession, loadVirtualTargets, saveVirtualSession, saveVirtualTargets, loadVirtualCardViewMode, saveVirtualCardViewMode, type VirtualSessionConfig, type VirtualTargetItem, type VirtualCardViewMode, } from '../utils/virtualTradingStorage'; import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, } from '../utils/virtualLiveStrategySync'; import '../styles/virtualTradingDashboard.css'; type RightTab = 'trade' | 'orderbook' | 'history'; interface Props { theme?: Theme; tickers?: Map; defaultMarket?: string; paperTradingEnabled?: boolean; paperAutoTradeEnabled?: boolean; paperAutoTradeBudgetPct?: number; onPaperAutoTradeEnabled?: (enabled: boolean) => void; onPaperOrderFilled?: () => void; } const VirtualTradingPage: React.FC = ({ theme = 'dark', tickers, defaultMarket = 'KRW-BTC', paperTradingEnabled = true, paperAutoTradeEnabled = false, paperAutoTradeBudgetPct = 95, onPaperAutoTradeEnabled, onPaperOrderFilled, }) => { const [targets, setTargets] = useState(() => loadVirtualTargets()); const [session, setSession] = useState(() => loadVirtualSession()); const [strategies, setStrategies] = useState([]); const [summary, setSummary] = useState(null); const [trades, setTrades] = useState([]); const [loading, setLoading] = useState(true); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [rightTab, setRightTab] = useState('trade'); const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); const [viewMode, setViewMode] = useState(() => loadVirtualCardViewMode()); const [globalDisplayMode, setGlobalDisplayMode] = useState('signal'); const [focusMarket, setFocusMarket] = useState(null); const orderAnchorRef = useRef(null); const { settings: appSettings } = useAppSettings(); const appChartDefaults = resolveAppDefaults(appSettings); const chartRealtimeSource = (appChartDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource; const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels; const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight; useEffect(() => { void Promise.all([ loadStrategies().then(setStrategies).catch(() => setStrategies([])), loadPaperSummary().then(setSummary), loadPaperTrades().then(setTrades), ]).finally(() => setLoading(false)); }, []); useEffect(() => { saveVirtualTargets(targets); }, [targets]); useEffect(() => { saveVirtualSession(session); }, [session]); useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]); const strategyNames = useMemo( () => Object.fromEntries(strategies.map(s => [s.id, s.name])), [strategies], ); const refreshPaperData = useCallback(async (opts?: { focusHistory?: boolean }) => { try { const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]); setSummary(sum); setTrades(tr); } catch { /* ignore */ } onPaperOrderFilled?.(); if (opts?.focusHistory) setRightTab('history'); }, [onPaperOrderFilled]); const handleGlobalDisplayModeChange = useCallback((mode: VirtualCardDisplayMode) => { setGlobalDisplayMode(mode); }, []); const targetRefs = useMemo(() => targets.map(t => ({ market: t.market, strategyId: t.strategyId ?? session.globalStrategyId, })), [targets, session.globalStrategyId]); const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running); const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus( targetRefs, session.running, ); useVirtualAutoTrade({ enabled: paperTradingEnabled && paperAutoTradeEnabled, session, targets, snapshots, tickers, paperAutoTradeBudgetPct, positions: summary?.positions, onFilled: () => void refreshPaperData({ focusHistory: true }), }); const mergedLiveStatus = useMemo(() => { if (!session.running) return liveStatusByMarket; const merged = { ...liveStatusByMarket }; const now = Date.now(); for (const [market, snap] of Object.entries(snapshots)) { if (snap?.updatedAt && now - snap.updatedAt < 8000) { merged[market] = 'live'; } } return merged; }, [liveStatusByMarket, snapshots, session.running]); const posQty = useMemo(() => { const p = summary?.positions?.find(x => x.symbol === selectedMarket); return coerceFiniteNumber(p?.quantity) ?? 0; }, [summary?.positions, selectedMarket]); const tradePrice = useMemo( () => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice), [tickers, selectedMarket], ); const handleSelectMarket = useCallback((market: string) => { setSelectedMarket(market); const price = tickers?.get(market)?.tradePrice; if (price != null && price > 0) { const seq = Date.now(); setFillBuy({ market, price, side: 'buy', seq }); setFillSell({ market, price, side: 'sell', seq: seq + 1 }); } setRightTab('trade'); }, [tickers]); const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => { setTargets(items); if (items.length > 0 && !items.some(t => t.market === selectedMarket)) { handleSelectMarket(items[0].market); } }, [selectedMarket, handleSelectMarket]); const handleGlobalStrategyChange = useCallback((strategyId: number | null) => { const next = { ...session, globalStrategyId: strategyId }; setSession(next); if (session.running && strategyId) { void syncVirtualTargetsToBackend(targets, next, true); } }, [session, targets]); const handleStart = useCallback(async () => { if (targets.length === 0) { window.alert('투자대상 종목을 먼저 추가하세요.'); return; } if (!session.globalStrategyId) { window.alert('투자전략을 선택하세요.'); return; } try { await syncVirtualTargetsToBackend(targets, session, true); setSession(s => ({ ...s, running: true })); } catch { window.alert('가상투자 시작 설정 저장에 실패했습니다.'); } }, [targets, session]); const handleStop = useCallback(async () => { try { await stopVirtualLiveOnBackend(targets, session); } catch { /* ignore */ } setSession(s => ({ ...s, running: false })); }, [targets, session]); const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => { const side = rowType === 'ask' ? 'buy' : 'sell'; const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; if (side === 'buy') setFillBuy(req); else setFillSell(req); setRightTab('trade'); }, [selectedMarket]); const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => { setTargets(prev => prev.map(t => t.market === market ? { ...t, strategyId } : t, )); if (!session.running || !strategyId) return; try { await saveLiveStrategySettings({ market, strategyId, isLiveCheck: true, executionType: session.executionType, positionMode: session.positionMode, skipWatchlistSync: true, }); } catch { window.alert('종목 전략 변경 저장에 실패했습니다.'); } }, [session]); const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => { if (!session.running || targets.length === 0) return; try { await syncVirtualTargetsToBackend(targets, nextSession, true); } catch { window.alert('가상투자 설정 동기화에 실패했습니다.'); } }, [session.running, targets]); const handleExecutionTypeChange = useCallback((executionType: VirtualSessionConfig['executionType']) => { const next = { ...session, executionType }; setSession(next); void resyncRunningSession(next); }, [session, resyncRunningSession]); const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => { const next = { ...session, positionMode }; setSession(next); void resyncRunningSession(next); }, [session, resyncRunningSession]); const rightTabs = (
); return ( void handleStart()} onStop={() => void handleStop()} paperAutoTradeEnabled={paperAutoTradeEnabled} onPaperAutoTradeEnabled={onPaperAutoTradeEnabled} paperTradingEnabled={paperTradingEnabled} /> )} headerActions={( )} leftStorageKey="vtd-left-width" leftDefaultWidth={380} leftCollapsedStorageKey="vtd-left-open" rightStorageKey="vtd-right-width" rightDefaultWidth={380} rightCollapsedStorageKey="vtd-right-open" left={( void handleTargetStrategyChange(market, strategyId)} /> )} center={( void handleTargetStrategyChange(market, strategyId)} liveStatusByMarket={mergedLiveStatus} lastTickAtByMarket={lastTickAtByMarket} selectedMarket={selectedMarket} onSelectMarket={handleSelectMarket} theme={theme} chartRealtimeSource={chartRealtimeSource} chartSeriesPriceLabels={chartSeriesPriceLabels} chartLiveReceiveHighlight={chartLiveReceiveHighlight} tickers={tickers} viewMode={viewMode} globalDisplayMode={globalDisplayMode} onFocusMarketChange={setFocusMarket} /> )} rightTabs={rightTabs} right={(
{rightTab === 'trade' ? ( void refreshPaperData({ focusHistory: true })} />
)} bottom={(
void refreshPaperData({ focusHistory: true })} />
)} /> ) : rightTab === 'orderbook' ? ( )} bottom={( )} /> ) : ( )} )} /> ); }; export default VirtualTradingPage;