487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
/**
|
||
* 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석
|
||
*/
|
||
import '../styles/backtestDashboard.css';
|
||
import '../styles/virtualTradingDashboard.css';
|
||
import '../styles/strategyEditorTheme.css';
|
||
import '../styles/strategyEvaluation.css';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import type { Theme, Timeframe } from '../types';
|
||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||
import {
|
||
loadStrategies,
|
||
loadStrategy,
|
||
type BacktestSignal,
|
||
type StrategyDto,
|
||
} from '../utils/backendApi';
|
||
import { hydrateStrategyDto } from '../utils/strategyHydrate';
|
||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
|
||
import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot';
|
||
import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel';
|
||
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
||
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
||
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
|
||
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||
import { getKoreanName } from '../utils/marketNameCache';
|
||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||
import {
|
||
mergeEvalGetParams,
|
||
type EvalIndicatorParams,
|
||
} from '../utils/strategyEvaluationParams';
|
||
import {
|
||
BACKTEST_STRATEGY_TIMEFRAME,
|
||
buildQuickRunTimeframeSelectOptions,
|
||
resolveBacktestExecTimeframe,
|
||
type BacktestRunTimeframeChoice,
|
||
} from '../utils/backtestRunTimeframe';
|
||
import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals';
|
||
import type { OHLCVBar } from '../types';
|
||
|
||
const LEFT_KEY = 'seval-left-width';
|
||
const LEFT_OPEN_KEY = 'seval-left-open';
|
||
const RIGHT_KEY = 'seval-right-width';
|
||
const LEFT_DEFAULT = 380;
|
||
const RIGHT_DEFAULT = 440;
|
||
|
||
type LeftTab = 'strategy' | 'settings';
|
||
|
||
function readMinWidth(key: string, fallback: number): number {
|
||
return Math.max(readStoredSize(key, fallback), fallback);
|
||
}
|
||
|
||
interface Props {
|
||
theme?: Theme;
|
||
}
|
||
|
||
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||
const { getParams: baseGetParams, getVisualConfig } = useIndicatorSettings();
|
||
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
|
||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||
const [strategySearch, setStrategySearch] = useState('');
|
||
const [selectedStrategyId, setSelectedStrategyId] = useState<number | null>(null);
|
||
const [selectedStrategy, setSelectedStrategy] = useState<StrategyDto | null>(null);
|
||
|
||
const [market, setMarket] = useState('KRW-BTC');
|
||
const [timeframeChoice, setTimeframeChoice] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
|
||
|
||
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
||
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
|
||
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
|
||
const [evalLoading, setEvalLoading] = useState(false);
|
||
const [evalError, setEvalError] = useState<string | null>(null);
|
||
const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null);
|
||
const [paramsRevision, setParamsRevision] = useState(0);
|
||
const [chartLoading, setChartLoading] = useState(false);
|
||
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
|
||
const evalSessionRef = useRef(0);
|
||
const paramsRevisionRef = useRef(paramsRevision);
|
||
|
||
const getEvalParams = useMemo(
|
||
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
|
||
[baseGetParams, appliedIndicatorParams],
|
||
);
|
||
|
||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||
const [leftOpen, setLeftOpen] = useState(() => readStoredBool(LEFT_OPEN_KEY, true));
|
||
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 toggleLeftPanel = useCallback(() => {
|
||
setLeftOpen(prev => {
|
||
const next = !prev;
|
||
storeBool(LEFT_OPEN_KEY, next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const leftWrapStyle = useMemo(
|
||
() => ({ '--seval-left-width': `${leftWidth}px` }) as React.CSSProperties,
|
||
[leftWidth],
|
||
);
|
||
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 strategyPrimaryTimeframe = useMemo(
|
||
() => (selectedStrategy ? resolveStrategyPrimaryTimeframe(selectedStrategy) : undefined),
|
||
[selectedStrategy],
|
||
);
|
||
|
||
const chartTimeframe = useMemo((): Timeframe => {
|
||
return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m');
|
||
}, [timeframeChoice, strategyPrimaryTimeframe]);
|
||
|
||
const tfOptions = useMemo(
|
||
() => buildQuickRunTimeframeSelectOptions(strategyPrimaryTimeframe),
|
||
[strategyPrimaryTimeframe],
|
||
);
|
||
|
||
/** 종목·시간봉·전략 변경 — 차트·평가 컨텍스트 초기화 */
|
||
useEffect(() => {
|
||
evalSessionRef.current += 1;
|
||
setSnapshot(undefined);
|
||
setEvalError(null);
|
||
setEvalLoading(true);
|
||
setBars([]);
|
||
setSelectedBarIndex(0);
|
||
}, [market, chartTimeframe, selectedStrategyId]);
|
||
|
||
/** 전략설정 파라미터 저장·초기화 */
|
||
useEffect(() => {
|
||
if (paramsRevisionRef.current === paramsRevision) return;
|
||
paramsRevisionRef.current = paramsRevision;
|
||
if (paramsRevision === 0) return;
|
||
evalSessionRef.current += 1;
|
||
setSnapshot(undefined);
|
||
setEvalError(null);
|
||
setEvalLoading(true);
|
||
}, [paramsRevision]);
|
||
|
||
useEffect(() => {
|
||
void loadStrategies().then(list => setStrategies(list ?? []));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!selectedStrategyId) {
|
||
setSelectedStrategy(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void loadStrategy(selectedStrategyId).then(s => {
|
||
if (cancelled) return;
|
||
setSelectedStrategy(s ? hydrateStrategyDto(s) : null);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [selectedStrategyId]);
|
||
|
||
const filteredStrategies = useMemo(() => {
|
||
const q = strategySearch.trim().toLowerCase();
|
||
if (!q) return strategies;
|
||
return strategies.filter(s =>
|
||
(s.name ?? '').toLowerCase().includes(q)
|
||
|| String(s.id ?? '').includes(q),
|
||
);
|
||
}, [strategies, strategySearch]);
|
||
|
||
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
|
||
|
||
const barSignalHighlight = useMemo(
|
||
() => resolveBarSignalHighlight(backtestSignals, selectedBarTimeSec, selectedBarIndex),
|
||
[backtestSignals, selectedBarTimeSec, selectedBarIndex],
|
||
);
|
||
|
||
const handleSignalsChange = useCallback((signals: BacktestSignal[]) => {
|
||
setBacktestSignals(signals);
|
||
}, []);
|
||
|
||
const refreshEvaluation = useCallback(async () => {
|
||
if (!selectedStrategyId || !selectedStrategy) {
|
||
setSnapshot(undefined);
|
||
setEvalLoading(false);
|
||
return;
|
||
}
|
||
if (chartLoading || bars.length === 0 || selectedBarTimeSec == null) {
|
||
setSnapshot(undefined);
|
||
return;
|
||
}
|
||
|
||
const session = evalSessionRef.current;
|
||
setEvalLoading(true);
|
||
setEvalError(null);
|
||
try {
|
||
const snap = await fetchEvaluationSnapshotAtBar(
|
||
market,
|
||
selectedStrategyId,
|
||
selectedStrategy,
|
||
selectedBarTimeSec,
|
||
appliedIndicatorParams,
|
||
bars,
|
||
chartTimeframe,
|
||
);
|
||
if (session !== evalSessionRef.current) return;
|
||
setSnapshot(snap ?? undefined);
|
||
} catch (e) {
|
||
if (session !== evalSessionRef.current) return;
|
||
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
|
||
setSnapshot(undefined);
|
||
} finally {
|
||
if (session === evalSessionRef.current) {
|
||
setEvalLoading(false);
|
||
}
|
||
}
|
||
}, [
|
||
chartLoading,
|
||
market,
|
||
selectedStrategyId,
|
||
selectedStrategy,
|
||
selectedBarTimeSec,
|
||
selectedBarIndex,
|
||
bars,
|
||
chartTimeframe,
|
||
appliedIndicatorParams,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
void refreshEvaluation();
|
||
}, [refreshEvaluation]);
|
||
|
||
const handleSelectStrategy = useCallback((s: StrategyDto) => {
|
||
if (s.id == null) return;
|
||
setSelectedStrategyId(s.id);
|
||
setSelectedStrategy(hydrateStrategyDto(s));
|
||
setAppliedIndicatorParams(null);
|
||
setParamsRevision(v => v + 1);
|
||
setTimeframeChoice(BACKTEST_STRATEGY_TIMEFRAME);
|
||
setLeftTab('strategy');
|
||
}, []);
|
||
|
||
const handleSelectStrategyById = useCallback((id: number) => {
|
||
const found = strategies.find(s => s.id === id);
|
||
if (found) {
|
||
handleSelectStrategy(found);
|
||
return;
|
||
}
|
||
setSelectedStrategyId(id);
|
||
setSelectedStrategy(null);
|
||
setAppliedIndicatorParams(null);
|
||
setParamsRevision(v => v + 1);
|
||
setTimeframeChoice(BACKTEST_STRATEGY_TIMEFRAME);
|
||
setLeftTab('strategy');
|
||
}, [strategies, handleSelectStrategy]);
|
||
|
||
const handleSaveIndicatorParams = useCallback((params: EvalIndicatorParams) => {
|
||
setAppliedIndicatorParams(params);
|
||
setParamsRevision(v => v + 1);
|
||
}, []);
|
||
|
||
const handleResetIndicatorParams = useCallback(() => {
|
||
setAppliedIndicatorParams(null);
|
||
setParamsRevision(v => v + 1);
|
||
}, []);
|
||
|
||
const strategyLabel = selectedStrategy?.name
|
||
? repairUtf8Mojibake(selectedStrategy.name)
|
||
: '전략 미선택';
|
||
|
||
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">Strategy Match Analysis</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="btd-body">
|
||
<div
|
||
className={`seval-left-wrap${leftOpen ? ' seval-left-wrap--open' : ''}`}
|
||
style={leftWrapStyle}
|
||
>
|
||
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
|
||
<div className="btd-exec-list seval-left-panel">
|
||
<div className="btd-exec-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={leftTab === 'strategy'}
|
||
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
|
||
onClick={() => setLeftTab('strategy')}
|
||
>
|
||
전략
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={leftTab === 'settings'}
|
||
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
|
||
onClick={() => setLeftTab('settings')}
|
||
disabled={!selectedStrategy}
|
||
>
|
||
전략설정
|
||
</button>
|
||
</div>
|
||
|
||
{leftTab === 'strategy' ? (
|
||
<>
|
||
<div className="btd-exec-search">
|
||
<span className="btd-exec-search-icon" aria-hidden>⌕</span>
|
||
<input
|
||
className="btd-exec-search-input"
|
||
placeholder="전략 검색…"
|
||
value={strategySearch}
|
||
onChange={e => setStrategySearch(e.target.value)}
|
||
/>
|
||
{strategySearch && (
|
||
<button
|
||
type="button"
|
||
className="btd-exec-search-clear"
|
||
onClick={() => setStrategySearch('')}
|
||
aria-label="검색어 지우기"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="btd-exec-scroll">
|
||
{filteredStrategies.length === 0 ? (
|
||
<p className="btd-sidebar-empty">저장된 전략이 없습니다</p>
|
||
) : (
|
||
filteredStrategies.map(s => {
|
||
const name = repairUtf8Mojibake(s.name ?? `전략 #${s.id}`);
|
||
const active = selectedStrategyId === s.id;
|
||
return (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
className={`btd-exec-item${active ? ' btd-exec-item--active' : ''}`}
|
||
onClick={() => handleSelectStrategy(s)}
|
||
>
|
||
<div className="btd-exec-item-top">
|
||
<span className="btd-exec-strategy">{name}</span>
|
||
</div>
|
||
{s.description && (
|
||
<p className="btd-exec-meta">{repairUtf8Mojibake(s.description)}</p>
|
||
)}
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<StrategyEvaluationSettingsTab
|
||
strategy={selectedStrategy}
|
||
getParams={baseGetParams}
|
||
appliedParams={appliedIndicatorParams}
|
||
onSave={handleSaveIndicatorParams}
|
||
onReset={handleResetIndicatorParams}
|
||
applying={evalLoading || chartLoading}
|
||
/>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
<SePanelCollapseHandle
|
||
side="left"
|
||
open={leftOpen}
|
||
onToggle={toggleLeftPanel}
|
||
label="전략 패널"
|
||
/>
|
||
</div>
|
||
|
||
{leftOpen && (
|
||
<div
|
||
className="btd-splitter btd-splitter--v"
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="좌측 패널 너비"
|
||
onPointerDown={onLeftSplit}
|
||
/>
|
||
)}
|
||
|
||
<main className={`btd-main${leftOpen ? '' : ' seval-main--left-collapsed'}`}>
|
||
<div className="btd-main-content seval-main-content">
|
||
<StrategyEvaluationChart
|
||
market={market}
|
||
timeframe={chartTimeframe}
|
||
timeframeChoice={timeframeChoice}
|
||
tfOptions={tfOptions}
|
||
strategies={strategies}
|
||
selectedStrategyId={selectedStrategyId}
|
||
onStrategySelect={handleSelectStrategyById}
|
||
onMarketChange={setMarket}
|
||
onTimeframeChoiceChange={setTimeframeChoice}
|
||
onChartLoadingChange={setChartLoading}
|
||
theme={theme}
|
||
strategy={selectedStrategy}
|
||
selectedBarIndex={selectedBarIndex}
|
||
onSelectedBarIndexChange={setSelectedBarIndex}
|
||
onBarsLoaded={setBars}
|
||
paramsRevision={paramsRevision}
|
||
getParamsOverride={getEvalParams}
|
||
getVisualConfigOverride={getVisualConfig}
|
||
evalLoading={evalLoading}
|
||
onSignalsChange={handleSignalsChange}
|
||
/>
|
||
</div>
|
||
</main>
|
||
|
||
<div
|
||
className="btd-splitter btd-splitter--v"
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="우측 패널 너비"
|
||
onPointerDown={onRightSplit}
|
||
/>
|
||
|
||
<aside className="btd-right seval-right-panel" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
|
||
<div className="seval-signal-head">
|
||
<span className="seval-signal-market">{getKoreanName(market)}</span>
|
||
<span
|
||
className={`seval-live-dot${evalLoading ? ' seval-live-dot--loading' : ''}`}
|
||
title={evalLoading ? '전략 조건 확인 중' : '선택 봉 기준 평가'}
|
||
/>
|
||
<span className={`seval-live-label${evalLoading ? ' seval-live-label--loading' : ''}`}>
|
||
{evalLoading ? '조건 확인 중…' : '봉 평가'}
|
||
</span>
|
||
</div>
|
||
|
||
{evalError && <p className="seval-eval-error">{evalError}</p>}
|
||
|
||
<div className="seval-signal-body">
|
||
<StrategyEvaluationSignalPanel
|
||
snapshot={snapshot}
|
||
barSignalHighlight={barSignalHighlight}
|
||
hasStrategy={selectedStrategyId != null}
|
||
className="seval-signal-panel"
|
||
/>
|
||
</div>
|
||
|
||
<footer className="seval-signal-footer">
|
||
<span className="seval-updated">
|
||
{selectedBarTimeSec != null
|
||
? new Date(selectedBarTimeSec * 1000).toLocaleString('ko-KR', {
|
||
month: 'numeric',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
: '—'}
|
||
{evalLoading ? ' · 평가 중…' : snapshot?.updatedAt
|
||
? ` · 갱신 ${new Date(snapshot.updatedAt).toLocaleTimeString('ko-KR')}`
|
||
: ''}
|
||
</span>
|
||
<span className="seval-strategy-chip">{strategyLabel}</span>
|
||
</footer>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|