/** * BacktestHistoryPage — Golden Analysis Command Center (참조 UI 3열) */ import '../styles/backtestDashboard.css'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { MarketSearchPanel } from './MarketSearchPanel'; import { loadBacktestResults, deleteBacktestResult, deletePaperTradesBatch, loadPaperTrades, loadPaperSummary, loadStrategies, runBacktest, loadBacktestSettings, loadIndicatorSettings, type BacktestResultRecord, type BacktestAnalysis, type BacktestSignal, type StrategyDto, } from '../utils/backendApi'; import type { Theme, Timeframe } from '../types'; import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; import { buildEquityFromSignals } from '../utils/backtestEquity'; import { formatChartTypeKo, formatTimeframeKo, normalizeEpochSec, normalizeEpochSecOptional, resolveBacktestRecordExecTimeframe, resolveBacktestChartBarCount, 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, notifyPaperTradesChanged } 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'; import { markStrategyIdsMissingUnlessValid } from '../utils/strategyMissingCache'; import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; import { BACKTEST_STRATEGY_TIMEFRAME, buildQuickRunTimeframeSelectOptions, parseBacktestRunTimeframeChoice, resolveBacktestExecTimeframe, type BacktestRunTimeframeChoice, } from '../utils/backtestRunTimeframe'; const LEFT_KEY = 'btd-left-width'; const RIGHT_KEY = 'btd-right-width'; const BACKTEST_FOCUS_KEY = 'backtest_focus_id'; 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 [runMarket, setRunMarket] = useState('KRW-BTC'); const [runTimeframe, setRunTimeframe] = useState(BACKTEST_STRATEGY_TIMEFRAME); const [runStrategyId, setRunStrategyId] = useState(null); const [runLoading, setRunLoading] = useState(false); const [runError, setRunError] = useState(null); const [showMarketSearch, setShowMarketSearch] = useState(false); const [marketQuery, setMarketQuery] = useState(''); const marketBtnRef = useRef(null); 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); markStrategyIdsMissingUnlessValid( strategies.map(s => s.id).filter((id): id is number => id != null && id > 0), trades.map(t => t.strategyId), ); 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); markStrategyIdsMissingUnlessValid( strategies.map(s => s.id).filter((id): id is number => id != null && id > 0), [ ...trades.map(t => t.strategyId), ...list.map(r => r.strategyId), ], ); 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); // 전략빌더에서 백테스팅 실행 후 자동 선택 let focusId: number | null = null; try { const stored = sessionStorage.getItem(BACKTEST_FOCUS_KEY); if (stored) { focusId = Number(stored); sessionStorage.removeItem(BACKTEST_FOCUS_KEY); } } catch { /* ignore */ } const focusRecord = focusId ? list.find(r => r.id === focusId) ?? null : null; setSelectedBacktest(prev => focusRecord ?? (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)); if (focusRecord) setTab('backtest'); 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 handleDeleteBacktest = useCallback(async (record: BacktestResultRecord) => { if (record.id == null) return; const market = toUpbitMarket(record.symbol); const ko = getKoreanName(market); if (!window.confirm(`"${ko}" 백테스팅 결과를 삭제하시겠습니까?`)) return; try { await deleteBacktestResult(record.id); setRecords(prev => { const next = prev.filter(r => r.id !== record.id); setSelectedBacktest(sel => (sel?.id === record.id ? next[0] ?? null : sel)); return next; }); } catch (e) { window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.'); } }, []); const handleDeleteLive = useCallback(async (item: LiveExecutionItem) => { const ids = item.trades.map(t => t.id).filter(id => id > 0); if (ids.length === 0) return; const market = toUpbitMarket(item.symbol); const ko = getKoreanName(market); if (!window.confirm(`"${ko}" ${item.strategyLabel} 실시간 매매 이력(${ids.length}건)을 삭제하시겠습니까?`)) return; try { const deleted = await deletePaperTradesBatch(ids); if (deleted <= 0) { window.alert('삭제할 체결 이력을 찾지 못했습니다.'); return; } await refreshLiveTrades(); notifyPaperTradesChanged(); } catch (e) { window.alert(e instanceof Error ? e.message : '삭제에 실패했습니다.'); } }, [refreshLiveTrades]); const handleQuickRun = useCallback(async () => { if (!runStrategyId) { window.alert('전략을 선택해주세요.'); return; } setRunLoading(true); setRunError(null); try { const strategy = strategies.find(s => s.id === runStrategyId); const strategyTimeframe = strategy ? resolveStrategyPrimaryTimeframe(strategy) : '1m'; const execTimeframe = resolveBacktestExecTimeframe(runTimeframe, strategyTimeframe); const [settings, indicatorParams] = await Promise.all([ loadBacktestSettings(), loadIndicatorSettings(), ]); const candleBundle = await fetchBacktestCandleBundle({ market: runMarket, timeframe: execTimeframe as Timeframe, buyDsl: strategy?.buyCondition, sellDsl: strategy?.sellCondition, indicatorParams, }); const { bars, evaluationBarCount } = candleBundle; if (bars.length < 5) { throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.'); } const result = await runBacktest({ strategyId: runStrategyId, bars, timeframe: execTimeframe, settings, indicatorParams, symbol: runMarket, strategyName: strategy?.name, evaluationBarCount, }); if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); const list = await loadBacktestResults(); setRecords(list); if (result.resultId) { const fresh = list.find(r => r.id === result.resultId) ?? list[0] ?? null; setSelectedBacktest(fresh); } else { setSelectedBacktest(list[0] ?? null); } setTab('backtest'); } catch (e) { setRunError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.'); } finally { setRunLoading(false); } }, [runMarket, runTimeframe, runStrategyId, strategies]); const runStrategy = useMemo( () => (runStrategyId ? strategies.find(s => s.id === runStrategyId) : undefined), [runStrategyId, strategies], ); const runStrategyTimeframe = useMemo( () => (runStrategy ? resolveStrategyPrimaryTimeframe(runStrategy) : undefined), [runStrategy], ); const runTimeframeOptions = useMemo( () => buildQuickRunTimeframeSelectOptions(runStrategyTimeframe), [runStrategyTimeframe], ); 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: resolveBacktestRecordExecTimeframe(selectedBacktest), toTimeSec: toSec, fromTimeSec: fromSec > 0 ? fromSec : undefined, barCount: resolveBacktestChartBarCount(selectedBacktest), 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(resolveBacktestRecordExecTimeframe(selectedBacktest)), 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
{/* 빠른 실행 툴바 */}
{showMarketSearch && ReactDOM.createPortal( { setRunMarket(market); setShowMarketSearch(false); setMarketQuery(''); }} onClose={() => { setShowMarketSearch(false); setMarketQuery(''); }} anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null} anchorPlacement="dropdown" />, document.body, )} {runError && ( ⚠ {runError} )}
{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;