/** * 백테스팅 워밍업 — 차트(표시 200 + 워밍업)와 동일하게 지표 수렴 후 평가 구간만 시그널 생성 */ import type { OHLCVBar, Timeframe } from '../types'; import { API_BASE } from './backendApi'; import { timeframeToCandleType } from './chartCandleType'; import { toHistoryApiIso } from './analysisChartData'; import { INDICATOR_WARMUP } from './upbitApi'; /** 차트 초기 표시·백테스트 평가 구간 기본 봉 수 (워밍업 제외) */ export const BACKTEST_DISPLAY_BAR_COUNT = 200; const HISTORY_PAGE_SIZE = 200; const MAX_BACKTEST_FETCH = 800; const MA_FIELD_RE = /^MA(\d+)$/; const EMA_FIELD_RE = /^EMA(\d+)$/; function collectPeriodFromField(field: unknown, out: Set): void { if (typeof field !== 'string') return; const ma = MA_FIELD_RE.exec(field); if (ma) { out.add(parseInt(ma[1], 10)); return; } const ema = EMA_FIELD_RE.exec(field); if (ema) out.add(parseInt(ema[1], 10)); } /** 전략 DSL 트리에서 MA/EMA 기간 추출 */ function walkStrategyDsl(node: unknown, out: Set): void { if (!node || typeof node !== 'object') return; const n = node as Record; const cond = n.condition; if (cond && typeof cond === 'object') { const c = cond as Record; collectPeriodFromField(c.leftField, out); collectPeriodFromField(c.rightField, out); const p = Number(c.period ?? c.leftPeriod ?? c.rightPeriod); if (Number.isFinite(p) && p > 0) out.add(Math.floor(p)); } for (const ch of (n.children as unknown[]) ?? []) walkStrategyDsl(ch, out); } function maxFromIndicatorParams( params: Record> | undefined, ): number { if (!params) return 0; let max = 0; const sma = params.SMA ?? params.sma; if (sma) { for (let i = 1; i <= 11; i++) { const v = Number(sma[`period${i}`]); if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v)); } } const ichimoku = params.IchimokuCloud ?? params.ICHIMOKU; if (ichimoku) { for (const key of ['basePeriods', 'laggingSpan2Periods', 'conversionPeriods', 'displacement']) { const v = Number(ichimoku[key]); if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v)); } } const macd = params.MACD ?? params.macd; if (macd) { for (const key of ['fastLength', 'slowLength', 'signalLength']) { const v = Number(macd[key]); if (Number.isFinite(v) && v > 0) max = Math.max(max, Math.floor(v)); } const slow = Number(macd.slowLength ?? 26); const sig = Number(macd.signalLength ?? 9); if (Number.isFinite(slow) && Number.isFinite(sig)) { max = Math.max(max, Math.floor(slow + sig)); } } for (const key of ['RSI', 'CCI', 'ADX', 'Stochastic']) { const block = params[key]; if (!block) continue; const len = Number(block.length ?? block.period); if (Number.isFinite(len) && len > 0) max = Math.max(max, Math.floor(len)); } return max; } /** * 전략·지표 설정 기준 워밍업 봉 수. * 차트 INDICATOR_WARMUP(100)과 동일한 안전 마진을 포함한다. */ export function computeBacktestWarmupBars( buyDsl?: unknown, sellDsl?: unknown, indicatorParams?: Record>, ): number { const periods = new Set(); walkStrategyDsl(buyDsl, periods); walkStrategyDsl(sellDsl, periods); let maxPeriod = periods.size > 0 ? Math.max(...periods) : 0; maxPeriod = Math.max(maxPeriod, maxFromIndicatorParams(indicatorParams)); // Ta4j SMA unstable + cross 1봉 여유 const strategyNeed = maxPeriod > 0 ? maxPeriod + 5 : 0; return Math.max(INDICATOR_WARMUP, strategyNeed); } /** 백엔드 history API — count>200 페이지네이션 */ export async function fetchBacktestHistoryCandles( market: string, timeframe: Timeframe, totalCount: number, toTimeSec?: number, ): Promise { const type = timeframeToCandleType(timeframe); const want = Math.min(Math.max(totalCount, 1), MAX_BACKTEST_FETCH); let remaining = want; let toIso: string | undefined = toTimeSec && toTimeSec > 0 ? toHistoryApiIso(toTimeSec) : undefined; const byTime = new Map(); while (remaining > 0) { const batch = Math.min(remaining, HISTORY_PAGE_SIZE); const params = new URLSearchParams({ market, type, count: String(batch), }); if (toIso) params.set('to', toIso); const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`); if (!res.ok) throw new Error(`캔들 로딩 실패 (${res.status})`); const raw = (await res.json()) as OHLCVBar[]; if (raw.length === 0) break; const before = byTime.size; for (const b of raw) byTime.set(b.time, b); if (byTime.size === before) break; remaining -= raw.length; const oldest = raw[0]?.time; if (!oldest || oldest <= 0) break; toIso = toHistoryApiIso(oldest); if (remaining > 0) { await new Promise(r => setTimeout(r, 110)); } } const sorted = [...byTime.values()].sort((a, b) => a.time - b.time); if (sorted.length <= want) return sorted; return sorted.slice(sorted.length - want); } export interface BacktestCandleBundle { bars: OHLCVBar[]; evaluationBarCount: number; warmupBarCount: number; } /** * 표시 구간(displayCount) + 워밍업을 포함한 캔들 묶음. * 백엔드 evaluationBarCount 로 마지막 displayCount 봉만 시그널 평가. */ export async function fetchBacktestCandleBundle(opts: { market: string; timeframe: Timeframe; displayCount?: number; buyDsl?: unknown; sellDsl?: unknown; indicatorParams?: Record>; toTimeSec?: number; }): Promise { const displayCount = opts.displayCount ?? BACKTEST_DISPLAY_BAR_COUNT; const warmupBarCount = computeBacktestWarmupBars( opts.buyDsl, opts.sellDsl, opts.indicatorParams, ); const total = displayCount + warmupBarCount; const bars = await fetchBacktestHistoryCandles( opts.market, opts.timeframe, total, opts.toTimeSec, ); const evaluationBarCount = Math.min(displayCount, bars.length); return { bars, evaluationBarCount, warmupBarCount }; } /** 이미 로드된 캔들(차트 rawBars)에 대한 평가·워밍업 구간 계산 */ export function resolveEvaluationFromLoadedBars( barCount: number, buyDsl?: unknown, sellDsl?: unknown, indicatorParams?: Record>, ): { evaluationBarCount: number; warmupBarCount: number } { const warmupBarCount = computeBacktestWarmupBars(buyDsl, sellDsl, indicatorParams); if (barCount <= warmupBarCount) { return { evaluationBarCount: Math.max(1, barCount), warmupBarCount: 0 }; } return { evaluationBarCount: barCount - warmupBarCount, warmupBarCount, }; }