import type { Candle, Pair } from '../types'; import { PAIRS } from '../types'; import { aggregate4h, H4 } from '../market/aggregate'; import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend'; import { updateChandelier } from '../strategy/chandelier'; import { sizePosition, type RiskConfig } from '../engine/sizing'; import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio'; import type { EquityPoint } from './metrics'; export interface BacktestConfig { startCapital: number; risk: RiskConfig; exec: ExecConfig; maxPositions: number; params: StrategyParams; tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt } export interface BacktestResult { trades: ClosedTrade[]; equityCurve: EquityPoint[]; finalEquity: number; } interface PairContext { pair: Pair; c15: Candle[]; c4h: Candle[]; ind: IndicatorSet; next4h: number; // Index der nächsten noch nicht verarbeiteten 4h-Candle } export function runBacktest(candles15ByPair: Map, cfg: BacktestConfig): BacktestResult { const portfolio = new Portfolio(cfg.startCapital, cfg.exec); const lastClose = new Map(); const equityCurve: EquityPoint[] = []; const contexts: PairContext[] = PAIRS.filter((p) => candles15ByPair.has(p)).map((pair) => { const c15 = candles15ByPair.get(pair)!; const c4h = aggregate4h(c15); return { pair, c15, c4h, ind: computeIndicators(c4h, cfg.params), next4h: 0 }; }); // Gemergte 15m-Timeline (Pair-Reihenfolge stabil → deterministisch) const timeline: { ts: number; pair: Pair; candle: Candle }[] = []; for (const ctx of contexts) { for (const candle of ctx.c15) { 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) - PAIRS.indexOf(b.pair)); const byPair = new Map(contexts.map((c) => [c.pair, c])); let lastEquityBucket = -1; for (const { ts, pair, candle } of timeline) { const ctx = byPair.get(pair)!; const bucket = Math.floor(ts / H4) * H4; // Bekannte Grenze: 4h-Bars eines Pairs werden erst verarbeitet, wenn dessen // nächste 15m-Candle eintrifft — bei Datenlücken eines Pairs verschiebt sich // dessen Verarbeitung relativ zu anderen Pairs (betrifft maxPositions-Reihenfolge). // 1) Neu abgeschlossene 4h-Candles dieses Pairs verarbeiten (alles < aktueller Bucket) while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) { const i = ctx.next4h++; const bar = ctx.c4h[i]; // 1a) Trailing-Stop der offenen Position nachziehen const pos = portfolio.positions.get(pair); if (pos) { const next = updateChandelier( { highestHigh: pos.highestHigh, stop: pos.stop }, bar.high, ctx.ind.atr[i], cfg.params.atrMultiplier, ); pos.highestHigh = next.highestHigh; pos.stop = next.stop; } // 1b) Entry-Evaluation const barCloseTs = bar.ts + H4; if ( !portfolio.positions.has(pair) && portfolio.positions.size < cfg.maxPositions && barCloseTs >= cfg.tradeFrom && barCloseTs < cfg.tradeTo ) { const ev = evaluateAt(ctx.c4h, ctx.ind, i); if (ev.signal === 'long') { const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; const equity = portfolio.equity(lastClose); const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk); if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount); } } } // 2) Stop-Check auf der 15m-Candle. // Bewusst AUCH auf der Entry-Candle (Entry = Open-Zeitpunkt dieser Candle, // ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv). // Pessimistisch-realistisch, nicht "wegoptimieren". const pos = portfolio.positions.get(pair); if (pos && candle.low <= pos.stop) { const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill portfolio.close(pair, ts, exitPrice, 'trailing_stop'); } 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: portfolio.equity(lastClose) }); } } // Offene Positionen glattstellen for (const pair of [...portfolio.positions.keys()]) { portfolio.close(pair, cfg.tradeTo, lastClose.get(pair)!, 'end_of_data'); } return { trades: portfolio.trades, equityCurve, finalEquity: portfolio.equity(lastClose), }; }