Backtest runner
@qtrader/backtest-runner
Backtest a trading idea on an OHLCV CSV with honest assumptions: next-bar fills, slippage, commission, no look-ahead. Built-in SMA crossover, RSI mean reversion, and buy-and-hold, plus a one-file plug-in format for your own strategies. Writes equity curve and trades CSVs and metrics (CAGR, volatility, Sharpe, Sortino, max drawdown, win rate, exposure). Not financial advice.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Self-test on tiny synthetic series with results worked out by hand (see README "Hand check").
- 3// Usage: node scripts/selftest.mjs (exits 1 on any failure)
- 4import { backtest, BUILTINS, loadBars, rsi, sma } from "./backtest.mjs";
- 6let passed = 0;
- 7let failed = 0;
- 8function close(actual, expected, label, tol = 1e-9) {
- 9 const ok = expected === null ? actual === null : typeof actual === "number" && Math.abs(actual - expected) <= tol;
- 10 if (ok) passed++;
- 11 else {
- 12 failed++;
- 13 console.error(`FAIL ${label}: expected ${expected}, got ${actual}`);
- 14 }
- 15}
- 16function eq(actual, expected, label) {
- 17 if (JSON.stringify(actual) === JSON.stringify(expected)) passed++;
- 18 else {
- 19 failed++;
- 20 console.error(`FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
- 21 }
- 22}
- 23const flat = (closes) => closes.map((c, i) => ({ date: `2026-01-${String(i + 1).padStart(2, "0")}`, open: c, high: c, low: c, close: c, volume: 0 }));
- 24const scripted = (targets) => ({ name: "scripted", params: {}, onBar: ({ i }) => targets[i] ?? null });
- 26// 1. Buy and hold, no costs, fractional: every number by hand
- 27{
- 28 const r = backtest(flat([100, 100, 110, 121, 108.9, 119.79]), BUILTINS["buy-hold"], { capital: 10000, fractional: true });
- 29 eq(r.equity.map((e) => Math.round(e.equity * 1e6) / 1e6), [10000, 10000, 11000, 12100, 10890, 11979], "buy-hold equity curve");
- 30 const m = r.metrics;
- 31 close(m.total_return, 0.1979, "buy-hold total return");
- 32 close(m.max_drawdown, -0.1, "buy-hold max drawdown");
- 33 // returns 0, .1, .1, -.1, .1: mean .04, sample sd sqrt(.032/4), downside sqrt(.01/5)
- 34 close(m.volatility, Math.sqrt(0.008) * Math.sqrt(252), "buy-hold volatility");
- 35 close(m.sharpe, (0.04 / Math.sqrt(0.008)) * Math.sqrt(252), "buy-hold sharpe");
- 36 close(m.sortino, (0.04 / Math.sqrt(0.002)) * Math.sqrt(252), "buy-hold sortino");
- 37 close(m.cagr, 1.1979 ** (252 / 5) - 1, "buy-hold cagr", 1e-6);
- 38 close(m.exposure, 5 / 6, "buy-hold exposure");
- 39 eq([m.trades, m.open_trades, m.win_rate], [0, 1, null], "open trade is not counted in win rate");
- 40 eq(r.trades[0].entry_date, "2026-01-02", "signal on bar 0 fills on bar 1");
- 41}
- 43// 2. Integer shares, slippage 10 bps, commission $0.01 per share
- 44{
- 45 const bars = [
- 46 { date: "d0", open: 100, high: 100, low: 100, close: 100 },
- 47 { date: "d1", open: 101, high: 104, low: 101, close: 104 },
- 48 { date: "d2", open: 105, high: 105, low: 103, close: 103 },
- 49 { date: "d3", open: 102, high: 106, low: 102, close: 106 },
- 50 { date: "d4", open: 107, high: 108, low: 107, close: 108 },
- 51 ];
- 52 const r = backtest(bars, scripted([1, null, 0]), { capital: 10000, slippageBps: 10, commissionPerShare: 0.01 });
- 53 const t = r.trades[0];
- 54 close(t.entry_price, 101.101, "buy fills at next open plus slippage");
- 55 eq(t.qty, 98, "floor(10000 / (101.101 + 0.01)) = 98 shares");
- 56 close(t.exit_price, 101.898, "sell fills at next open minus slippage");
- 57 close(t.commission, 1.96, "commission both sides");
- 58 close(t.pnl, 76.146, "trade pnl", 1e-6);
- 59 eq([t.entry_date, t.exit_date, t.bars_held, t.status], ["d1", "d3", 2, "closed"], "trade dates");
- 60 close(r.equity[1].equity, 10283.122, "equity after entry", 1e-6);
- 61 close(r.equity[2].equity, 10185.122, "equity next bar", 1e-6);
- 62 close(r.equity[4].equity, 10076.146, "equity after exit", 1e-6);
- 63 close(r.metrics.win_rate, 1, "win rate");
- 64 close(r.metrics.exposure, 0.4, "exposure 2 of 5 bars");
- 65 close(r.metrics.total_commission, 1.96, "total commission");
- 66}
- 68// 3. No look-ahead: history ends at i, the last bar's signal never fills, history is read-only
- 69{
- 70 const seen = [];
- 71 const peek = {
- 72 name: "peek",
- 73 params: {},
- 74 onBar({ i, bars, closes }) {
- 75 seen.push([bars.length, closes.length, bars[i + 1], closes[i + 1], closes.slice(-1)[0]]);
- 76 try {
- 77 bars[0] = null;
- 78 seen.push("mutated");
- 79 } catch {
- 80 // expected
- 81 }
- 82 return i === 3 ? 1 : null;
- 83 },
- 84 };
- 85 const r = backtest(flat([1, 2, 3, 4, 5]), peek, { fractional: true });
- 86 eq(seen.length, 4, "strategy called for bars 0..n-2 only");
- 87 eq(seen[2], [3, 3, undefined, undefined, 3], "bar 2 sees exactly 3 bars and nothing after");
- 88 eq(r.equity[4].position > 0, true, "signal on bar 3 fills on bar 4");
- 89 const late = backtest(flat([1, 2, 3]), scripted([null, null, 1]), {});
- 90 eq(late.trades.length, 0, "signal on the last bar cannot fill");
- 91}
- 93// 4. SMA cross by hand: fast 2, slow 3 on 10,9,8,9,10,11,10,9,8
- 94{
- 95 const r = backtest(flat([10, 9, 8, 9, 10, 11, 10, 9, 8]), BUILTINS["sma-cross"], { capital: 1000, fractional: true, params: { fast: 2, slow: 3 } });
- 96 const t = r.trades[0];
- 97 eq([r.trades.length, t.entry_date, t.exit_date], [1, "2026-01-06", "2026-01-09"], "cross up on bar 4 fills bar 5; cross down on bar 7 fills bar 8");
- 98 close(t.entry_price, 11, "entry price");
- 99 close(t.exit_price, 8, "exit price");
- 100 close(t.pnl, (1000 / 11) * -3, "pnl = 90.909 shares x -3");
- 101 close(r.metrics.win_rate, 0, "losing trade");
- 102}
- 104// 5. Indicators
- 105close(sma([1, 2, 3, 4], 2), 3.5, "sma");
- 106close(sma([1, 2, 3, 4], 2, 1), 1.5, "sma up to index");
- 107eq(sma([1], 2), null, "sma warmup");
- 108close(rsi([10, 11, 10], 2), 50, "rsi seed: avg gain .5, avg loss .5");
- 109close(rsi([10, 11, 10, 12], 2), 100 - 100 / 6, "rsi Wilder step: gain 1.25, loss .25, RS 5");
- 110// The incremental built-in matches the reference rsi()
- 111{
- 112 const closes = [44, 44.3, 44.1, 43.6, 44.3, 44.8, 45.1, 45.4, 45.8, 46.1, 45.9, 46.2, 45.6, 46.3, 46.3, 46, 46.4, 46.2, 45.6, 46.2];
- 113 const s = BUILTINS["rsi-revert"];
- 114 const state = {};
- 115 for (let i = 0; i < closes.length; i++) s.onBar({ i, closes, params: { period: 14, entry: 30, exit: 50 }, state });
- 116 close(state.rsi, rsi(closes, 14), "incremental RSI equals reference", 1e-9);
- 117}
- 119// 6. Metrics edge cases and shorts
- 120{
- 121 const r = backtest(flat([100, 100, 100]), BUILTINS["buy-hold"], { fractional: true });
- 122 eq([r.metrics.sharpe, r.metrics.sortino, r.metrics.volatility], [null, null, 0], "flat equity: no Sharpe or Sortino");
- 123 const s = backtest(flat([100, 100, 90, 90]), scripted([-1]), { capital: 1000, fractional: true, allowShort: true });
- 124 close(s.equity[3].equity, 1100, "short gains when price falls");
- 125 eq(s.trades[0].side, "short", "short trade");
- 126 const noShort = backtest(flat([100, 100, 90]), scripted([-1]), { fractional: true });
- 127 eq(noShort.trades.length, 0, "shorts ignored without allowShort");
- 128 // flip long to short in one order: two legs, two trades
- 129 const flip = backtest(flat([10, 10, 12, 12, 9]), scripted([1, null, -1]), { capital: 100, fractional: true, allowShort: true });
- 130 eq(flip.trades.map((t) => [t.side, t.status]), [["long", "closed"], ["short", "open"]], "flip closes long and opens short");
- 131 close(flip.trades[0].pnl, 20, "long leg pnl 10 shares x +2");
- 132}
- 134// 7. Input validation
- 135function throws(fn, text, label) {
- 136 try {
- 137 fn();
- 138 failed++;
- 139 console.error(`FAIL ${label}: no error`);
- 140 } catch (e) {
- 141 if (e.message.includes(text)) passed++;
- 142 else {
- 143 failed++;
- 144 console.error(`FAIL ${label}: ${e.message}`);
- 145 }
- 146 }
- 147}
- 148throws(() => loadBars("date,open,high,low\n2026-01-01,1,1,1\n"), '"close"', "missing close");
- 149throws(() => loadBars("date,open,high,low,close\n2026-01-01,1,1,1,1\n2026-01-01,1,1,1,1\n"), "duplicate date", "duplicate dates");
- 150throws(() => loadBars("date,open,high,low,close\n2026-01-01,1,0.5,0.4,1\n"), "high/low", "bad high/low");
- 151eq(loadBars("timestamp,open,high,low,close\n2026-01-02,2,2,2,2\n2026-01-01,1,1,1,1\n").map((b) => b.date), ["2026-01-01", "2026-01-02"], "sorts dates and accepts timestamp");
- 152throws(() => backtest(flat([1, 2, 3]), BUILTINS["sma-cross"], { params: { fsat: 3 } }), "unknown param", "typo in param");
- 154console.log(`${passed} passed, ${failed} failed`);
- 155process.exit(failed ? 1 : 0);
