import type { Candle, Pair } from '../types'; import { PAIRS } from '../types'; import { aggregateTf, H4 } from '../market/aggregate'; import { atr } from '../indicators/atr'; import { adx } from '../indicators/adx'; import type { ClosedTrade, ExecConfig } from '../engine/portfolio'; import type { EquityPoint } from './metrics'; import type { BacktestResult } from './runner'; export interface GridParams { spacingAtrMult: number; // Level-Abstand = mult × ATR(atrPeriod, tf) bei Aktivierung gridLevels: number; // Buy-Levels unterhalb des Centers adxMax: number; // Grid nur aktiv im Seitwärtsregime (ADX < adxMax) atrPeriod: number; tfMs: number; // Entscheidungs-Timeframe (Aktivierung/Deaktivierung, ATR/ADX-Basis) /** * true: Range-Breakdown/Ausbruch/Trendbeginn liquidiert alle Lots (harter Stop). * false: Lots werden nie mit Verlust verkauft (nur TP oder end_of_data); * Re-Center nur, wenn das Grid leer ist und der Preis die Range verlassen hat. */ hardStop: boolean; } export const DEFAULT_GRID_PARAMS: GridParams = { spacingAtrMult: 1.0, gridLevels: 4, adxMax: 20, atrPeriod: 14, tfMs: H4, hardStop: true, }; export interface GridConfig { startCapital: number; exec: ExecConfig; params: GridParams; minNotionalUsdt: number; tradeFrom: number; // ms inklusiv — Aktivierungen/Fills erst ab hier tradeTo: number; // ms exklusiv — danach Zwangsglattstellung } interface Lot { levelIdx: number; qty: number; entryTs: number; entryPrice: number; // Fill inkl. Slippage entryCost: number; // qty×fill + Fee riskAmount: number; // Distanz zum Grid-Stop × qty } interface ActiveGrid { center: number; spacing: number; // eingefroren bei Aktivierung — kein Level-Chasing stopPrice: number; // center − (N+1)×spacing upperExit: number; // center + (N+1)×spacing budgetPerLevel: number; lots: (Lot | null)[]; // Index = Level (0 = oberstes Buy-Level bei center − 1×spacing) } /** * ATR-Grid mit ADX-Regime-Filter, long-only, je Pair unabhängig. * Tf-Close (default 4h): Aktivierung/Deaktivierung; 15m: Fills (Sells vor * Buys — ein im selben Bar gekaufter Lot kann nicht im selben Bar verkaufen). * Fee/Slippage-Mathematik identisch zu Portfolio (pessimistische Fills). */ export function runGridBacktest(candles15ByPair: Map, cfg: GridConfig): BacktestResult { const { exec, params: p } = cfg; let cash = cfg.startCapital; const trades: ClosedTrade[] = []; const grids = new Map(); const lastClose = new Map(); const equityCurve: EquityPoint[] = []; const equity = (): number => { let eq = cash; for (const [pair, g] of grids) { const last = lastClose.get(pair) ?? 0; for (const lot of g.lots) if (lot) eq += lot.qty * last; } return eq; }; const buy = (pair: Pair, ts: number, price: number, levelIdx: number, g: ActiveGrid): void => { const fill = price * (1 + exec.slippage); const qty = g.budgetPerLevel / fill; const cost = qty * fill; const fee = cost * exec.feeRate; if (cash < cost + fee) return; // kein Cash → Fill entfällt cash -= cost + fee; g.lots[levelIdx] = { levelIdx, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee, riskAmount: Math.max((price - g.stopPrice) * qty, 1e-9), }; }; const sell = (pair: Pair, ts: number, price: number, lot: Lot, reason: ClosedTrade['exitReason']): void => { const fill = price * (1 - exec.slippage); const proceeds = lot.qty * fill; const fee = proceeds * exec.feeRate; cash += proceeds - fee; const pnl = proceeds - fee - lot.entryCost; trades.push({ pair, entryTs: lot.entryTs, entryPrice: lot.entryPrice, exitTs: ts, exitPrice: fill, qty: lot.qty, pnl, r: pnl / lot.riskAmount, exitReason: reason, side: 'long', }); }; const liquidate = (pair: Pair, ts: number, price: number, reason: ClosedTrade['exitReason']): void => { const g = grids.get(pair); if (!g) return; for (const lot of g.lots) if (lot) sell(pair, ts, price, lot, reason); grids.delete(pair); }; // --- Kontexte + gemergte 15m-Timeline (wie runner.ts) --- const contexts = PAIRS.filter((pr) => candles15ByPair.has(pr)).map((pair) => { const c15 = candles15ByPair.get(pair)!; const c4h = aggregateTf(c15, p.tfMs); return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h: 0 }; }); const byPair = new Map(contexts.map((c) => [c.pair, c])); const timeline: { ts: number; pair: Pair; candle: Candle }[] = []; for (const ctx of contexts) { for (const candle of candles15ByPair.get(ctx.pair)!) { if (candle.ts < cfg.tradeTo) timeline.push({ ts: candle.ts, pair: ctx.pair, candle }); } } timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number])); let lastEquityBucket = -1; for (const { ts, pair, candle } of timeline) { const ctx = byPair.get(pair)!; const bucket = Math.floor(ts / p.tfMs) * p.tfMs; // 1) Neu abgeschlossene Tf-Bars: Deaktivierung / Aktivierung while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) { const i = ctx.next4h++; const bar = ctx.c4h[i]; const barCloseTs = bar.ts + p.tfMs; if (barCloseTs < cfg.tradeFrom || barCloseTs >= cfg.tradeTo) continue; const g = grids.get(pair); if (g) { const outOfRange = bar.close < g.stopPrice || bar.close > g.upperExit; if (p.hardStop) { const trendStart = !Number.isNaN(ctx.adx[i]) && ctx.adx[i] >= p.adxMax + 5; // Hysterese if (outOfRange || trendStart) liquidate(pair, barCloseTs, bar.close, 'grid_stop'); } else if (outOfRange && g.lots.every((l) => !l)) { grids.delete(pair); // leeres Grid folgt dem Preis (Re-Center ohne Verlust) } } else if ( !Number.isNaN(ctx.atr[i]) && !Number.isNaN(ctx.adx[i]) && ctx.adx[i] < p.adxMax ) { const spacing = p.spacingAtrMult * ctx.atr[i]; const budgetPerLevel = equity() / contexts.length / p.gridLevels; if (spacing > 0 && budgetPerLevel >= cfg.minNotionalUsdt) { grids.set(pair, { center: bar.close, spacing, stopPrice: bar.close - (p.gridLevels + 1) * spacing, upperExit: bar.close + (p.gridLevels + 1) * spacing, budgetPerLevel, lots: Array(p.gridLevels).fill(null), }); } } } // 2) 15m-Fills auf aktivem Grid: Sells zuerst (nur vor diesem Bar gekaufte Lots), dann Buys const g = grids.get(pair); if (g && ts >= cfg.tradeFrom) { for (let k = 0; k < g.lots.length; k++) { const lot = g.lots[k]; if (!lot || lot.entryTs >= ts) continue; const tp = g.center - k * g.spacing; // ein Spacing über dem Buy-Level k (center − (k+1)·S) if (candle.high >= tp) { sell(pair, ts, tp, lot, 'grid_tp'); g.lots[k] = null; } } for (let k = 0; k < g.lots.length; k++) { const levelPrice = g.center - (k + 1) * g.spacing; if (!g.lots[k] && candle.low <= levelPrice) buy(pair, ts, levelPrice, k, g); } } lastClose.set(pair, candle.close); // 3) Equity-Punkt einmal pro 4h-Bucket if (bucket !== lastEquityBucket && ts >= cfg.tradeFrom) { lastEquityBucket = bucket; equityCurve.push({ ts: bucket, equity: equity() }); } } // Zwangsglattstellung for (const pair of [...grids.keys()]) { liquidate(pair, cfg.tradeTo, lastClose.get(pair) ?? 0, 'end_of_data'); } return { trades, equityCurve, finalEquity: equity() }; }