전략평가 화면 전략 일괄평가 기능
This commit is contained in:
@@ -7,6 +7,7 @@ import '../styles/strategyEditorTheme.css';
|
||||
import '../styles/strategyEditor.css';
|
||||
import '../styles/strategyEvaluation.css';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { Theme, Timeframe } from '../types';
|
||||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
@@ -72,6 +73,8 @@ import {
|
||||
buildAiPanelSelectionSummary,
|
||||
} from '../utils/strategyEvaluationAiVerify';
|
||||
import type { StrategyEvaluationAiVerifyResponse } from '../utils/backendApi';
|
||||
import { useStrategyBulkEvaluation } from '../hooks/useStrategyBulkEvaluation';
|
||||
import StrategyBulkEvalBadge, { StrategyBulkEvalToolbar } from './strategyEvaluation/StrategyBulkEvalControls';
|
||||
|
||||
const LEFT_KEY = 'seval-left-width';
|
||||
const LEFT_OPEN_KEY = 'seval-left-open';
|
||||
@@ -91,7 +94,26 @@ interface Props {
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
const strategyEvaluationQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
gcTime: 10 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function StrategyEvaluationPage(props: Props) {
|
||||
return (
|
||||
<QueryClientProvider client={strategyEvaluationQueryClient}>
|
||||
<StrategyEvaluationPageInner {...props} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyEvaluationPageInner({ theme = 'dark' }: Props) {
|
||||
const {
|
||||
getParams: baseGetParams,
|
||||
getVisualConfig,
|
||||
@@ -413,6 +435,72 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
});
|
||||
}, [selectedStrategy, backtestSignals, initialCapital, market]);
|
||||
|
||||
const bulkEval = useStrategyBulkEvaluation({
|
||||
strategies,
|
||||
market,
|
||||
timeframe: chartTimeframe,
|
||||
bars,
|
||||
evaluationBarCount: evalWindowMeta.evaluationBarCount,
|
||||
windowEndTimeSec: evalWindowMeta.windowEndTimeSec,
|
||||
initialCapital,
|
||||
selectedStrategyId,
|
||||
getParams: getEvalParams,
|
||||
appliedIndicatorParams,
|
||||
});
|
||||
|
||||
const autoBulkEvalTriggerKey = useMemo(() => {
|
||||
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
|
||||
const strategyIds = strategies.map(s => s.id).filter(Boolean).join(',');
|
||||
return [
|
||||
market,
|
||||
chartTimeframe,
|
||||
selectedStrategyId ?? 0,
|
||||
evalWindowMeta.windowEndTimeSec ?? 'latest',
|
||||
evalWindowMeta.evaluationBarCount,
|
||||
bars.length,
|
||||
lastBarTime,
|
||||
paramsRevision,
|
||||
strategyIds,
|
||||
].join('|');
|
||||
}, [
|
||||
market,
|
||||
chartTimeframe,
|
||||
selectedStrategyId,
|
||||
evalWindowMeta.windowEndTimeSec,
|
||||
evalWindowMeta.evaluationBarCount,
|
||||
bars,
|
||||
paramsRevision,
|
||||
strategies,
|
||||
]);
|
||||
|
||||
const lastAutoBulkEvalKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartLoading) {
|
||||
bulkEval.cancelBulkEvaluation();
|
||||
}
|
||||
}, [chartLoading, bulkEval.cancelBulkEvaluation]);
|
||||
|
||||
useEffect(() => {
|
||||
const waitingForSelectedSignals = selectedStrategyId != null && signalScanRunning;
|
||||
if (chartLoading || waitingForSelectedSignals) return;
|
||||
if (bars.length < 10 || !strategies.some(s => s.id != null)) return;
|
||||
|
||||
const key = autoBulkEvalTriggerKey;
|
||||
if (lastAutoBulkEvalKeyRef.current === key) return;
|
||||
lastAutoBulkEvalKeyRef.current = key;
|
||||
|
||||
void bulkEval.startBulkEvaluation({ restart: true });
|
||||
}, [
|
||||
autoBulkEvalTriggerKey,
|
||||
chartLoading,
|
||||
signalScanRunning,
|
||||
selectedStrategyId,
|
||||
bars.length,
|
||||
strategies,
|
||||
bulkEval.startBulkEvaluation,
|
||||
]);
|
||||
|
||||
const refreshEvaluation = useCallback(async () => {
|
||||
if (!selectedStrategyId || !selectedStrategy) {
|
||||
setSnapshot(undefined);
|
||||
@@ -687,11 +775,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
|
||||
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>
|
||||
{selectedStrategy && (signalScanRunning || analysisSummary) && (
|
||||
<header className="btd-header btd-header--seval-summary">
|
||||
<div className="btd-header-actions seval-header-analysis">
|
||||
{selectedStrategy && signalScanRunning && (
|
||||
<span className="seval-header-analysis-busy">시그널 계산 중…</span>
|
||||
@@ -732,6 +817,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="btd-body">
|
||||
<div
|
||||
@@ -740,6 +826,28 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
>
|
||||
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
|
||||
<div className="btd-exec-list seval-left-panel">
|
||||
<div className="seval-left-panel-header">
|
||||
<h1 className="seval-left-panel-title">전략 평가</h1>
|
||||
<div className="seval-left-panel-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="seval-strategy-add-btn seval-strategy-add-btn--panel"
|
||||
title="전략 추가·편집"
|
||||
aria-label="전략 추가·편집"
|
||||
onClick={() => setEditorModalOpen(true)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<StrategyBulkEvalToolbar
|
||||
isRunning={bulkEval.isRunning}
|
||||
canStart={bulkEval.canStart}
|
||||
onStart={() => { void bulkEval.startBulkEvaluation({ restart: true }); }}
|
||||
onCancel={bulkEval.cancelBulkEvaluation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="seval-left-tabs-row">
|
||||
<div className="btd-exec-tabs" role="tablist">
|
||||
<button
|
||||
@@ -771,19 +879,6 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
전략설정
|
||||
</button>
|
||||
</div>
|
||||
{leftTab === 'strategy' && (
|
||||
<button
|
||||
type="button"
|
||||
className="seval-strategy-add-btn"
|
||||
title="전략 추가·편집"
|
||||
aria-label="전략 추가·편집"
|
||||
onClick={() => setEditorModalOpen(true)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{leftTab === 'strategy' ? (
|
||||
@@ -823,6 +918,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
>
|
||||
<div className="btd-exec-item-top">
|
||||
<span className="btd-exec-strategy">{name}</span>
|
||||
{s.id != null && (
|
||||
<StrategyBulkEvalBadge
|
||||
strategyId={s.id}
|
||||
scanKey={bulkEval.scanKey}
|
||||
isRunning={bulkEval.isRunning}
|
||||
isCurrent={bulkEval.currentStrategyId === s.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{s.description && (
|
||||
<p className="btd-exec-meta">{repairUtf8Mojibake(s.description)}</p>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { formatEvaluationReturnPct } from '../../utils/strategyEvaluationReport';
|
||||
import { useStrategyBulkEvalReturns } from '../../hooks/useStrategyBulkEvaluation';
|
||||
|
||||
function ReturnValue({ pct, label }: { pct: number; label: string }) {
|
||||
const pos = pct > 0;
|
||||
const neg = pct < 0;
|
||||
const zero = !pos && !neg;
|
||||
return (
|
||||
<span
|
||||
className={`seval-bulk-item-return${
|
||||
pos ? ' seval-bulk-item-return--pos' : neg ? ' seval-bulk-item-return--neg' : zero ? ' seval-bulk-item-return--zero' : ''
|
||||
}`}
|
||||
title={`${label} 수익률`}
|
||||
>
|
||||
<span className="seval-bulk-item-return-label">{label}</span>
|
||||
{formatEvaluationReturnPct(pct)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
strategyId: number;
|
||||
scanKey: string;
|
||||
isRunning: boolean;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export default function StrategyBulkEvalBadge({
|
||||
strategyId,
|
||||
scanKey,
|
||||
isRunning,
|
||||
isCurrent,
|
||||
}: Props) {
|
||||
const { data: returns, isFetching } = useStrategyBulkEvalReturns(strategyId, scanKey);
|
||||
|
||||
if (isRunning && isCurrent) {
|
||||
return (
|
||||
<span className="seval-bulk-item-badge seval-bulk-item-badge--loading" aria-label="평가 중">
|
||||
<span className="btd-run-spinner seval-bulk-item-spinner" aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!returns) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<span className="seval-bulk-item-badge seval-bulk-item-badge--loading" aria-hidden>
|
||||
<span className="btd-run-spinner seval-bulk-item-spinner" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="seval-bulk-item-returns" title="일괄매수 · 분할매수 수익률">
|
||||
<ReturnValue pct={returns.fullBuyReturnPct} label="일괄" />
|
||||
<ReturnValue pct={returns.splitBuyReturnPct} label="분할" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
isRunning: boolean;
|
||||
canStart: boolean;
|
||||
onStart: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function StrategyBulkEvalToolbar({
|
||||
isRunning,
|
||||
canStart,
|
||||
onStart,
|
||||
onCancel,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="seval-bulk-eval-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`seval-bulk-eval-btn${isRunning ? ' seval-bulk-eval-btn--running' : ''}`}
|
||||
title={isRunning ? '전략 일괄 평가 중… (클릭 시 중지)' : '전략 목록 일괄 평가 (일괄·분할매수)'}
|
||||
aria-label={isRunning ? '전략 일괄 평가 중지' : '전략 일괄 평가'}
|
||||
disabled={!isRunning && !canStart}
|
||||
onClick={() => {
|
||||
if (isRunning) {
|
||||
onCancel?.();
|
||||
} else {
|
||||
void onStart();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isRunning ? (
|
||||
<span className="btd-run-spinner seval-bulk-eval-btn-spinner" aria-hidden />
|
||||
) : (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user