/** * 전략 DSL → 가상투자 이퀄라이저 표시용 조건 추출 */ import type { LogicNode } from './strategyTypes'; import { CONDITION_LABEL } from './strategyTypes'; import type { StrategyDto } from './backendApi'; import { formatIndicatorDisplayLabel } from './indicatorRegistry'; export interface VirtualConditionRow { id: string; indicatorType: string; displayName: string; conditionType: string; conditionLabel: string; targetValue: number | null; timeframe: string; side: 'buy' | 'sell'; /** 지표 계산 plot id (RSI, MACD 등) */ plotKey: string; /** 백엔드 Ta4j Rule.isSatisfied 결과 (있으면 우선) */ satisfied?: boolean | null; /** 백엔드 임계값 라벨 (MA cross 등) */ thresholdLabel?: string; } function walk( node: LogicNode | null | undefined, timeframe: string, side: 'buy' | 'sell', out: VirtualConditionRow[], seen: Set, ): void { if (!node) return; if (node.type === 'TIMEFRAME') { const tf = node.candleType ?? timeframe; node.children?.forEach(c => walk(c, tf, side, out, seen)); return; } if (node.type === 'NOT') { const notChild = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child; if (notChild) walk(notChild, timeframe, side, out, seen); return; } if (node.type === 'CONDITION' && node.condition) { const c = node.condition; const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`; if (seen.has(key)) return; seen.add(key); const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType; const target = coerceFiniteNumber(c.targetValue ?? c.compareValue ?? null); const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType; out.push({ id: `${node.id}-${side}`, indicatorType: c.indicatorType, displayName: formatIndicatorDisplayLabel(c.indicatorType), conditionType: c.conditionType, conditionLabel: condLabel, targetValue: target, timeframe, side, plotKey, }); return; } node.children?.forEach(c => walk(c, timeframe, side, out, seen)); } /** API·DSL에서 문자열로 올 수 있는 값을 안전하게 숫자로 변환 */ export function coerceFiniteNumber(v: unknown): number | null { if (v == null || v === '') return null; if (typeof v === 'number') return Number.isFinite(v) ? v : null; const n = Number(v); return Number.isFinite(n) ? n : null; } export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] { if (!strategy) return []; const out: VirtualConditionRow[] = []; const seen = new Set(); walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen); walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen); return out; } /** 조건 충족 여부 (단순 임계값 비교) */ export function isConditionMet( conditionType: string, current: number | null | unknown, target: number | null | unknown, ): boolean | null { const cur = coerceFiniteNumber(current); const tgt = coerceFiniteNumber(target); if (cur == null || tgt == null) return null; switch (conditionType) { case 'GT': case 'CROSS_UP': return cur > tgt; case 'LT': case 'CROSS_DOWN': return cur < tgt; case 'GTE': return cur >= tgt; case 'LTE': return cur <= tgt; case 'EQ': return Math.abs(cur - tgt) < 1e-6; case 'NEQ': return Math.abs(cur - tgt) >= 1e-6; default: return null; } } export function formatIndicatorValue(v: number | null | unknown): string { const n = coerceFiniteNumber(v); if (n == null) return '—'; if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); return n.toFixed(2); }