Files
goldenChart/frontend/src/components/StrategyEvaluationPage.tsx
T
2026-06-16 00:36:00 +09:00

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석
*/
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,
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 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 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 (
<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}
appliedIndicatorParams={appliedIndicatorParams}
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}
strategy={selectedStrategy}
barTimeSec={selectedBarTimeSec}
barSignalHighlight={barSignalHighlight}
hasStrategy={selectedStrategyId != null}
className="seval-signal-panel"
getParams={getEvalParams}
/>
</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>
);
}