/** * BacktestHistoryPage — 전략편집기와 동일한 3열 + 하단 패널 레이아웃 */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { loadBacktestResults, deleteBacktestResult, type BacktestResultRecord, type BacktestAnalysis, type BacktestSignal, } from '../utils/backendApi'; import type { Theme } from '../types'; import { buildEquityFromSignals } from '../utils/backtestEquity'; import { BacktestResultDashboard } from './backtest/BacktestResultDashboard'; import BacktestSparkline from './backtest/BacktestSparkline'; import BuilderPageShell from './layout/BuilderPageShell'; const pct = (v: number) => isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%'; const fmtDate = (iso: string) => { const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; 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 TimelineItemProps { r: BacktestResultRecord; active: boolean; onClick: () => void; } 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 ( ); } interface Props { theme?: Theme; } export function BacktestHistoryPage({ theme = 'dark' }: Props) { 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); setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null)); setLoading(false); }, []); useEffect(() => { void fetchList(); }, [fetchList]); const deleteAll = useCallback(async () => { if (records.length === 0) return; if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return; await Promise.all(records.map(r => deleteBacktestResult(r.id))); setRecords([]); setSelected(null); }, [records]); const selectedDetail = selected ? parseDetail(selected) : null; const signalStats = useMemo(() => { const sigs = selectedDetail?.signals ?? []; if (!sigs.length) return null; const buy = sigs.filter(s => s.type === 'BUY' || s.type === 'SHORT_EXIT').length; const sell = sigs.filter(s => s.type === 'SELL' || s.type === 'SHORT_ENTRY' || s.type === 'PARTIAL_SELL').length; return { buy, sell, total: sigs.length }; }, [selectedDetail]); const leftContent = records.length === 0 ? (

백테스팅 이력이 없습니다.

차트 화면에서 전략을 선택하고
백테스팅을 실행하세요.

) : (
{records.map(r => ( setSelected(r)} /> ))}
); return ( )} leftTitle="실행 이력" left={leftContent} centerHead={selected ? ( {selected.strategyName || '전략 없음'} · {selected.symbol.replace(/^KRW-/, '')} ) : undefined} center={selected && selectedDetail ? ( ) : (
📈

좌측 목록에서 백테스팅 결과를 선택하세요.

)} rightTitle="결과 요약" right={selected ? (
전략
{selected.strategyName || '전략 없음'}
종목 / 타임프레임
{selected.symbol.replace(/^KRW-/, '')} · {selected.timeframe}
실행일 / 봉 수
{fmtDate(selected.createdAt)} · {selected.barCount.toLocaleString()}봉
총 수익률
= 0 ? 'up' : 'down'}`}> {pct(selected.totalReturn ?? 0)}
{selectedDetail?.analysis && (
MDD / Sharpe
{pct(selectedDetail.analysis.maxDrawdownPct)} · {selectedDetail.analysis.sharpeRatio.toFixed(2)}
)}
) : (
선택된 결과가 없습니다.
)} footerLabel={signalStats ? '시그널 요약' : undefined} footer={signalStats ? (
매수 시그널 {signalStats.buy}
매도 시그널 {signalStats.sell}
총 시그널 {signalStats.total}
분석 봉 수 {selected?.barCount.toLocaleString() ?? '—'}
) : undefined} /> ); } export default BacktestHistoryPage;