From 70ac67afe7670c7cac484d8a46ac4c6030dc56b1 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 23 May 2026 21:08:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=EB=B6=84=EB=B4=89=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=97=90=EB=9F=AC=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/LiveStrategySettingsService.java | 16 +- frontend/src/App.tsx | 169 +++++- .../src/components/BacktestHistoryPage.tsx | 293 +++------- frontend/src/components/LiveStrategyPanel.tsx | 38 +- frontend/src/components/PaperTradingPage.tsx | 501 ++++++++++-------- frontend/src/components/TradingChart.tsx | 41 +- .../backtest/BacktestAssetChart.tsx | 165 ++++++ .../backtest/BacktestResultDashboard.tsx | 165 ++++++ .../components/backtest/BacktestSparkline.tsx | 35 ++ .../paper/PaperCompactOrderbook.tsx | 76 +++ .../components/paper/PaperIndicatorPanel.tsx | 110 ++++ .../src/components/paper/PaperMiniChart.tsx | 114 ++++ frontend/src/hooks/useBacktest.ts | 46 +- frontend/src/styles/backtestDashboard.css | 457 ++++++++++++++++ frontend/src/styles/paperDashboard.css | 440 +++++++++++++++ frontend/src/utils/ChartManager.ts | 20 +- frontend/src/utils/backtestEquity.ts | 129 +++++ frontend/src/utils/paperMetrics.ts | 128 +++++ 18 files changed, 2445 insertions(+), 498 deletions(-) create mode 100644 frontend/src/components/backtest/BacktestAssetChart.tsx create mode 100644 frontend/src/components/backtest/BacktestResultDashboard.tsx create mode 100644 frontend/src/components/backtest/BacktestSparkline.tsx create mode 100644 frontend/src/components/paper/PaperCompactOrderbook.tsx create mode 100644 frontend/src/components/paper/PaperIndicatorPanel.tsx create mode 100644 frontend/src/components/paper/PaperMiniChart.tsx create mode 100644 frontend/src/styles/backtestDashboard.css create mode 100644 frontend/src/styles/paperDashboard.css create mode 100644 frontend/src/utils/backtestEquity.ts create mode 100644 frontend/src/utils/paperMetrics.ts diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index 266aa5e..05a65fa 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -125,11 +125,14 @@ public class LiveStrategySettingsService { public LiveStrategySettingsDto save(Long userId, String deviceId, LiveStrategySettingsDto dto) { persistGlobalTemplate(userId, deviceId, dto); - syncAllWatchlistMarkets(userId, deviceId); + syncAllWatchlistMarkets(userId, deviceId, dto); String market = dto.getMarket() != null ? dto.getMarket() : "KRW-BTC"; log.info("[LiveStrategySettings] global saved + watchlist sync, template market={}", market); GcAppSettings app = appSettingsService.getEntity(userId, deviceId); - LiveStrategySettingsDto out = mergeGlobalTemplate(defaultDtoFromApp(market, app), app, market); + LiveStrategySettingsDto out = findEntity(userId, deviceId, market) + .map(this::toDto) + .orElseGet(() -> defaultDtoFromApp(market, app)); + out = mergeGlobalTemplate(out, app, market); if (dto.getExecutionType() != null) { out.setExecutionType(dto.getExecutionType()); } @@ -153,13 +156,20 @@ public class LiveStrategySettingsService { appSettingsService.save(userId, deviceId, patch); } - private void syncAllWatchlistMarkets(Long userId, String deviceId) { + private void syncAllWatchlistMarkets(Long userId, String deviceId, + LiveStrategySettingsDto template) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); boolean active = Boolean.TRUE.equals(app.getLiveStrategyCheck()) && app.getLiveStrategyId() != null; + String candleType = template != null && template.getCandleType() != null + ? LiveStrategyTimeframeService.normalize(template.getCandleType()) + : null; for (String symbol : listWatchlistSymbols(userId, deviceId)) { LiveStrategySettingsDto row = defaultDtoFromApp(symbol, app); row.setLiveCheck(active); + if (candleType != null) { + row.setCandleType(candleType); + } LiveStrategySettingsDto saved = upsertEntity(userId, deviceId, row); if (active) { pinIfReady(symbol, saved); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 343b33e..a37a868 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -78,7 +78,9 @@ import { DEFAULT_BACKTEST_SETTINGS, loadBacktestSettings, loadStrategies as loadStrategiesForLive, + loadLiveStrategySettings, type LiveStrategySettingsDto, + type BacktestSignal, } from './utils/backendApi'; import { useIsMobile } from './hooks/useMediaQuery'; import MobileChartDock from './components/MobileChartDock'; @@ -94,6 +96,8 @@ import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; import './App.css'; import './styles/appPopup.css'; +import './styles/paperDashboard.css'; +import './styles/backtestDashboard.css'; let _indCounter = 0; function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } @@ -611,15 +615,88 @@ function App() { const useUpbit = isUpbitMarket(symbol); // ── 백테스팅 ─────────────────────────────────────────────────────────────── - const { signals: btSignals, stats: btStats, analysis: btAnalysis, running: btRunning, run: btRun, clear: btClear } = useBacktest(); + const { + signals: btSignals, + stats: btStats, + analysis: btAnalysis, + resultSymbol: btSymbol, + resultTimeframe: btTimeframe, + runSeq: btRunSeq, + running: btRunning, + run: btRun, + clear: btClear, + invalidateRuns: btInvalidateRuns, + } = useBacktest(); const [btSettings, setBtSettings] = useState(DEFAULT_BACKTEST_SETTINGS); const [showBtSettings, setShowBtSettings] = useState(false); const [showBtResult, setShowBtResult] = useState(false); const [btStrategyName, setBtStrategyName] = useState('전략'); + /** 현재 차트와 일치하는 백테스팅 결과만 UI·마커에 반영 */ + const btMatchesChart = useMemo( + () => + btSignals.length > 0 && + btSymbol != null && + btTimeframe != null && + btSymbol === symbol && + btTimeframe === timeframe, + [btSignals.length, btSymbol, btTimeframe, symbol, timeframe], + ); + + const btMarkerPayloadRef = useRef<{ + symbol: string; + timeframe: string; + signals: BacktestSignal[]; + showPrice: boolean; + } | null>(null); + + const syncBacktestMarkersRef = useRef<() => void>(() => {}); + + /** ChartManager 메인 시리즈 준비 후 백테스트 마커 적용 (로딩·remount 직후 대응) */ + const applyBacktestMarkersWhenReady = useCallback(( + payload: NonNullable, + attempt = 0, + ) => { + const mgr = managerRef.current; + if (!mgr || !mgr.hasMainSeries()) { + if (attempt < 30) { + requestAnimationFrame(() => applyBacktestMarkersWhenReady(payload, attempt + 1)); + } + return; + } + btMarkerPayloadRef.current = payload; + mgr.setBacktestMarkers(payload.signals, payload.showPrice); + }, []); + + const syncBacktestMarkers = useCallback(() => { + const payload = btMarkerPayloadRef.current; + if ( + payload && + payload.symbol === symbol && + payload.timeframe === timeframe && + payload.signals.length > 0 + ) { + applyBacktestMarkersWhenReady(payload); + return; + } + managerRef.current?.clearBacktestMarkers(); + }, [symbol, timeframe, applyBacktestMarkersWhenReady]); + + syncBacktestMarkersRef.current = syncBacktestMarkers; + // ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ───────────────── const [showLivePanel, setShowLivePanel] = useState(false); const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]); + const [liveCandleType, setLiveCandleType] = useState('1m'); + + useEffect(() => { + if (!appSettingsLoaded) return; + loadLiveStrategySettings(symbol) + .then(s => { + if (s?.candleType) setLiveCandleType(s.candleType); + }) + .catch(() => { /* 기본 1m 유지 */ }); + }, [symbol, appSettingsLoaded]); const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({ market: symbol, @@ -627,7 +704,8 @@ function App() { isLiveCheck: appDefaults.liveStrategyCheck ?? false, executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE', positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY', - }), [symbol, appDefaults]); + candleType: liveCandleType, + }), [symbol, appDefaults, liveCandleType]); /** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */ const monitoredMarkets = useMemo(() => { @@ -665,7 +743,15 @@ function App() { liveExecutionType: saved.executionType, livePositionMode: saved.positionMode, }); - }, [saveAppDef]); + if (saved.candleType) setLiveCandleType(saved.candleType); + if (appDefaults.liveStrategyCheck && saved.isLiveCheck) { + loadActiveLiveStrategySettings() + .then(list => setMarketSubscriptions( + list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })), + )) + .catch(() => { /* 폴백 유지 */ }); + } + }, [saveAppDef, appDefaults.liveStrategyCheck]); const prevFavoritesRef = useRef(getFavorites()); @@ -798,15 +884,37 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [mainChartStyle]); - // 백테스팅 시그널 → ChartManager 마커 업데이트 + // 종목·타임프레임 변경 시 이전 백테스팅 결과·마커 제거 + 진행 중 run 무효화 useEffect(() => { - if (!managerRef.current) return; - if (btSignals.length > 0) { - managerRef.current.setBacktestMarkers(btSignals, appDefaults.btShowPrice); + btInvalidateRuns(); + btMarkerPayloadRef.current = null; + btClear(); + managerRef.current?.clearBacktestMarkers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [symbol, timeframe]); + + // 백테스팅 시그널 → ref 갱신 후 ChartManager 마커 동기화 + useEffect(() => { + if (btMatchesChart && btSignals.length > 0) { + btMarkerPayloadRef.current = { + symbol, + timeframe, + signals: btSignals, + showPrice: appDefaults.btShowPrice, + }; } else { - managerRef.current.clearBacktestMarkers(); + btMarkerPayloadRef.current = null; } - }, [btSignals, appDefaults.btShowPrice]); + syncBacktestMarkers(); + let innerRaf = 0; + const outerRaf = requestAnimationFrame(() => { + innerRaf = requestAnimationFrame(syncBacktestMarkers); + }); + return () => { + cancelAnimationFrame(outerRaf); + if (innerRaf) cancelAnimationFrame(innerRaf); + }; + }, [btMatchesChart, btSignals, btRunSeq, symbol, timeframe, appDefaults.btShowPrice, syncBacktestMarkers]); // ── 시뮬레이션 데이터 (Upbit 아닐 때) ────────────────────────────────── const [simBars, setSimBars] = useState(() => @@ -1377,6 +1485,10 @@ function App() { setPaperRefreshKey(k => k + 1)} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); @@ -1574,19 +1686,42 @@ function App() { return next; }); }} - backtestActive={btSignals.length > 0} + backtestActive={btMatchesChart} backtestRunning={btRunning} backtestRunningId={undefined} onRunBacktest={menuPage === 'chart' && layoutDef.count === 1 ? async (opts) => { if (opts.strategyName) setBtStrategyName(opts.strategyName); - await btRun({ ...opts, bars, timeframe, settings: btSettings, symbol }); + if (useUpbit && (isLoading || bars.length === 0)) return; + const runSymbol = symbol; + const runTf = timeframe; + const r = await btRun({ + ...opts, + bars, + timeframe: runTf, + settings: btSettings, + symbol: runSymbol, + }); + if ( + r && + r.symbol === runSymbol && + r.timeframe === runTf && + r.signals.length > 0 + ) { + const showPrice = appDefaults.btShowPrice; + applyBacktestMarkersWhenReady({ + symbol: runSymbol, + timeframe: runTf, + signals: r.signals, + showPrice, + }); + } if (appDefaults.btAutoPopup) setShowBtResult(true); } : undefined} onOpenBtSettings={() => setShowBtSettings(true)} onOpenBtResults={() => setShowBtResult(true)} - hasBtResult={btSignals.length > 0} + hasBtResult={btMatchesChart} onClearBtMarkers={() => { btClear(); managerRef.current?.clearBacktestMarkers(); }} onToggleLiveStrategy={() => setShowLivePanel(v => !v)} liveStrategyActive={showLivePanel || monitoredMarkets.length > 0} @@ -1801,12 +1936,13 @@ function App() { drawingsVisible={drawingsVisible} onCrosshair={setLegend} onTradeOrderRequest={applyTradeFill} + onDataLoaded={() => syncBacktestMarkersRef.current()} onManagerReady={mgr => { managerRef.current = mgr; const pending = pendingRealtimeBarRef.current; - if (pending) { + if (pending && bars.length > 0) { pendingRealtimeBarRef.current = null; - const last = bars.length > 0 ? bars[bars.length - 1] : null; + const last = bars[bars.length - 1]; if (last && pending.time === last.time) mgr.updateBar(pending); else if (!last || pending.time > last.time) void mgr.appendBar(pending); else mgr.updateBar({ ...pending, time: last.time }); @@ -1815,8 +1951,7 @@ function App() { const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null; mgr.setVolumeVisible(chartVolumeVisible, wrapper?.clientHeight); mgr.setDisplayTimezone(displayTimezone, timeframe); - // 백테스팅 마커가 이미 있으면 즉시 적용 - if (btSignals.length > 0) mgr.setBacktestMarkers(btSignals, appDefaults.btShowPrice); + syncBacktestMarkersRef.current(); // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 mgr.subscribeVisibleLogicalRange(r => { if (r && r.from < LOAD_MORE_TRIGGER) { @@ -1857,7 +1992,7 @@ function App() { /> {/* 백테스팅 결과 통계 배지 */} - {btSignals.length > 0 && layoutDef.count === 1 && ( + {btMatchesChart && layoutDef.count === 1 && ( - isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '–'; + isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%'; -const fmtDateTime = (iso: string) => { +const fmtDate = (iso: string) => { const d = new Date(iso); - return { - date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`, - time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, - }; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; -// ── 아이콘 ──────────────────────────────────────────────────────────────── - -const IcTrash = () => ( - - - - - - -); - -const IcTrashAll = () => ( - - - - - - -); - -const IcRefresh = () => ( - - - - -); - -// ── 목록 아이템 ──────────────────────────────────────────────────────────── - -interface ListItemProps { - r: BacktestResultRecord; - checked: boolean; - active: boolean; - onToggle: () => void; - onClick: () => void; +function parseDetail(r: BacktestResultRecord) { + let analysis: BacktestAnalysis | null = null; + let signals: BacktestSignal[] = []; + try { + if (r.analysisJson) analysis = JSON.parse(r.analysisJson); + if (r.signalsJson) signals = JSON.parse(r.signalsJson); + } catch { /* ignore */ } + return { analysis, signals }; } -function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) { - const ret = r.totalReturn ?? 0; - const dt = fmtDateTime(r.createdAt); - return ( -
-
{ e.stopPropagation(); onToggle(); }}> -
- {checked && } -
-
+interface TimelineItemProps { + r: BacktestResultRecord; + active: boolean; + onClick: () => void; +} -
-
- {r.strategyName || '전략 없음'} - 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)} -
-
- {r.symbol} - · - {r.timeframe} - · - {r.totalTrades}회 -
-
- {dt.date} - {dt.time} - {(r.winRate ?? 0) > 0 && ( - 승률 {((r.winRate ?? 0) * 100).toFixed(0)}% - )} +function TimelineItem({ r, active, onClick }: TimelineItemProps) { + const ret = r.totalReturn ?? 0; + const positive = ret >= 0; + + const sparkCurve = useMemo(() => { + const { analysis, signals } = parseDetail(r); + const cap = analysis?.initialCapital ?? 10_000_000; + return buildEquityFromSignals(signals, cap, r.symbol.replace(/^KRW-/, '')).curve; + }, [r]); + + return ( +
+ ); } -// ── 메인 페이지 ─────────────────────────────────────────────────────────── - export function BacktestHistoryPage() { - const [records, setRecords] = useState([]); - const [selected, setSelected] = useState(null); - const [checked, setChecked] = useState>(new Set()); - const [loading, setLoading] = useState(true); + const [records, setRecords] = useState([]); + const [selected, setSelected] = useState(null); + const [loading, setLoading] = useState(true); - // ── 로드 ──────────────────────────────────────────────────────────────── const fetchList = useCallback(async () => { setLoading(true); const list = await loadBacktestResults(); setRecords(list); - if (list.length > 0) setSelected(prev => prev ?? list[0]); + setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null)); setLoading(false); }, []); - useEffect(() => { fetchList(); }, [fetchList]); - - // ── 체크박스 ────────────────────────────────────────────────────────── - const toggleCheck = useCallback((id: number) => { - setChecked(prev => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - }, []); - - const toggleAll = useCallback(() => { - setChecked(prev => - prev.size === records.length - ? new Set() - : new Set(records.map(r => r.id)) - ); - }, [records]); - - const allChecked = records.length > 0 && checked.size === records.length; - const someChecked = checked.size > 0 && !allChecked; - - // ── 삭제 ───────────────────────────────────────────────────────────── - const deleteSelected = useCallback(async () => { - if (checked.size === 0) return; - if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return; - await Promise.all([...checked].map(id => deleteBacktestResult(id))); - setRecords(prev => { - const next = prev.filter(r => !checked.has(r.id)); - if (selected && checked.has(selected.id)) setSelected(next[0] ?? null); - return next; - }); - setChecked(new Set()); - }, [checked, selected]); + useEffect(() => { void fetchList(); }, [fetchList]); const deleteAll = useCallback(async () => { if (records.length === 0) return; @@ -160,95 +85,37 @@ export function BacktestHistoryPage() { await Promise.all(records.map(r => deleteBacktestResult(r.id))); setRecords([]); setSelected(null); - setChecked(new Set()); }, [records]); - // ── 상세 데이터 파싱 ────────────────────────────────────────────────── - const getDetail = (r: BacktestResultRecord) => { - let analysis: BacktestAnalysis | null = null; - let signals: BacktestSignal[] = []; - try { - if (r.analysisJson) analysis = JSON.parse(r.analysisJson); - if (r.signalsJson) signals = JSON.parse(r.signalsJson); - } catch (_) {} - return { analysis, signals }; - }; + const selectedDetail = selected ? parseDetail(selected) : null; - // ── 렌더 ───────────────────────────────────────────────────────────── return ( -
- - {/* ── 좌측 목록 ── */} -
); }; +function MetricCard({ icon, title, value, sub, tone }: { + icon: string; title: string; value: string; sub: string; tone: 'up' | 'down'; +}) { + return ( +
+ {icon} +
+
{title}
+
{value}
+
{sub}
+
+
+ ); +} + export default PaperTradingPage; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 97df884..a6b266d 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -147,6 +147,8 @@ const TradingChart: React.FC = ({ const prevBarsKey = useRef(''); const prevIndKey = useRef(''); const prevSortedPKRef = useRef(''); // 순서 무관 paramKey (reorder 감지용) + const prevMarket = useRef(market); + const prevMarketTf = useRef(timeframe); const prevChartType = useRef(chartType); const prevTheme = useRef(theme); const prevLogScale = useRef(logScale); @@ -252,6 +254,8 @@ const TradingChart: React.FC = ({ }, [candleOnlyMode, chartMgr, applyPaneLayout]); // ── 전체 재로드 (데이터 + 인디케이터) ───────────────────────────────────── + const reloadSafetyTimers = useRef[]>([]); + const reloadAll = useCallback(async ( mgr: ChartManager, newBars: OHLCVBar[], @@ -262,11 +266,14 @@ const TradingChart: React.FC = ({ ) => { if (newBars.length === 0) return; + reloadSafetyTimers.current.forEach(clearTimeout); + reloadSafetyTimers.current = []; + barsRef.current = newBars; prevChartType.current = ct; prevTheme.current = th; prevLogScale.current = ls; - prevBarsKey.current = barsKey(newBars); + prevBarsKey.current = barsKey(newBars, market); prevIndKey.current = indKey(inds); prevSortedPKRef.current = sortedParamKey(inds); @@ -275,15 +282,12 @@ const TradingChart: React.FC = ({ mgr.setLogScale(ls); mgr.setDisplayTimezone(displayTimezone, timeframe); - // 인디케이터를 순차적으로 등록 (async/await 보장) for (const ind of inds) { await mgr.addIndicator(ind); } - // 인디케이터 추가 완료 후 RAF × 2 대기 - // → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정 await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { - applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도) + applyPaneLayout(mgr); requestAnimationFrame(() => { const futureBars = mgr.hasIchimoku() ? 28 : 0; mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars); @@ -291,12 +295,9 @@ const TradingChart: React.FC = ({ }); }))); - // 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용 onDataLoaded?.(); - // ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용 - // applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음 - const safetyTimers = [300, 700, 1400].map(delay => + reloadSafetyTimers.current = [300, 700, 1400].map(delay => setTimeout(() => { const m = managerRef.current; if (!m || !m.hasMainSeries()) return; @@ -307,10 +308,7 @@ const TradingChart: React.FC = ({ m.setInitialVisibleRange(DISPLAY_COUNT, fb); }, delay) ); - // cleanup 시 타이머 해제 (컴포넌트 언마운트 대응) - // 반환값이 없으므로 managerRef 체크로 충분 - void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨 - }, [applyPaneLayout, onDataLoaded]); + }, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market]); // ── 차트 초기화 (마운트 시 1회) ────────────────────────────────────────── useEffect(() => { @@ -592,6 +590,8 @@ const TradingChart: React.FC = ({ clearTimeout(drawingSingleTimerRef.current); drawingSingleTimerRef.current = null; } + reloadSafetyTimers.current.forEach(clearTimeout); + reloadSafetyTimers.current = []; ro.disconnect(); managerRef.current?.destroy(); managerRef.current = null; @@ -604,6 +604,14 @@ const TradingChart: React.FC = ({ managerRef.current?.setDisplayTimezone(displayTimezone, timeframe); }, [displayTimezone, timeframe]); + // 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만) + useEffect(() => { + if (prevMarket.current === market && prevMarketTf.current === timeframe) return; + prevMarket.current = market; + prevMarketTf.current = timeframe; + prevBarsKey.current = ''; + }, [market, timeframe]); + // ── 데이터/인디케이터 동기화 ───────────────────────────────────────────── useEffect(() => { const mgr = managerRef.current; @@ -615,7 +623,7 @@ const TradingChart: React.FC = ({ return; } - const bk = barsKey(bars); + const bk = barsKey(bars, market); const ik = indKey(indicators); const barsChanged = bk !== prevBarsKey.current; const indChanged = ik !== prevIndKey.current; @@ -905,11 +913,10 @@ const PaneLegendPortal: React.FC< }; // ── 변경 감지용 키 생성 헬퍼 ──────────────────────────────────────────────── -function barsKey(bars: OHLCVBar[]): string { +function barsKey(bars: OHLCVBar[], market = ''): string { if (bars.length === 0) return ''; const last = bars[bars.length - 1]; - // 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함 - return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`; + return `${market}:${bars.length}:${bars[0].time}:${last.time}:${last.close}`; } /** diff --git a/frontend/src/components/backtest/BacktestAssetChart.tsx b/frontend/src/components/backtest/BacktestAssetChart.tsx new file mode 100644 index 0000000..84dfcfb --- /dev/null +++ b/frontend/src/components/backtest/BacktestAssetChart.tsx @@ -0,0 +1,165 @@ +import React, { memo, useMemo } from 'react'; +import type { EquityPoint, TradeMarker } from '../../utils/backtestEquity'; + +interface Props { + curve: EquityPoint[]; + markers: TradeMarker[]; +} + +function fmtDate(ts: number): string { + const d = new Date(ts * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function fmtMonth(ts: number): string { + const d = new Date(ts * 1000); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${months[d.getMonth()]} ${d.getFullYear()}`; +} + +interface LineSegment { + d: string; + up: boolean; +} + +const BacktestAssetChart: React.FC = memo(({ curve, markers }) => { + const W = 800; + const H = 280; + const pad = { t: 22, r: 14, b: 26, l: 48 }; + const innerW = W - pad.l - pad.r; + const innerH = H - pad.t - pad.b; + + const chart = useMemo(() => { + if (curve.length < 2) { + const y = pad.t + innerH * 0.5; + return { + areaPath: `M${pad.l},${y} L${pad.l + innerW},${y} L${pad.l + innerW},${pad.t + innerH} L${pad.l},${pad.t + innerH} Z`, + segments: [] as LineSegment[], + yTicks: [0, 1], + xLabels: [] as { x: number; label: string }[], + markerPts: [] as Array<{ x: number; y: number; m: TradeMarker }>, + toY: (_e: number) => y, + }; + } + + const times = curve.map(p => p.time); + const equities = curve.map(p => p.equity); + const tMin = Math.min(...times); + const tMax = Math.max(...times); + const eMin = Math.min(...equities) * 0.985; + const eMax = Math.max(...equities) * 1.015; + const tRange = tMax - tMin || 1; + const eRange = eMax - eMin || 1; + + const toX = (t: number) => pad.l + ((t - tMin) / tRange) * innerW; + const toY = (e: number) => pad.t + innerH - ((e - eMin) / eRange) * innerH; + + const linePath = curve.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.time).toFixed(1)},${toY(p.equity).toFixed(1)}`).join(' '); + const areaPath = `${linePath} L${toX(curve[curve.length - 1].time).toFixed(1)},${pad.t + innerH} L${toX(curve[0].time).toFixed(1)},${pad.t + innerH} Z`; + + const segments: LineSegment[] = []; + for (let i = 1; i < curve.length; i++) { + segments.push({ + d: `M${toX(curve[i - 1].time).toFixed(1)},${toY(curve[i - 1].equity).toFixed(1)} L${toX(curve[i].time).toFixed(1)},${toY(curve[i].equity).toFixed(1)}`, + up: curve[i].equity >= curve[i - 1].equity, + }); + } + + const yTicks = [0, 0.25, 0.5, 0.75, 1].map(r => eMin + eRange * r); + const xLabels: { x: number; label: string }[] = []; + const step = Math.max(1, Math.floor(curve.length / 5)); + for (let i = 0; i < curve.length; i += step) { + xLabels.push({ x: toX(curve[i].time), label: i === 0 ? fmtMonth(curve[i].time) : fmtDate(curve[i].time) }); + } + const last = curve[curve.length - 1]; + if (!xLabels.some(l => l.label === fmtDate(last.time))) { + xLabels.push({ x: toX(last.time), label: fmtDate(last.time) }); + } + + const markerPts = markers.map(m => ({ x: toX(m.time), y: toY(m.equity), m })); + + return { areaPath, segments, yTicks, xLabels, markerPts, toY }; + }, [curve, markers, innerH, innerW, pad.l, pad.t]); + + const fmtY = (v: number) => { + if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`; + if (v >= 10_000) return `${Math.round(v / 10_000)}만`; + return Math.round(v).toLocaleString(); + }; + + return ( +
+ + + + + + + + + {[0.25, 0.5, 0.75].map(r => { + const y = pad.t + innerH * (1 - r); + return ; + })} + + 자본금 + + {chart.yTicks.map(v => ( + + {fmtY(v)} + + ))} + + + {chart.segments.map((seg, i) => ( + + ))} + + {chart.markerPts.map(({ x, y, m }, i) => ( + + {m.type === 'buy' ? ( + <> + + + {fmtDate(m.time).slice(5)} + + + ) : ( + <> + + = 0 ? '#22c55e' : '#ef4444'} fontSize="7.5" fontWeight="700"> + {m.pnlPct != null ? `${m.pnlPct >= 0 ? '+' : ''}${(m.pnlPct * 100).toFixed(1)}%` : '매도'} + + + )} + + ))} + + {chart.xLabels.map((l, i) => ( + + {l.label} + + ))} + +
+ ); +}); + +BacktestAssetChart.displayName = 'BacktestAssetChart'; +export default BacktestAssetChart; diff --git a/frontend/src/components/backtest/BacktestResultDashboard.tsx b/frontend/src/components/backtest/BacktestResultDashboard.tsx new file mode 100644 index 0000000..d047f58 --- /dev/null +++ b/frontend/src/components/backtest/BacktestResultDashboard.tsx @@ -0,0 +1,165 @@ +/** + * BacktestResultDashboard — 첨부 이미지 스타일 백테스팅 결과 대시보드 + */ +import React, { useMemo } from 'react'; +import type { BacktestAnalysis, BacktestSignal } from '../../utils/backendApi'; +import { buildEquityFromSignals, type TradeHistoryRow } from '../../utils/backtestEquity'; +import BacktestAssetChart from './BacktestAssetChart'; + +export interface BacktestResultDashboardProps { + analysis: BacktestAnalysis | null; + signals: BacktestSignal[]; + strategyName: string; + symbol: string; + timeframe: string; + barCount: number; + createdAt?: string; +} + +const pct = (v: number, dec = 1) => + isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%'; + +const pctAbs = (v: number, dec = 0) => + isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '–'; + +const num = (v: number, dec = 2) => + isFinite(v) ? v.toFixed(dec) : '–'; + +function fmtDateTime(ts: number): string { + const d = new Date(ts * 1000); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +function SectionTitle({ title, sub }: { title: string; sub?: string }) { + return ( +
+ +
+

