From 2514e0adaebfb40f1517bfff9cd76c4918914f99 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 19 Jun 2026 07:19:32 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=84=EC=84=9D=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=A0=88=ED=8F=AC=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ComplexStrategyDslAdapterTest.java | 87 ++++- frontend/src/App.css | 64 ++++ .../src/components/BacktestResultModal.tsx | 60 +++- .../src/components/StrategyEvaluationPage.tsx | 12 +- .../backtest/BacktestAnalysisReportModal.tsx | 13 + frontend/src/utils/backtestEquity.ts | 2 + .../src/utils/strategyEvaluationReport.ts | 335 ++++++++++++++++++ 7 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 frontend/src/utils/strategyEvaluationReport.ts diff --git a/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java b/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java index 0610bc1..5c39daf 100644 --- a/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java +++ b/backend/src/test/java/com/goldenchart/service/ComplexStrategyDslAdapterTest.java @@ -44,7 +44,92 @@ class ComplexStrategyDslAdapterTest { seedStorage(MARKET, "5m", buildSyntheticSeries("5m", 80)); } - /** Logic Expression 예시와 동일 구조의 매수 조건 */ + /** OR(AND, AND) — 4개 조건을 평탄 OR로 처리하면 true가 되는 케이스에서 false여야 함 */ + @Test + void orAndNested_doesNotFlattenChildrenToOr() throws Exception { + JsonNode buy = MAPPER.readTree(""" + { + "type": "OR", + "children": [ + { + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1 + }}, + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 + }} + ] + }, + { + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1 + }}, + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 + }} + ] + } + ] + } + """); + + Rule rule = adapter.toRule(buy, primary1m, Map.of()); + int idx = primary1m.getEndIndex(); + // 평탄 OR(4조건)이면 close>K_1 하나만으로 true — AND 중첩이면 각 분기 false → 전체 false + assertFalse(rule.isSatisfied(idx, null), + "OR(AND)는 분기 내 모든 조건이 충족될 때만 true — 단일 true 자식으로는 true가 되면 안 됨"); + } + + @Test + void orAndNested_oneFullAndBranchSatisfies() throws Exception { + JsonNode buy = MAPPER.readTree(""" + { + "type": "OR", + "children": [ + { + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 + }}, + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_0", "candleRange": 1 + }} + ] + }, + { + "type": "AND", + "children": [ + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_999999999", "candleRange": 1 + }}, + { "type": "CONDITION", "condition": { + "indicatorType": "MA", "conditionType": "GT", + "leftField": "CLOSE_PRICE", "rightField": "K_1", "candleRange": 1 + }} + ] + } + ] + } + """); + + Rule rule = adapter.toRule(buy, primary1m, Map.of()); + assertTrue(rule.isSatisfied(primary1m.getEndIndex(), null), + "OR(AND) — 한 분기의 AND가 모두 true이면 전체 true"); + } + + /** Logic Expression 예시와 동일 구조의 매수 조건 */ @Test void buyCondition_compilesAndEvaluates() throws Exception { JsonNode buy = MAPPER.readTree(""" diff --git a/frontend/src/App.css b/frontend/src/App.css index ed9c592..7e0b0bf 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -9287,6 +9287,70 @@ html.desktop-client .tmb-logo-version { .brd-pnl-net-label{ font-size: 11.5px; color: var(--text3); } .brd-pnl-net-val { font-size: 15px; font-weight: 800; } +/* ── 전략평가 — 매수 방식별 비교 ── */ +.brd-eval-filter-note { + margin: 0 0 10px; + padding: 8px 10px; + font-size: 11px; + line-height: 1.45; + color: var(--text3); + background: var(--bg3); + border-radius: 6px; +} +.brd-alloc-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} +.brd-alloc-tab { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg2); + color: var(--text2); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} +.brd-alloc-tab--active { + border-color: var(--brd-blue); + background: color-mix(in srgb, var(--brd-blue) 12%, var(--bg2)); + color: var(--brd-blue); +} +.brd-alloc-compare { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.brd-alloc-card { + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg2); + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} +.brd-alloc-card--active { + border-color: var(--brd-blue); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--brd-blue) 35%, transparent); +} +.brd-alloc-card-label { + font-size: 11px; + color: var(--text3); + margin-bottom: 6px; +} +.brd-alloc-card-return { + font-size: 22px; + font-weight: 800; + margin-bottom: 4px; +} +.brd-alloc-card-sub { + font-size: 11px; + color: var(--text2); +} +.brd-card--full { grid-column: 1 / -1; } + /* ── 시그널 테이블 ── */ .brd-sig-scroll { max-height: 200px; overflow-y: auto; } .brd-sig-table { width: 100%; border-collapse: collapse; font-size: 11.5px; } diff --git a/frontend/src/components/BacktestResultModal.tsx b/frontend/src/components/BacktestResultModal.tsx index 2f4594a..e344fb9 100644 --- a/frontend/src/components/BacktestResultModal.tsx +++ b/frontend/src/components/BacktestResultModal.tsx @@ -4,10 +4,11 @@ * BacktestDashboard — 카드 기반 대시보드 (재사용 가능) * BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼 */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { repairUtf8Mojibake } from '../utils/textEncoding'; +import type { EvaluationAllocationReport } from './backtest/BacktestAnalysisReportModal'; import { pct, pctAbs, @@ -58,6 +59,9 @@ export interface BacktestDashboardProps { reportMode?: boolean; /** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */ hideSignals?: boolean; + /** 전략평가 — 일괄/분할 매수 방식별 분석 */ + evaluationAllocations?: EvaluationAllocationReport[]; + reportSignalFilterNote?: string; } // ── 시그널 타입 ─────────────────────────────────────────────────────────── @@ -70,8 +74,17 @@ export function BacktestDashboard({ analysis, signals, strategyName, symbol, timeframe, barCount, createdAt, reportMode = false, hideSignals = false, + evaluationAllocations, + reportSignalFilterNote, }: BacktestDashboardProps) { - const a = analysis; + const [allocMode, setAllocMode] = useState<'full' | 'split'>('split'); + + const displayAnalysis = useMemo(() => { + if (!evaluationAllocations?.length) return analysis; + return evaluationAllocations.find(e => e.mode === allocMode)?.analysis ?? analysis; + }, [analysis, evaluationAllocations, allocMode]); + + const a = displayAnalysis; if (!a) { return (
@@ -120,6 +133,49 @@ export function BacktestDashboard({
+ {evaluationAllocations && evaluationAllocations.length >= 2 && ( +
+
+ + {reportSignalFilterNote && ( +

{reportSignalFilterNote}

+ )} +
+ {evaluationAllocations.map(item => ( + + ))} +
+
+ {evaluationAllocations.map(item => ( +
setAllocMode(item.mode)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setAllocMode(item.mode); }} + > +
{item.label}
+
+ {pct(item.analysis.totalReturnPct)} +
+
+ 최종 {wonFmt(item.analysis.finalEquity)} · 승률 {pctAbs(item.analysis.winRate)} +
+
+ ))} +
+
+
+ )} + {/* ── Row 1: KPI 카드 6개 ── */}
diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 2046bea..f70ab50 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -48,7 +48,7 @@ import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals import type { OHLCVBar } from '../types'; import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal'; import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal'; -import { buildChartBacktestReportModel } from '../utils/backtestReportModel'; +import { buildStrategyEvaluationReportModel } from '../utils/strategyEvaluationReport'; import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup'; import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel'; import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab'; @@ -604,7 +604,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { timeframe: chartTimeframe, symbol: market, strategyName: selectedStrategy.name, - settings: btSettings, + settings: { ...btSettings, positionMode: 'SIGNAL_ONLY' }, indicatorParams, evaluationBarCount, }); @@ -614,14 +614,16 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { return; } - const model = buildChartBacktestReportModel({ - analysis: res.analysis ?? null, - stats: res.stats ?? null, + const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000; + const model = buildStrategyEvaluationReportModel({ signals: res.signals, + initialCapital, strategyName: selectedStrategy.name ?? strategyLabel, symbol: market.replace(/^KRW-/, ''), timeframe: chartTimeframe, barCount: bars.length, + firstBarClose: bars[0]?.close, + lastBarClose: bars[bars.length - 1]?.close, }); if (!model) { window.alert('분석 데이터를 생성할 수 없습니다.'); diff --git a/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx b/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx index 7ee5de6..addf23d 100644 --- a/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx +++ b/frontend/src/components/backtest/BacktestAnalysisReportModal.tsx @@ -8,6 +8,14 @@ import { BacktestDashboard } from '../BacktestResultModal'; import { printReportDocument } from '../../utils/printReportDocument'; import '../../styles/backtestReportPrint.css'; +export type EvaluationAllocationMode = 'full' | 'split'; + +export interface EvaluationAllocationReport { + mode: EvaluationAllocationMode; + label: string; + analysis: BacktestAnalysis; +} + export interface BacktestAnalysisReportModel { analysis: BacktestAnalysis; signals: BacktestSignal[]; @@ -18,6 +26,9 @@ export interface BacktestAnalysisReportModel { barCount: number; createdAt?: string; reportKind: 'backtest' | 'live'; + /** 전략평가 — 일괄/분할 매수 방식별 분석 */ + evaluationAllocations?: EvaluationAllocationReport[]; + reportSignalFilterNote?: string; } interface Props { @@ -135,6 +146,8 @@ export default function BacktestAnalysisReportModal({ open, onClose, model }: Pr barCount={model.barCount} createdAt={model.createdAt} reportMode + evaluationAllocations={model.evaluationAllocations} + reportSignalFilterNote={model.reportSignalFilterNote} />