Skip to content
SlashClone
Made by@qtrader

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.

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