가상투자 메뉴 기능 구현

This commit is contained in:
Macbook
2026-05-25 06:41:45 +09:00
parent 0cfe7fc84c
commit 8b373b11e3
33 changed files with 3897 additions and 99 deletions
@@ -0,0 +1,336 @@
/**
* 가상투자 대시보드 — 모의투자 레이아웃 기반, 다종목 전략 모니터링
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadStrategies,
saveLiveStrategySettings,
type PaperSummaryDto,
type StrategyDto,
} from '../utils/backendApi';
import type { Theme, TradeOrderFillRequest } from '../types';
import TradeOrderPanel from './TradeOrderPanel';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import PaperSplitPanel from './paper/PaperSplitPanel';
import BuilderPageShell from './layout/BuilderPageShell';
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
import VirtualTargetGrid from './virtual/VirtualTargetGrid';
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
import {
loadVirtualSession,
loadVirtualTargets,
saveVirtualSession,
saveVirtualTargets,
type VirtualSessionConfig,
type VirtualTargetItem,
} from '../utils/virtualTradingStorage';
import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook';
interface Props {
theme?: Theme;
tickers?: Map<string, { tradePrice: number | null }>;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
const VirtualTradingPage: React.FC<Props> = ({
theme = 'dark',
tickers,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
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 [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 orderAnchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
loadPaperSummary().then(setSummary).finally(() => setLoading(false));
}, []);
useEffect(() => { saveVirtualTargets(targets); }, [targets]);
useEffect(() => { saveVirtualSession(session); }, [session]);
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 liveStatusByMarket = useVirtualTargetLiveStatus(targetRefs, session.running);
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 p?.quantity ?? 0;
}, [summary?.positions, selectedMarket]);
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
setTargets(items);
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
setSelectedMarket(items[0].market);
}
}, [selectedMarket]);
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
setSession(s => ({ ...s, globalStrategyId: strategyId }));
}, []);
const handleStart = useCallback(async () => {
if (targets.length === 0) {
window.alert('투자대상 종목을 먼저 추가하세요.');
return;
}
if (!session.globalStrategyId) {
window.alert('투자전략을 선택하세요.');
return;
}
const template = {
isLiveCheck: true,
executionType: session.executionType,
positionMode: session.positionMode,
};
try {
await Promise.all(targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
...template,
}),
));
setSession(s => ({ ...s, running: true }));
} catch {
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
}
}, [targets, session]);
const handleStop = useCallback(async () => {
if (targets.length === 0) {
setSession(s => ({ ...s, running: false }));
return;
}
try {
await Promise.all(targets.map(t =>
saveLiveStrategySettings({
market: t.market,
strategyId: t.strategyId ?? session.globalStrategyId,
isLiveCheck: false,
executionType: session.executionType,
positionMode: session.positionMode,
}),
));
} 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 headerControls = (
<div className="vtd-header-controls">
<label className="vtd-header-field">
<span></span>
<select
className="vtd-header-select"
value={session.globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
handleGlobalStrategyChange(v ? Number(v) : null);
}}
>
<option value=""> </option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="vtd-header-field">
<span></span>
<select
className="vtd-header-select"
value={session.executionType}
onChange={e => setSession(s => ({
...s,
executionType: e.target.value === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
}))}
>
<option value="CANDLE_CLOSE"> </option>
<option value="REALTIME_TICK"> </option>
</select>
</label>
<label className="vtd-header-field">
<span> </span>
<select
className="vtd-header-select"
value={session.positionMode}
onChange={e => setSession(s => ({
...s,
positionMode: e.target.value === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
}))}
>
<option value="LONG_ONLY"> </option>
<option value="SIGNAL_ONLY"> </option>
</select>
</label>
{!session.running ? (
<button type="button" className="bps-btn bps-btn--primary" onClick={() => void handleStart()}>
</button>
) : (
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleStop()}>
</button>
)}
</div>
);
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>
</div>
);
return (
<BuilderPageShell
theme={theme}
title="가상투자"
subtitle="Virtual Trading"
loading={loading}
loadingText="가상투자 대시보드 로딩…"
leftStorageKey="vtd-left-width"
leftDefaultWidth={380}
rightStorageKey="vtd-right-width"
rightDefaultWidth={380}
headerActions={headerControls}
left={(
<VirtualLeftTargetPanel
targets={targets}
globalStrategyId={session.globalStrategyId}
strategies={strategies}
selectedMarket={selectedMarket}
onTargetsChange={handleTargetsChange}
onSelectMarket={setSelectedMarket}
/>
)}
center={(
<VirtualTargetGrid
targets={targets}
strategies={strategies}
snapshots={snapshots}
running={session.running}
globalStrategyId={session.globalStrategyId}
liveStatusByMarket={mergedLiveStatus}
theme={theme}
/>
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={summary?.cashBalance ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={setSelectedMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
bottom={(
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
/>
)}
/>
) : (
<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}
/>
)}
/>
)}
</div>
)}
/>
);
};
export default VirtualTradingPage;