/** * 전략평가 차트 — 선택 봉 기준 N봉 추세선 Drawing 생성 */ import type { Drawing } from '../types'; import type { OHLCVBar } from '../types'; import type { BacktestSettingsDto, StrategyDto } from './backendApi'; import type { ConditionDSL, LogicNode } from './strategyTypes'; import { trendLineConfigFromSettings, trendLineSegmentAt, trendLineKindForCross, isTrendLinePriceScaleField } from './trendLineGeometry'; import { calculateIndicator } from './indicatorRegistry'; import { DSL_TO_REGISTRY } from './strategyToChartIndicators'; function walkTrendLineConditions( node: LogicNode | null | undefined, side: 'buy' | 'sell', out: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }>, ): void { if (!node) return; if (node.type === 'TIMEFRAME') { node.children?.forEach(c => walkTrendLineConditions(c, side, out)); return; } if (node.type === 'NOT') { const child = node.children?.[0]; if (child) walkTrendLineConditions(child, side, out); return; } if (node.type === 'AND' || node.type === 'OR') { node.children?.forEach(c => walkTrendLineConditions(c, side, out)); return; } if ((!node.type || node.type === 'CONDITION') && node.condition) { const ct = node.condition.conditionType; if (ct === 'TREND_LINE_CROSS_UP' || ct === 'TREND_LINE_CROSS_DOWN') { out.push({ side, cond: node.condition }); } } } function registryType(dslType: string): string { return DSL_TO_REGISTRY[dslType] ?? dslType; } function isPriceScaleField(field?: string): boolean { if (!field) return true; const f = field.toUpperCase(); return f.includes('CLOSE') || f.includes('PRICE') || f.includes('HIGH') || f.includes('LOW') || f.includes('OPEN') || f.includes('MA') || f.includes('EMA') || f.includes('UPPER') || f.includes('MIDDLE') || f.includes('LOWER') || f.includes('BAND') || f.includes('DC_'); } async function resolveSourceValues( bars: OHLCVBar[], cond: ConditionDSL, getParams: (type: string) => Record, ): Promise { const lf = (cond.leftField ?? 'CLOSE_PRICE').toUpperCase(); if (lf === 'CLOSE_PRICE' || lf === 'CURRENT') { return bars.map(b => b.close); } if (lf === 'HIGH_PRICE') return bars.map(b => b.high); if (lf === 'LOW_PRICE') return bars.map(b => b.low); if (lf === 'OPEN_PRICE') return bars.map(b => b.open); const regType = registryType(cond.indicatorType); try { const params = { ...getParams(regType), ...(cond.params ?? {}) }; const { plots } = await calculateIndicator(regType, bars, params); const plotKeys = Object.keys(plots); if (plotKeys.length === 0) return null; let plotKey = plotKeys[0]!; if (lf.includes('K') && plots.plot0) plotKey = 'plot0'; else if (lf.includes('SIGNAL') && plots.plot1) plotKey = 'plot1'; else if (lf.includes('MACD') && plots.plot0) plotKey = 'plot0'; else if (lf.includes('UPPER') && plots.plot2) plotKey = 'plot2'; else if (lf.includes('LOWER') && plots.plot0) plotKey = 'plot0'; else if (lf.includes('MIDDLE') && plots.plot1) plotKey = 'plot1'; const plot = plots[plotKey as keyof typeof plots]; if (!plot || !Array.isArray(plot)) return null; const byTime = new Map(plot.map((p: { time: number; value: number }) => [p.time, p.value])); return bars.map(b => { const v = byTime.get(b.time); return v != null && Number.isFinite(v) ? v : NaN; }); } catch { return bars.map(b => b.close); } } /** 선택 봉(barIndex) 기준 추세선만 반환 — 시그널 전체 순회하지 않음 */ export async function buildSignalTrendLineDrawings(opts: { bars: OHLCVBar[]; /** 캔들 선택봉 인덱스 */ barIndex: number; strategy: StrategyDto | null; settings?: BacktestSettingsDto | null; getParams: (type: string) => Record; }): Promise { const { bars, barIndex, strategy, settings, getParams } = opts; if (!strategy || bars.length === 0 || barIndex < 0 || barIndex >= bars.length) return []; const conditions: Array<{ side: 'buy' | 'sell'; cond: ConditionDSL }> = []; walkTrendLineConditions(strategy.buyCondition as LogicNode | null, 'buy', conditions); walkTrendLineConditions(strategy.sellCondition as LogicNode | null, 'sell', conditions); if (conditions.length === 0) return []; const priceConds = conditions.filter(c => isPriceScaleField(c.cond.leftField)); if (priceConds.length === 0) return []; const config = trendLineConfigFromSettings(settings); const barTimes = bars.map(b => b.time); const drawings: Drawing[] = []; const sourceCache = new Map(); for (const { cond } of priceConds) { const key = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`; if (!sourceCache.has(key)) { const values = await resolveSourceValues(bars, cond, getParams); if (values) sourceCache.set(key, values); } } for (const { side, cond } of priceConds) { const cacheKey = `${cond.indicatorType}:${cond.leftField}:${JSON.stringify(cond.params ?? {})}`; const sourceValues = sourceCache.get(cacheKey); if (!sourceValues) continue; const lookback = Math.max(2, cond.lookbackPeriod ?? config.defaultLookback); const kind = trendLineKindForCross(cond.conditionType, side); const priceScale = isTrendLinePriceScaleField(cond.leftField); const seg = trendLineSegmentAt( barTimes, sourceValues, bars, barIndex, lookback, config, kind, priceScale, ); if (!seg) continue; const id = `tl-${barIndex}-${side}-${cond.conditionType}-${lookback}-${config.mode}`; drawings.push({ id, type: 'trendline', points: [ { time: seg.startTimeSec, price: seg.startPrice }, { time: seg.endTimeSec, price: seg.endPrice }, ], color: side === 'buy' ? '#26a69a' : '#ef5350', lineWidth: 2, style: 'dashed', visible: true, }); } return drawings; }