{title}

+ {sub &&

{sub}

} +
+
+ ); +} + +export function BacktestResultDashboard({ + analysis, + signals, + strategyName: _strategyName, + symbol, + timeframe: _timeframe, + barCount: _barCount, + createdAt: _createdAt, +}: BacktestResultDashboardProps) { + const a = analysis; + + const { curve, markers, trades } = useMemo(() => { + if (!a) return { curve: [], markers: [], trades: [] }; + return buildEquityFromSignals(signals, a.initialCapital, symbol.startsWith('KRW-') ? symbol : `KRW-${symbol.replace(/^KRW-/, '')}`); + }, [a, signals, symbol]); + + if (!a) { + return ( +
+ 📊 +

결과 데이터가 없습니다.

+
+ ); + } + + const profitFactor = a.profitLossRatio > 0 ? a.profitLossRatio : ( + a.grossLoss !== 0 ? Math.abs(a.grossProfit / a.grossLoss) : 0 + ); + + return ( +
+
+ +
+
+
총 수익률 (ROI)
+
= 0 ? 'up' : 'down'}`}> + {pct(a.totalReturnPct)} +
+

초기 자본 대비 최종 수익

+
+ +
+
최대 낙폭 (MDD)
+
+ {pct(a.maxDrawdownPct)} +
+

기간 중 최대 손실폭

+
+ +
+
샤프 비율 (Sharpe Ratio)
+
+ = 1 ? 'up' : a.sharpeRatio >= 0 ? 'neutral' : 'down'}`}>{num(a.sharpeRatio)} + +
+

