653 lines
25 KiB
TypeScript
653 lines
25 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,
|
|
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<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<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
|
|
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);
|
|
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 (
|
|
<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(parseBacktestRunTimeframeChoice(e.target.value))}
|
|
disabled={runLoading}
|
|
title="백테스팅 실행 시간봉"
|
|
>
|
|
{runTimeframeOptions.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'); }}
|
|
onDeleteBacktest={r => void handleDeleteBacktest(r)}
|
|
onDeleteLive={item => void handleDeleteLive(item)}
|
|
/>
|
|
</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;
|