Files
goldenChart/frontend/src/components/VirtualTradingPage.tsx
T
Macbook 9f9e025598 매매탭 주문패널을 스플릿 카드 레이아웃으로 복원 (상단 매수, 하단 매도)
- TradeTabOrderPanel(탭 방식) → TradeSplitOrderPanel(카드 분리 방식)으로 전환
- VirtualTradingPage·PaperTradingPage에 '거래' 외부 탭 복원
- 거래 내역(미체결/체결/원장)을 '거래' 탭 하위로 원복

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:10:33 +09:00

612 lines
24 KiB
TypeScript

/**
* 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadPaperTrades,
loadStrategies,
saveLiveStrategySettings,
pinCandleWatch,
loadLiveStrategySettings,
type PaperSummaryDto,
type PaperTradeDto,
type StrategyDto,
} from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker';
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
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 { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
import {
loadVirtualSession,
loadVirtualTargets,
saveVirtualSession,
saveVirtualTargets,
loadVirtualCardViewMode,
saveVirtualCardViewMode,
loadVirtualGridPreset,
saveVirtualGridPreset,
type VirtualSessionConfig,
type VirtualTargetItem,
type VirtualCardViewMode,
} from '../utils/virtualTradingStorage';
import { useAppSettings, resolveAppDefaults, subscribeAppSettings } from '../hooks/useAppSettings';
import { coerceFiniteNumber } from '../utils/safeFormat';
import type { TradeNotificationGridPresetId } from '../utils/tradeNotificationGridPresets';
import {
syncVirtualTargetsToBackend,
stopVirtualLiveOnBackend,
} from '../utils/virtualLiveStrategySync';
import {
syncStrategyTimeframesFromLayoutIfNeeded,
warnStrategyTimeframeMismatch,
} from '../utils/strategyTimeframeSync';
import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin';
import { persistVirtualTargetPinned } from '../utils/virtualTargetMutations';
import {
applyDefaultGlobalStrategyToSession,
ensureDefaultGlobalStrategyId,
freezeTargetsBeforeGlobalStrategyChange,
resolveVirtualTargetStrategyId,
} from '../utils/virtualTargetStrategy';
import { virtualTargetLimitMessage } from '../utils/virtualTargetLimits';
import { hydrateVirtualTargetsIfEmpty } from '../utils/virtualTargetsHydrate';
import {
mergeTargetsWithLiveStrategySettings,
mergeVirtualSessionWithAppLive,
resolveVirtualTargetStrategyIdWithApp,
} from '../utils/virtualTargetHydrate';
import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook' | 'history';
interface Props {
theme?: Theme;
tickers?: Map<string, TickerData>;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
onPaperOrderFilled?: () => void;
}
const VirtualTradingPage: React.FC<Props> = ({
theme = 'dark',
tickers,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
paperAutoTradeBudgetPct = 95,
onPaperAutoTradeEnabled,
onPaperOrderFilled,
}) => {
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(defaultMarket);
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(() => loadVirtualGridPreset());
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
const [focusMarket, setFocusMarket] = useState<string | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const preferencesHydratedRef = useRef(false);
const { settings: appSettings, isLoaded: settingsLoaded } = useAppSettings();
const appChartDefaults = resolveAppDefaults(appSettings);
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
?? 'BACKEND_STOMP') as ChartRealtimeSource;
const chartCandleAreaPriceLabels = appChartDefaults.chartCandleAreaPriceLabels;
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
const chartPaneSeparator = appChartDefaults.chartPaneSeparator;
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
const virtualTargetMaxCount = appChartDefaults.virtualTargetMaxCount;
const appLiveStrategyId = appChartDefaults.liveStrategyId ?? null;
const reloadFromUiPreferences = useCallback(() => {
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
setSession(sessionFromDb);
setTargets(loadVirtualTargets());
}, [appLiveStrategyId]);
useEffect(() => {
if (!settingsLoaded) return;
let cancelled = false;
preferencesHydratedRef.current = false;
setLoading(true);
void (async () => {
reloadFromUiPreferences();
let nextTargets = loadVirtualTargets();
if (nextTargets.length === 0) {
nextTargets = await hydrateVirtualTargetsIfEmpty();
if (!cancelled && nextTargets.length > 0) {
setTargets(nextTargets);
saveVirtualTargets(nextTargets);
}
}
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
if (!cancelled) {
setSession(sessionFromDb);
}
if (nextTargets.length > 0) {
const withLive = await mergeTargetsWithLiveStrategySettings(
nextTargets,
sessionFromDb,
appLiveStrategyId,
);
if (!cancelled && withLive !== nextTargets) {
setTargets(withLive);
}
}
if (!cancelled) {
preferencesHydratedRef.current = true;
}
await Promise.all([
loadStrategies()
.then(list => {
if (cancelled) return;
setStrategies(list);
const validIds = new Set(
list.map(s => s.id).filter((id): id is number => id != null && id > 0),
);
setSession(prev =>
applyDefaultGlobalStrategyToSession(
mergeVirtualSessionWithAppLive(prev, appLiveStrategyId),
list,
appLiveStrategyId,
),
);
setTargets(prev =>
prev.map(t =>
t.strategyId != null && !validIds.has(t.strategyId)
? { ...t, strategyId: null }
: t,
),
);
})
.catch(() => { if (!cancelled) setStrategies([]); }),
loadPaperSummary().then(sum => { if (!cancelled) setSummary(sum); }),
loadPaperTrades().then(tr => { if (!cancelled) setTrades(tr); }),
]);
if (!cancelled) setLoading(false);
})();
return () => { cancelled = true; };
}, [settingsLoaded, appLiveStrategyId, reloadFromUiPreferences]);
useEffect(() => {
if (!settingsLoaded) return;
return subscribeAppSettings(() => {
reloadFromUiPreferences();
});
}, [settingsLoaded, reloadFromUiPreferences]);
/** DB is_pinned·strategyId → ui_preferences 투자대상 동기화 */
useEffect(() => {
if (!settingsLoaded) return;
let cancelled = false;
const initial = loadVirtualTargets();
if (initial.length === 0) return;
const sessionFromDb = mergeVirtualSessionWithAppLive(loadVirtualSession(), appLiveStrategyId);
void (async () => {
const merged = await Promise.all(
initial.map(async t => {
try {
const s = await loadLiveStrategySettings(t.market);
let next = t;
if (!!t.pinned !== !!s.isPinned) {
next = { ...next, pinned: !!s.isPinned };
}
if (next.strategyId == null && s.strategyId != null && s.strategyId > 0 && s.isLiveCheck) {
next = { ...next, strategyId: s.strategyId };
}
return next;
} catch {
return t;
}
}),
);
const withLive = await mergeTargetsWithLiveStrategySettings(
merged,
sessionFromDb,
appLiveStrategyId,
);
if (!cancelled) setTargets(withLive);
})();
return () => { cancelled = true; };
}, [settingsLoaded, appLiveStrategyId]);
useEffect(() => {
if (!preferencesHydratedRef.current) return;
saveVirtualTargets(targets);
}, [targets]);
useEffect(() => {
if (!preferencesHydratedRef.current) return;
saveVirtualSession(session);
}, [session]);
useEffect(() => { saveVirtualCardViewMode(viewMode); }, [viewMode]);
useEffect(() => { saveVirtualGridPreset(gridPreset); }, [gridPreset]);
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 handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
setGridPreset(preset);
}, []);
const targetRefs = useMemo(() =>
targets.map(t => ({
market: t.market,
strategyId: resolveVirtualTargetStrategyIdWithApp(t, session, appLiveStrategyId),
})),
[targets, session, appLiveStrategyId]);
const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots(
targetRefs,
strategies,
session.running,
);
const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus(
targetRefs,
session.running,
);
/** 백엔드 자동 체결 후 UI 갱신(프론트는 주문 실행 안 함) */
useEffect(() => {
if (!session.running) return;
const onTradesChanged = () => { void refreshPaperData(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
const pollId = window.setInterval(onTradesChanged, 20_000);
return () => {
window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged);
window.clearInterval(pollId);
};
}, [session.running, refreshPaperData]);
const mergedLiveStatus = useMemo(() => {
const merged = { ...liveStatusByMarket };
const now = Date.now();
/** 폴링 주기(3~5s) + 지연 여유 — 지표 스냅샷 갱신 시 STOMP 틱 없어도 실시간 표시 */
const SNAP_LIVE_MS = 12_000;
for (const [market, snap] of Object.entries(snapshots)) {
if (snap?.updatedAt && now - snap.updatedAt < SNAP_LIVE_MS) {
merged[market] = 'live';
}
}
return merged;
}, [liveStatusByMarket, snapshots]);
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 { 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 handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
if (items.length > virtualTargetMaxCount) {
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
return;
}
setTargets(items);
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
handleSelectMarket(items[0].market);
}
}, [selectedMarket, handleSelectMarket, virtualTargetMaxCount]);
const handleTogglePin = useCallback((market: string) => {
setTargets(prev => {
const item = prev.find(t => t.market === market);
if (!item) return prev;
const pinned = !item.pinned;
void persistVirtualTargetPinned(market, pinned).catch(() => {
window.alert('고정 설정 저장에 실패했습니다.');
});
return prev.map(t => (t.market === market ? { ...t, pinned } : t));
});
}, []);
/** 상단 기본전략 — 신규 추가 종목 템플릿만 변경, 기존 종목 전략은 유지 */
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
setTargets(prev => freezeTargetsBeforeGlobalStrategyChange(prev, session.globalStrategyId));
setSession(prev => ({ ...prev, globalStrategyId: strategyId }));
}, [session.globalStrategyId]);
const handleStart = useCallback(async () => {
if (targets.length === 0) {
window.alert('투자대상 종목을 먼저 추가하세요.');
return;
}
const globalStrategyId = ensureDefaultGlobalStrategyId(session.globalStrategyId, strategies);
if (!globalStrategyId) {
window.alert('투자전략을 선택하세요.');
return;
}
const sessionForStart = { ...session, globalStrategyId };
if (session.globalStrategyId !== globalStrategyId) {
setSession(sessionForStart);
}
const globalStrat = strategies.find(s => s.id === globalStrategyId);
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(
globalStrategyId,
globalStrat,
);
if (!synced) return;
const ok = await warnStrategyTimeframeMismatch(globalStrategyId, globalStrat);
if (!ok) return;
try {
await syncVirtualTargetsToBackend(targets, sessionForStart, true);
setSession(s => ({ ...s, running: true }));
} catch {
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
}
}, [targets, session, strategies]);
const handleStop = useCallback(async () => {
try {
await stopVirtualLiveOnBackend(targets, session);
} catch { /* ignore */ }
setSession(s => ({ ...s, running: false }));
}, [targets, session]);
/** 실시간 차트 우측 호가와 동일: 매수 호가(bid) → 매수 탭, 매도 호가(ask) → 매도 탭 */
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 handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
setTargets(prev => prev.map(t =>
t.market === market ? { ...t, strategyId } : t,
));
if (!session.running) return;
const effectiveId = resolveVirtualTargetStrategyId({ strategyId }, session.globalStrategyId);
if (effectiveId == null) return;
try {
await saveLiveStrategySettings({
market,
strategyId: effectiveId,
isLiveCheck: true,
executionType: session.executionType,
positionMode: session.positionMode,
skipWatchlistSync: true,
skipGlobalTemplate: true,
});
await pinStrategyEvaluationTimeframes(market, effectiveId);
} 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 = (
<div className="bps-right-tabs">
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>거래</button>
</div>
);
return (
<BuilderPageShell
theme={theme}
title="가상매매"
subtitle="Virtual Trading"
pageClassName="bps-page--vtd"
loading={loading}
loadingText="가상매매 대시보드 로딩…"
collapsiblePanels
headerCenter={(
<VirtualSessionHeaderControls
session={session}
strategies={strategies}
onStrategyChange={handleGlobalStrategyChange}
onExecutionTypeChange={handleExecutionTypeChange}
onPositionModeChange={handlePositionModeChange}
onStart={() => void handleStart()}
onStop={() => void handleStop()}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperAutoTradeEnabled={onPaperAutoTradeEnabled}
paperTradingEnabled={paperTradingEnabled}
/>
)}
headerActions={(
<VirtualViewHeaderControls
session={session}
viewMode={viewMode}
onViewModeChange={setViewMode}
globalDisplayMode={globalDisplayMode}
onGlobalDisplayModeChange={handleGlobalDisplayModeChange}
showSignalChartToggle={!focusMarket}
gridPreset={gridPreset}
onGridPresetChange={handleGridPresetChange}
showGridLayoutPicker={!focusMarket}
/>
)}
leftStorageKey="vtd-left-width"
leftDefaultWidth={380}
leftCollapsedStorageKey="vtd-left-open"
rightStorageKey="vtd-right-width"
rightDefaultWidth={440}
rightCollapsedStorageKey="vtd-right-open"
left={(
<VirtualLeftTargetPanel
targets={targets}
globalStrategyId={session.globalStrategyId}
strategies={strategies}
selectedMarket={selectedMarket}
tickers={tickers}
onTargetsChange={handleTargetsChange}
onSelectMarket={handleSelectMarket}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
onTogglePin={handleTogglePin}
virtualTargetMaxCount={virtualTargetMaxCount}
/>
)}
center={(
<VirtualTargetGrid
targets={targets}
strategies={strategies}
snapshots={snapshots}
snapshotLoadingByMarket={snapshotLoadingByMarket}
session={session}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
liveStatusByMarket={mergedLiveStatus}
lastTickAtByMarket={lastTickAtByMarket}
selectedMarket={selectedMarket}
onSelectMarket={handleSelectMarket}
theme={theme}
chartRealtimeSource={chartRealtimeSource}
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartPaneSeparator={chartPaneSeparator}
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
tickers={tickers}
viewMode={viewMode}
globalDisplayMode={globalDisplayMode}
onFocusMarketChange={setFocusMarket}
gridPreset={gridPreset}
appLiveStrategyId={appLiveStrategyId}
/>
)}
rightTabs={rightTabs}
right={(
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? (
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
) : rightTab === 'orderbook' ? (
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
) : (
<PaperTradeHistoryList
trades={trades}
strategyNames={strategyNames}
tickers={tickers}
onSelectMarket={handleSelectMarket}
emptyText="수동·자동 매매 체결 내역이 없습니다."
className="ptd-trade-history--fill"
/>
)}
</TradeRightPanelBody>
)}
/>
);
};
export default VirtualTradingPage;