전략평가 화면 기능 수정

This commit is contained in:
Macbook
2026-06-12 14:48:07 +09:00
parent d80a854d5b
commit c562a4eab2
4 changed files with 273 additions and 21 deletions
@@ -20,7 +20,8 @@ import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnaps
import VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
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 {
@@ -36,6 +37,7 @@ import {
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;
@@ -68,6 +70,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
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 evalSessionRef = useRef(0);
const paramsRevisionRef = useRef(paramsRevision);
const getEvalParams = useMemo(
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
@@ -75,6 +80,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
);
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);
@@ -82,6 +88,19 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
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;
@@ -121,6 +140,27 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
[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 ?? []));
}, []);
@@ -150,10 +190,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
const refreshEvaluation = useCallback(async () => {
if (!selectedStrategyId || !selectedStrategy || selectedBarTimeSec == null) {
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 {
@@ -164,14 +211,26 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
selectedBarTimeSec,
appliedIndicatorParams,
);
if (session !== evalSessionRef.current) return;
setSnapshot(snap ?? undefined);
} catch (e) {
if (session !== evalSessionRef.current) return;
setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
setSnapshot(undefined);
} finally {
setEvalLoading(false);
if (session === evalSessionRef.current) {
setEvalLoading(false);
}
}
}, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, appliedIndicatorParams]);
}, [
chartLoading,
market,
selectedStrategyId,
selectedStrategy,
selectedBarTimeSec,
bars.length,
appliedIndicatorParams,
]);
useEffect(() => {
void refreshEvaluation();
@@ -225,8 +284,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
</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={`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"
@@ -306,8 +369,16 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
/>
)}
</div>
</aside>
</aside>
<SePanelCollapseHandle
side="left"
open={leftOpen}
onToggle={toggleLeftPanel}
label="전략 패널"
/>
</div>
{leftOpen && (
<div
className="btd-splitter btd-splitter--v"
role="separator"
@@ -315,8 +386,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
aria-label="좌측 패널 너비"
onPointerDown={onLeftSplit}
/>
)}
<main className="btd-main">
<main className={`btd-main${leftOpen ? '' : ' seval-main--left-collapsed'}`}>
<div className="btd-main-content seval-main-content">
<StrategyEvaluationChart
market={market}
@@ -328,6 +400,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
onStrategySelect={handleSelectStrategyById}
onMarketChange={setMarket}
onTimeframeChoiceChange={setTimeframeChoice}
onChartLoadingChange={setChartLoading}
theme={theme}
strategy={selectedStrategy}
selectedBarIndex={selectedBarIndex}
@@ -336,6 +409,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
paramsRevision={paramsRevision}
getParamsOverride={getEvalParams}
getVisualConfigOverride={getVisualConfig}
evalLoading={evalLoading}
/>
</div>
</main>
@@ -351,19 +425,33 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
<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>
<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>}
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode="summary"
loading={evalLoading}
hasStrategy={selectedStrategyId != null}
className="seval-signal-panel"
/>
<div className="seval-signal-body">
{evalLoading && (
<div className="seval-eval-loading" aria-live="polite" aria-busy="true">
<span className="btd-run-spinner seval-eval-loading-spinner" aria-hidden />
<span className="seval-eval-loading-text"> </span>
</div>
)}
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode="summary"
loading={evalLoading}
loadingMessage="전략 조건 확인 중…"
hasStrategy={selectedStrategyId != null}
className={`seval-signal-panel${evalLoading ? ' seval-signal-panel--loading' : ''}`}
/>
</div>
<footer className="seval-signal-footer">
<span className="seval-updated">