전략평가 화면 기능 수정

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 VirtualTargetSignalPanel from './virtual/VirtualTargetSignalPanel';
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; 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 { getKoreanName } from '../utils/marketNameCache';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import { import {
@@ -36,6 +37,7 @@ import {
import type { OHLCVBar } from '../types'; import type { OHLCVBar } from '../types';
const LEFT_KEY = 'seval-left-width'; const LEFT_KEY = 'seval-left-width';
const LEFT_OPEN_KEY = 'seval-left-open';
const RIGHT_KEY = 'seval-right-width'; const RIGHT_KEY = 'seval-right-width';
const LEFT_DEFAULT = 380; const LEFT_DEFAULT = 380;
const RIGHT_DEFAULT = 440; const RIGHT_DEFAULT = 440;
@@ -68,6 +70,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [evalError, setEvalError] = useState<string | null>(null); const [evalError, setEvalError] = useState<string | null>(null);
const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null); const [appliedIndicatorParams, setAppliedIndicatorParams] = useState<EvalIndicatorParams | null>(null);
const [paramsRevision, setParamsRevision] = useState(0); const [paramsRevision, setParamsRevision] = useState(0);
const [chartLoading, setChartLoading] = useState(false);
const evalSessionRef = useRef(0);
const paramsRevisionRef = useRef(paramsRevision);
const getEvalParams = useMemo( const getEvalParams = useMemo(
() => mergeEvalGetParams(baseGetParams, appliedIndicatorParams), () => mergeEvalGetParams(baseGetParams, appliedIndicatorParams),
@@ -75,6 +80,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
); );
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT)); 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 [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
const leftRef = useRef(leftWidth); const leftRef = useRef(leftWidth);
const rightRef = useRef(rightWidth); const rightRef = useRef(rightWidth);
@@ -82,6 +88,19 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
rightRef.current = rightWidth; rightRef.current = rightWidth;
const onLeftSplit = usePanelResize('vertical', setLeftWidth, () => leftRef.current, 300, 520, v => storeSize(LEFT_KEY, v)); 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 onRightSplit = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const startX = e.clientX; const startX = e.clientX;
const start = rightRef.current; const start = rightRef.current;
@@ -121,6 +140,27 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
[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(() => { useEffect(() => {
void loadStrategies().then(list => setStrategies(list ?? [])); void loadStrategies().then(list => setStrategies(list ?? []));
}, []); }, []);
@@ -150,10 +190,17 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null; const selectedBarTimeSec = bars[selectedBarIndex]?.time ?? null;
const refreshEvaluation = useCallback(async () => { 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); setSnapshot(undefined);
return; return;
} }
const session = evalSessionRef.current;
setEvalLoading(true); setEvalLoading(true);
setEvalError(null); setEvalError(null);
try { try {
@@ -164,14 +211,26 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
selectedBarTimeSec, selectedBarTimeSec,
appliedIndicatorParams, appliedIndicatorParams,
); );
if (session !== evalSessionRef.current) return;
setSnapshot(snap ?? undefined); setSnapshot(snap ?? undefined);
} catch (e) { } catch (e) {
if (session !== evalSessionRef.current) return;
setEvalError(e instanceof Error ? e.message : '조건 평가 실패'); setEvalError(e instanceof Error ? e.message : '조건 평가 실패');
setSnapshot(undefined); setSnapshot(undefined);
} finally { } finally {
if (session === evalSessionRef.current) {
setEvalLoading(false); setEvalLoading(false);
} }
}, [market, selectedStrategyId, selectedStrategy, selectedBarTimeSec, appliedIndicatorParams]); }
}, [
chartLoading,
market,
selectedStrategyId,
selectedStrategy,
selectedBarTimeSec,
bars.length,
appliedIndicatorParams,
]);
useEffect(() => { useEffect(() => {
void refreshEvaluation(); void refreshEvaluation();
@@ -225,7 +284,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
</header> </header>
<div className="btd-body"> <div className="btd-body">
<aside className="btd-sidebar" style={{ width: leftWidth, flex: `0 0 ${leftWidth}px` }}> <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-list seval-left-panel">
<div className="btd-exec-tabs" role="tablist"> <div className="btd-exec-tabs" role="tablist">
<button <button
@@ -307,7 +370,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
)} )}
</div> </div>
</aside> </aside>
<SePanelCollapseHandle
side="left"
open={leftOpen}
onToggle={toggleLeftPanel}
label="전략 패널"
/>
</div>
{leftOpen && (
<div <div
className="btd-splitter btd-splitter--v" className="btd-splitter btd-splitter--v"
role="separator" role="separator"
@@ -315,8 +386,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
aria-label="좌측 패널 너비" aria-label="좌측 패널 너비"
onPointerDown={onLeftSplit} onPointerDown={onLeftSplit}
/> />
)}
<main className="btd-main"> <main className={`btd-main${leftOpen ? '' : ' seval-main--left-collapsed'}`}>
<div className="btd-main-content seval-main-content"> <div className="btd-main-content seval-main-content">
<StrategyEvaluationChart <StrategyEvaluationChart
market={market} market={market}
@@ -328,6 +400,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
onStrategySelect={handleSelectStrategyById} onStrategySelect={handleSelectStrategyById}
onMarketChange={setMarket} onMarketChange={setMarket}
onTimeframeChoiceChange={setTimeframeChoice} onTimeframeChoiceChange={setTimeframeChoice}
onChartLoadingChange={setChartLoading}
theme={theme} theme={theme}
strategy={selectedStrategy} strategy={selectedStrategy}
selectedBarIndex={selectedBarIndex} selectedBarIndex={selectedBarIndex}
@@ -336,6 +409,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
paramsRevision={paramsRevision} paramsRevision={paramsRevision}
getParamsOverride={getEvalParams} getParamsOverride={getEvalParams}
getVisualConfigOverride={getVisualConfig} getVisualConfigOverride={getVisualConfig}
evalLoading={evalLoading}
/> />
</div> </div>
</main> </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` }}> <aside className="btd-right seval-right-panel" style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}>
<div className="seval-signal-head"> <div className="seval-signal-head">
<span className="seval-signal-market">{getKoreanName(market)}</span> <span className="seval-signal-market">{getKoreanName(market)}</span>
<span className="seval-live-dot" title="선택 봉 기준 평가" /> <span
<span className="seval-live-label"> </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> </div>
{evalError && <p className="seval-eval-error">{evalError}</p>} {evalError && <p className="seval-eval-error">{evalError}</p>}
<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 <VirtualTargetSignalPanel
snapshot={snapshot} snapshot={snapshot}
viewMode="summary" viewMode="summary"
loading={evalLoading} loading={evalLoading}
loadingMessage="전략 조건 확인 중…"
hasStrategy={selectedStrategyId != null} hasStrategy={selectedStrategyId != null}
className="seval-signal-panel" className={`seval-signal-panel${evalLoading ? ' seval-signal-panel--loading' : ''}`}
/> />
</div>
<footer className="seval-signal-footer"> <footer className="seval-signal-footer">
<span className="seval-updated"> <span className="seval-updated">
@@ -36,6 +36,7 @@ interface Props {
onStrategySelect: (strategyId: number) => void; onStrategySelect: (strategyId: number) => void;
onMarketChange: (market: string) => void; onMarketChange: (market: string) => void;
onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void; onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void;
onChartLoadingChange?: (loading: boolean) => void;
theme?: Theme; theme?: Theme;
strategy: StrategyDto | null; strategy: StrategyDto | null;
selectedBarIndex: number; selectedBarIndex: number;
@@ -55,6 +56,8 @@ interface Props {
hlines: import('../../utils/indicatorRegistry').HLineDef[]; hlines: import('../../utils/indicatorRegistry').HLineDef[];
cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors; cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors;
}; };
/** 선택 봉 조건 평가 진행 중 */
evalLoading?: boolean;
} }
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
@@ -76,6 +79,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onStrategySelect, onStrategySelect,
onMarketChange, onMarketChange,
onTimeframeChoiceChange, onTimeframeChoiceChange,
onChartLoadingChange,
theme = 'dark', theme = 'dark',
strategy, strategy,
selectedBarIndex, selectedBarIndex,
@@ -84,6 +88,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
paramsRevision = 0, paramsRevision = 0,
getParamsOverride, getParamsOverride,
getVisualConfigOverride, getVisualConfigOverride,
evalLoading = false,
}) => { }) => {
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings(); const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
const getParams = getParamsOverride ?? baseGetParams; const getParams = getParamsOverride ?? baseGetParams;
@@ -206,14 +211,23 @@ const StrategyEvaluationChart: React.FC<Props> = ({
} }
void runBacktestForBars(bars); void runBacktestForBars(bars);
return () => { ++backtestGenRef.current; }; return () => { ++backtestGenRef.current; };
}, [strategy?.id, bars, loading, runBacktestForBars]); }, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runBacktestForBars]);
useEffect(() => { useEffect(() => {
applyMarkers(); applyMarkers();
}, [signals, applyMarkers]); }, [signals, applyMarkers]);
useEffect(() => {
onChartLoadingChange?.(loading);
}, [loading, onChartLoadingChange]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
++backtestGenRef.current;
setSignals([]);
setBacktestError(null);
setBacktestRunning(false);
managerRef.current?.clearBacktestMarkers();
setLoading(true); setLoading(true);
setError(null); setError(null);
const toTimeSec = Math.floor(Date.now() / 1000); const toTimeSec = Math.floor(Date.now() / 1000);
@@ -235,7 +249,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [market, timeframe, onBarsLoaded, onSelectedBarIndexChange]); }, [market, timeframe, strategy?.id, onBarsLoaded, onSelectedBarIndexChange]);
const scrollToBar = useCallback((barIdx: number) => { const scrollToBar = useCallback((barIdx: number) => {
const mgr = chartMgr; const mgr = chartMgr;
@@ -351,7 +365,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
))} ))}
</select> </select>
{selectedBar && ( {selectedBar && (
<span className="seval-chart-bar-time"> <span className={`seval-chart-bar-time${evalLoading ? ' seval-chart-bar-time--loading' : ''}`}>
{evalLoading && (
<span className="btd-run-spinner seval-chart-bar-spinner" aria-hidden />
)}
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')} {new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
</span> </span>
)} )}
@@ -18,6 +18,8 @@ interface Props {
receiving?: boolean; receiving?: boolean;
/** 업비트 캔들 warm-up · live-conditions 수집 중 */ /** 업비트 캔들 warm-up · live-conditions 수집 중 */
loading?: boolean; loading?: boolean;
/** loading=true 일 때 빈 패널 문구 (미지정 시 기본) */
loadingMessage?: string;
/** false — 전략 미지정 안내 */ /** false — 전략 미지정 안내 */
hasStrategy?: boolean; hasStrategy?: boolean;
className?: string; className?: string;
@@ -28,6 +30,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
viewMode = 'summary', viewMode = 'summary',
receiving = false, receiving = false,
loading = false, loading = false,
loadingMessage,
hasStrategy = true, hasStrategy = true,
className = '', className = '',
}) => { }) => {
@@ -51,7 +54,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
return ( return (
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}> <p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
{loading {loading
? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)' ? (loadingMessage ?? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)')
: !hasStrategy : !hasStrategy
? '투자전략을 선택하세요 (상단 또는 종목별 전략)' ? '투자전략을 선택하세요 (상단 또는 종목별 전략)'
: '전략 조건·지표 데이터 없음'} : '전략 조건·지표 데이터 없음'}
+144
View File
@@ -1,5 +1,81 @@
/* ── 전략 평가 — btd-page 레이아웃 위 보조 스타일 ─────────────────────────── */ /* ── 전략 평가 — btd-page 레이아웃 위 보조 스타일 ─────────────────────────── */
/* ── 좌측 패널 접기 ───────────────────────────────────────────────────────── */
.seval-left-wrap {
display: flex;
flex-direction: row;
align-items: stretch;
flex-shrink: 0;
min-height: 0;
height: 100%;
position: relative;
z-index: 8;
}
.seval-left-sidebar {
width: 0;
flex: 0 0 0;
min-width: 0;
max-width: 0;
padding: 0;
border-right: none;
overflow: hidden;
transition:
width 0.22s cubic-bezier(0.4, 0, 0.2, 1),
flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.seval-left-sidebar--open {
width: var(--seval-left-width, 380px);
flex: 0 0 var(--seval-left-width, 380px);
max-width: var(--seval-left-width, 380px);
padding: 10px 8px;
border-right: 1px solid var(--btd-divider, var(--se-border));
}
.seval-left-wrap .se-panel-handle {
align-self: center;
flex-shrink: 0;
position: relative;
z-index: 20;
width: 16px;
height: 56px;
border: 1px solid var(--se-border);
color: var(--se-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
padding: 0;
background: transparent;
}
.seval-left-wrap .se-panel-handle--left {
margin-left: -1px;
background: var(--se-bg-elevated, var(--bg2));
border-left: none;
border-radius: 0 8px 8px 0;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.35);
}
.seval-left-wrap .se-panel-handle--left:hover {
color: var(--se-text);
background: var(--se-bg-muted, var(--bg3));
box-shadow: 3px 0 14px rgba(0, 0, 0, 0.45);
}
.seval-left-wrap--open .se-panel-handle--left,
.seval-left-wrap .se-panel-handle--left.se-panel-handle--open {
border-color: color-mix(in srgb, var(--se-accent, #3d9be9) 30%, var(--se-border));
}
.seval-main--left-collapsed {
flex: 1;
min-width: 0;
}
.seval-left-panel { .seval-left-panel {
height: 100%; height: 100%;
} }
@@ -55,6 +131,9 @@
} }
.seval-chart-bar-time { .seval-chart-bar-time {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.68rem; font-size: 0.68rem;
color: var(--btd-gold, var(--se-gold)); color: var(--btd-gold, var(--se-gold));
padding: 4px 8px; padding: 4px 8px;
@@ -64,6 +143,15 @@
white-space: nowrap; white-space: nowrap;
} }
.seval-chart-bar-time--loading {
border-color: color-mix(in srgb, var(--se-accent, #3d9be9) 45%, var(--se-border));
color: var(--se-text-muted);
}
.seval-chart-bar-spinner {
flex-shrink: 0;
}
.seval-chart-hint { .seval-chart-hint {
font-size: 0.66rem; font-size: 0.66rem;
color: var(--se-text-muted); color: var(--se-text-muted);
@@ -230,6 +318,12 @@
box-shadow: 0 0 8px #3fb950; box-shadow: 0 0 8px #3fb950;
} }
.seval-live-dot--loading {
background: var(--se-accent, #3d9be9);
box-shadow: 0 0 8px color-mix(in srgb, var(--se-accent, #3d9be9) 55%, transparent);
animation: seval-live-pulse 0.9s ease-in-out infinite;
}
.seval-live-label { .seval-live-label {
margin-left: auto; margin-left: auto;
font-size: 0.68rem; font-size: 0.68rem;
@@ -237,6 +331,50 @@
color: #3fb950; color: #3fb950;
} }
.seval-live-label--loading {
color: var(--se-accent, #3d9be9);
}
@keyframes seval-live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.88); }
}
.seval-signal-body {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.seval-eval-loading {
position: absolute;
inset: 0;
z-index: 6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--se-bg, #0d1117) 78%, transparent);
backdrop-filter: blur(2px);
pointer-events: none;
}
.seval-eval-loading-spinner {
width: 18px;
height: 18px;
border-width: 2px;
color: var(--se-accent, #3d9be9);
}
.seval-eval-loading-text {
font-size: 0.72rem;
font-weight: 600;
color: var(--se-text-muted);
}
.seval-signal-panel { .seval-signal-panel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -244,6 +382,12 @@
padding: 0 2px; padding: 0 2px;
} }
.seval-signal-panel--loading {
opacity: 0.42;
pointer-events: none;
user-select: none;
}
.seval-eval-error { .seval-eval-error {
margin: 0 4px 8px; margin: 0 4px 8px;
font-size: 0.72rem; font-size: 0.72rem;