+
+
전략 평가
+
+
+
{ void bulkEval.startBulkEvaluation({ restart: true }); }}
+ onCancel={bulkEval.cancelBulkEvaluation}
+ />
+
+
- {leftTab === 'strategy' && (
-
- )}
{leftTab === 'strategy' ? (
@@ -823,6 +918,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
>
{name}
+ {s.id != null && (
+
+ )}
{s.description && (
{repairUtf8Mojibake(s.description)}
diff --git a/frontend/src/components/strategyEvaluation/StrategyBulkEvalControls.tsx b/frontend/src/components/strategyEvaluation/StrategyBulkEvalControls.tsx
new file mode 100644
index 0000000..5ee64de
--- /dev/null
+++ b/frontend/src/components/strategyEvaluation/StrategyBulkEvalControls.tsx
@@ -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 (
+
+ {label}
+ {formatEvaluationReturnPct(pct)}
+
+ );
+}
+
+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 (
+
+
+
+ );
+ }
+
+ if (!returns) {
+ if (isFetching) {
+ return (
+
+
+
+ );
+ }
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+interface ToolbarProps {
+ isRunning: boolean;
+ canStart: boolean;
+ onStart: () => void;
+ onCancel?: () => void;
+}
+
+export function StrategyBulkEvalToolbar({
+ isRunning,
+ canStart,
+ onStart,
+ onCancel,
+}: ToolbarProps) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/useStrategyBulkEvaluation.ts b/frontend/src/hooks/useStrategyBulkEvaluation.ts
new file mode 100644
index 0000000..1b8725e
--- /dev/null
+++ b/frontend/src/hooks/useStrategyBulkEvaluation.ts
@@ -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
;
+ appliedIndicatorParams: Record> | 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(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(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({
+ queryKey: strategyId != null
+ ? strategyBulkEvalKeys.returns(scanKey, strategyId)
+ : ['strategyBulkEval', 'idle'],
+ queryFn: async () => null,
+ enabled: false,
+ staleTime: 60_000,
+ gcTime: 10 * 60_000,
+ placeholderData: (prev) => prev,
+ });
+}
diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css
index c772cc4..bef6d43 100644
--- a/frontend/src/styles/strategyEvaluation.css
+++ b/frontend/src/styles/strategyEvaluation.css
@@ -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 {
diff --git a/frontend/src/utils/strategyBulkEvaluation.ts b/frontend/src/utils/strategyBulkEvaluation.ts
new file mode 100644
index 0000000..7958d16
--- /dev/null
+++ b/frontend/src/utils/strategyBulkEvaluation.ts
@@ -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;
+}): Promise {
+ 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,
+ };
+}
diff --git a/package-lock.json b/package-lock.json
index e50c402..ba7c084 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",