/** * @module backtest/backtester * Event-driven, no-look-ahead backtester for the live strategy. * * Design goals: * - Measure the EXACT strategy that trades live. It reuses `generateSignals` * (entries) and `analyzeExit` (runner trailing) verbatim, plus the same cost * formulas (`getSpread`, `calculateCommission`) and P&L math (`calculatePnL`) * the live `TradeManager` uses. No reimplementation of the edge. * - No look-ahead: at bar i the entry scan only sees candles[0..i]. * - Honest fills: SL/TP are detected against each subsequent bar's intrabar * high/low (not a polled close, which the live engine optimistically uses). * - Faithful trade lifecycle: 70% partial at TP1, SL→breakeven, 30% runner to * TP2 with structure trailing — exactly like `TradeManager.updatePrices`. * * Same-bar ambiguity (a bar spans both SL and a TP) is resolved by `tieBreak`: * - 'pessimistic' (default): assume SL filled first — honest lower bound. * - 'optimistic': assume the TP filled first — upper bound. * - 'proximity': assume the level nearer the bar's open filled first. */ import { generateSignals } from '../analysis/signalGenerator.js'; import { analyzeExit, calculatePnL } from '../analysis/exitManager.js'; import { getSpread, calculateCommission } from '../components/tradeManager.js'; /** * P&L in dollars for an arbitrary lot size at a given fill price. * Delegates to the same `calculatePnL` the live engine uses (lot override). */ function pnlForLots(trade, fillPrice, lots) { return calculatePnL({ ...trade, lotSize: lots }, fillPrice, trade.symbol, fillPrice); } /** * Decide which level a bar touched first when it spans both. * @returns {'sl'|'tp'} */ function firstTouch(open, slLevel, tpLevel, mode) { if (mode === 'optimistic') return 'tp'; if (mode === 'proximity') { return Math.abs(open - slLevel) <= Math.abs(open - tpLevel) ? 'sl' : 'tp'; } return 'sl'; // pessimistic } /** Open a trade from a signal (mirrors TradeManager.takeTrade bookkeeping). */ function openTrade(sig, symbol, candleTime) { const commission = calculateCommission(symbol, sig.entry, sig.lotSize); return { type: sig.type, symbol, entry: sig.entry, sl: sig.sl, tp1: sig.tp1, tp2: sig.tp2, lotSize: sig.lotSize, // remaining lots (shrinks to 30% after partial) initialLots: sig.lotSize, riskAmount: sig.riskAmount, initialRiskDist: Math.abs(sig.entry - sig.sl), commission, realizedPnL: 0, partialClosed: false, slMoved: false, quality: sig.quality, score: sig.score, confluences: sig.confluences, entryTime: candleTime, }; } /** Book the 70% partial at TP1 and move SL to breakeven (mirrors _triggerPartialClose). */ function bookPartial(t, exitPrice) { const partLots = t.initialLots * 0.7; t.realizedPnL += pnlForLots(t, exitPrice, partLots); t.lotSize = t.initialLots * 0.3; t.partialClosed = true; t.partialExitPrice = exitPrice; t.sl = t.entry; // breakeven t.slMoved = true; } /** Finalize a trade and push the record (mirrors _closeTrade accounting). */ function bookClose(t, reason, exitPrice, trades, onClose) { const remainingPnL = pnlForLots(t, exitPrice, t.lotSize); const grossPnL = (t.realizedPnL || 0) + remainingPnL; const netPnL = grossPnL - (t.commission || 0); trades.push({ type: t.type, symbol: t.symbol, entry: t.entry, sl: t.sl, tp1: t.tp1, tp2: t.tp2, initialLots: t.initialLots, quality: t.quality, score: t.score, entryTime: t.entryTime, exitTime: exitPrice && t._barTime ? t._barTime : null, closeReason: reason, exitPrice, partialClosed: !!t.partialClosed, realizedPnL: t.realizedPnL || 0, commission: t.commission || 0, grossPnL, pnl: netPnL, rMultiple: t.initialRiskDist > 0 ? netPnL / (t.riskAmount || 1) : 0, }); onClose(); } /** * Process one bar against an active trade: trailing, then SL/TP fills. * Mirrors TradeManager.updatePrices order (SL → TP2 → TP1) with intrabar extremes. */ function manageBar(t, bar, hist, tieBreak, trades, onClose) { t._barTime = bar.time; const spread = getSpread(t.symbol); // Runner: trail the stop using the SAME analyzeExit logic the server applies. if (t.partialClosed) { try { const ex = analyzeExit(hist, t); if (ex && ex.newSL != null) { const better = t.type === 'LONG' ? (ex.newSL > t.sl && ex.newSL >= t.entry) : (ex.newSL < t.sl && ex.newSL <= t.entry); if (better) { t.sl = ex.newSL; t.slMoved = true; } } } catch { /* structure can fail on short data — ignore */ } } if (t.type === 'LONG') { // LONG values at bid: compare levels directly to the bar's low/high. const hitSL = bar.low <= t.sl; const hitTP1 = bar.high >= t.tp1; const hitTP2 = bar.high >= t.tp2; if (!t.partialClosed) { if (hitSL && hitTP1) { if (firstTouch(bar.open, t.sl, t.tp1, tieBreak) === 'sl') { bookClose(t, 'SL Hit', t.sl, trades, onClose); return; } bookPartial(t, t.tp1); if (hitTP2) { bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } return; // runner continues with SL at breakeven } if (hitSL) { bookClose(t, 'SL Hit', t.sl, trades, onClose); return; } if (hitTP2) { bookPartial(t, t.tp1); bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } if (hitTP1) { bookPartial(t, t.tp1); return; } } else { // Runner: SL sits at/above breakeven. if (hitSL && hitTP2) { if (firstTouch(bar.open, t.sl, t.tp2, tieBreak) === 'sl') { bookClose(t, 'Trailing Stop', t.sl, trades, onClose); return; } bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } if (hitSL) { bookClose(t, 'Trailing Stop', t.sl, trades, onClose); return; } if (hitTP2) { bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } } } else { // SHORT values at ask (bid + spread): shift the bar extremes by the spread. const adverse = bar.high + spread; // toward SL (above) const favor = bar.low + spread; // toward TP (below) const hitSL = adverse >= t.sl; const hitTP1 = favor <= t.tp1; const hitTP2 = favor <= t.tp2; if (!t.partialClosed) { if (hitSL && hitTP1) { if (firstTouch(bar.open + spread, t.sl, t.tp1, tieBreak) === 'sl') { bookClose(t, 'SL Hit', t.sl, trades, onClose); return; } bookPartial(t, t.tp1); if (hitTP2) { bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } return; } if (hitSL) { bookClose(t, 'SL Hit', t.sl, trades, onClose); return; } if (hitTP2) { bookPartial(t, t.tp1); bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } if (hitTP1) { bookPartial(t, t.tp1); return; } } else { if (hitSL && hitTP2) { if (firstTouch(bar.open + spread, t.sl, t.tp2, tieBreak) === 'sl') { bookClose(t, 'Trailing Stop', t.sl, trades, onClose); return; } bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } if (hitSL) { bookClose(t, 'Trailing Stop', t.sl, trades, onClose); return; } if (hitTP2) { bookClose(t, 'TP2 Hit', t.tp2, trades, onClose); return; } } } } /** * Backtest one symbol over a candle series. * * @param {{time:number,open:number,high:number,low:number,close:number,volume:number}[]} candles * @param {string} symbol * @param {{ warmup?:number, tieBreak?:'pessimistic'|'optimistic'|'proximity', strategyOpts?:object, onProgress?:(i:number,n:number)=>void }} [opts] * strategyOpts is forwarded to generateSignals (e.g. { blockRanging, maxEntryDistAtr }). * @returns {{ trades: object[], stats: object }} */ export function backtestSymbol(candles, symbol, opts = {}) { const warmup = opts.warmup ?? 200; // need EMA200 for trend const tieBreak = opts.tieBreak ?? 'pessimistic'; const trades = []; let active = null; if (!candles || candles.length <= warmup + 2) { return { trades, stats: computeStats(trades, symbol) }; } for (let i = warmup; i < candles.length; i++) { const bar = candles[i]; if (active) { // Manage the open trade on this (post-entry) bar. The entry bar itself is // never used for fills because the trade was opened on the prior iteration. const hist = candles.slice(0, i + 1); manageBar(active, bar, hist, tieBreak, trades, () => { active = null; }); continue; // one position per symbol; also blocks same-candle re-entry } // Flat: scan for a signal as of bar i (history ends at i — no future data). const hist = candles.slice(0, i + 1); let signals = generateSignals(hist, symbol, 0, opts.strategyOpts || {}); signals = signals.filter(s => s.quality === 'A' || s.quality === 'B'); if (signals.length > 0) { active = openTrade(signals[0], symbol, bar.time); // fills begin next bar } if (opts.onProgress && i % 500 === 0) opts.onProgress(i, candles.length); } // Mark-to-market any trade still open at the end of the series. if (active) { active._barTime = candles[candles.length - 1].time; const lastClose = candles[candles.length - 1].close; bookClose(active, 'End of data', lastClose, trades, () => { active = null; }); } return { trades, stats: computeStats(trades, symbol) }; } /** * Aggregate trade records into performance metrics. * @param {object[]} trades * @param {string} [symbol] */ export function computeStats(trades, symbol = 'ALL') { const n = trades.length; let wins = 0, losses = 0, grossWin = 0, grossLoss = 0, totalCommission = 0; let bal = 0, peak = 0, maxDD = 0; const equityCurve = []; for (const t of trades) { const p = t.pnl; totalCommission += t.commission || 0; if (p >= 0) { wins++; grossWin += p; } else { losses++; grossLoss += Math.abs(p); } bal += p; equityCurve.push(bal); peak = Math.max(peak, bal); maxDD = Math.max(maxDD, peak - bal); } const netPnL = grossWin - grossLoss; return { symbol, trades: n, wins, losses, winRate: n ? (wins / n) * 100 : 0, profitFactor: grossLoss > 0 ? grossWin / grossLoss : (grossWin > 0 ? Infinity : 0), netPnL, grossWin, grossLoss, avgWin: wins ? grossWin / wins : 0, avgLoss: losses ? grossLoss / losses : 0, expectancy: n ? netPnL / n : 0, totalCommission, maxDrawdown: maxDD, equityCurve, }; }