Files
goldenChart/frontend/src/components/StrategyEvaluationPage.tsx
T

389 lines
14 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/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);
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">
<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}
strategies={strategies}
selectedStrategyId={selectedStrategyId}
onStrategySelect={handleSelectStrategyById}
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>
);
}