/** * 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석 */ import '../styles/backtestDashboard.css'; import '../styles/virtualTradingDashboard.css'; import '../styles/strategyEditorTheme.css'; import '../styles/strategyEditor.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 StrategyEvaluationConditionPanel from './strategyEvaluation/StrategyEvaluationConditionPanel'; import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal'; 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, buildEvalParamsFromStrategy, 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 RIGHT_OPEN_KEY = 'seval-right-open'; 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('strategy'); const [strategies, setStrategies] = useState([]); const [strategySearch, setStrategySearch] = useState(''); const [selectedStrategyId, setSelectedStrategyId] = useState(null); const [selectedStrategy, setSelectedStrategy] = useState(null); const [market, setMarket] = useState('KRW-BTC'); const [timeframeChoice, setTimeframeChoice] = useState(BACKTEST_STRATEGY_TIMEFRAME); const [bars, setBars] = useState([]); const [selectedBarIndex, setSelectedBarIndex] = useState(0); const [snapshot, setSnapshot] = useState(); const [evalLoading, setEvalLoading] = useState(false); const [evalError, setEvalError] = useState(null); const [appliedIndicatorParams, setAppliedIndicatorParams] = useState(null); const [paramsRevision, setParamsRevision] = useState(0); const [chartLoading, setChartLoading] = useState(false); const [backtestSignals, setBacktestSignals] = useState([]); const [editorModalOpen, setEditorModalOpen] = useState(false); 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 [rightOpen, setRightOpen] = useState(() => readStoredBool(RIGHT_OPEN_KEY, true)); 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 toggleRightPanel = useCallback(() => { setRightOpen(prev => { const next = !prev; storeBool(RIGHT_OPEN_KEY, next); return next; }); }, []); const leftWrapStyle = useMemo( () => ({ '--seval-left-width': `${leftWidth}px` }) as React.CSSProperties, [leftWidth], ); const rightWrapStyle = useMemo( () => ({ '--seval-right-width': `${rightWidth}px` }) as React.CSSProperties, [rightWidth], ); const onRightSplit = useCallback((e: React.PointerEvent) => { 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 ?? [])); }, []); const refreshStrategies = useCallback(async () => { const list = await loadStrategies(); const next = list ?? []; setStrategies(next); if (selectedStrategyId == null) return; const still = next.find(s => s.id === selectedStrategyId); if (still) { setSelectedStrategy(hydrateStrategyDto(still)); return; } setSelectedStrategyId(null); setSelectedStrategy(null); setAppliedIndicatorParams(null); setParamsRevision(v => v + 1); }, [selectedStrategyId]); const handleCloseStrategyEditor = useCallback(() => { setEditorModalOpen(false); void refreshStrategies(); }, [refreshStrategies]); 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 indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); const snap = await fetchEvaluationSnapshotAtBar( market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, indicatorParams, 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, getEvalParams, ]); 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 (

전략 평가

Strategy Match Analysis
{leftOpen && (
)}
{rightOpen && (
)}
{editorModalOpen && ( { void refreshStrategies(); }} /> )}
); }