/** * 전략 DSL → 가상투자 이퀄라이저 표시용 조건 추출 */ import type { LogicNode } from './strategyTypes'; import { CONDITION_LABEL } from './strategyTypes'; import type { StrategyDto } from './backendApi'; import { formatIndicatorDisplayLabel } from './indicatorRegistry'; import { coerceFiniteNumber, safeToFixed } from './safeFormat'; import { asLogicNode } from './strategyHydrate'; import { normalizeStartCandleType } from './strategyStartNodes'; import { parseThresholdField } from './conditionPeriods'; export { coerceFiniteNumber } from './safeFormat'; 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 = normalizeStartCandleType( node.candleTypes?.[0] ?? 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 === 'AND' || node.type === 'OR') { node.children?.forEach(c => walk(c, timeframe, side, out, seen)); return; } // StrategyDslToTa4jAdapter 와 동일: type 미설정(구버전 DSL) 시 CONDITION 으로 처리 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.length}`; if (seen.has(rowId)) return; seen.add(rowId); const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType; const target = coerceFiniteNumber( c.targetValue ?? c.compareValue ?? parseThresholdField(c.rightField) ?? null, ); const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType; out.push({ id: rowId, indicatorType: c.indicatorType, displayName: formatIndicatorDisplayLabel(c.indicatorType), conditionType: c.conditionType, conditionLabel: condLabel, targetValue: target, timeframe: rowTf, side, plotKey, }); return; } node.children?.forEach(c => walk(c, timeframe, side, out, seen)); } /** 목록 표시용 — 동일 지표 복수 조건 구분 */ export function formatVirtualConditionListLabel( row: Pick, ): string { const parts = [row.displayName]; if (row.thresholdLabel) parts.push(row.thresholdLabel); else if (row.conditionLabel) parts.push(row.conditionLabel); if (row.timeframe && row.timeframe !== '1m') parts.push(row.timeframe); return parts.join(' · '); } export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] { if (!strategy) return []; const out: VirtualConditionRow[] = []; const seen = new Set(); walk(asLogicNode(strategy.buyCondition), '1m', 'buy', out, seen); walk(asLogicNode(strategy.sellCondition), '1m', 'sell', out, seen); return out; } 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 safeToFixed(n, 2); }