가상투자 메뉴 기능 구현
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user