/** * BacktestHistoryPage — Golden Analysis Command Center (참조 UI 3열) */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { loadBacktestResults, deleteBacktestResult, loadPaperTrades, loadPaperSummary, loadStrategies, type BacktestResultRecord, type BacktestAnalysis, type BacktestSignal, type StrategyDto, } from '../utils/backendApi'; import type { Theme } from '../types'; import { buildEquityFromSignals } from '../utils/backtestEquity'; import { formatChartTypeKo, formatTimeframeKo, normalizeEpochSec, normalizeEpochSecOptional, toUpbitMarket, } from '../utils/backtestUiUtils'; import BacktestExecutionList, { type ExecutionListTab } from './backtest/BacktestExecutionList'; import BacktestAnalysisChart from './backtest/BacktestAnalysisChart'; import BacktestKpiPanel from './backtest/BacktestKpiPanel'; import BacktestTradeHistoryCard from './backtest/BacktestTradeHistoryCard'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import { buildBacktestReportModel, buildLiveReportModel, } from '../utils/backtestReportModel'; import { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups'; import { PAPER_TRADES_CHANGED_EVENT } from '../utils/paperTradeEvents'; import { readStoredSize, storeSize, usePanelResize, } from './strategyEditor/usePanelResize'; import '../styles/strategyEditorTheme.css'; import { getKoreanName } from '../utils/marketNameCache'; import { repairUtf8Mojibake } from '../utils/textEncoding'; import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyNameResolver'; const LEFT_KEY = 'btd-left-width'; const RIGHT_KEY = 'btd-right-width'; const LEFT_DEFAULT = 380; const RIGHT_DEFAULT = 440; function readMinWidth(key: string, fallback: number): number { return Math.max(readStoredSize(key, fallback), fallback); } 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 }; } interface Props { theme?: Theme; } export function BacktestHistoryPage({ theme = 'dark' }: Props) { const [tab, setTab] = useState('backtest'); const [records, setRecords] = useState([]); const [liveItems, setLiveItems] = useState([]); const [selectedBacktest, setSelectedBacktest] = useState(null); const [selectedLive, setSelectedLive] = useState(null); const [loading, setLoading] = useState(true); const [reportOpen, setReportOpen] = useState(false); const [strategies, setStrategies] = useState([]); const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT)); const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT)); const leftRef = useRef(leftWidth); const rightRef = useRef(rightWidth); leftRef.current = leftWidth; rightRef.current = rightWidth; const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v)); const onRightSplit = useCallback((e: React.PointerEvent) => { const startX = e.clientX; const start = rightRef.current; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.classList.add('btd-splitter--active'); const splitter = e.currentTarget; const onMove = (ev: PointerEvent) => { const delta = ev.clientX - startX; setRightWidth(Math.min(600, Math.max(360, start - delta))); }; const onUp = (ev: PointerEvent) => { document.body.style.cursor = ''; document.body.style.userSelect = ''; splitter.releasePointerCapture(ev.pointerId); splitter.classList.remove('btd-splitter--active'); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); storeSize(RIGHT_KEY, rightRef.current); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); }, []); const refreshLiveTrades = useCallback(async () => { const [trades, summary, strategies] = await Promise.all([ loadPaperTrades(), loadPaperSummary(), loadStrategies(), ]); const baseNames = strategyNamesFromList(strategies); const strategyNames = await enrichStrategyNameMap( baseNames, trades.map(t => t.strategyId), ); const live = buildLiveExecutionItems(trades, summary, strategyNames); setLiveItems(live); setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null)); }, []); const fetchAll = useCallback(async () => { setLoading(true); const [list, trades, summary, strategies] = await Promise.all([ loadBacktestResults(), loadPaperTrades(), loadPaperSummary(), loadStrategies(), ]); const baseNames = strategyNamesFromList(strategies); const strategyNames = await enrichStrategyNameMap( baseNames, [ ...trades.map(t => t.strategyId), ...list.map(r => r.strategyId), ], ); const live = buildLiveExecutionItems(trades, summary, strategyNames); const enrichedRecords = list.map(r => ({ ...r, strategyName: r.strategyName?.trim() || (r.strategyId != null ? strategyNames[r.strategyId] : undefined) || r.strategyName || '전략 없음', })); setStrategies(strategies); setRecords(enrichedRecords); setLiveItems(live); setSelectedBacktest(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null)); setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null)); setLoading(false); }, []); useEffect(() => { void fetchAll(); }, [fetchAll]); useEffect(() => { const onTradesChanged = () => { void refreshLiveTrades(); }; window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onTradesChanged); }, [refreshLiveTrades]); const deleteAll = useCallback(async () => { if (records.length === 0) return; if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return; await Promise.all(records.map(r => deleteBacktestResult(r.id))); await fetchAll(); }, [records, fetchAll]); const backtestDetail = selectedBacktest ? parseDetail(selectedBacktest) : null; const activeSignals = useMemo((): BacktestSignal[] => { if (tab === 'backtest') return backtestDetail?.signals ?? []; if (!selectedLive) return []; return paperTradesToSignals(selectedLive.trades); }, [tab, backtestDetail, selectedLive]); const equityData = useMemo(() => { if (tab === 'backtest' && backtestDetail?.analysis) { return buildEquityFromSignals( backtestDetail.signals, backtestDetail.analysis.initialCapital, selectedBacktest!.symbol, ); } if (tab === 'live' && selectedLive) { return buildEquityFromSignals(paperTradesToSignals(selectedLive.trades), 10_000_000, selectedLive.symbol); } return { curve: [], markers: [], trades: [] }; }, [tab, backtestDetail, selectedBacktest, selectedLive]); const chartProps = useMemo(() => { if (tab === 'backtest' && selectedBacktest) { const signals = backtestDetail?.signals ?? []; const signalMax = signals.reduce((m, s) => Math.max(m, s.time ?? 0), 0); const toSec = normalizeEpochSec(selectedBacktest.toTime) || signalMax || Math.floor(Date.now() / 1000); const fromSec = normalizeEpochSecOptional(selectedBacktest.fromTime); return { symbol: selectedBacktest.symbol, timeframe: selectedBacktest.timeframe, toTimeSec: toSec, fromTimeSec: fromSec > 0 ? fromSec : undefined, barCount: selectedBacktest.barCount || 300, strategyId: selectedBacktest.strategyId, }; } if (tab === 'live' && selectedLive?.trades.length) { const last = selectedLive.trades[selectedLive.trades.length - 1]; const ts = last.createdAt ? Math.floor(new Date(last.createdAt).getTime() / 1000) : Math.floor(Date.now() / 1000); return { symbol: selectedLive.symbol, timeframe: '1h', toTimeSec: ts, fromTimeSec: undefined as number | undefined, barCount: 300, strategyId: selectedLive.trades.find(t => t.strategyId)?.strategyId ?? null, }; } return null; }, [tab, selectedBacktest, selectedLive, backtestDetail]); const centerHead = useMemo(() => { if (tab === 'backtest' && selectedBacktest) { const market = toUpbitMarket(selectedBacktest.symbol); return { ko: getKoreanName(market), strategy: selectedBacktest.strategyName || '전략 없음', metaParts: [ formatTimeframeKo(selectedBacktest.timeframe), formatChartTypeKo('candlestick'), ], }; } if (tab === 'live' && selectedLive) { const market = toUpbitMarket(selectedLive.symbol); return { ko: getKoreanName(market), strategy: selectedLive.strategyLabel, metaParts: [ formatTimeframeKo(chartProps?.timeframe ?? '1h'), formatChartTypeKo('candlestick'), selectedLive.sourceLabel, ], }; } return null; }, [tab, selectedBacktest, selectedLive, chartProps]); const footerTrades = equityData.trades; const strategyNamesById = useMemo( () => Object.fromEntries( strategies.map(s => [s.id, repairUtf8Mojibake(s.name)]), ), [strategies], ); const reportModel = useMemo(() => { if (tab === 'backtest' && selectedBacktest && backtestDetail?.analysis) { return buildBacktestReportModel( selectedBacktest, backtestDetail.analysis, backtestDetail.signals, strategyNamesById, ); } if (tab === 'live' && selectedLive) { return buildLiveReportModel(selectedLive, activeSignals); } return null; }, [tab, selectedBacktest, backtestDetail, selectedLive, activeSignals, strategyNamesById]); const showRightDetail = tab === 'backtest' ? backtestDetail?.analysis != null : selectedLive != null; if (loading) { return (

백테스팅

Golden Analysis Command Center
분석 데이터 로딩…
); } return (

백테스팅

Golden Analysis Command Center
{tab === 'backtest' && ( )}
{centerHead && (

{centerHead.ko}

{centerHead.strategy} {centerHead.metaParts.length > 0 && ( {' · '} {centerHead.metaParts.filter(Boolean).join(' · ')} )}

)}
{chartProps ? ( setReportOpen(true)} reportDisabled={!reportModel} /> ) : (
📈

좌측 목록에서 분석할 실행 결과를 선택하세요.

)}
setReportOpen(false)} model={reportModel} />
); } export default BacktestHistoryPage;