백테스팅 적용

This commit is contained in:
Macbook
2026-05-25 20:23:54 +09:00
parent 02d14e4b2b
commit cae1c3a624
33 changed files with 3399 additions and 385 deletions
+242 -163
View File
@@ -1,27 +1,40 @@
/**
* BacktestHistoryPage — 전략편집기와 동일한 3열 + 하단 패널 레이아웃
* BacktestHistoryPage — Golden Analysis Command Center (참조 UI 3열)
*/
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadBacktestResults,
deleteBacktestResult,
loadPaperTrades,
loadPaperSummary,
loadStrategies,
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';
import { normalizeEpochSec, normalizeEpochSecOptional } 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 { buildLiveExecutionItems, paperTradesToSignals, type LiveExecutionItem } from '../utils/liveExecutionGroups';
import {
readStoredSize,
storeSize,
usePanelResize,
} from './strategyEditor/usePanelResize';
import '../styles/strategyEditorTheme.css';
const pct = (v: number) =>
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
const LEFT_KEY = 'btd-left-width';
const RIGHT_KEY = 'btd-right-width';
const LEFT_DEFAULT = 380;
const RIGHT_DEFAULT = 440;
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 readMinWidth(key: string, fallback: number): number {
return Math.max(readStoredSize(key, fallback), fallback);
}
function parseDetail(r: BacktestResultRecord) {
let analysis: BacktestAnalysis | null = null;
@@ -33,186 +46,252 @@ function parseDetail(r: BacktestResultRecord) {
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 (
<button type="button" className={`btd-timeline-item${active ? ' btd-timeline-item--active' : ''}`} onClick={onClick}>
<span className="btd-timeline-node" />
<div className="btd-timeline-card">
<div className="btd-timeline-top">
<span className="btd-timeline-name">{r.strategyName || '전략 없음'}</span>
<BacktestSparkline curve={sparkCurve} positive={positive} />
</div>
<span className="btd-timeline-date">{fmtDate(r.createdAt)}</span>
<span className={`btd-timeline-roi${positive ? ' up' : ' down'}`}>
ROI {pct(ret)}
</span>
</div>
</button>
);
}
interface Props {
theme?: Theme;
}
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
const [tab, setTab] = useState<ExecutionListTab>('backtest');
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
const [liveItems, setLiveItems] = useState<LiveExecutionItem[]>([]);
const [selectedBacktest, setSelectedBacktest] = useState<BacktestResultRecord | null>(null);
const [selectedLive, setSelectedLive] = useState<LiveExecutionItem | null>(null);
const [loading, setLoading] = useState(true);
const fetchList = useCallback(async () => {
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<HTMLDivElement>) => {
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 fetchAll = useCallback(async () => {
setLoading(true);
const list = await loadBacktestResults();
const [list, trades, summary, strategies] = await Promise.all([
loadBacktestResults(),
loadPaperTrades(),
loadPaperSummary(),
loadStrategies(),
]);
const strategyNames = Object.fromEntries(strategies.map(s => [s.id, s.name]));
const live = buildLiveExecutionItems(trades, summary, strategyNames);
setRecords(list);
setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
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 fetchList(); }, [fetchList]);
useEffect(() => { void fetchAll(); }, [fetchAll]);
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]);
await fetchAll();
}, [records, fetchAll]);
const selectedDetail = selected ? parseDetail(selected) : null;
const backtestDetail = selectedBacktest ? parseDetail(selectedBacktest) : 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 activeSignals = useMemo((): BacktestSignal[] => {
if (tab === 'backtest') return backtestDetail?.signals ?? [];
if (!selectedLive) return [];
return paperTradesToSignals(selectedLive.trades);
}, [tab, backtestDetail, selectedLive]);
const leftContent = records.length === 0 ? (
<div className="btd-sidebar-empty">
<p> .</p>
<p className="btd-sidebar-hint"> <br /> .</p>
</div>
) : (
<div className="btd-timeline">
<span className="btd-timeline-line" />
{records.map(r => (
<TimelineItem
key={r.id}
r={r}
active={selected?.id === r.id}
onClick={() => setSelected(r)}
/>
))}
</div>
);
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 centerTitle = tab === 'backtest' && selectedBacktest
? `${selectedBacktest.strategyName || '전략'} · ${selectedBacktest.symbol.replace(/^KRW-/, '')} · ${selectedBacktest.timeframe}`
: tab === 'live' && selectedLive
? `${selectedLive.strategyLabel} · ${selectedLive.symbol.replace(/^KRW-/, '')} · ${selectedLive.sourceLabel}`
: '';
const footerTrades = equityData.trades;
const showRightDetail = tab === 'backtest'
? backtestDetail?.analysis != null
: selectedLive != null;
if (loading) {
return (
<div className={`btd-page se-page se-page--${theme}`}>
<header className="btd-header">
<div className="btd-header-brand">
<h1 className="btd-header-title"></h1>
<span className="btd-header-sub">Golden Analysis Command Center</span>
</div>
</header>
<div className="btd-loading"> </div>
</div>
);
}
return (
<BuilderPageShell
theme={theme}
title="백테스팅"
subtitle="Backtest History"
loading={loading}
loadingText="백테스팅 이력 로딩…"
leftStorageKey="btd-left-width"
footerStorageKey="btd-footer-height"
headerActions={(
<>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void fetchList()}></button>
<button type="button" className="bps-btn bps-btn--danger" disabled={records.length === 0} onClick={() => void deleteAll()}> </button>
</>
)}
leftTitle="실행 이력"
left={leftContent}
centerHead={selected ? (
<span className="bps-center-head-title">{selected.strategyName || '전략 없음'} · {selected.symbol.replace(/^KRW-/, '')}</span>
) : undefined}
center={selected && selectedDetail ? (
<BacktestResultDashboard
analysis={selectedDetail.analysis}
signals={selectedDetail.signals}
strategyName={selected.strategyName}
symbol={selected.symbol}
timeframe={selected.timeframe}
barCount={selected.barCount}
createdAt={selected.createdAt}
/>
) : (
<div className="btd-empty">
<span>📈</span>
<p> .</p>
<div className={`btd-page se-page se-page--${theme}`}>
<header className="btd-header">
<div className="btd-header-brand">
<h1 className="btd-header-title"></h1>
<span className="btd-header-sub">Golden Analysis Command Center</span>
</div>
)}
rightTitle="결과 요약"
right={selected ? (
<div className="btd-meta">
<div className="bps-card">
<div className="bps-card-head"></div>
<div className="bps-card-body btd-meta-value">{selected.strategyName || '전략 없음'}</div>
</div>
<div className="bps-card">
<div className="bps-card-head"> / </div>
<div className="bps-card-body btd-meta-value">{selected.symbol.replace(/^KRW-/, '')} · {selected.timeframe}</div>
</div>
<div className="bps-card">
<div className="bps-card-head"> / </div>
<div className="bps-card-body btd-meta-value">{fmtDate(selected.createdAt)} · {selected.barCount.toLocaleString()}</div>
</div>
<div className="bps-card">
<div className="bps-card-head"> </div>
<div className={`bps-card-body btd-meta-value ${(selected.totalReturn ?? 0) >= 0 ? 'up' : 'down'}`}>
{pct(selected.totalReturn ?? 0)}
</div>
</div>
{selectedDetail?.analysis && (
<div className="bps-card">
<div className="bps-card-head">MDD / Sharpe</div>
<div className="bps-card-body btd-meta-value">
{pct(selectedDetail.analysis.maxDrawdownPct)} · {selectedDetail.analysis.sharpeRatio.toFixed(2)}
</div>
</div>
<div className="btd-header-actions">
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}></button>
{tab === 'backtest' && (
<button type="button" className="btd-btn btd-btn--danger" disabled={records.length === 0} onClick={() => void deleteAll()}> </button>
)}
</div>
) : (
<div className="bps-empty"> .</div>
)}
footerLabel={signalStats ? '시그널 요약' : undefined}
footer={signalStats ? (
<div className="btd-footer-stats">
<div className="btd-footer-stat">
<span className="btd-footer-stat-label"> </span>
<span className="btd-footer-stat-value up">{signalStats.buy}</span>
</header>
<div className="btd-body">
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}>
<BacktestExecutionList
tab={tab}
onTabChange={setTab}
backtestRecords={records}
liveItems={liveItems}
selectedBacktestId={selectedBacktest?.id ?? null}
selectedLiveId={selectedLive?.id ?? null}
onSelectBacktest={r => { setSelectedBacktest(r); setTab('backtest'); }}
onSelectLive={item => { setSelectedLive(item); setTab('live'); }}
/>
</aside>
<div
className="btd-splitter btd-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비"
onPointerDown={onLeftSplit}
/>
<main className="btd-main">
{centerTitle && (
<div className="btd-main-head">
<h2 className="btd-main-title">{centerTitle}</h2>
</div>
)}
<div className="btd-main-content">
{chartProps ? (
<BacktestAnalysisChart
symbol={chartProps.symbol}
timeframe={chartProps.timeframe}
toTimeSec={chartProps.toTimeSec}
fromTimeSec={chartProps.fromTimeSec}
barCount={chartProps.barCount}
signals={activeSignals}
strategyId={chartProps.strategyId}
theme={theme}
/>
) : (
<div className="btd-empty">
<span>📈</span>
<p> .</p>
</div>
)}
</div>
<div className="btd-footer-stat">
<span className="btd-footer-stat-label"> </span>
<span className="btd-footer-stat-value down">{signalStats.sell}</span>
</main>
<div
className="btd-splitter btd-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비"
onPointerDown={onRightSplit}
/>
<aside className="btd-right" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
<div className="btd-right-stack">
<div className={`btd-right-section btd-right-section--kpi${showRightDetail ? '' : ' btd-right-section--solo'}`}>
<BacktestKpiPanel
analysis={tab === 'backtest' ? (backtestDetail?.analysis ?? null) : null}
equityCurve={equityData.curve}
totalReturnOverride={tab === 'live' && selectedLive ? selectedLive.totalReturnPct : undefined}
winRateOverride={tab === 'live' && selectedLive ? selectedLive.winRatePct : undefined}
tradeCountOverride={tab === 'live' && selectedLive ? selectedLive.tradeCount : undefined}
mddOverride={tab === 'live' && selectedLive ? selectedLive.mddPct : undefined}
sharpeOverride={tab === 'live' && selectedLive ? selectedLive.sharpeRatio : undefined}
profitFactorOverride={tab === 'live' && selectedLive ? selectedLive.profitFactor : undefined}
emptyText={tab === 'backtest' ? '백테스팅 결과를 선택하세요.' : '실시간 매매 결과를 선택하세요.'}
/>
</div>
{showRightDetail && (
<div className="btd-right-section btd-right-section--trades">
<BacktestTradeHistoryCard trades={footerTrades} />
</div>
)}
</div>
<div className="btd-footer-stat">
<span className="btd-footer-stat-label"> </span>
<span className="btd-footer-stat-value">{signalStats.total}</span>
</div>
<div className="btd-footer-stat">
<span className="btd-footer-stat-label"> </span>
<span className="btd-footer-stat-value">{selected?.barCount.toLocaleString() ?? '—'}</span>
</div>
</div>
) : undefined}
/>
</aside>
</div>
</div>
);
}