전략평가 화면 기능 수정
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -36,6 +36,7 @@ interface Props {
|
||||
onStrategySelect: (strategyId: number) => void;
|
||||
onMarketChange: (market: string) => void;
|
||||
onTimeframeChoiceChange: (choice: BacktestRunTimeframeChoice) => void;
|
||||
onChartLoadingChange?: (loading: boolean) => void;
|
||||
theme?: Theme;
|
||||
strategy: StrategyDto | null;
|
||||
selectedBarIndex: number;
|
||||
@@ -55,6 +56,8 @@ interface Props {
|
||||
hlines: import('../../utils/indicatorRegistry').HLineDef[];
|
||||
cloudColors?: import('../../utils/ichimokuConfig').IchimokuCloudColors;
|
||||
};
|
||||
/** 선택 봉 조건 평가 진행 중 */
|
||||
evalLoading?: boolean;
|
||||
}
|
||||
|
||||
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
|
||||
@@ -76,6 +79,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
onStrategySelect,
|
||||
onMarketChange,
|
||||
onTimeframeChoiceChange,
|
||||
onChartLoadingChange,
|
||||
theme = 'dark',
|
||||
strategy,
|
||||
selectedBarIndex,
|
||||
@@ -84,6 +88,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
paramsRevision = 0,
|
||||
getParamsOverride,
|
||||
getVisualConfigOverride,
|
||||
evalLoading = false,
|
||||
}) => {
|
||||
const { getParams: baseGetParams, getVisualConfig: baseGetVisual } = useIndicatorSettings();
|
||||
const getParams = getParamsOverride ?? baseGetParams;
|
||||
@@ -206,14 +211,23 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
}
|
||||
void runBacktestForBars(bars);
|
||||
return () => { ++backtestGenRef.current; };
|
||||
}, [strategy?.id, bars, loading, runBacktestForBars]);
|
||||
}, [strategy?.id, market, timeframe, paramsRevision, bars, loading, runBacktestForBars]);
|
||||
|
||||
useEffect(() => {
|
||||
applyMarkers();
|
||||
}, [signals, applyMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
onChartLoadingChange?.(loading);
|
||||
}, [loading, onChartLoadingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
++backtestGenRef.current;
|
||||
setSignals([]);
|
||||
setBacktestError(null);
|
||||
setBacktestRunning(false);
|
||||
managerRef.current?.clearBacktestMarkers();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const toTimeSec = Math.floor(Date.now() / 1000);
|
||||
@@ -235,7 +249,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [market, timeframe, onBarsLoaded, onSelectedBarIndexChange]);
|
||||
}, [market, timeframe, strategy?.id, onBarsLoaded, onSelectedBarIndexChange]);
|
||||
|
||||
const scrollToBar = useCallback((barIdx: number) => {
|
||||
const mgr = chartMgr;
|
||||
@@ -351,7 +365,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
||||
))}
|
||||
</select>
|
||||
{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')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Props {
|
||||
receiving?: boolean;
|
||||
/** 업비트 캔들 warm-up · live-conditions 수집 중 */
|
||||
loading?: boolean;
|
||||
/** loading=true 일 때 빈 패널 문구 (미지정 시 기본) */
|
||||
loadingMessage?: string;
|
||||
/** false — 전략 미지정 안내 */
|
||||
hasStrategy?: boolean;
|
||||
className?: string;
|
||||
@@ -28,6 +30,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
viewMode = 'summary',
|
||||
receiving = false,
|
||||
loading = false,
|
||||
loadingMessage,
|
||||
hasStrategy = true,
|
||||
className = '',
|
||||
}) => {
|
||||
@@ -51,7 +54,7 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
return (
|
||||
<p className={`vtd-muted vtd-card-empty${loading ? ' vtd-card-empty--loading' : ''}${className ? ` ${className}` : ''}`}>
|
||||
{loading
|
||||
? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)'
|
||||
? (loadingMessage ?? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)')
|
||||
: !hasStrategy
|
||||
? '투자전략을 선택하세요 (상단 또는 종목별 전략)'
|
||||
: '전략 조건·지표 데이터 없음'}
|
||||
|
||||
Reference in New Issue
Block a user