diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 0f14514..3c13336 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -1050,8 +1050,7 @@ public class StrategyDslToTa4jAdapter { if (cond == null || cond.isNull()) return ""; int candleRange = cond.path("candleRange").asInt(1); String mode = cond.path("candleRangeMode").asText(""); - if (mode.isBlank()) mode = candleRange <= 1 ? "CURRENT" : "EXISTS_IN"; - if ("CURRENT".equals(mode)) return ""; + if (mode.isBlank()) return ""; int n = Math.max(2, candleRange); return switch (mode) { case "EXISTS_IN" -> "최근 " + n + "봉 내 "; @@ -1060,11 +1059,11 @@ public class StrategyDslToTa4jAdapter { }; } - /** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */ + /** candleRangeMode 가 명시된 경우에만 윈도우 평가 (candleRange 숫자만으로는 적용하지 않음) */ private String resolveCandleRangeMode(JsonNode cond, int candleRange) { String mode = cond.path("candleRangeMode").asText(""); - if (!mode.isBlank()) return mode; - return candleRange <= 1 ? "CURRENT" : "EXISTS_IN"; + if (mode.isBlank()) return "CURRENT"; + return mode; } /** 최근 N봉(현재 봉 포함) 윈도우에 inner 규칙 적용 */ diff --git a/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java b/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java index 6abb829..e89a708 100644 --- a/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java +++ b/backend/src/test/java/com/goldenchart/service/CandleRangeWindowRuleTest.java @@ -55,13 +55,17 @@ class CandleRangeWindowRuleTest { } @Test - void legacyCandleRangeFour_treatedAsExistsIn() throws Exception { + void legacyCandleRangeWithoutMode_treatedAsCurrent() throws Exception { Rule rule = toGtRule(""" "candleRange": 4 """); + Rule explicit = toGtRule(""" + "candleRangeMode": "CURRENT", + "candleRange": 4 + """); int idx = series.getEndIndex(); - assertTrue(rule.isSatisfied(idx, null), - "candleRangeMode 미설정 + candleRange=4 → EXISTS_IN 호환"); + assertEquals(explicit.isSatisfied(idx, null), rule.isSatisfied(idx, null), + "candleRangeMode 미설정 + candleRange=4 → CURRENT (윈도우 미적용)"); } @Test diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index eeca78d..c246a9e 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -457,6 +457,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
= ({ [strategy, getParams], ); + const resolveInitialDisplayCount = useCallback( + (plotWidth: number) => { + const approx = Math.floor(plotWidth / 6); + return Math.min(DISPLAY_COUNT, Math.max(80, approx), bars.length || DISPLAY_COUNT); + }, + [bars.length], + ); + + const fitChartViewport = useCallback(() => { + const mgr = managerRef.current; + if (!mgr?.hasMainSeries() || bars.length < 2) return; + const count = Math.min(DISPLAY_COUNT, bars.length); + mgr.setInitialVisibleRange(count); + }, [bars.length]); + const applyMarkers = useCallback((attempt = 0) => { const mgr = managerRef.current; if (!mgr?.hasMainSeries()) { @@ -167,6 +186,7 @@ const StrategyEvaluationChart: React.FC = ({ strategy.sellCondition, indicatorParams, ); + const evalCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, evaluationBarCount); const res = await runBacktest({ strategyId: strategy.id, bars: barData.map(b => ({ @@ -182,7 +202,7 @@ const StrategyEvaluationChart: React.FC = ({ strategyName: strategy.name, settings: btSettings, indicatorParams, - evaluationBarCount, + evaluationBarCount: evalCount, }); if (gen !== backtestGenRef.current) return; if (!res) { @@ -239,9 +259,15 @@ const StrategyEvaluationChart: React.FC = ({ managerRef.current?.clearBacktestMarkers(); setLoading(true); setError(null); - const toTimeSec = Math.floor(Date.now() / 1000); - void loadAnalysisCandles(market, timeframe, toTimeSec, 300) - .then(data => { + void fetchBacktestCandleBundle({ + market, + timeframe, + displayCount: BACKTEST_DISPLAY_BAR_COUNT, + buyDsl: strategy?.buyCondition, + sellDsl: strategy?.sellCondition, + indicatorParams, + }) + .then(({ bars: data }) => { if (cancelled) return; setBars(data); onBarsLoaded?.(data); @@ -258,7 +284,7 @@ const StrategyEvaluationChart: React.FC = ({ if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [market, timeframe, strategy?.id, onBarsLoaded, onSelectedBarIndexChange]); + }, [market, timeframe, strategy?.id, strategy?.buyCondition, strategy?.sellCondition, indicatorParams, onBarsLoaded, onSelectedBarIndexChange]); const scrollToBar = useCallback((barIdx: number) => { const mgr = chartMgr; @@ -275,7 +301,8 @@ const StrategyEvaluationChart: React.FC = ({ mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux); } applyMarkers(); - }, [paneAreaRatio, applyMarkers]); + fitChartViewport(); + }, [paneAreaRatio, applyMarkers, fitChartViewport]); const onCandlesReady = useCallback(() => { const mgr = managerRef.current; @@ -284,7 +311,8 @@ const StrategyEvaluationChart: React.FC = ({ mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux); } applyMarkers(); - }, [paneAreaRatio, applyMarkers]); + fitChartViewport(); + }, [paneAreaRatio, applyMarkers, fitChartViewport]); const stepBar = useCallback((delta: number) => { if (bars.length === 0) return; @@ -482,6 +510,7 @@ const StrategyEvaluationChart: React.FC = ({ showHoverToolbar={false} showChartRightToolbar={false} showCandlePaneControls={false} + resolveInitialDisplayCount={resolveInitialDisplayCount} /> {chartMgr && ( void; +} + +const StrategyEvaluationInterpretModal: React.FC = ({ + side, + interpretation, + barTimeSec, + strategyName, + onClose, +}) => { + const title = side === 'buy' ? '매수 조건 해석' : '매도 조건 해석'; + const barLabel = formatBarTimeLabel(barTimeSec); + + const overallClass = interpretation.overallMet === true + ? 'seval-interpret-overall--met' + : interpretation.overallMet === false + ? 'seval-interpret-overall--unmet' + : 'seval-interpret-overall--unknown'; + + const intro = useMemo(() => { + const parts = [`${barLabel} 시점`, strategyName ? `「${strategyName}」` : null].filter(Boolean); + return parts.join(' · '); + }, [barLabel, strategyName]); + + return ( + +
+

{intro}

+ +
+ + {interpretation.overallMet === true + ? '전체 충족' + : interpretation.overallMet === false + ? '전체 미충족' + : '전체 평가 불명'} + +

{interpretation.overallSummary}

+
+ + {interpretation.conditions.length === 0 ? ( +

표시할 조건이 없습니다.

+ ) : ( +
    + {interpretation.conditions.map(item => ( +
  • +
    + + {item.statusLabel} + + + {item.summary || item.label} + +
    +

    {item.reason}

    +
  • + ))} +
+ )} + +
+ ※ 조건별 결과는 Ta4j Rule 평가값이며, AND/OR/NOT 전체 결합 결과는 상단 「전체 충족/미충족」을 참고하세요. +
+
+
+ ); +}; + +export default StrategyEvaluationInterpretModal; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx index 8b0230a..3d64be2 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationSignalPanel.tsx @@ -1,14 +1,22 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; +import type { StrategyDto } from '../../utils/backendApi'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { buildConditionMetrics, type ConditionMetric, } from '../../utils/virtualSignalMetrics'; +import { + buildConditionContextMap, + buildSideInterpretation, +} from '../../utils/strategyEvaluationInterpret'; import StrategyEvaluationMatchVisual from './StrategyEvaluationMatchVisual'; +import StrategyEvaluationInterpretModal from './StrategyEvaluationInterpretModal'; import type { BarSignalHighlight } from '../../utils/strategyEvaluationBarSignals'; interface Props { snapshot: VirtualIndicatorSnapshot | undefined; + strategy?: StrategyDto | null; + barTimeSec?: number | null; /** 선택 봉에 백테스트 매수/매도 시그널 존재 여부 */ barSignalHighlight?: BarSignalHighlight; loading?: boolean; @@ -17,49 +25,99 @@ interface Props { className?: string; } +const InterpretIcon = () => ( + + + + +); + interface SideCardProps { side: 'buy' | 'sell'; metrics: ConditionMetric[]; overallMet?: boolean | null; signalActive?: boolean; + strategy?: StrategyDto | null; + barTimeSec?: number | null; } -const SideCard: React.FC = ({ side, metrics, overallMet, signalActive = false }) => { +const SideCard: React.FC = ({ + side, + metrics, + overallMet, + signalActive = false, + strategy, + barTimeSec, +}) => { + const [interpretOpen, setInterpretOpen] = useState(false); const title = side === 'buy' ? '매수 조건' : '매도 조건'; const overallLabel = overallMet === true ? '전체 충족' : overallMet === false ? '전체 미충족' : null; + const contextMap = useMemo(() => buildConditionContextMap(strategy), [strategy]); + const interpretation = useMemo( + () => buildSideInterpretation(side, metrics, overallMet, contextMap), + [side, metrics, overallMet, contextMap], + ); + return ( -
-
-
- {title} - {overallLabel && ( - - {overallLabel} - - )} + <> +
+
+
+ {title} + {overallLabel && ( + + {overallLabel} + + )} +
+ +
+
+
-
-
- -
-
+ + + {interpretOpen && ( + setInterpretOpen(false)} + /> + )} + ); }; const StrategyEvaluationSignalPanel: React.FC = ({ snapshot, + strategy, + barTimeSec, barSignalHighlight, loading = false, loadingMessage, @@ -92,12 +150,16 @@ const StrategyEvaluationSignalPanel: React.FC = ({ metrics={sellMetrics} overallMet={snapshot?.overallExitMet} signalActive={barSignalHighlight?.sell ?? false} + strategy={strategy} + barTimeSec={barTimeSec} />
); diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index 7bf4d11..a03df0f 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -1138,6 +1138,200 @@ cursor: not-allowed; } +/* ── 전략 판별 해석 버튼 · 팝업 ─────────────────────────────────────────── */ +.seval-interpret-btn { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--se-border) 85%, transparent); + background: color-mix(in srgb, var(--se-input-bg, #121826) 92%, transparent); + color: var(--se-text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.seval-interpret-btn:hover:not(:disabled) { + color: var(--se-accent, #3f7ef5); + border-color: color-mix(in srgb, var(--se-accent, #3f7ef5) 45%, transparent); + background: color-mix(in srgb, var(--se-accent, #3f7ef5) 10%, transparent); +} + +.seval-interpret-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.seval-interpret-icon { + display: block; +} + +.seval-interpret-overlay { + z-index: 10999; +} + +.seval-interpret-modal { + max-height: min(88vh, 720px); + display: flex; + flex-direction: column; +} + +.seval-interpret-body { + overflow: auto; + max-height: min(72vh, 600px); + padding: 12px 16px 16px !important; +} + +.seval-interpret-content { + font-size: 0.84rem; + line-height: 1.65; + color: var(--se-text); +} + +.seval-interpret-intro { + margin: 0 0 12px; + font-size: 0.78rem; + color: var(--se-text-muted); +} + +.seval-interpret-overall { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--se-input-bg, #121826) 88%, transparent); +} + +.seval-interpret-overall--met { + border-color: color-mix(in srgb, #3f7ef5 35%, transparent); + background: color-mix(in srgb, #3f7ef5 8%, transparent); +} + +.seval-interpret-overall--unmet { + border-color: color-mix(in srgb, var(--se-text-muted) 25%, transparent); +} + +.seval-interpret-overall-badge { + display: inline-flex; + margin-bottom: 6px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.03em; +} + +.seval-interpret-overall--met .seval-interpret-overall-badge { + color: #7eb6ff; + background: color-mix(in srgb, #3f7ef5 18%, transparent); +} + +.seval-interpret-overall--unmet .seval-interpret-overall-badge { + color: var(--se-text-muted); + background: color-mix(in srgb, var(--se-text-muted) 12%, transparent); +} + +.seval-interpret-overall--unknown .seval-interpret-overall-badge { + color: #f0ad4e; + background: color-mix(in srgb, #f0ad4e 14%, transparent); +} + +.seval-interpret-overall-text { + margin: 0; + font-size: 0.82rem; +} + +.seval-interpret-empty { + margin: 0; + color: var(--se-text-muted); +} + +.seval-interpret-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.seval-interpret-item { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--se-input-bg, #121826) 90%, transparent); +} + +.seval-interpret-item--match { + border-left: 3px solid #3f7ef5; +} + +.seval-interpret-item--nomatch { + border-left: 3px solid color-mix(in srgb, var(--se-text-muted) 55%, #ef5350); +} + +.seval-interpret-item--unknown { + border-left: 3px solid #f0ad4e; +} + +.seval-interpret-item-head { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; +} + +.seval-interpret-status { + flex-shrink: 0; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.seval-interpret-status--match { + color: #7eb6ff; + background: color-mix(in srgb, #3f7ef5 16%, transparent); +} + +.seval-interpret-status--nomatch { + color: #ff8a80; + background: color-mix(in srgb, #ef5350 14%, transparent); +} + +.seval-interpret-status--unknown { + color: #f0ad4e; + background: color-mix(in srgb, #f0ad4e 14%, transparent); +} + +.seval-interpret-item-title { + min-width: 0; + font-size: 0.8rem; + font-weight: 700; + line-height: 1.4; + word-break: break-word; +} + +.seval-interpret-reason { + margin: 0; + font-size: 0.78rem; + color: var(--se-text-muted); + line-height: 1.55; +} + +.seval-interpret-footnote { + margin: 14px 0 0; + padding-top: 10px; + border-top: 1px solid var(--se-border, rgba(255, 255, 255, 0.08)); + font-size: 0.72rem; + color: var(--se-text-muted); +} + @media (max-width: 960px) { .seval-chart-hint { display: none; diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index 6a76dbc..fc94d87 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -2,6 +2,7 @@ * 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명 */ import type { LogicNode } from './strategyTypes'; +import { CONDITION_LABEL } from './strategyTypes'; import { resolveCandleRangeMode, candleRangeWindowBars, diff --git a/frontend/src/utils/strategyEvaluationInterpret.ts b/frontend/src/utils/strategyEvaluationInterpret.ts new file mode 100644 index 0000000..65c01e1 --- /dev/null +++ b/frontend/src/utils/strategyEvaluationInterpret.ts @@ -0,0 +1,304 @@ +/** + * 전략 평가 — 선택 봉 기준 조건별 충족/미충족 해석 + */ +import type { StrategyDto } from './backendApi'; +import type { ConditionDSL, LogicNode } from './strategyTypes'; +import { + formatCandleRangeClause, + resolveCandleRangeMode, + candleRangeWindowBars, +} from './strategyTypes'; +import { asLogicNode } from './strategyHydrate'; +import { normalizeStartCandleType } from './strategyStartNodes'; +import { + formatIndicatorValue, + formatVirtualConditionListLabel, +} from './virtualStrategyConditions'; +import { + formatStrategyThreshold, + type ConditionMetric, +} from './virtualSignalMetrics'; +import { nodeToText } from './strategyEditorShared'; + +export interface ConditionInterpretContext { + condition: ConditionDSL; + summary: string; + timeframe: string; +} + +export interface ConditionInterpretation { + id: string; + label: string; + summary: string; + status: 'match' | 'nomatch' | 'unknown'; + statusLabel: string; + reason: string; +} + +export interface SideInterpretation { + side: 'buy' | 'sell'; + sideTitle: string; + overallMet: boolean | null; + overallSummary: string; + conditions: ConditionInterpretation[]; +} + +function walkContext( + node: LogicNode | null | undefined, + timeframe: string, + side: 'buy' | 'sell', + out: Map, + seen: Set, +): void { + if (!node) return; + + if (node.type === 'TIMEFRAME') { + const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe); + node.children?.forEach(c => walkContext(c, tf, side, out, seen)); + return; + } + + if (node.type === 'NOT') { + const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child; + if (child) walkContext(child, timeframe, side, out, seen); + return; + } + + if (node.type === 'AND' || node.type === 'OR') { + node.children?.forEach(c => walkContext(c, timeframe, side, out, seen)); + return; + } + + if ((!node.type || node.type === 'CONDITION') && node.condition) { + const c = node.condition; + const rowTf = normalizeStartCandleType(c.leftCandleType ?? c.rightCandleType ?? timeframe); + const rowId = node.id ? `${node.id}-${side}` : `${side}:${rowTf}:${c.indicatorType}:${out.size}`; + if (seen.has(rowId)) return; + seen.add(rowId); + out.set(rowId, { + condition: c, + summary: nodeToText(node), + timeframe: rowTf, + }); + return; + } + + node.children?.forEach(c => walkContext(c, timeframe, side, out, seen)); +} + +export function buildConditionContextMap( + strategy: StrategyDto | null | undefined, +): Map { + const out = new Map(); + const seen = new Set(); + walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen); + walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen); + return out; +} + +function rangeNote(cond?: ConditionDSL): string { + if (!cond) return ''; + const mode = resolveCandleRangeMode(cond); + if (mode === 'CURRENT') return ''; + const n = candleRangeWindowBars(cond); + if (mode === 'EXISTS_IN') return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번이라도 성립하면 충족으로 판정합니다. `; + return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번도 없어야 충족으로 판정합니다. `; +} + +function comparePhrase( + conditionType: string, + current: number | null, + target: number | null, + thresholdLabel: string | undefined, + satisfied: boolean, +): string { + const cur = formatIndicatorValue(current); + const ref = thresholdLabel + ?? (target != null ? formatStrategyThreshold(conditionType, target, '') : '기준값'); + + if (current == null) { + return satisfied + ? `조건(${ref})을(를) 충족합니다.` + : `조건(${ref})을(를) 충족하지 못했습니다.`; + } + + switch (conditionType) { + case 'GTE': + return satisfied + ? `현재값 ${cur} ≥ ${ref.replace(/^≥\s*/, '')} 이므로 충족합니다.` + : `현재값 ${cur} < ${ref.replace(/^≥\s*/, '')} 이므로 미충족입니다.`; + case 'LTE': + return satisfied + ? `현재값 ${cur} ≤ ${ref.replace(/^≤\s*/, '')} 이므로 충족합니다.` + : `현재값 ${cur} > ${ref.replace(/^≤\s*/, '')} 이므로 미충족입니다.`; + case 'GT': + return satisfied + ? `현재값 ${cur} > ${ref.replace(/^>\s*/, '')} 이므로 충족합니다.` + : `현재값 ${cur} ≤ ${ref.replace(/^>\s*/, '')} 이므로 미충족입니다.`; + case 'LT': + return satisfied + ? `현재값 ${cur} < ${ref.replace(/^<\s*/, '')} 이므로 충족합니다.` + : `현재값 ${cur} ≥ ${ref.replace(/^<\s*/, '')} 이므로 미충족입니다.`; + case 'EQ': + return satisfied + ? `현재값 ${cur} = ${ref.replace(/^=\s*/, '')} 이므로 충족합니다.` + : `현재값 ${cur} ≠ ${ref.replace(/^=\s*/, '')} 이므로 미충족입니다.`; + default: + return satisfied + ? `현재값 ${cur}, 기준 ${ref} — 조건 충족.` + : `현재값 ${cur}, 기준 ${ref} — 조건 미충족.`; + } +} + +function crossPhrase( + cond: ConditionDSL | undefined, + conditionLabel: string, + thresholdLabel: string | undefined, + satisfied: boolean, +): string { + const range = cond ? formatCandleRangeClause(cond).trim() : ''; + const mode = cond ? resolveCandleRangeMode(cond) : 'CURRENT'; + const target = thresholdLabel ?? '비교 대상'; + + if (mode === 'EXISTS_IN') { + const n = cond ? candleRangeWindowBars(cond) : 0; + return satisfied + ? `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 확인되어 충족합니다.` + : `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 없어 미충족입니다.`; + } + if (mode === 'NOT_EXISTS_IN') { + const n = cond ? candleRangeWindowBars(cond) : 0; + return satisfied + ? `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 없어 충족합니다.` + : `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 있어 미충족입니다.`; + } + return satisfied + ? `현재 봉에서 ${conditionLabel}(${target})이(가) 발생하여 충족합니다.` + : `현재 봉에서 ${conditionLabel}(${target})이(가) 없어 미충족입니다. (이미 돌파 후 유지 중인 경우에도 현재 봉 기준 미충족일 수 있습니다.)`; +} + +function buildReason( + metric: ConditionMetric, + ctx?: ConditionInterpretContext, +): string { + const { row, status } = metric; + const cond = ctx?.condition; + const prefix = rangeNote(cond); + + if (status === 'unknown') { + return `${prefix}봉 데이터 또는 지표 값이 부족하여 이 조건을 평가하지 못했습니다.`; + } + + const threshold = row.thresholdLabel + ?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel); + + if (row.conditionType === 'CROSS_UP' || row.conditionType === 'CROSS_DOWN') { + return prefix + crossPhrase(cond, row.conditionLabel, row.thresholdLabel, status === 'match'); + } + + if (row.conditionType === 'SLOPE_UP' || row.conditionType === 'SLOPE_DOWN') { + const dir = row.conditionType === 'SLOPE_UP' ? '상승' : '하락'; + return prefix + (status === 'match' + ? `최근 기울기가 ${dir} 추세로 판정되어 충족합니다.` + : `최근 기울기가 ${dir} 추세가 아니어서 미충족입니다.`); + } + + if (row.conditionType === 'HOLD_N_DAYS') { + const hd = cond?.holdDays ?? 3; + return prefix + (status === 'match' + ? `조건이 ${hd}봉 연속 유지되어 충족합니다.` + : `조건이 ${hd}봉 연속 유지되지 않아 미충족입니다.`); + } + + return prefix + comparePhrase( + row.conditionType, + row.currentValue, + row.targetValue, + threshold, + status === 'match', + ); +} + +export function interpretConditionMetric( + metric: ConditionMetric, + ctx?: ConditionInterpretContext, +): ConditionInterpretation { + const { row, status } = metric; + const label = formatVirtualConditionListLabel(row); + const summary = ctx?.summary ?? label; + + const statusLabel = status === 'match' + ? '충족' + : status === 'nomatch' + ? '미충족' + : status === 'pending' + ? '부분 충족' + : '평가 불가'; + + const normalizedStatus: ConditionInterpretation['status'] = status === 'match' + ? 'match' + : status === 'nomatch' + ? 'nomatch' + : 'unknown'; + + return { + id: row.id, + label, + summary, + status: normalizedStatus, + statusLabel, + reason: buildReason(metric, ctx), + }; +} + +function buildOverallSummary( + side: 'buy' | 'sell', + metrics: ConditionMetric[], + overallMet: boolean | null | undefined, +): string { + const sideKo = side === 'buy' ? '매수' : '매도'; + const evaluable = metrics.filter(m => m.status !== 'unknown'); + const metCount = evaluable.filter(m => m.status === 'match').length; + + if (evaluable.length === 0) { + return `${sideKo} 조건을 평가할 수 있는 데이터가 없습니다.`; + } + + if (overallMet === true) { + return `${sideKo} DSL 전체 Rule(AND/OR/NOT 포함)이 충족되었습니다. 아래 ${evaluable.length}개 조건이 동시에 true 입니다.`; + } + + if (overallMet === false) { + if (metCount === 0) { + return `${sideKo} DSL 전체 Rule이 미충족입니다. ${evaluable.length}개 조건 모두 false 입니다.`; + } + return `${sideKo} DSL 전체 Rule이 미충족입니다. ${evaluable.length}개 중 ${metCount}개만 충족 — AND 결합 시 하나라도 false면 전체 false 입니다.`; + } + + return `${sideKo} 조건 ${evaluable.length}개 중 ${metCount}개 충족 (리프 조건 기준).`; +} + +export function buildSideInterpretation( + side: 'buy' | 'sell', + metrics: ConditionMetric[], + overallMet: boolean | null | undefined, + contextMap: Map, +): SideInterpretation { + return { + side, + sideTitle: side === 'buy' ? '매수 조건' : '매도 조건', + overallMet: overallMet ?? null, + overallSummary: buildOverallSummary(side, metrics, overallMet), + conditions: metrics.map(m => interpretConditionMetric(m, contextMap.get(m.row.id))), + }; +} + +export function formatBarTimeLabel(barTimeSec: number | null | undefined): string { + if (barTimeSec == null) return '선택 봉'; + return new Date(barTimeSec * 1000).toLocaleString('ko-KR', { + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/frontend/src/utils/strategyTypes.ts b/frontend/src/utils/strategyTypes.ts index 87c761e..be37f7e 100644 --- a/frontend/src/utils/strategyTypes.ts +++ b/frontend/src/utils/strategyTypes.ts @@ -24,7 +24,7 @@ export interface ConditionDSL { holdDays?: number; /** LOWEST_LTE/GTE — 최근 N봉 rolling min/max 비교 */ lookbackPeriod?: number; - /** 캔들 범위 모드 — 미설정 시 candleRange>1 이면 EXISTS_IN 으로 해석 */ + /** CURRENT | EXISTS_IN | NOT_EXISTS_IN — 명시된 경우에만 윈도우 평가 */ candleRangeMode?: CandleRangeMode; /** EXISTS_IN/NOT_EXISTS_IN 일 때 윈도우 크기(N). CURRENT 일 때는 1 */ candleRange?: number; @@ -372,13 +372,12 @@ export function saveStrategiesToStorage(strategies: StrategyDSLDto[]): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies)); } -/** candleRangeMode 미설정 시 candleRange>1 → EXISTS_IN (구버전 호환) */ +/** candleRangeMode 가 명시된 경우에만 윈도우 모드 (미설정 시 CURRENT) */ export function resolveCandleRangeMode( cond: Pick, ): CandleRangeMode { if (cond.candleRangeMode) return cond.candleRangeMode; - const n = cond.candleRange ?? 1; - return n <= 1 ? 'CURRENT' : 'EXISTS_IN'; + return 'CURRENT'; } export function candleRangeWindowBars(