전략평가 메뉴 추가

This commit is contained in:
Macbook
2026-06-12 14:39:17 +09:00
parent cb1bde2563
commit ae9266bd28
23 changed files with 1977 additions and 19 deletions
@@ -0,0 +1,370 @@
/**
* 전략 평가 — 저장 전략 × 종목 × 봉 시점 조건 일치율 분석
*/
import '../styles/strategyEvaluation.css';
import '../styles/backtestDashboard.css';
import '../styles/virtualTradingDashboard.css';
import '../styles/strategyEditorTheme.css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Theme, Timeframe } from '../types';
import {
loadStrategies,
loadStrategy,
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 type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import { readStoredSize, 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 type { OHLCVBar } from '../types';
const LEFT_KEY = 'seval-left-width';
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 getEvalParams = useMemo(
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
[baseGetParams, appliedIndicatorParams],
);
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 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(() => {
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 refreshEvaluation = useCallback(async () => {
if (!selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) {
setSnapshot(undefined);
return;
}
setEvalLoading(true);
setEvalError(null);
try {
const snap = await fetchEvaluationSnapshotAtBar(
market,
selectedStrategyId,
selectedStrategy,
selectedBarTimeSec,
appliedIndicatorParams,
);
setSnapshot(snap ?? undefined);
} catch (e) {
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
setSnapshot(undefined);
} finally {
setEvalLoading(false);
}
}, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, 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);
setLeftTab('strategy');
}, []);
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">
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}>
<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}
/>
)}
</div>
</aside>
<div
className="btd-splitter btd-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비"
onPointerDown={onLeftSplit}
/>
<main className="btd-main">
<div className="btd-main-content seval-main-content">
<StrategyEvaluationChart
market={market}
timeframe={chartTimeframe}
timeframeChoice={timeframeChoice}
tfOptions={tfOptions}
onMarketChange={setMarket}
onTimeframeChoiceChange={setTimeframeChoice}
theme={theme}
strategy={selectedStrategy}
selectedBarIndex={selectedBarIndex}
onSelectedBarIndexChange={setSelectedBarIndex}
onBarsLoaded={setBars}
paramsRevision={paramsRevision}
getParamsOverride={getEvalParams}
getVisualConfigOverride={getVisualConfig}
/>
</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" title="선택 봉 기준 평가" />
<span className="seval-live-label"> </span>
</div>
{evalError && <p className="seval-eval-error">{evalError}</p>}
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode="summary"
loading={evalLoading}
hasStrategy={selectedStrategyId != null}
className="seval-signal-panel"
/>
<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>
);
}