/** * 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { loadPaperSummary, loadPaperTradesPaged, loadPaperPendingOrders, loadStrategies, resetPaperAccount, type PaperOrderDto, type PaperPositionDto, type PaperSummaryDto, type PaperTradeDto, type StrategyDto, } from '../utils/backendApi'; import type { TickerData } from '../hooks/useMarketTicker'; import { coerceFiniteNumber } from '../utils/safeFormat'; import PaperInvestmentKpi from './paper/PaperInvestmentKpi'; import PaperAllocationTable from './paper/PaperAllocationTable'; import PaperPendingOrdersList from './paper/PaperPendingOrdersList'; import PaperLedgerTab from './paper/PaperLedgerTab'; import PaperAssetTrendChart from './paper/PaperAssetTrendChart'; import { computePaperMetrics } from '../utils/paperMetrics'; import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade'; import type { Theme, TradeOrderFillRequest } from '../types'; import PaperAnalysisChart from './paper/PaperAnalysisChart'; import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab'; import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab'; import PaperTradeHistoryList from './paper/PaperTradeHistoryList'; import BuilderPageShell from './layout/BuilderPageShell'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; type HistoryTab = 'open' | 'recent' | 'ledger'; type CenterTab = 'invest' | 'chart'; type RightTab = 'trade' | 'orderbook' | 'history'; type LeftTab = 'strategy' | 'settings'; interface Props { theme?: Theme; tickers?: Map; onGoChart?: (market: string) => void; refreshKey?: number; defaultMarket?: string; paperTradingEnabled?: boolean; paperAutoTradeEnabled?: boolean; onPaperOrderFilled?: () => void; /** 설정 → 모의투자 카테고리로 이동 */ onOpenSettings?: () => void; } function buildMarkPrices( summary: PaperSummaryDto | null, tickers?: Map, ): Record { const m: Record = {}; summary?.positions?.forEach(p => { const t = tickers?.get(p.symbol)?.tradePrice; if (t != null && t > 0) m[p.symbol] = t; }); return m; } function positionPriceKey( positions: PaperPositionDto[] | undefined, tickers?: Map, ): string { if (!positions?.length) return ''; return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|'); } function coinCode(symbol: string): string { return symbol.replace(/^KRW-/, ''); } const PaperTradingPage: React.FC = ({ theme = 'dark', tickers, refreshKey = 0, defaultMarket = 'KRW-BTC', paperTradingEnabled = true, paperAutoTradeEnabled = false, onPaperOrderFilled, onOpenSettings, }) => { const [strategies, setStrategies] = useState([]); const [summary, setSummary] = useState(null); const [trades, setTrades] = useState([]); const [pendingOrders, setPendingOrders] = useState([]); const [dataRefreshKey, setDataRefreshKey] = useState(0); const [initialLoading, setInitialLoading] = useState(true); const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [centerTab, setCenterTab] = useState('invest'); const [historyTab, setHistoryTab] = useState('recent'); const [rightTab, setRightTab] = useState('trade'); const [leftTab, setLeftTab] = useState('strategy'); const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); const orderAnchorRef = useRef(null); const tickersRef = useRef(tickers); tickersRef.current = tickers; const summaryRef = useRef(summary); summaryRef.current = summary; const loadData = useCallback(async (opts?: { focusHistory?: boolean }) => { try { const base = await loadPaperSummary(); const marks = buildMarkPrices(base, tickersRef.current); const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base; setSummary(full); const tradePage = await loadPaperTradesPaged({ page: 0, size: 100 }); setTrades(tradePage.content); setPendingOrders(await loadPaperPendingOrders()); setDataRefreshKey(k => k + 1); } finally { setInitialLoading(false); } if (opts?.focusHistory) setRightTab('history'); }, []); useEffect(() => { void Promise.all([ loadData(), loadStrategies().then(setStrategies).catch(() => setStrategies([])), ]); }, [loadData]); useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]); const priceKey = positionPriceKey(summary?.positions, tickers); const lastPriceKeyRef = useRef(''); useEffect(() => { if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return; lastPriceKeyRef.current = priceKey; let cancelled = false; (async () => { const marks = buildMarkPrices(summaryRef.current, tickersRef.current); if (!Object.keys(marks).length) return; try { const full = await loadPaperSummary(marks); if (!cancelled) setSummary(full); } catch { /* ignore */ } })(); return () => { cancelled = true; }; }, [priceKey, initialLoading]); const strategyNames = useMemo( () => Object.fromEntries(strategies.map(s => [s.id, s.name])), [strategies], ); const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]); const s = summary; const posQty = useMemo(() => { const p = s?.positions?.find(x => x.symbol === selectedMarket); return coerceFiniteNumber(p?.quantity) ?? 0; }, [s?.positions, selectedMarket]); const selectedAlloc = useMemo( () => s?.allocations?.find(a => a.symbol === selectedMarket), [s?.allocations, selectedMarket], ); const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0; const tradePrice = useMemo( () => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice), [tickers, selectedMarket], ); const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket); const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket); const selectedTicker = tickers?.get(selectedMarket); const orderbookPrevClose = selectedTicker ? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0) : 0; const orderbookTickerInfo = selectedTicker ? { tradePrice: selectedTicker.tradePrice, changeRate: selectedTicker.changeRate, changePrice: selectedTicker.changePrice, accTradePrice24: selectedTicker.accTradePrice24, accTradeVolume24: selectedTicker.accTradeVolume24, highPrice: selectedTicker.highPrice, lowPrice: selectedTicker.lowPrice, openingPrice: selectedTicker.openingPrice, } : undefined; 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 handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => { setRightTab('trade'); const side = rowType === 'bid' ? 'buy' : 'sell'; const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() }; if (side === 'buy') setFillBuy(req); else setFillSell(req); }, [selectedMarket]); const handleReset = useCallback(async () => { if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return; const res = await resetPaperAccount(); if (res) { setSummary(res); setTrades([]); setPendingOrders([]); } }, []); const rightTabs = (
); const leftTabs = (
); return ( 모의투자가 꺼져 있습니다. 설정에서 활성화하세요. ) : undefined} headerCenterMode="between" headerActions={( <> )} leftTabs={leftTabs} left={leftTab === 'strategy' ? ( ) : ( void loadData()} onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }} /> )} centerHead={(
{coinCode(selectedMarket)} / KRW
)} center={centerTab === 'invest' ? (
void loadData()} />
) : (
)} rightTabs={rightTabs} right={( {rightTab === 'trade' ? ( void loadData({ focusHistory: true })} /> ) : rightTab === 'orderbook' ? ( ) : (
{historyTab === 'open' ? ( void loadData()} /> ) : historyTab === 'ledger' ? ( ) : ( )}
)}
)} /> ); }; function MetricCard({ label, value, tone }: { label: string; value: string; tone: 'up' | 'down'; }) { return (
{label} {value}
); } export default PaperTradingPage;