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// Event-driven, single-symbol backtester over an OHLCV CSV. Node built-ins only.
- 3// Decisions happen after each bar's close using bars 0..i only; orders fill at the NEXT bar's open,
- 4// with slippage and commission. Not financial advice: past results do not predict future ones.
- 5//
- 6// Usage:
- 7// node scripts/backtest.mjs --data data/SPY-1Day.csv --strategy sma-cross [--param fast=20] [--param slow=50]
- 8// node scripts/backtest.mjs --data data/SPY-1Day.csv --strategy-file strategies/my-strategy.mjs
- 9// Options: --capital 100000 --commission-per-share 0 --commission-bps 0 --slippage-bps 5
- 10// --fractional --allow-short --periods-per-year 252 --rf 0 --out out/backtest
- 11// Writes <out>-equity.csv, <out>-trades.csv, <out>-metrics.json.
- 12import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
- 13import { dirname, resolve } from "node:path";
- 14import { fileURLToPath, pathToFileURL } from "node:url";
- 16// ---------- CSV ----------
- 17export function parseCsv(text) {
- 18 const rows = [];
- 19 let row = [];
- 20 let field = "";
- 21 let quoted = false;
- 22 const pushRow = () => {
- 23 row.push(field);
- 24 field = "";
- 25 if (row.some((v) => v.trim() !== "")) rows.push(row);
- 26 row = [];
- 27 };
- 28 for (let i = 0; i < text.length; i++) {
- 29 const ch = text[i];
- 30 if (quoted) {
- 31 if (ch === '"' && text[i + 1] === '"') {
- 32 field += '"';
- 33 i++;
- 34 } else if (ch === '"') quoted = false;
- 35 else field += ch;
- 36 } else if (ch === '"') quoted = true;
- 37 else if (ch === ",") {
- 38 row.push(field);
- 39 field = "";
- 40 } else if (ch === "\n" || ch === "\r") {
- 41 if (ch === "\r" && text[i + 1] === "\n") i++;
- 42 pushRow();
- 43 } else field += ch;
- 44 }
- 45 pushRow();
- 46 const [header = [], ...body] = rows;
- 47 const keys = header.map((h) => h.trim().toLowerCase());
- 48 return body.map((r) => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? "").trim()])));
- 49}
- 51export function loadBars(text) {
- 52 const rows = parseCsv(text);
- 53 if (rows.length === 0) throw new Error("price file has no rows");
- 54 const dateKey = ["date", "timestamp", "time", "datetime"].find((k) => k in rows[0]);
- 55 if (!dateKey) throw new Error("price file needs a date or timestamp column");
- 56 for (const k of ["open", "high", "low", "close"]) if (!(k in rows[0])) throw new Error(`price file needs a "${k}" column`);
- 57 const bars = rows.map((r, idx) => {
- 58 const b = { date: r[dateKey], open: +r.open, high: +r.high, low: +r.low, close: +r.close, volume: r.volume === undefined || r.volume === "" ? 0 : +r.volume };
- 59 for (const k of ["open", "high", "low", "close"]) {
- 60 if (!Number.isFinite(b[k]) || b[k] <= 0) throw new Error(`line ${idx + 2}: ${k} "${r[k]}" is not a positive number`);
- 61 }
- 62 if (b.high < Math.max(b.open, b.close) - 1e-9 || b.low > Math.min(b.open, b.close) + 1e-9) {
- 63 throw new Error(`line ${idx + 2}: high/low do not contain open/close`);
- 64 }
- 65 return b;
- 66 });
- 67 for (let i = 1; i < bars.length; i++) {
- 68 if (bars[i].date <= bars[i - 1].date) {
- 69 if (bars[i].date === bars[i - 1].date) throw new Error(`duplicate date ${bars[i].date}`);
- 70 bars.sort((a, b) => (a.date < b.date ? -1 : 1));
- 71 break;
- 72 }
- 73 }
- 74 return bars.map((b) => Object.freeze(b));
- 75}
- 77// ---------- Indicators (all use only values at index <= end) ----------
- 78export function sma(values, n, end = values.length - 1) {
- 79 if (end + 1 < n) return null;
- 80 let s = 0;
- 81 for (let k = end - n + 1; k <= end; k++) s += values[k];
- 82 return s / n;
- 83}
- 85// Wilder's RSI computed over values[0..end]. Seeds with the simple average of the first n changes.
- 86export function rsi(values, n, end = values.length - 1) {
- 87 if (end < n) return null;
- 88 let gain = 0;
- 89 let loss = 0;
- 90 for (let k = 1; k <= n; k++) {
- 91 const d = values[k] - values[k - 1];
- 92 if (d > 0) gain += d;
- 93 else loss -= d;
- 94 }
- 95 gain /= n;
- 96 loss /= n;
- 97 for (let k = n + 1; k <= end; k++) {
- 98 const d = values[k] - values[k - 1];
- 99 gain = (gain * (n - 1) + Math.max(d, 0)) / n;
- 100 loss = (loss * (n - 1) + Math.max(-d, 0)) / n;
- 101 }
- 102 if (loss === 0) return gain === 0 ? 50 : 100;
- 103 return 100 - 100 / (1 + gain / loss);
- 104}
- 106export function highest(values, n, end = values.length - 1) {
- 107 if (end + 1 < n) return null;
- 108 let m = -Infinity;
- 109 for (let k = end - n + 1; k <= end; k++) m = Math.max(m, values[k]);
- 110 return m;
- 111}
- 113export function lowest(values, n, end = values.length - 1) {
- 114 if (end + 1 < n) return null;
- 115 let m = Infinity;
- 116 for (let k = end - n + 1; k <= end; k++) m = Math.min(m, values[k]);
- 117 return m;
- 118}
- 120// ---------- Built-in strategies ----------
- 121// onBar returns a target exposure: 1 long, 0 flat, -1 short, or null to keep the current target.
- 122export const BUILTINS = {
- 123 "buy-hold": {
- 124 name: "buy-hold",
- 125 params: {},
- 126 onBar: () => 1,
- 127 },
- 128 "sma-cross": {
- 129 name: "sma-cross",
- 130 params: { fast: 20, slow: 50 },
- 131 onBar({ closes, params }) {
- 132 if (params.fast >= params.slow) throw new Error("sma-cross: fast must be smaller than slow");
- 133 const f = sma(closes, params.fast);
- 134 const s = sma(closes, params.slow);
- 135 if (f === null || s === null) return 0;
- 136 return f > s ? 1 : 0;
- 137 },
- 138 },
- 139 "rsi-revert": {
- 140 name: "rsi-revert",
- 141 params: { period: 14, entry: 30, exit: 50 },
- 142 // Incremental Wilder RSI kept in state, so each bar is O(1).
- 143 onBar({ i, closes, params, state }) {
- 144 const n = params.period;
- 145 if (i === 0) {
- 146 state.g = 0;
- 147 state.l = 0;
- 148 return 0;
- 149 }
- 150 const d = closes[i] - closes[i - 1];
- 151 const up = Math.max(d, 0);
- 152 const down = Math.max(-d, 0);
- 153 if (i <= n) {
- 154 state.g += up / n;
- 155 state.l += down / n;
- 156 if (i < n) return 0;
- 157 } else {
- 158 state.g = (state.g * (n - 1) + up) / n;
- 159 state.l = (state.l * (n - 1) + down) / n;
- 160 }
- 161 const value = state.l === 0 ? (state.g === 0 ? 50 : 100) : 100 - 100 / (1 + state.g / state.l);
- 162 state.rsi = value;
- 163 if (value < params.entry) return 1;
- 164 if (value > params.exit) return 0;
- 165 return null;
- 166 },
- 167 },
- 168};
- 170// A read-only window onto arr[0..i]: index i+1 and beyond read as undefined, length is i+1.
- 171function historyView(arr, i) {
- 172 return new Proxy(arr, {
- 173 get(target, prop, receiver) {
- 174 if (prop === "length") return i + 1;
- 175 if (typeof prop === "string" && /^\d+$/.test(prop)) return Number(prop) <= i ? target[prop] : undefined;
- 176 return Reflect.get(target, prop, receiver);
- 177 },
- 178 has(target, prop) {
- 179 if (typeof prop === "string" && /^\d+$/.test(prop)) return Number(prop) <= i;
- 180 return Reflect.has(target, prop);
- 181 },
- 182 set() {
- 183 throw new Error("strategies cannot modify price history");
- 184 },
- 185 });
- 186}
- 188// ---------- Engine ----------
- 189export function backtest(bars, strategy, options = {}) {
- 190 const o = {
- 191 capital: 100000,
- 192 commissionPerShare: 0,
- 193 commissionBps: 0,
- 194 slippageBps: 0,
- 195 fractional: false,
- 196 allowShort: false,
- 197 periodsPerYear: 252,
- 198 rf: 0,
- 199 ...options,
- 200 };
- 201 const defaults = strategy.params ?? {};
- 202 for (const k of Object.keys(o.params ?? {})) {
- 203 if (!(k in defaults)) throw new Error(`unknown param "${k}" for ${strategy.name ?? "strategy"}. Known: ${Object.keys(defaults).join(", ") || "none"}`);
- 204 }
- 205 const params = { ...defaults, ...(o.params ?? {}) };
- 206 const closes = bars.map((b) => b.close);
- 207 const slip = o.slippageBps / 10000;
- 208 const bps = o.commissionBps / 10000;
- 209 let cash = o.capital;
- 210 let qty = 0;
- 211 let target = 0;
- 212 let pending = null;
- 213 const state = {};
- 214 const equity = [];
- 215 const trades = [];
- 216 let open = null;
- 217 let tradeId = 0;
- 219 const fillSegment = (date, i, delta, price, commission) => {
- 220 // delta: signed shares; price: fill price incl. slippage
- 221 if (delta === 0) return;
- 222 if (!open) {
- 223 open = { id: ++tradeId, side: delta > 0 ? "long" : "short", entry_date: date, entry_index: i, entryQty: 0, entryValue: 0, exitQty: 0, exitValue: 0, commission: 0, cashflow: 0, maxQty: 0 };
- 224 }
- 225 const increasing = (open.side === "long") === delta > 0;
- 226 if (increasing) {
- 227 open.entryQty += Math.abs(delta);
- 228 open.entryValue += Math.abs(delta) * price;
- 229 } else {
- 230 open.exitQty += Math.abs(delta);
- 231 open.exitValue += Math.abs(delta) * price;
- 232 open.exit_date = date;
- 233 open.exit_index = i;
- 234 }
- 235 open.commission += commission;
- 236 open.cashflow += -delta * price - commission;
- 237 };
- 239 const closeTrade = () => {
- 240 const t = open;
- 241 const entry = t.entryValue / t.entryQty;
- 242 trades.push({
- 243 id: t.id,
- 244 side: t.side,
- 245 entry_date: t.entry_date,
- 246 entry_price: entry,
- 247 exit_date: t.exit_date,
- 248 exit_price: t.exitValue / t.exitQty,
- 249 qty: t.entryQty,
- 250 commission: t.commission,
- 251 pnl: t.cashflow,
- 252 return_pct: t.cashflow / t.entryValue,
- 253 bars_held: t.exit_index - t.entry_index,
- 254 status: "closed",
- 255 });
- 256 open = null;
- 257 };
- 259 const execute = (i, newTarget) => {
- 260 const bar = bars[i];
- 261 const equityAtOpen = cash + qty * bar.open;
- 262 let desired;
- 263 if (newTarget === 0) desired = 0;
- 264 else {
- 265 const px = newTarget > 0 ? bar.open * (1 + slip) : bar.open * (1 - slip);
- 266 const perShareCost = px * (1 + bps) + o.commissionPerShare;
- 267 const raw = (Math.abs(newTarget) * equityAtOpen) / perShareCost;
- 268 desired = Math.sign(newTarget) * (o.fractional ? raw : Math.floor(raw + 1e-9));
- 269 }
- 270 const delta = desired - qty;
- 271 if (delta === 0) return;
- 272 // Split into closing and opening legs when crossing zero
- 273 const legs = [];
- 274 if (qty !== 0 && (desired === 0 || Math.sign(desired) !== Math.sign(qty))) {
- 275 legs.push(-qty);
- 276 if (desired !== 0) legs.push(desired);
- 277 } else legs.push(delta);
- 278 for (const leg of legs) {
- 279 const price = leg > 0 ? bar.open * (1 + slip) : bar.open * (1 - slip);
- 280 const commission = Math.abs(leg) * o.commissionPerShare + Math.abs(leg) * price * bps;
- 281 cash -= leg * price + commission;
- 282 fillSegment(bar.date, i, leg, price, commission);
- 283 qty += leg;
- 284 if (qty === 0 || Math.abs(qty) < 1e-12) {
- 285 qty = 0;
- 286 closeTrade();
- 287 }
- 288 }
- 289 };
- 291 for (let i = 0; i < bars.length; i++) {
- 292 const bar = bars[i];
- 293 if (pending !== null) {
- 294 execute(i, pending);
- 295 target = pending;
- 296 pending = null;
- 297 }
- 298 const eq = cash + qty * bar.close;
- 299 equity.push({ date: bar.date, close: bar.close, position: qty, cash, equity: eq });
- 300 if (i === bars.length - 1) break; // no next bar to fill a new order
- 301 const ctx = {
- 302 i,
- 303 bar,
- 304 bars: historyView(bars, i),
- 305 closes: historyView(closes, i),
- 306 params,
- 307 state,
- 308 position: { qty, target },
- 309 equity: eq,
- 310 ind: { sma, rsi, highest, lowest },
- 311 };
- 312 let next = strategy.onBar(ctx);
- 313 if (next === null || next === undefined) continue;
- 314 if (typeof next !== "number" || !Number.isFinite(next)) throw new Error(`strategy returned ${next}; return a number between -1 and 1, or null`);
- 315 if (next < 0 && !o.allowShort) next = 0;
- 316 next = Math.max(-1, Math.min(1, next));
- 317 if (next !== target) pending = next;
- 318 }
- 320 const last = bars[bars.length - 1];
- 321 if (open) {
- 322 const t = open;
- 323 const unrealized = t.cashflow + qty * last.close;
- 324 trades.push({
- 325 id: t.id,
- 326 side: t.side,
- 327 entry_date: t.entry_date,
- 328 entry_price: t.entryValue / t.entryQty,
- 329 exit_date: "",
- 330 exit_price: "",
- 331 qty: t.entryQty,
- 332 commission: t.commission,
- 333 pnl: unrealized,
- 334 return_pct: unrealized / t.entryValue,
- 335 bars_held: bars.length - 1 - t.entry_index,
- 336 status: "open",
- 337 });
- 338 }
- 339 // drawdown series
- 340 let peak = -Infinity;
- 341 for (const e of equity) {
- 342 peak = Math.max(peak, e.equity);
- 343 e.drawdown = e.equity / peak - 1;
- 344 }
- 345 const metrics = computeMetrics(equity, trades, bars, o);
- 346 return { strategy: strategy.name ?? "custom", params, options: publicOptions(o), equity, trades, metrics };
- 347}
- 349function publicOptions(o) {
- 350 const { params, ...rest } = o;
- 351 return rest;
- 352}
- 354export function computeMetrics(equity, trades, bars, o) {
- 355 const values = equity.map((e) => e.equity);
- 356 const rets = [];
- 357 for (let i = 1; i < values.length; i++) rets.push(values[i] / values[i - 1] - 1);
- 358 const n = rets.length;
- 359 const total = values[values.length - 1] / values[0] - 1;
- 360 const ppy = o.periodsPerYear;
- 361 const rfp = o.rf / ppy;
- 362 const mean = n ? rets.reduce((a, b) => a + b, 0) / n : 0;
- 363 const sd = n > 1 ? Math.sqrt(rets.reduce((a, r) => a + (r - mean) ** 2, 0) / (n - 1)) : 0;
- 364 const downside = n ? Math.sqrt(rets.reduce((a, r) => a + Math.min(0, r - rfp) ** 2, 0) / n) : 0;
- 365 let maxDd = 0;
- 366 let ddStart = 0;
- 367 let peakIdx = 0;
- 368 let maxDdBars = 0;
- 369 let peak = values[0];
- 370 for (let i = 0; i < values.length; i++) {
- 371 if (values[i] >= peak) {
- 372 peak = values[i];
- 373 peakIdx = i;
- 374 }
- 375 const dd = values[i] / peak - 1;
- 376 if (dd < maxDd) {
- 377 maxDd = dd;
- 378 ddStart = peakIdx;
- 379 }
- 380 maxDdBars = Math.max(maxDdBars, i - peakIdx);
- 381 }
- 382 const closed = trades.filter((t) => t.status === "closed");
- 383 const wins = closed.filter((t) => t.pnl > 0);
- 384 const losses = closed.filter((t) => t.pnl <= 0);
- 385 const grossWin = wins.reduce((a, t) => a + t.pnl, 0);
- 386 const grossLoss = -losses.reduce((a, t) => a + t.pnl, 0);
- 387 const exposed = equity.filter((e) => e.position !== 0).length;
- 388 const bh = bars[bars.length - 1].close / bars[0].close - 1;
- 389 const years = n / ppy;
- 390 const round = (x) => (x === null || !Number.isFinite(x) ? null : Math.round(x * 1e10) / 1e10);
- 391 return {
- 392 start: equity[0].date,
- 393 end: equity[equity.length - 1].date,
- 394 bars: values.length,
- 395 starting_equity: round(values[0]),
- 396 ending_equity: round(values[values.length - 1]),
- 397 total_return: round(total),
- 398 cagr: years > 0 ? round((1 + total) ** (1 / years) - 1) : null,
- 399 volatility: round(sd * Math.sqrt(ppy)),
- 400 sharpe: sd > 0 ? round(((mean - rfp) / sd) * Math.sqrt(ppy)) : null,
- 401 sortino: downside > 0 ? round(((mean - rfp) / downside) * Math.sqrt(ppy)) : null,
- 402 max_drawdown: round(maxDd),
- 403 max_drawdown_peak: equity[ddStart].date,
- 404 longest_drawdown_bars: maxDdBars,
- 405 trades: closed.length,
- 406 open_trades: trades.length - closed.length,
- 407 win_rate: closed.length ? round(wins.length / closed.length) : null,
- 408 profit_factor: grossLoss > 0 ? round(grossWin / grossLoss) : null,
- 409 avg_trade_pnl: closed.length ? round(closed.reduce((a, t) => a + t.pnl, 0) / closed.length) : null,
- 410 exposure: round(exposed / values.length),
- 411 total_commission: round(trades.reduce((a, t) => a + t.commission, 0)),
- 412 buy_hold_return: round(bh),
- 413 };
- 414}
- 416// ---------- Output ----------
- 417const fmt = (x) => (typeof x === "number" ? String(Math.round(x * 1e6) / 1e6) : x ?? "");
- 419export function equityCsv(equity) {
- 420 const lines = ["date,close,position,cash,equity,drawdown"];
- 421 for (const e of equity) lines.push([e.date, fmt(e.close), fmt(e.position), fmt(e.cash), fmt(e.equity), fmt(e.drawdown)].join(","));
- 422 return lines.join("\n") + "\n";
- 423}
- 425export function tradesCsv(trades) {
- 426 const cols = ["id", "side", "entry_date", "entry_price", "exit_date", "exit_price", "qty", "commission", "pnl", "return_pct", "bars_held", "status"];
- 427 return [cols.join(","), ...trades.map((t) => cols.map((c) => fmt(t[c])).join(","))].join("\n") + "\n";
- 428}
- 430export async function loadStrategy(spec) {
- 431 if (spec.file) {
- 432 const mod = await import(pathToFileURL(resolve(spec.file)).href);
- 433 const s = mod.default ?? mod;
- 434 if (typeof s.onBar !== "function") throw new Error(`${spec.file} must export default { name, params, onBar(ctx) }`);
- 435 return s;
- 436 }
- 437 const s = BUILTINS[spec.name];
- 438 if (!s) throw new Error(`unknown strategy "${spec.name}". Built-ins: ${Object.keys(BUILTINS).join(", ")}`);
- 439 return s;
- 440}
- 442function parseCli(argv) {
- 443 const o = { params: {} };
- 444 const flags = new Set(["fractional", "allow-short"]);
- 445 for (let i = 0; i < argv.length; i++) {
- 446 const a = argv[i];
- 447 if (!a.startsWith("--")) throw new Error(`unexpected argument "${a}"`);
- 448 const k = a.slice(2);
- 449 if (flags.has(k)) {
- 450 o[k] = true;
- 451 continue;
- 452 }
- 453 const v = argv[++i];
- 454 if (v === undefined) throw new Error(`--${k} needs a value`);
- 455 if (k === "param") {
- 456 const [pk, pv] = v.split("=");
- 457 if (!pk || pv === undefined) throw new Error(`--param must look like name=value, got "${v}"`);
- 458 o.params[pk] = Number.isFinite(Number(pv)) ? Number(pv) : pv;
- 459 } else o[k] = v;
- 460 }
- 461 return o;
- 462}
- 464async function main(argv) {
- 465 let cli;
- 466 try {
- 467 cli = parseCli(argv);
- 468 } catch (e) {
- 469 console.error(`Usage error: ${e.message}`);
- 470 process.exit(2);
- 471 }
- 472 if (!cli.data) {
- 473 console.error("Usage: node scripts/backtest.mjs --data <ohlcv.csv> (--strategy sma-cross|rsi-revert|buy-hold | --strategy-file <file.mjs>) [--param k=v] [--out out/backtest]");
- 474 process.exit(2);
- 475 }
- 476 const num = (k, d) => {
- 477 if (cli[k] === undefined) return d;
- 478 const n = Number(cli[k]);
- 479 if (!Number.isFinite(n) || n < 0) {
- 480 console.error(`--${k} must be a non-negative number`);
- 481 process.exit(2);
- 482 }
- 483 return n;
- 484 };
- 485 try {
- 486 const bars = loadBars(readFileSync(cli.data, "utf8"));
- 487 if (bars.length < 2) throw new Error("need at least 2 bars");
- 488 const strategy = await loadStrategy(cli["strategy-file"] ? { file: cli["strategy-file"] } : { name: cli.strategy ?? "sma-cross" });
- 489 const result = backtest(bars, strategy, {
- 490 capital: num("capital", 100000),
- 491 commissionPerShare: num("commission-per-share", 0),
- 492 commissionBps: num("commission-bps", 0),
- 493 slippageBps: num("slippage-bps", 5),
- 494 fractional: !!cli.fractional,
- 495 allowShort: !!cli["allow-short"],
- 496 periodsPerYear: num("periods-per-year", 252),
- 497 rf: num("rf", 0),
- 498 params: cli.params,
- 499 });
- 500 const base = cli.out ?? "out/backtest";
- 501 mkdirSync(dirname(base) || ".", { recursive: true });
- 502 const equityPath = base + "-equity.csv";
- 503 const tradesPath = base + "-trades.csv";
- 504 const metricsPath = base + "-metrics.json";
- 505 writeFileSync(equityPath, equityCsv(result.equity));
- 506 writeFileSync(tradesPath, tradesCsv(result.trades));
- 507 writeFileSync(metricsPath, JSON.stringify({ strategy: result.strategy, params: result.params, options: result.options, metrics: result.metrics }, null, 2) + "\n");
- 508 const m = result.metrics;
- 509 const p = (x) => (x === null ? "n/a" : `${(x * 100).toFixed(2)}%`);
- 510 const r = (x) => (x === null ? "n/a" : x.toFixed(2));
- 511 console.log(`${result.strategy} ${JSON.stringify(result.params)} on ${cli.data}, ${m.start} to ${m.end} (${m.bars} bars)`);
- 512 console.log(`Total ${p(m.total_return)} | CAGR ${p(m.cagr)} | Vol ${p(m.volatility)} | Sharpe ${r(m.sharpe)} | Sortino ${r(m.sortino)} | Max DD ${p(m.max_drawdown)}`);
- 513 console.log(`Trades ${m.trades} (+${m.open_trades} open) | Win rate ${p(m.win_rate)} | Exposure ${p(m.exposure)} | Buy and hold ${p(m.buy_hold_return)}`);
- 514 console.log(`Wrote ${equityPath}, ${tradesPath}, ${metricsPath}`);
- 515 } catch (e) {
- 516 console.error(`Backtest failed: ${e.message}`);
- 517 process.exit(1);
- 518 }
- 519}
- 521function isMain() {
- 522 try {
- 523 return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
- 524 } catch {
- 525 return false;
- 526 }
- 527}
- 529if (isMain()) main(process.argv.slice(2));
