/** * 전략 평가 — 선택 봉 기준 조건별 충족/미충족 해석 */ 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, buildDefFromGetParams, type DefType, DEF_DEFAULTS } 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, def: DefType, ): 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, def)); return; } if (node.type === 'NOT') { const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child; if (child) walkContext(child, timeframe, side, out, seen, def); return; } if (node.type === 'AND' || node.type === 'OR') { node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def)); 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, def), timeframe: rowTf, }); return; } node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def)); } export function buildConditionContextMap( strategy: StrategyDto | null | undefined, getParams?: (type: string, defaults?: Record) => Record, ): Map { const def = getParams ? buildDefFromGetParams(getParams) : DEF_DEFAULTS; const out = new Map(); const seen = new Set(); walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen, def); walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen, def); 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', }); }