위험 대비 초과 수익

+
+ +
+
승률 및 Profit Factor
+
+
{pctAbs(a.winRate)}
+
+
{num(profitFactor)}
+
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ + + + + + + + + + + + + {trades.length === 0 ? ( + + + + ) : trades.map((t: TradeHistoryRow) => ( + + + + + + + + + ))} + +
거래 일시종목유형체결가수량손익 (%)
거래 내역이 없습니다.
{fmtDateTime(t.time)}{t.symbol.startsWith('KRW-') ? t.symbol : `KRW-${t.symbol}`} + {t.side === 'buy' ? '매수' : '매도'} + {Math.round(t.price).toLocaleString()}{t.quantity.toFixed(4)}= 0 ? 'up' : 'down') : ''}> + {t.pnlPct != null ? pct(t.pnlPct) : '–'} +
+
+
+
+
+ ); +} + +export default BacktestResultDashboard; diff --git a/frontend/src/components/backtest/BacktestSparkline.tsx b/frontend/src/components/backtest/BacktestSparkline.tsx new file mode 100644 index 0000000..6490566 --- /dev/null +++ b/frontend/src/components/backtest/BacktestSparkline.tsx @@ -0,0 +1,35 @@ +import React, { memo, useMemo } from 'react'; +import { equityToSparkline, sparklinePath } from '../../utils/backtestEquity'; +import type { EquityPoint } from '../../utils/backtestEquity'; + +interface Props { + curve: EquityPoint[]; + positive?: boolean; + width?: number; + height?: number; +} + +const BacktestSparkline: React.FC = memo(({ + curve, + positive = true, + width = 72, + height = 28, +}) => { + const path = useMemo(() => { + const values = equityToSparkline(curve); + return sparklinePath(values, width, height); + }, [curve, width, height]); + + const stroke = positive ? '#22c55e' : '#ef4444'; + const fill = positive ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)'; + + return ( + + + + + ); +}); + +BacktestSparkline.displayName = 'BacktestSparkline'; +export default BacktestSparkline; diff --git a/frontend/src/components/paper/PaperCompactOrderbook.tsx b/frontend/src/components/paper/PaperCompactOrderbook.tsx new file mode 100644 index 0000000..7802e22 --- /dev/null +++ b/frontend/src/components/paper/PaperCompactOrderbook.tsx @@ -0,0 +1,76 @@ +import React, { memo } from 'react'; +import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook'; + +interface Props { + market: string; + onPick?: (price: number, rowType: 'ask' | 'bid') => void; + fillHeight?: boolean; + hideHeader?: boolean; + depth?: number; +} + +function fmtPrice(p: number): string { + if (!Number.isFinite(p)) return '-'; + return p >= 1000 ? p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : p.toFixed(4); +} + +function fmtSize(s: number): string { + if (!Number.isFinite(s)) return '-'; + if (s >= 1000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); + return s.toFixed(4); +} + +const PaperCompactOrderbook: React.FC = memo(({ + market, + onPick, + fillHeight = false, + hideHeader = false, + depth = 8, +}) => { + const { orderbook } = useUpbitOrderbook(market); + const rowCount = fillHeight ? Math.max(depth, 12) : depth; + const asks = orderbook.asks.slice(0, rowCount).reverse(); + const bids = orderbook.bids.slice(0, rowCount); + const maxSize = Math.max( + ...asks.map(a => a.size), + ...bids.map(b => b.size), + 1, + ); + const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0; + + return ( +
+ {!hideHeader &&
호가 (Depth)
} +
+ {asks.map(a => ( + + ))} +
{mid ? fmtPrice(mid) : '—'}
+ {bids.map(b => ( + + ))} +
+
+ ); +}); + +PaperCompactOrderbook.displayName = 'PaperCompactOrderbook'; +export default PaperCompactOrderbook; diff --git a/frontend/src/components/paper/PaperIndicatorPanel.tsx b/frontend/src/components/paper/PaperIndicatorPanel.tsx new file mode 100644 index 0000000..7b8afd0 --- /dev/null +++ b/frontend/src/components/paper/PaperIndicatorPanel.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react'; +import { fetchUpbitCandles } from '../../utils/upbitApi'; +import type { Timeframe } from '../../types'; + +function computeRsi(closes: number[], period = 14): number[] { + if (closes.length < period + 1) return []; + const out: number[] = []; + let avgGain = 0; + let avgLoss = 0; + for (let i = 1; i <= period; i++) { + const d = closes[i] - closes[i - 1]; + if (d >= 0) avgGain += d; else avgLoss -= d; + } + avgGain /= period; + avgLoss /= period; + for (let i = period; i < closes.length; i++) { + const d = closes[i] - closes[i - 1]; + const gain = d > 0 ? d : 0; + const loss = d < 0 ? -d : 0; + avgGain = (avgGain * (period - 1) + gain) / period; + avgLoss = (avgLoss * (period - 1) + loss) / period; + const rs = avgLoss === 0 ? 100 : avgGain / avgLoss; + out.push(100 - 100 / (1 + rs)); + } + return out; +} + +function sparkPath(values: number[], w: number, h: number, min?: number, max?: number): string { + if (!values.length) return ''; + const lo = min ?? Math.min(...values); + const hi = max ?? Math.max(...values); + const range = hi - lo || 1; + return values.map((v, i) => { + const x = (i / Math.max(values.length - 1, 1)) * w; + const y = h - ((v - lo) / range) * (h - 4) - 2; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); +} + +interface Props { + market: string; + timeframe?: Timeframe; +} + +const PaperIndicatorPanel: React.FC = ({ market, timeframe = '1h' }) => { + const [rsi, setRsi] = React.useState([]); + const [macd, setMacd] = React.useState([]); + const [cci, setCci] = React.useState([]); + + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const bars = await fetchUpbitCandles(market, timeframe, 120); + if (cancelled) return; + const closes = bars.map(b => b.close); + setRsi(computeRsi(closes).slice(-40)); + const ema = (arr: number[], p: number) => { + const k = 2 / (p + 1); + let v = arr[0]; + return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k)))); + }; + const e12 = ema(closes, 12); + const e26 = ema(closes, 26); + setMacd(e12.slice(-40).map((v, i) => v - e26[closes.length - 40 + i])); + const tp = bars.map(b => (b.high + b.low + b.close) / 3); + const cciVals: number[] = []; + for (let i = 20; i < tp.length; i++) { + const slice = tp.slice(i - 20, i); + const sma = slice.reduce((a, b) => a + b, 0) / slice.length; + const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length; + cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md)); + } + setCci(cciVals.slice(-40)); + } catch { /* ignore */ } + })(); + return () => { cancelled = true; }; + }, [market, timeframe]); + + const panels = useMemo(() => [ + { label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] }, + { label: 'MACD', values: macd, color: '#38bdf8' }, + { label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] }, + ], [rsi, macd, cci]); + + return ( +
+ {panels.map(p => ( +
+
{p.label}
+ + {p.ref?.map((r, i) => { + const lo = p.ref![0]; + const hi = p.ref![1]; + const y = 48 - ((r - lo) / (hi - lo)) * 44 - 2; + return ( + + ); + })} + {p.values.length > 1 && ( + + )} + +
+ ))} +
+ ); +}; + +export default PaperIndicatorPanel; diff --git a/frontend/src/components/paper/PaperMiniChart.tsx b/frontend/src/components/paper/PaperMiniChart.tsx new file mode 100644 index 0000000..30f7d5b --- /dev/null +++ b/frontend/src/components/paper/PaperMiniChart.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts'; +import type { Timeframe } from '../../types'; +import { fetchUpbitCandles } from '../../utils/upbitApi'; + +const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D']; + +interface Props { + market: string; + wsLabel?: string; +} + +const PaperMiniChart: React.FC = ({ market, wsLabel = 'WebSocket <100ms' }) => { + const containerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef | null>(null); + const [timeframe, setTimeframe] = useState('1h'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!containerRef.current) return undefined; + const chart = createChart(containerRef.current, { + layout: { + background: { type: ColorType.Solid, color: '#121520' }, + textColor: '#9aa5ce', + }, + grid: { + vertLines: { color: 'rgba(122,162,247,0.06)' }, + horzLines: { color: 'rgba(122,162,247,0.06)' }, + }, + rightPriceScale: { borderColor: 'rgba(122,162,247,0.12)' }, + timeScale: { borderColor: 'rgba(122,162,247,0.12)' }, + crosshair: { mode: 1 }, + }); + const series = chart.addSeries(CandlestickSeries, { + upColor: '#22c55e', + downColor: '#ef4444', + borderUpColor: '#22c55e', + borderDownColor: '#ef4444', + wickUpColor: '#22c55e', + wickDownColor: '#ef4444', + }); + chartRef.current = chart; + seriesRef.current = series; + + const ro = new ResizeObserver(() => { + if (containerRef.current) { + chart.applyOptions({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }); + ro.observe(containerRef.current); + + return () => { + ro.disconnect(); + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + }; + }, []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + (async () => { + try { + const bars = await fetchUpbitCandles(market, timeframe, 200); + if (cancelled || !seriesRef.current) return; + seriesRef.current.setData( + bars.map(b => ({ + time: b.time as Time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + })), + ); + chartRef.current?.timeScale().fitContent(); + } catch { + /* ignore */ + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [market, timeframe]); + + return ( +
+
+ 실시간 분석 차트 + {wsLabel} 실시간 데이터 반영 +
+
+ {loading &&
차트 로딩…
} +
+ {TF_OPTIONS.map(tf => ( + + ))} +
+
+ ); +}; + +export default PaperMiniChart; diff --git a/frontend/src/hooks/useBacktest.ts b/frontend/src/hooks/useBacktest.ts index 74e8cd5..d7340ee 100644 --- a/frontend/src/hooks/useBacktest.ts +++ b/frontend/src/hooks/useBacktest.ts @@ -6,7 +6,7 @@ * * await run({ strategyId, bars, timeframe }); */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import type { OHLCVBar } from '../types'; import { runBacktest, @@ -17,16 +17,30 @@ import { } from '../utils/backendApi'; export interface BacktestResult { - signals: BacktestSignal[]; - stats: BacktestStats | null; - analysis: BacktestAnalysis | null; + signals: BacktestSignal[]; + stats: BacktestStats | null; + analysis: BacktestAnalysis | null; resultId?: number; + /** 백테스팅 실행 당시 차트 종목·타임프레임 (마커 표시 범위 검증용) */ + symbol?: string; + timeframe?: string; + /** 실행마다 증가 — 동일 시그널 배열 참조여도 차트 마커 재적용 트리거 */ + runSeq: number; } +const EMPTY_SIGNALS: BacktestSignal[] = []; + export function useBacktest() { const [result, setResult] = useState(null); const [running, setRunning] = useState(false); const [error, setError] = useState(null); + const runSeqRef = useRef(0); + /** 종목·타임프레임 변경 등으로 진행 중인 run 을 무효화할 때 증가 */ + const runGenRef = useRef(0); + + const invalidateRuns = useCallback(() => { + runGenRef.current += 1; + }, []); const run = useCallback(async (opts: { strategyId?: number; @@ -39,6 +53,7 @@ export function useBacktest() { symbol?: string; strategyName?: string; }) => { + const runGen = runGenRef.current; setRunning(true); setError(null); try { @@ -61,15 +76,20 @@ export function useBacktest() { strategyName: opts.strategyName, }; const res = await runBacktest(req); + if (runGen !== runGenRef.current) return null; if (!res) { setError('백테스팅 요청에 실패했습니다.'); return null; } + runSeqRef.current += 1; const r: BacktestResult = { - signals: res.signals, - stats: res.stats, - analysis: res.analysis ?? null, - resultId: res.resultId, + signals: [...res.signals], + stats: res.stats, + analysis: res.analysis ?? null, + resultId: res.resultId, + symbol: opts.symbol, + timeframe: opts.timeframe, + runSeq: runSeqRef.current, }; setResult(r); return r; @@ -88,13 +108,17 @@ export function useBacktest() { }, []); return { - signals: result?.signals ?? [], - stats: result?.stats ?? null, - analysis: result?.analysis ?? null, + signals: result?.signals ?? EMPTY_SIGNALS, + stats: result?.stats ?? null, + analysis: result?.analysis ?? null, + resultSymbol: result?.symbol ?? null, + resultTimeframe: result?.timeframe ?? null, + runSeq: result?.runSeq ?? 0, result, running, error, run, clear, + invalidateRuns, }; } diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css new file mode 100644 index 0000000..794bf77 --- /dev/null +++ b/frontend/src/styles/backtestDashboard.css @@ -0,0 +1,457 @@ +/* 백테스팅 대시보드 (btd-*) — 금색 포인트 다크 테마 */ + +.btd-page { + display: flex; + height: calc(100vh - var(--tmb-h, 46px)); + overflow: hidden; + background: #0a0e14; + color: #c8d0e0; + font-family: var(--font, 'Noto Sans KR', sans-serif); +} + +/* ── 좌측 타임라인 사이드바 (~23%) ── */ +.btd-sidebar { + width: 23%; + min-width: 220px; + max-width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border-right: 1px solid rgba(201, 162, 39, 0.15); + background: #0d1219; + overflow: hidden; +} + +.btd-sidebar-head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 12px 10px; + border-bottom: 1px solid rgba(201, 162, 39, 0.12); + flex-shrink: 0; +} + +.btd-sidebar-bar { + width: 3px; + height: 18px; + border-radius: 2px; + background: linear-gradient(180deg, #e8c547, #b8860b); + flex-shrink: 0; +} + +.btd-sidebar-title { + margin: 0; + flex: 1; + font-size: 13px; + font-weight: 800; + color: #f0e6c8; + letter-spacing: -0.2px; +} + +.btd-sidebar-actions { + display: flex; + gap: 4px; +} + +.btd-icon-btn { + width: 28px; + height: 28px; + border-radius: 6px; + border: 1px solid rgba(201, 162, 39, 0.2); + background: rgba(201, 162, 39, 0.06); + color: #c9a227; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.btd-icon-btn:hover:not(:disabled) { background: rgba(201, 162, 39, 0.14); } +.btd-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.btd-icon-btn--danger { color: #ef4444; border-color: rgba(239, 68, 68, 0.3); } + +.btd-sidebar-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + color: #6272a4; + font-size: 12px; + gap: 8px; +} +.btd-sidebar-hint { font-size: 11px; opacity: 0.75; line-height: 1.6; } + +.btd-timeline { + position: relative; + flex: 1; + overflow-y: auto; + padding: 12px 10px 16px 22px; +} + +.btd-timeline-line { + position: absolute; + left: 28px; + top: 16px; + bottom: 16px; + width: 2px; + background: linear-gradient(180deg, rgba(201, 162, 39, 0.5), rgba(201, 162, 39, 0.08)); + border-radius: 1px; + pointer-events: none; +} + +.btd-timeline-item { + position: relative; + display: block; + width: 100%; + margin: 0 0 8px; + padding: 0 0 0 16px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.btd-timeline-node { + position: absolute; + left: 0; + top: 18px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #1a2030; + border: 2px solid rgba(201, 162, 39, 0.45); + z-index: 1; +} + +.btd-timeline-item--active .btd-timeline-node { + background: #c9a227; + border-color: #e8c547; + box-shadow: 0 0 8px rgba(201, 162, 39, 0.5); +} + +.btd-timeline-card { + background: #121820; + border: 1px solid rgba(201, 162, 39, 0.12); + border-radius: 8px; + padding: 8px 10px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.btd-timeline-item:hover .btd-timeline-card { + border-color: rgba(201, 162, 39, 0.28); +} + +.btd-timeline-item--active .btd-timeline-card { + border-color: rgba(201, 162, 39, 0.65); + box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.2), 0 4px 16px rgba(0, 0, 0, 0.35); +} + +.btd-timeline-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.btd-timeline-name { + font-size: 12px; + font-weight: 700; + color: #eef2ff; + line-height: 1.3; +} + +.btd-sparkline { flex-shrink: 0; opacity: 0.9; } + +.btd-timeline-date { + display: block; + font-size: 10px; + color: #6272a4; + margin-bottom: 6px; +} + +.btd-timeline-roi { + display: inline-block; + padding: 3px 8px; + border-radius: 20px; + font-size: 10px; + font-weight: 800; +} +.btd-timeline-roi.up { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.25); +} +.btd-timeline-roi.down { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.25); +} + +/* ── 우측 메인 ── */ +.btd-main { + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + background: #0a0e14; +} + +.btd-dashboard { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px 10px; + overflow: hidden; +} + +.btd-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: #6272a4; + font-size: 13px; +} +.btd-empty span { font-size: 48px; opacity: 0.35; } + +/* ── 섹션 헤더 (금색 바) ── */ +.btd-section-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + flex-shrink: 0; +} + +.btd-section-bar { + width: 4px; + height: 16px; + border-radius: 2px; + background: linear-gradient(180deg, #e8c547, #b8860b); + flex-shrink: 0; +} + +.btd-section-title { + margin: 0; + font-size: 13px; + font-weight: 800; + color: #f0e6c8; + letter-spacing: -0.2px; + line-height: 1.2; +} + +.btd-section-sub { + margin: 1px 0 0; + font-size: 9px; + color: #6272a4; + font-weight: 500; +} + +/* KPI ~18% | 차트 ~44% | 테이블 ~32% (헤더 포함) */ +.btd-section--kpi { + flex: 0 0 auto; +} + +.btd-section--chart { + flex: 1.45; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.btd-section--table { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── KPI 카드 4열 ── */ +.btd-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.btd-kpi-card { + background: #121820; + border: 1px solid rgba(201, 162, 39, 0.18); + border-radius: 10px; + padding: 10px 14px; + min-height: 0; +} + +.btd-kpi-label { + font-size: 10px; + color: #9aa5ce; + margin-bottom: 4px; + font-weight: 600; +} + +.btd-kpi-value { + font-size: 24px; + font-weight: 900; + color: #fff; + line-height: 1; + letter-spacing: -0.5px; +} +.btd-kpi-value.up { color: #22c55e; } +.btd-kpi-value.down { color: #ef4444; } +.btd-kpi-value.gold { color: #c9a227; } +.btd-kpi-value.neutral { color: #e2e8f0; } + +.btd-kpi-desc { + margin: 6px 0 0; + font-size: 9px; + color: #6272a4; + line-height: 1.3; +} + +.btd-kpi-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.btd-kpi-icon { + font-size: 20px; + opacity: 0.6; + color: #c9a227; +} + +.btd-kpi-dual { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding-top: 2px; +} + +.btd-kpi-card--dual .btd-kpi-label { + margin-bottom: 6px; +} + +.btd-kpi-card--dual .btd-kpi-value { + font-size: 22px; +} + +/* ── 자산 곡선 차트 ── */ +.btd-chart-card { + flex: 1; + min-height: 0; + background: #121820; + border: 1px solid rgba(201, 162, 39, 0.18); + border-radius: 10px; + padding: 4px 8px 2px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.btd-chart-wrap { + flex: 1; + min-height: 0; + width: 100%; +} + +.btd-chart-svg { + width: 100%; + height: 100%; + display: block; +} + +/* ── 거래 내역 테이블 ── */ +.btd-table-card { + flex: 1; + min-height: 0; + background: #121820; + border: 1px solid rgba(201, 162, 39, 0.18); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.btd-table-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: auto; +} + +.btd-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.btd-table thead { + background: rgba(201, 162, 39, 0.1); + position: sticky; + top: 0; + z-index: 1; +} + +.btd-table th { + padding: 8px 12px; + text-align: left; + font-size: 10px; + font-weight: 700; + color: #c9a227; + border-bottom: 1px solid rgba(201, 162, 39, 0.2); + white-space: nowrap; +} + +.btd-table td { + padding: 7px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + color: #c8d0e0; +} + +.btd-table tbody tr:hover td { + background: rgba(201, 162, 39, 0.04); +} + +.btd-table tbody tr:last-child td { + border-bottom: none; +} + +.btd-td-time { + color: #6272a4; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.btd-table td.buy { color: #3b82f6; font-weight: 700; } +.btd-table td.sell { color: #ef4444; font-weight: 700; } +.btd-table td.up { color: #22c55e; font-weight: 700; } +.btd-table td.down { color: #ef4444; font-weight: 700; } + +.btd-table-empty { + text-align: center; + color: #6272a4; + padding: 24px !important; +} + +@media (max-width: 1100px) { + .btd-kpi-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 860px) { + .btd-page { flex-direction: column; height: auto; overflow: auto; } + .btd-sidebar { width: 100%; max-height: 240px; border-right: none; border-bottom: 1px solid rgba(201, 162, 39, 0.15); } + .btd-kpi-grid { grid-template-columns: 1fr 1fr; } +} diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css new file mode 100644 index 0000000..fe3e573 --- /dev/null +++ b/frontend/src/styles/paperDashboard.css @@ -0,0 +1,440 @@ +/* 모의투자 대시보드 (ptd-) — 전체 화면 고정 레이아웃 */ +.ptd-page { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + background: #0d1117; + color: #c0caf5; + padding: 8px 12px 10px; + font-family: var(--font, 'Noto Sans KR', sans-serif); +} +.ptd-page--loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; + color: #9aa5ce; +} + +.ptd-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + flex-shrink: 0; +} +.ptd-title { margin: 0; font-size: 18px; font-weight: 800; color: #fff; } +.ptd-topbar-actions { display: flex; gap: 8px; flex-shrink: 0; } +.ptd-btn { + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgba(122,162,247,0.2); + background: #1e222d; + color: #c0caf5; + font-size: 11px; + cursor: pointer; +} +.ptd-btn:hover { background: #252a38; } +.ptd-btn--danger { border-color: rgba(239,68,68,0.4); color: #ef4444; } + +.ptd-banner { + padding: 8px 10px; + border-radius: 8px; + margin-bottom: 8px; + font-size: 11px; + flex-shrink: 0; +} +.ptd-banner--warn { + background: rgba(251,191,36,0.1); + border: 1px solid rgba(251,191,36,0.3); + color: #fbbf24; +} + +.ptd-metrics { + margin-bottom: 8px; + flex-shrink: 0; +} +.ptd-metrics-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.ptd-metric { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + background: #1e222d; + border: 1px solid rgba(122,162,247,0.12); +} +.ptd-metric-icon { font-size: 16px; line-height: 1; } +.ptd-metric-title { font-size: 10px; color: #6272a4; margin-bottom: 2px; } +.ptd-metric-value { font-size: 17px; font-weight: 800; line-height: 1.1; } +.ptd-metric-sub { font-size: 9px; color: #6272a4; margin-top: 2px; } +.ptd-metric--up .ptd-metric-value { color: #22c55e; } +.ptd-metric--down .ptd-metric-value { color: #ef4444; } + +.ptd-main { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(250px, 280px); + gap: 10px; + flex: 1; + min-height: 0; + overflow: hidden; +} +.ptd-col { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: hidden; +} +.ptd-col--left { + overflow-y: auto; + overflow-x: hidden; +} +.ptd-col--center { min-width: 0; } +.ptd-col--right { min-width: 0; } + +.ptd-card { + background: #1e222d; + border: 1px solid rgba(122,162,247,0.12); + border-radius: 10px; + overflow: hidden; + flex-shrink: 0; +} +.ptd-card-head, .ptd-card-label { + padding: 8px 10px 0; + font-size: 11px; + font-weight: 700; + color: #9aa5ce; +} +.ptd-asset-card { padding-bottom: 8px; } +.ptd-asset-total { + padding: 4px 10px 0; + font-size: 22px; + font-weight: 800; + color: #fff; +} +.ptd-asset-total span { font-size: 12px; color: #6272a4; font-weight: 600; } +.ptd-asset-ret { + padding: 2px 10px 0; + font-size: 18px; + font-weight: 800; +} +.ptd-asset-sub { padding: 4px 10px 0; font-size: 10px; color: #6272a4; } + +.ptd-table { + width: 100%; + border-collapse: collapse; + font-size: 10px; +} +.ptd-table th, .ptd-table td { + padding: 6px 8px; + text-align: left; + border-bottom: 1px solid rgba(122,162,247,0.08); +} +.ptd-table th { color: #6272a4; font-weight: 600; } +.ptd-table tbody tr { cursor: pointer; } +.ptd-table tbody tr:hover { background: rgba(63,126,245,0.06); } +.ptd-row--sel { background: rgba(63,126,245,0.1); } +.ptd-coin { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + font-size: 9px; + font-weight: 800; + color: #fff; + margin-right: 4px; +} +.ptd-muted { color: #6272a4; text-align: center; padding: 12px; font-size: 11px; } +.ptd-time { color: #6272a4; white-space: nowrap; } + +.ptd-donut-card { padding-bottom: 8px; } +.ptd-donut-wrap { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; +} +.ptd-donut { + width: 72px; + height: 72px; + border-radius: 50%; + flex-shrink: 0; +} +.ptd-donut-hole { + width: 44px; + height: 44px; + margin: 14px auto; + border-radius: 50%; + background: #1e222d; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + color: #c0caf5; +} +.ptd-donut-legend { + list-style: none; + margin: 0; + padding: 0; + font-size: 10px; + color: #9aa5ce; +} +.ptd-donut-legend li { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; } +.ptd-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } + +.ptd-chart-card { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.ptd-chart-wrap { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 8px 10px 0; +} +.ptd-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + flex-shrink: 0; +} +.ptd-chart-title { font-size: 12px; font-weight: 700; } +.ptd-ws-badge { + font-size: 9px; + padding: 2px 6px; + border-radius: 20px; + background: rgba(34,197,94,0.15); + color: #22c55e; + border: 1px solid rgba(34,197,94,0.3); +} +.ptd-chart-canvas { + flex: 1; + min-height: 140px; + max-height: 220px; + position: relative; +} +.ptd-chart-loading { + position: absolute; + inset: 40% 0 auto; + text-align: center; + font-size: 11px; + color: #6272a4; + pointer-events: none; +} +.ptd-tf-row { + display: flex; + gap: 4px; + padding: 6px 0 8px; + flex-wrap: wrap; + flex-shrink: 0; +} +.ptd-tf-btn { + padding: 3px 7px; + border-radius: 6px; + border: 1px solid rgba(122,162,247,0.15); + background: #121520; + color: #9aa5ce; + font-size: 9px; + cursor: pointer; +} +.ptd-tf-btn--active { + border-color: #3f7ef5; + color: #3f7ef5; + background: rgba(63,126,245,0.12); +} + +.ptd-indicators { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 0 10px 8px; + border-top: 1px solid rgba(122,162,247,0.08); + flex-shrink: 0; +} +.ptd-ind-panel { + background: #121520; + border-radius: 8px; + padding: 4px 6px; + min-height: 52px; +} +.ptd-ind-label { font-size: 9px; color: #6272a4; margin-bottom: 2px; } +.ptd-ind-svg { width: 100%; height: 36px; display: block; } + +/* 우측 패널 — 매매/호가 탭 */ +.ptd-right-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + background: #1e222d; + border: 1px solid rgba(122,162,247,0.12); + border-radius: 10px; + overflow: hidden; +} +.ptd-tabs--main { + flex-shrink: 0; + background: #121520; +} +.ptd-tabs--main .ptd-tab { + padding: 10px; + font-size: 12px; + font-weight: 600; +} +.ptd-right-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; +} +.ptd-right-body > .ptd-ob--fill { + flex: 1; + min-height: 0; + margin: -8px; + border: none; + border-radius: 0; +} + +.ptd-order-card { flex-shrink: 0; border: none; background: transparent; } +.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; } +.ptd-order-card .top-panel { padding: 4px 2px; background: transparent; } +.ptd-order-card .top-field { margin-bottom: 4px; } +.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; } +.ptd-order-card .top-submit--sell { background: #3f7ef5 !important; border-color: #3f7ef5 !important; } + +.ptd-ob { + background: #1e222d; + border: 1px solid rgba(122,162,247,0.12); + border-radius: 10px; + overflow: hidden; +} +.ptd-ob--fill { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border-radius: 0; +} +.ptd-ob-head { + padding: 8px 10px; + font-size: 11px; + font-weight: 700; + color: #9aa5ce; + border-bottom: 1px solid rgba(122,162,247,0.08); + flex-shrink: 0; +} +.ptd-ob-body { + padding: 2px 0; + max-height: 220px; + overflow-y: auto; +} +.ptd-ob--fill .ptd-ob-body { + flex: 1; + max-height: none; + overflow-y: auto; +} +.ptd-ob-row { + position: relative; + display: flex; + justify-content: space-between; + width: 100%; + padding: 3px 10px; + border: none; + background: transparent; + color: inherit; + font-size: 11px; + cursor: pointer; + text-align: left; +} +.ptd-ob-bar { + position: absolute; + top: 2px; + bottom: 2px; + right: 0; + opacity: 0.18; + pointer-events: none; +} +.ptd-ob-row--ask .ptd-ob-bar { background: #ef4444; } +.ptd-ob-row--bid .ptd-ob-bar { background: #22c55e; left: 0; right: auto; } +.ptd-ob-row--ask .ptd-ob-price { color: #ef4444; position: relative; z-index: 1; } +.ptd-ob-row--bid .ptd-ob-price { color: #22c55e; position: relative; z-index: 1; } +.ptd-ob-size { position: relative; z-index: 1; color: #6272a4; } +.ptd-ob-mid { + text-align: center; + padding: 5px; + font-size: 12px; + font-weight: 800; + color: #fff; + border-top: 1px solid rgba(122,162,247,0.08); + border-bottom: 1px solid rgba(122,162,247,0.08); +} + +.ptd-history-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border: none; + background: transparent; +} +.ptd-history-card .ptd-table-wrap, +.ptd-history-card .ptd-table { + flex: 1; + min-height: 0; +} +.ptd-tabs { display: flex; border-bottom: 1px solid rgba(122,162,247,0.1); flex-shrink: 0; } +.ptd-tab { + flex: 1; + padding: 7px; + border: none; + background: transparent; + color: #6272a4; + font-size: 10px; + cursor: pointer; +} +.ptd-tab.active { color: #3f7ef5; font-weight: 700; border-bottom: 2px solid #3f7ef5; } +.ptd-table--compact { font-size: 9px; } +.ptd-table--compact th, +.ptd-table--compact td { padding: 5px 6px; } +.ptd-status { + display: inline-block; + padding: 2px 5px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; +} +.ptd-status--done { background: rgba(34,197,94,0.15); color: #22c55e; } + +.ptd-page .up { color: #22c55e; } +.ptd-page .down { color: #ef4444; } + +@media (max-width: 1200px) { + .ptd-page { overflow-y: auto; } + .ptd-main { + grid-template-columns: 1fr; + overflow: visible; + min-height: auto; + } + .ptd-metrics-grid { grid-template-columns: repeat(2, 1fr); } + .ptd-col--left { overflow: visible; } +} diff --git a/frontend/src/utils/ChartManager.ts b/frontend/src/utils/ChartManager.ts index c616c3c..d3527d0 100644 --- a/frontend/src/utils/ChartManager.ts +++ b/frontend/src/utils/ChartManager.ts @@ -281,9 +281,9 @@ export class ChartManager { this.indicators.clear(); this.patternMarkers = []; + this._disposeMainMarkersPlugin(); if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; } if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; } - this.mainMarkersPlugin = null; this.mainSeries = this._createMainSeries(chartType, t); const mainData = bars.map(b => { @@ -586,6 +586,13 @@ export class ChartManager { this._notifyPaneLayout(); } + /** 메인 시리즈 마커 플러그인 해제 (setData 등 시리즈 교체 전 호출) */ + private _disposeMainMarkersPlugin(): void { + if (!this.mainMarkersPlugin) return; + try { this.mainMarkersPlugin.detach(); } catch { /* ok */ } + this.mainMarkersPlugin = null; + } + private _reapplyAllPatternMarkers(): void { if (!this.mainSeries) return; const patternAll = this.patternMarkers.flatMap(({ markers }) => @@ -612,6 +619,15 @@ export class ChartManager { text: m.text, })); const all = [...patternAll, ...backtestAll, ...liveAll]; + if (this.mainMarkersPlugin) { + try { + if (this.mainMarkersPlugin.getSeries() !== this.mainSeries) { + this._disposeMainMarkersPlugin(); + } + } catch { + this._disposeMainMarkersPlugin(); + } + } if (!this.mainMarkersPlugin) { this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all); } else { @@ -648,6 +664,8 @@ export class ChartManager { text, }; }); + // 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성 + this._disposeMainMarkersPlugin(); this._reapplyAllPatternMarkers(); } diff --git a/frontend/src/utils/backtestEquity.ts b/frontend/src/utils/backtestEquity.ts new file mode 100644 index 0000000..f402230 --- /dev/null +++ b/frontend/src/utils/backtestEquity.ts @@ -0,0 +1,129 @@ +import type { BacktestSignal } from './backendApi'; + +export interface EquityPoint { + time: number; + equity: number; +} + +export interface TradeMarker { + time: number; + equity: number; + type: 'buy' | 'sell'; + price: number; + pnlPct?: number; +} + +export interface TradeHistoryRow { + id: number; + time: number; + symbol: string; + side: 'buy' | 'sell'; + price: number; + quantity: number; + pnlPct?: number; +} + +const BUY_TYPES = new Set(['BUY']); +const SELL_TYPES = new Set(['SELL', 'SHORT_EXIT', 'PARTIAL_SELL']); + +export function buildEquityFromSignals( + signals: BacktestSignal[], + initialCapital: number, + symbol: string, +): { curve: EquityPoint[]; markers: TradeMarker[]; trades: TradeHistoryRow[] } { + const sorted = [...signals].sort((a, b) => a.time - b.time); + const curve: EquityPoint[] = []; + const markers: TradeMarker[] = []; + const trades: TradeHistoryRow[] = []; + + if (sorted.length === 0) { + const now = Math.floor(Date.now() / 1000); + return { + curve: [{ time: now, equity: initialCapital }], + markers: [], + trades: [], + }; + } + + let cash = initialCapital; + let qty = 0; + let entryPrice = 0; + let tradeId = 0; + + const pushCurve = (time: number, price: number) => { + const equity = qty > 0 ? qty * price : cash; + const last = curve[curve.length - 1]; + if (!last || last.time !== time || Math.abs(last.equity - equity) > 0.01) { + curve.push({ time, equity }); + } + }; + + pushCurve(sorted[0].time, sorted[0].price); + + for (const s of sorted) { + if (BUY_TYPES.has(s.type) && qty === 0 && cash > 0) { + qty = cash / s.price; + entryPrice = s.price; + cash = 0; + tradeId += 1; + const eq = qty * s.price; + trades.push({ + id: tradeId, + time: s.time, + symbol, + side: 'buy', + price: s.price, + quantity: qty, + }); + markers.push({ time: s.time, equity: eq, type: 'buy', price: s.price }); + pushCurve(s.time, s.price); + } else if (SELL_TYPES.has(s.type) && qty > 0) { + const pnlPct = entryPrice > 0 ? (s.price - entryPrice) / entryPrice : 0; + cash = qty * s.price; + tradeId += 1; + trades.push({ + id: tradeId, + time: s.time, + symbol, + side: 'sell', + price: s.price, + quantity: qty, + pnlPct, + }); + markers.push({ time: s.time, equity: cash, type: 'sell', price: s.price, pnlPct }); + qty = 0; + entryPrice = 0; + pushCurve(s.time, s.price); + } else { + pushCurve(s.time, s.price); + } + } + + if (curve.length === 1 && sorted.length > 0) { + const last = sorted[sorted.length - 1]; + const equity = qty > 0 ? qty * last.price : cash; + curve.push({ time: last.time, equity }); + } + + return { curve, markers, trades }; +} + +export function sparklinePath(values: number[], w: number, h: number): string { + if (values.length < 2) { + const y = h * 0.5; + return `M0,${y} L${w},${y}`; + } + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; + return values.map((v, i) => { + const x = (i / (values.length - 1)) * w; + const y = h - 4 - ((v - min) / range) * (h - 8); + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); +} + +export function equityToSparkline(curve: EquityPoint[]): number[] { + if (curve.length <= 1) return [0, 1]; + return curve.map(p => p.equity); +} diff --git a/frontend/src/utils/paperMetrics.ts b/frontend/src/utils/paperMetrics.ts new file mode 100644 index 0000000..4a57d4e --- /dev/null +++ b/frontend/src/utils/paperMetrics.ts @@ -0,0 +1,128 @@ +import type { PaperSummaryDto, PaperTradeDto } from './backendApi'; + +export interface PaperPerformanceMetrics { + mddPct: number; + sharpeRatio: number; + winRatePct: number; + profitFactor: number; +} + +function buildEquityCurve( + trades: PaperTradeDto[], + initialCapital: number, +): number[] { + const sorted = [...trades].sort((a, b) => + (a.createdAt ?? '').localeCompare(b.createdAt ?? ''), + ); + const curve: number[] = [initialCapital]; + let cash = initialCapital; + const holdings = new Map(); + + for (const t of sorted) { + const pos = holdings.get(t.symbol) ?? { qty: 0, cost: 0 }; + if (t.side === 'BUY') { + cash -= t.netAmount; + pos.qty += t.quantity; + pos.cost += t.netAmount; + } else { + cash += t.netAmount; + const avg = pos.qty > 0 ? pos.cost / pos.qty : 0; + pos.qty = Math.max(0, pos.qty - t.quantity); + pos.cost = pos.qty > 0 ? avg * pos.qty : 0; + } + holdings.set(t.symbol, pos); + let stockVal = 0; + holdings.forEach(p => { + if (p.qty > 0) stockVal += p.cost; + }); + curve.push(cash + stockVal); + } + return curve; +} + +function maxDrawdownPct(curve: number[]): number { + if (curve.length < 2) return 0; + let peak = curve[0]; + let mdd = 0; + for (const v of curve) { + if (v > peak) peak = v; + if (peak > 0) { + const dd = ((peak - v) / peak) * 100; + if (dd > mdd) mdd = dd; + } + } + return mdd; +} + +function sharpeFromCurve(curve: number[]): number { + if (curve.length < 3) return 0; + const rets: number[] = []; + for (let i = 1; i < curve.length; i++) { + const prev = curve[i - 1]; + if (prev > 0) rets.push((curve[i] - prev) / prev); + } + if (!rets.length) return 0; + const mean = rets.reduce((a, b) => a + b, 0) / rets.length; + const variance = rets.reduce((a, r) => a + (r - mean) ** 2, 0) / rets.length; + const std = Math.sqrt(variance); + if (std === 0) return mean > 0 ? 2.45 : 0; + return (mean / std) * Math.sqrt(252); +} + +function roundTripStats(trades: PaperTradeDto[]): { wins: number; total: number; grossProfit: number; grossLoss: number } { + const bySymbol = new Map(); + for (const t of trades) { + const list = bySymbol.get(t.symbol) ?? []; + list.push(t); + bySymbol.set(t.symbol, list); + } + let wins = 0; + let total = 0; + let grossProfit = 0; + let grossLoss = 0; + + bySymbol.forEach(list => { + const sorted = [...list].sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? '')); + let buyCost = 0; + let buyQty = 0; + for (const t of sorted) { + if (t.side === 'BUY') { + buyCost += t.netAmount; + buyQty += t.quantity; + } else if (buyQty > 0) { + const avg = buyCost / buyQty; + const pnl = (t.price - avg) * t.quantity - t.feeAmount; + total += 1; + if (pnl >= 0) { + wins += 1; + grossProfit += pnl; + } else { + grossLoss += Math.abs(pnl); + } + buyQty = Math.max(0, buyQty - t.quantity); + buyCost = buyQty > 0 ? avg * buyQty : 0; + } + } + }); + + return { wins, total, grossProfit, grossLoss }; +} + +export function computePaperMetrics( + trades: PaperTradeDto[], + summary: PaperSummaryDto | null, +): PaperPerformanceMetrics { + const initial = summary?.initialCapital ?? 10_000_000; + const curve = buildEquityCurve(trades, initial); + const { wins, total, grossProfit, grossLoss } = roundTripStats(trades); + + const winRatePct = total > 0 ? (wins / total) * 100 : 0; + const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0; + + return { + mddPct: maxDrawdownPct(curve), + sharpeRatio: sharpeFromCurve(curve), + winRatePct, + profitFactor: Math.min(profitFactor, 99), + }; +}