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 (
{reportSignalFilterNote}
+ )} +