전략평가 화면 전략 일괄평가 기능
This commit is contained in:
Generated
+27
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
@@ -1155,6 +1156,32 @@
|
||||
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||
"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": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -213,6 +216,54 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -220,6 +271,15 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -948,9 +1008,123 @@
|
||||
.seval-header-analysis {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
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 {
|
||||
font-size: 0.72rem;
|
||||
color: var(--se-text-muted);
|
||||
@@ -985,7 +1159,8 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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": {
|
||||
"name": "@goldenchart/desktop",
|
||||
"version": "0.1.48",
|
||||
"version": "0.1.59",
|
||||
"dependencies": {
|
||||
"@goldenchart/shared": "*",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
@@ -81,6 +81,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
@@ -1534,6 +1535,32 @@
|
||||
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||
"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": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||
|
||||
Reference in New Issue
Block a user