전략평가 화면 전략 일괄평가 기능
This commit is contained in:
Generated
+27
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-virtual": "^3.14.2",
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"lightweight-charts": "^5.2.0",
|
"lightweight-charts": "^5.2.0",
|
||||||
@@ -1155,6 +1156,32 @@
|
|||||||
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.101.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||||
|
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.101.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||||
|
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.101.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-virtual": "^3.14.2",
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"lightweight-charts": "^5.2.0",
|
"lightweight-charts": "^5.2.0",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../styles/strategyEditorTheme.css';
|
|||||||
import '../styles/strategyEditor.css';
|
import '../styles/strategyEditor.css';
|
||||||
import '../styles/strategyEvaluation.css';
|
import '../styles/strategyEvaluation.css';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import type { Theme, Timeframe } from '../types';
|
import type { Theme, Timeframe } from '../types';
|
||||||
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +73,8 @@ import {
|
|||||||
buildAiPanelSelectionSummary,
|
buildAiPanelSelectionSummary,
|
||||||
} from '../utils/strategyEvaluationAiVerify';
|
} from '../utils/strategyEvaluationAiVerify';
|
||||||
import type { StrategyEvaluationAiVerifyResponse } from '../utils/backendApi';
|
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_KEY = 'seval-left-width';
|
||||||
const LEFT_OPEN_KEY = 'seval-left-open';
|
const LEFT_OPEN_KEY = 'seval-left-open';
|
||||||
@@ -91,7 +94,26 @@ interface Props {
|
|||||||
theme?: Theme;
|
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 {
|
const {
|
||||||
getParams: baseGetParams,
|
getParams: baseGetParams,
|
||||||
getVisualConfig,
|
getVisualConfig,
|
||||||
@@ -413,6 +435,72 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
});
|
});
|
||||||
}, [selectedStrategy, backtestSignals, initialCapital, market]);
|
}, [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 () => {
|
const refreshEvaluation = useCallback(async () => {
|
||||||
if (!selectedStrategyId || !selectedStrategy) {
|
if (!selectedStrategyId || !selectedStrategy) {
|
||||||
setSnapshot(undefined);
|
setSnapshot(undefined);
|
||||||
@@ -687,11 +775,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`btd-page se-page se-page--${theme}`}>
|
<div className={`btd-page se-page se-page--${theme}`}>
|
||||||
<header className="btd-header">
|
{selectedStrategy && (signalScanRunning || analysisSummary) && (
|
||||||
<div className="btd-header-brand">
|
<header className="btd-header btd-header--seval-summary">
|
||||||
<h1 className="btd-header-title">전략 평가</h1>
|
|
||||||
<span className="btd-header-sub">Strategy Match Analysis</span>
|
|
||||||
</div>
|
|
||||||
<div className="btd-header-actions seval-header-analysis">
|
<div className="btd-header-actions seval-header-analysis">
|
||||||
{selectedStrategy && signalScanRunning && (
|
{selectedStrategy && signalScanRunning && (
|
||||||
<span className="seval-header-analysis-busy">시그널 계산 중…</span>
|
<span className="seval-header-analysis-busy">시그널 계산 중…</span>
|
||||||
@@ -732,6 +817,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="btd-body">
|
<div className="btd-body">
|
||||||
<div
|
<div
|
||||||
@@ -740,6 +826,28 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
>
|
>
|
||||||
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
|
<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="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="seval-left-tabs-row">
|
||||||
<div className="btd-exec-tabs" role="tablist">
|
<div className="btd-exec-tabs" role="tablist">
|
||||||
<button
|
<button
|
||||||
@@ -771,19 +879,6 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
전략설정
|
전략설정
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{leftTab === 'strategy' ? (
|
{leftTab === 'strategy' ? (
|
||||||
@@ -823,6 +918,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="btd-exec-item-top">
|
<div className="btd-exec-item-top">
|
||||||
<span className="btd-exec-strategy">{name}</span>
|
<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>
|
</div>
|
||||||
{s.description && (
|
{s.description && (
|
||||||
<p className="btd-exec-meta">{repairUtf8Mojibake(s.description)}</p>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { OHLCVBar } from '../types';
|
||||||
|
import type { StrategyDto } from '../utils/backendApi';
|
||||||
|
import {
|
||||||
|
buildBarsFingerprint,
|
||||||
|
buildStrategyBulkEvalScanKey,
|
||||||
|
evaluateStrategyBulkReturns,
|
||||||
|
strategyBulkEvalKeys,
|
||||||
|
type StrategyBulkEvalResult,
|
||||||
|
} from '../utils/strategyBulkEvaluation';
|
||||||
|
import { mergeEvalGetParams } from '../utils/strategyEvaluationParams';
|
||||||
|
|
||||||
|
interface UseStrategyBulkEvaluationOpts {
|
||||||
|
strategies: StrategyDto[];
|
||||||
|
market: string;
|
||||||
|
timeframe: string;
|
||||||
|
bars: OHLCVBar[];
|
||||||
|
evaluationBarCount: number;
|
||||||
|
windowEndTimeSec: number | null;
|
||||||
|
initialCapital: number;
|
||||||
|
selectedStrategyId: number | null;
|
||||||
|
getParams: (type: string) => Record<string, number | string | boolean>;
|
||||||
|
appliedIndicatorParams: Record<string, Record<string, number | string | boolean>> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStrategyBulkEvaluation({
|
||||||
|
strategies,
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
bars,
|
||||||
|
evaluationBarCount,
|
||||||
|
windowEndTimeSec,
|
||||||
|
initialCapital,
|
||||||
|
selectedStrategyId,
|
||||||
|
getParams,
|
||||||
|
appliedIndicatorParams,
|
||||||
|
}: UseStrategyBulkEvaluationOpts) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [currentStrategyId, setCurrentStrategyId] = useState<number | null>(null);
|
||||||
|
const runGenRef = useRef(0);
|
||||||
|
|
||||||
|
const scanKey = useMemo(
|
||||||
|
() => buildStrategyBulkEvalScanKey({
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
windowEndTimeSec,
|
||||||
|
evaluationBarCount,
|
||||||
|
barsFingerprint: buildBarsFingerprint(bars),
|
||||||
|
}),
|
||||||
|
[market, timeframe, windowEndTimeSec, evaluationBarCount, bars],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getParamsForStrategy = useCallback((strategy: StrategyDto) => {
|
||||||
|
if (strategy.id === selectedStrategyId && appliedIndicatorParams) {
|
||||||
|
return mergeEvalGetParams(getParams, appliedIndicatorParams);
|
||||||
|
}
|
||||||
|
return getParams;
|
||||||
|
}, [selectedStrategyId, appliedIndicatorParams, getParams]);
|
||||||
|
|
||||||
|
const startBulkEvaluation = useCallback(async (options?: { restart?: boolean }) => {
|
||||||
|
if (!options?.restart && isRunning) return;
|
||||||
|
if (bars.length < 10) return;
|
||||||
|
const list = strategies.filter((s): s is StrategyDto & { id: number } => s.id != null);
|
||||||
|
if (list.length === 0) return;
|
||||||
|
|
||||||
|
const gen = ++runGenRef.current;
|
||||||
|
setIsRunning(true);
|
||||||
|
setCurrentStrategyId(null);
|
||||||
|
|
||||||
|
for (const strategy of list) {
|
||||||
|
if (gen !== runGenRef.current) break;
|
||||||
|
setCurrentStrategyId(strategy.id);
|
||||||
|
|
||||||
|
const queryKey = strategyBulkEvalKeys.returns(scanKey, strategy.id);
|
||||||
|
await queryClient.fetchQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => evaluateStrategyBulkReturns({
|
||||||
|
strategy,
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
bars,
|
||||||
|
evaluationBarCount,
|
||||||
|
initialCapital,
|
||||||
|
getParams: getParamsForStrategy(strategy),
|
||||||
|
}),
|
||||||
|
staleTime: 0,
|
||||||
|
gcTime: 10 * 60_000,
|
||||||
|
}).catch(() => {
|
||||||
|
queryClient.setQueryData(queryKey, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>(resolve => { setTimeout(resolve, 0); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gen === runGenRef.current) {
|
||||||
|
setCurrentStrategyId(null);
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isRunning,
|
||||||
|
bars,
|
||||||
|
strategies,
|
||||||
|
queryClient,
|
||||||
|
scanKey,
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
evaluationBarCount,
|
||||||
|
initialCapital,
|
||||||
|
getParamsForStrategy,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cancelBulkEvaluation = useCallback(() => {
|
||||||
|
runGenRef.current += 1;
|
||||||
|
setCurrentStrategyId(null);
|
||||||
|
setIsRunning(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRunning,
|
||||||
|
currentStrategyId,
|
||||||
|
scanKey,
|
||||||
|
startBulkEvaluation,
|
||||||
|
cancelBulkEvaluation,
|
||||||
|
canStart: bars.length >= 10 && strategies.some(s => s.id != null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목록 아이템 — React Query 캐시 구독 */
|
||||||
|
export function useStrategyBulkEvalReturns(
|
||||||
|
strategyId: number | null | undefined,
|
||||||
|
scanKey: string,
|
||||||
|
) {
|
||||||
|
return useQuery<StrategyBulkEvalResult | null>({
|
||||||
|
queryKey: strategyId != null
|
||||||
|
? strategyBulkEvalKeys.returns(scanKey, strategyId)
|
||||||
|
: ['strategyBulkEval', 'idle'],
|
||||||
|
queryFn: async () => null,
|
||||||
|
enabled: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
gcTime: 10 * 60_000,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -167,6 +167,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seval-left-panel {
|
.seval-left-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +216,54 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-left-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 2px 10px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-bottom: 1px solid var(--btd-divider, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-panel-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
background: var(--se-title-gradient, linear-gradient(90deg, var(--se-gold), #ffe566));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-left-panel-toolbar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-strategy-add-btn--panel {
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-header--seval-summary {
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-header--seval-summary:empty,
|
||||||
|
.btd-header--seval-summary .seval-header-analysis:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-left-tabs-row {
|
.seval-left-tabs-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -220,6 +271,15 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-left-tabs-row-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-left-tabs-row .btd-exec-tabs {
|
.seval-left-tabs-row .btd-exec-tabs {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -948,9 +1008,123 @@
|
|||||||
.seval-header-analysis {
|
.seval-header-analysis {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-toolbar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-mode {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border, #30363d);
|
||||||
|
background: var(--se-input-bg, var(--bg2));
|
||||||
|
color: var(--se-text, var(--text));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-mode:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--se-border, #30363d);
|
||||||
|
background: var(--se-input-bg, var(--bg2));
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--btd-gold, var(--se-gold));
|
||||||
|
color: var(--btd-gold, var(--se-gold));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-btn--running {
|
||||||
|
border-color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 45%, var(--se-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-eval-btn-spinner,
|
||||||
|
.seval-bulk-item-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-returns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-return-label {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-return {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-return--zero {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-return--pos {
|
||||||
|
color: #b6ff5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-return--neg {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seval-bulk-item-badge--loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.seval-header-analysis-busy {
|
.seval-header-analysis-busy {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--se-text-muted);
|
color: var(--se-text-muted);
|
||||||
@@ -985,7 +1159,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.seval-analysis-return--pos strong {
|
.seval-analysis-return--pos strong {
|
||||||
color: #3fb950;
|
color: #b6ff5a;
|
||||||
|
text-shadow: 0 0 8px color-mix(in srgb, #b6ff5a 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-analysis-return--neg strong {
|
.seval-analysis-return--neg strong {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { OHLCVBar } from '../types';
|
||||||
|
import type { StrategyDto } from './backendApi';
|
||||||
|
import { fetchLiveConditionScanSignals } from './backendApi';
|
||||||
|
import { buildEvalParamsFromStrategy } from './strategyEvaluationParams';
|
||||||
|
import { buildStrategyEvaluationToolbarSummary } from './strategyEvaluationReport';
|
||||||
|
|
||||||
|
export interface StrategyBulkEvalResult {
|
||||||
|
fullBuyReturnPct: number;
|
||||||
|
splitBuyReturnPct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyBulkEvalContext {
|
||||||
|
market: string;
|
||||||
|
timeframe: string;
|
||||||
|
windowEndTimeSec: number | null;
|
||||||
|
evaluationBarCount: number;
|
||||||
|
barsFingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStrategyBulkEvalScanKey(ctx: StrategyBulkEvalContext): string {
|
||||||
|
const windowKey = ctx.windowEndTimeSec ?? 'latest';
|
||||||
|
return `${ctx.market}|${ctx.timeframe}|${windowKey}|${ctx.evaluationBarCount}|${ctx.barsFingerprint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBarsFingerprint(bars: OHLCVBar[]): string {
|
||||||
|
if (bars.length === 0) return '0';
|
||||||
|
const first = bars[0];
|
||||||
|
const last = bars[bars.length - 1];
|
||||||
|
return `${bars.length}:${first.time}:${last.time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const strategyBulkEvalKeys = {
|
||||||
|
all: ['strategyBulkEval'] as const,
|
||||||
|
returns: (scanKey: string, strategyId: number) =>
|
||||||
|
[...strategyBulkEvalKeys.all, scanKey, strategyId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function evaluateStrategyBulkReturns(opts: {
|
||||||
|
strategy: StrategyDto;
|
||||||
|
market: string;
|
||||||
|
timeframe: string;
|
||||||
|
bars: OHLCVBar[];
|
||||||
|
evaluationBarCount: number;
|
||||||
|
initialCapital: number;
|
||||||
|
getParams: (type: string) => Record<string, number | string | boolean>;
|
||||||
|
}): Promise<StrategyBulkEvalResult> {
|
||||||
|
const strategyId = opts.strategy.id;
|
||||||
|
if (!strategyId || opts.bars.length < 10) {
|
||||||
|
throw new Error('평가 데이터 부족');
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams);
|
||||||
|
const signals = await fetchLiveConditionScanSignals(
|
||||||
|
opts.market,
|
||||||
|
strategyId,
|
||||||
|
opts.bars.map(b => ({
|
||||||
|
time: b.time,
|
||||||
|
open: b.open,
|
||||||
|
high: b.high,
|
||||||
|
low: b.low,
|
||||||
|
close: b.close,
|
||||||
|
volume: b.volume,
|
||||||
|
})),
|
||||||
|
opts.timeframe,
|
||||||
|
indicatorParams,
|
||||||
|
opts.evaluationBarCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = buildStrategyEvaluationToolbarSummary(signals, {
|
||||||
|
initialCapital: opts.initialCapital,
|
||||||
|
symbol: opts.market.replace(/^KRW-/, ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullBuyReturnPct: summary.fullBuyReturnPct,
|
||||||
|
splitBuyReturnPct: summary.splitBuyReturnPct,
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+28
-1
@@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
"desktop": {
|
"desktop": {
|
||||||
"name": "@goldenchart/desktop",
|
"name": "@goldenchart/desktop",
|
||||||
"version": "0.1.48",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@goldenchart/shared": "*",
|
"@goldenchart/shared": "*",
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@tanstack/react-virtual": "^3.14.2",
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"lightweight-charts": "^5.2.0",
|
"lightweight-charts": "^5.2.0",
|
||||||
@@ -1534,6 +1535,32 @@
|
|||||||
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.101.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||||
|
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.101.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||||
|
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.101.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user