Files
goldenChart/frontend/src/components/BacktestHistoryPage.tsx
T
Macbook 7f1c63ef87 전략빌더 상단 툴바에 빠른 백테스팅 실행 기능 추가
- StrategyEditorPage: 종목 검색(MarketSearchPanel), 타임프레임 select, 실행 버튼 UI 추가
- 백테스팅 완료 후 백테스팅 화면으로 자동 이동 (onNavigateToBacktest)
- BacktestHistoryPage: sessionStorage backtest_focus_id로 신규 결과 자동 선택
- App.tsx: StrategyEditorPage에 onNavigateToBacktest prop 전달
- strategyEditor.css: se-quick-run 툴바 스타일 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 16:15:30 +09:00

580 lines
22 KiB
TypeScript

/**
* 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,
loadPaperTrades,
loadPaperSummary,
loadStrategies,
runBacktest,
loadBacktestSettings,
API_BASE,
type BacktestResultRecord,
type BacktestAnalysis,
type BacktestSignal,
type StrategyDto,
} from '../utils/backendApi';
import { timeframeToCandleType } from '../utils/chartCandleType';
import type { Theme, Timeframe } 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 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;
}
const QUICK_RUN_TIMEFRAMES: { value: Timeframe; label: string }[] = [
{ value: '1m', label: '1분' },
{ value: '3m', label: '3분' },
{ value: '5m', label: '5분' },
{ value: '10m', label: '10분' },
{ value: '15m', label: '15분' },
{ value: '30m', label: '30분' },
{ value: '1h', label: '1시간' },
{ value: '4h', label: '4시간' },
{ value: '1D', label: '일봉' },
{ value: '1W', label: '주봉' },
];
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
const [tab, setTab] = useState<ExecutionListTab>('backtest');
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
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 [reportOpen, setReportOpen] = useState(false);
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
// ── 빠른 백테스팅 실행 ──────────────────────────────────────────────────────
const [runMarket, setRunMarket] = useState<string>('KRW-BTC');
const [runTimeframe, setRunTimeframe] = useState<Timeframe>('1D');
const [runStrategyId, setRunStrategyId] = useState<number | null>(null);
const [runLoading, setRunLoading] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [showMarketSearch, setShowMarketSearch] = useState(false);
const [marketQuery, setMarketQuery] = useState('');
const marketBtnRef = useRef<HTMLButtonElement>(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<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 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);
// 전략빌더에서 백테스팅 실행 후 자동 선택
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 handleQuickRun = useCallback(async () => {
if (!runStrategyId) {
window.alert('전략을 선택해주세요.');
return;
}
setRunLoading(true);
setRunError(null);
try {
const [settings, barsRes] = await Promise.all([
loadBacktestSettings(),
fetch(`${API_BASE}/candles/history?${new URLSearchParams({
market: runMarket,
type: timeframeToCandleType(runTimeframe),
count: '800',
})}`),
]);
if (!barsRes.ok) throw new Error(`캔들 로딩 실패 (${barsRes.status})`);
const bars = (await barsRes.json()) as Array<{
time: number; open: number; high: number; low: number; close: number; volume: number;
}>;
if (bars.length < 5) {
throw new Error('캔들 데이터가 부족합니다. 잠시 후 다시 시도하거나 타임프레임을 변경해 주세요.');
}
const strategy = strategies.find(s => s.id === runStrategyId);
const result = await runBacktest({
strategyId: runStrategyId,
bars,
timeframe: runTimeframe,
settings,
symbol: runMarket,
strategyName: strategy?.name,
});
if (!result) throw new Error('백테스팅 실행에 실패했습니다.');
// 결과 목록 갱신 후 새 결과 자동 선택
await fetchAll();
setTab('backtest');
} catch (e) {
setRunError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.');
} finally {
setRunLoading(false);
}
}, [runMarket, runTimeframe, runStrategyId, strategies, 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 (
<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 (
<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>
{/* 빠른 실행 툴바 */}
<div className="btd-header-run">
<button
ref={marketBtnRef}
type="button"
className="btd-run-market-btn"
disabled={runLoading}
title="종목 검색"
onClick={() => {
setMarketQuery('');
setShowMarketSearch(v => !v);
}}
>
<span className="btd-run-market-ko">{getKoreanName(runMarket)}</span>
<span className="btd-run-market-sym">{runMarket.replace('KRW-', '')}</span>
<svg className="btd-run-market-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{showMarketSearch && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={runMarket}
query={marketQuery}
onQueryChange={setMarketQuery}
onSelect={market => {
setRunMarket(market);
setShowMarketSearch(false);
setMarketQuery('');
}}
onClose={() => {
setShowMarketSearch(false);
setMarketQuery('');
}}
anchorRect={marketBtnRef.current?.getBoundingClientRect() ?? null}
anchorPlacement="dropdown"
/>,
document.body,
)}
<select
className="btd-run-select"
value={runTimeframe}
onChange={e => setRunTimeframe(e.target.value as Timeframe)}
disabled={runLoading}
title="타임프레임"
>
{QUICK_RUN_TIMEFRAMES.map(tf => (
<option key={tf.value} value={tf.value}>{tf.label}</option>
))}
</select>
<select
className="btd-run-select btd-run-select--strategy"
value={runStrategyId ?? ''}
onChange={e => setRunStrategyId(e.target.value ? Number(e.target.value) : null)}
disabled={runLoading}
title="전략"
>
<option value="">전략 선택</option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button
type="button"
className={`btd-run-btn${runLoading ? ' btd-run-btn--loading' : ''}`}
disabled={!runStrategyId || runLoading}
onClick={() => void handleQuickRun()}
title="백테스팅 실행"
>
{runLoading ? (
<span className="btd-run-spinner" />
) : (
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true">
<path d="M3 2.5l10 5.5-10 5.5V2.5z" />
</svg>
)}
</button>
{runError && (
<span className="btd-run-error" title={runError}> {runError}</span>
)}
</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>
</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">
{centerHead && (
<div className="btd-main-head">
<h2 className="btd-main-ko">{centerHead.ko}</h2>
<p className="btd-main-strategy">
{centerHead.strategy}
{centerHead.metaParts.length > 0 && (
<span className="btd-main-meta">
{' · '}
{centerHead.metaParts.filter(Boolean).join(' · ')}
</span>
)}
</p>
</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}
onOpenReport={() => setReportOpen(true)}
reportDisabled={!reportModel}
/>
) : (
<div className="btd-empty">
<span>📈</span>
<p>좌측 목록에서 분석할 실행 결과를 선택하세요.</p>
</div>
)}
</div>
</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>
</aside>
</div>
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
</div>
);
}
export default BacktestHistoryPage;