가상투자 설정 수정

This commit is contained in:
Macbook
2026-05-25 22:30:54 +09:00
parent cae1c3a624
commit 1e950c7db4
24 changed files with 1015 additions and 362 deletions
+129 -46
View File
@@ -4,9 +4,11 @@
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';
@@ -14,10 +16,17 @@ 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 {
@@ -25,14 +34,21 @@ import {
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';
type RightTab = 'trade' | 'orderbook' | 'history';
interface Props {
theme?: Theme;
@@ -40,30 +56,34 @@ interface Props {
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
paperAutoTradeBudgetPct?: number;
onPaperAutoTradeEnabled?: (enabled: boolean) => void;
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,
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);
@@ -72,12 +92,35 @@ const VirtualTradingPage: React.FC<Props> = ({
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
useEffect(() => {
loadStrategies().then(setStrategies).catch(() => setStrategies([]));
loadPaperSummary().then(setSummary).finally(() => setLoading(false));
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 => ({
@@ -92,6 +135,17 @@ const VirtualTradingPage: React.FC<Props> = ({
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 };
@@ -133,8 +187,12 @@ const VirtualTradingPage: React.FC<Props> = ({
}, [selectedMarket, handleSelectMarket]);
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
setSession(s => ({ ...s, globalStrategyId: strategyId }));
}, []);
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) {
@@ -145,19 +203,8 @@ const VirtualTradingPage: React.FC<Props> = ({
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,
}),
));
await syncVirtualTargetsToBackend(targets, session, true);
setSession(s => ({ ...s, running: true }));
} catch {
window.alert('가상투자 시작 설정 저장에 실패했습니다.');
@@ -165,20 +212,8 @@ const VirtualTradingPage: React.FC<Props> = ({
}, [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,
}),
));
await stopVirtualLiveOnBackend(targets, session);
} catch { /* ignore */ }
setSession(s => ({ ...s, running: false }));
}, [targets, session]);
@@ -202,24 +237,41 @@ const VirtualTradingPage: React.FC<Props> = ({
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']) => {
setSession(s => ({ ...s, executionType }));
}, []);
const next = { ...session, executionType };
setSession(next);
void resyncRunningSession(next);
}, [session, resyncRunningSession]);
const handlePositionModeChange = useCallback((positionMode: VirtualSessionConfig['positionMode']) => {
setSession(s => ({ ...s, 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>
);
@@ -228,9 +280,34 @@ const VirtualTradingPage: React.FC<Props> = ({
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"
@@ -255,11 +332,6 @@ const VirtualTradingPage: React.FC<Props> = ({
strategies={strategies}
snapshots={snapshots}
session={session}
onStrategyChange={handleGlobalStrategyChange}
onExecutionTypeChange={handleExecutionTypeChange}
onPositionModeChange={handlePositionModeChange}
onStart={() => void handleStart()}
onStop={() => void handleStop()}
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
liveStatusByMarket={mergedLiveStatus}
lastTickAtByMarket={lastTickAtByMarket}
@@ -269,6 +341,9 @@ const VirtualTradingPage: React.FC<Props> = ({
chartRealtimeSource={chartRealtimeSource}
chartSeriesPriceLabels={chartSeriesPriceLabels}
tickers={tickers}
viewMode={viewMode}
globalDisplayMode={globalDisplayMode}
onFocusMarketChange={setFocusMarket}
/>
)}
rightTabs={rightTabs}
@@ -292,7 +367,7 @@ const VirtualTradingPage: React.FC<Props> = ({
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
</div>
)}
@@ -308,12 +383,12 @@ const VirtualTradingPage: React.FC<Props> = ({
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
onPaperOrderFilled={() => void refreshPaperData({ focusHistory: true })}
/>
</div>
)}
/>
) : (
) : rightTab === 'orderbook' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
@@ -339,6 +414,14 @@ const VirtualTradingPage: React.FC<Props> = ({
/>
)}
/>
) : (
<PaperTradeHistoryList
trades={trades}
strategyNames={strategyNames}
onSelectMarket={handleSelectMarket}
emptyText="수동·자동 매매 체결 내역이 없습니다."
className="ptd-trade-history--fill"
/>
)}
</div>
)}