전략평가 화면 전략 일괄평가 기능

This commit is contained in:
Macbook
2026-06-19 20:26:59 +09:00
parent 994a2024c7
commit cc30d317ec
8 changed files with 683 additions and 21 deletions
@@ -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>
);
}