435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
/**
|
|
* 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링
|
|
*/
|
|
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<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 [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
|
const [focusMarket, setFocusMarket] = useState<string | null>(null);
|
|
const orderAnchorRef = useRef<HTMLDivElement>(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 = (
|
|
<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')}>
|
|
거래{trades.length > 0 ? ` (${trades.length})` : ''}
|
|
</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}
|
|
/>
|
|
)}
|
|
leftStorageKey="vtd-left-width"
|
|
leftDefaultWidth={380}
|
|
leftCollapsedStorageKey="vtd-left-open"
|
|
rightStorageKey="vtd-right-width"
|
|
rightDefaultWidth={380}
|
|
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)}
|
|
/>
|
|
)}
|
|
center={(
|
|
<VirtualTargetGrid
|
|
targets={targets}
|
|
strategies={strategies}
|
|
snapshots={snapshots}
|
|
session={session}
|
|
onTargetStrategyChange={(market, strategyId) => 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={(
|
|
<div className="ptd-right-body" ref={orderAnchorRef}>
|
|
{rightTab === 'trade' ? (
|
|
<PaperSplitPanel
|
|
className="ptd-split-panel--right"
|
|
topTitle="매수"
|
|
bottomTitle="매도"
|
|
top={(
|
|
<div className="ptd-order-card">
|
|
<TradeOrderPanel
|
|
side="buy"
|
|
market={selectedMarket}
|
|
tradePrice={tradePrice}
|
|
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
|
|
fillRequest={fillBuy}
|
|
searchAnchorRef={orderAnchorRef}
|
|
onMarketSelect={handleSelectMarket}
|
|
showSymbolField
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
|
/>
|
|
</div>
|
|
)}
|
|
bottom={(
|
|
<div className="ptd-order-card">
|
|
<TradeOrderPanel
|
|
side="sell"
|
|
market={selectedMarket}
|
|
tradePrice={tradePrice}
|
|
availableCoinQty={posQty}
|
|
fillRequest={fillSell}
|
|
onMarketSelect={handleSelectMarket}
|
|
showSymbolField={false}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
|
|
/>
|
|
</div>
|
|
)}
|
|
/>
|
|
) : rightTab === 'orderbook' ? (
|
|
<PaperSplitPanel
|
|
className="ptd-split-panel--right"
|
|
topTitle="매도 호가"
|
|
bottomTitle="매수 호가"
|
|
top={(
|
|
<PaperCompactOrderbook
|
|
market={selectedMarket}
|
|
onPick={handleObPick}
|
|
section="asks"
|
|
fillHeight
|
|
hideHeader
|
|
depth={10}
|
|
/>
|
|
)}
|
|
bottom={(
|
|
<PaperCompactOrderbook
|
|
market={selectedMarket}
|
|
onPick={handleObPick}
|
|
section="bids"
|
|
fillHeight
|
|
hideHeader
|
|
depth={10}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<PaperTradeHistoryList
|
|
trades={trades}
|
|
strategyNames={strategyNames}
|
|
onSelectMarket={handleSelectMarket}
|
|
emptyText="수동·자동 매매 체결 내역이 없습니다."
|
|
className="ptd-trade-history--fill"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default VirtualTradingPage;
|