tradelab 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +183 -373
  3. package/dist/cjs/index.cjs +39 -12
  4. package/dist/cjs/live.cjs +457 -18
  5. package/docs/README.md +32 -66
  6. package/docs/api-reference.md +269 -144
  7. package/docs/backtest-engine.md +167 -321
  8. package/docs/data-reporting-cli.md +114 -156
  9. package/docs/examples.md +6 -6
  10. package/docs/live-trading.md +254 -134
  11. package/docs/mcp.md +244 -23
  12. package/docs/research.md +99 -45
  13. package/examples/mcpLiveTrading.js +77 -0
  14. package/package.json +11 -3
  15. package/src/engine/optimize.js +25 -1
  16. package/src/engine/portfolio.js +6 -2
  17. package/src/live/dashboard/server.js +67 -8
  18. package/src/live/engine/paperEngine.js +21 -11
  19. package/src/live/index.js +2 -0
  20. package/src/live/session.js +439 -0
  21. package/src/mcp/liveTools.js +202 -0
  22. package/src/mcp/schemas.js +119 -0
  23. package/src/mcp/server.js +5 -1
  24. package/src/mcp/tools.js +125 -2
  25. package/src/research/monteCarlo.js +6 -2
  26. package/templates/dashboard.html +595 -108
  27. package/types/index.d.ts +25 -0
  28. package/types/live.d.ts +102 -1
  29. package/types/mcp.d.ts +17 -0
  30. package/docs/superpowers/plans/2026-00-overview.md +0 -101
  31. package/docs/superpowers/plans/2026-01-metrics-correctness.md +0 -873
  32. package/docs/superpowers/plans/2026-02-indicator-library.md +0 -677
  33. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +0 -882
  34. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +0 -981
  35. package/docs/superpowers/plans/2026-05-mcp-server.md +0 -758
  36. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +0 -508
  37. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +0 -535
  38. package/docs/superpowers/plans/2026-08-live-dashboard.md +0 -547
  39. package/docs/superpowers/plans/HANDOFF.md +0 -88
@@ -1,873 +0,0 @@
1
- # Metrics Correctness Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Make `buildMetrics` output safe to serialize and comparable across timeframes — annualize the risk ratios, clamp non-finite values, and add benchmark-relative alpha/beta/information-ratio.
6
-
7
- **Architecture:** Three small pure helpers (`finite.js`, `annualize.js`, `benchmark.js`) feed into the existing `buildMetrics`. `buildMetrics` gains two optional inputs (`interval`, `benchmarkReturns`) and emits new keys only — no existing key is renamed or removed. Every engine that calls `buildMetrics` (`backtest`, `backtestTicks`, `barSystemRunner`, `walkForward`, `portfolio`) passes the `interval` it already knows.
8
-
9
- **Tech Stack:** Node ESM, `node:test`.
10
-
11
- ---
12
-
13
- ### Task 1: Finite-clamping helper
14
-
15
- **Files:**
16
-
17
- - Create: `src/metrics/finite.js`
18
- - Test: `test/metrics/finite.test.js`
19
-
20
- - [ ] **Step 1: Write the failing test**
21
-
22
- ```js
23
- // test/metrics/finite.test.js
24
- import test from "node:test";
25
- import assert from "node:assert/strict";
26
- import { clampFinite, BIG_NUMBER } from "../../src/metrics/finite.js";
27
-
28
- test("clampFinite passes finite numbers through", () => {
29
- assert.equal(clampFinite(1.5), 1.5);
30
- assert.equal(clampFinite(0), 0);
31
- assert.equal(clampFinite(-3), -3);
32
- });
33
-
34
- test("clampFinite maps +Infinity to +BIG_NUMBER and -Infinity to -BIG_NUMBER", () => {
35
- assert.equal(clampFinite(Infinity), BIG_NUMBER);
36
- assert.equal(clampFinite(-Infinity), -BIG_NUMBER);
37
- });
38
-
39
- test("clampFinite maps NaN/undefined/null to the fallback (default 0)", () => {
40
- assert.equal(clampFinite(NaN), 0);
41
- assert.equal(clampFinite(undefined), 0);
42
- assert.equal(clampFinite(null), 0);
43
- assert.equal(clampFinite(NaN, -1), -1);
44
- });
45
- ```
46
-
47
- - [ ] **Step 2: Run test to verify it fails**
48
-
49
- Run: `node --test test/metrics/finite.test.js`
50
- Expected: FAIL — cannot find module `../../src/metrics/finite.js`.
51
-
52
- - [ ] **Step 3: Write minimal implementation**
53
-
54
- ```js
55
- // src/metrics/finite.js
56
-
57
- // Sentinel for "effectively infinite" metric values (e.g. profit factor with zero
58
- // losses). Large enough to be unmistakable, small enough to survive JSON.stringify
59
- // and JSON.parse round-trips without becoming Infinity.
60
- export const BIG_NUMBER = 1e9;
61
-
62
- /**
63
- * Coerce a metric to a finite, JSON-safe number.
64
- * +/-Infinity clamp to +/-BIG_NUMBER. NaN/null/undefined become `fallback`.
65
- */
66
- export function clampFinite(value, fallback = 0) {
67
- if (value === Infinity) return BIG_NUMBER;
68
- if (value === -Infinity) return -BIG_NUMBER;
69
- if (typeof value === "number" && Number.isFinite(value)) return value;
70
- return fallback;
71
- }
72
- ```
73
-
74
- - [ ] **Step 4: Run test to verify it passes**
75
-
76
- Run: `node --test test/metrics/finite.test.js`
77
- Expected: PASS (3 tests).
78
-
79
- - [ ] **Step 5: Commit**
80
-
81
- ```bash
82
- git add src/metrics/finite.js test/metrics/finite.test.js
83
- git commit -m "feat: add clampFinite metric helper
84
-
85
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
86
- ```
87
-
88
- ---
89
-
90
- ### Task 2: Annualization factor from interval
91
-
92
- **Files:**
93
-
94
- - Create: `src/metrics/annualize.js`
95
- - Test: `test/metrics/annualize.test.js`
96
-
97
- - [ ] **Step 1: Write the failing test**
98
-
99
- ```js
100
- // test/metrics/annualize.test.js
101
- import test from "node:test";
102
- import assert from "node:assert/strict";
103
- import { periodsPerYear } from "../../src/metrics/annualize.js";
104
-
105
- test("periodsPerYear maps common intervals to trading-period counts", () => {
106
- assert.equal(periodsPerYear("1d"), 252);
107
- assert.equal(periodsPerYear("1h"), 252 * 6.5);
108
- assert.equal(periodsPerYear("5m"), 252 * 6.5 * 12);
109
- assert.equal(periodsPerYear("1wk"), 52);
110
- });
111
-
112
- test("periodsPerYear falls back to estBarMs when interval is unknown", () => {
113
- // 1-hour bars in ms, 24/7 market assumption => 24*365 periods
114
- const oneHourMs = 60 * 60 * 1000;
115
- assert.equal(
116
- periodsPerYear("weird", oneHourMs),
117
- Math.round((365 * 24 * 60 * 60 * 1000) / oneHourMs)
118
- );
119
- });
120
-
121
- test("periodsPerYear returns 252 when nothing is resolvable", () => {
122
- assert.equal(periodsPerYear(undefined, undefined), 252);
123
- });
124
- ```
125
-
126
- - [ ] **Step 2: Run test to verify it fails**
127
-
128
- Run: `node --test test/metrics/annualize.test.js`
129
- Expected: FAIL — cannot find module.
130
-
131
- - [ ] **Step 3: Write minimal implementation**
132
-
133
- ```js
134
- // src/metrics/annualize.js
135
-
136
- const TRADING_DAYS = 252;
137
- const RTH_HOURS = 6.5; // US regular trading hours per day
138
- const MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000;
139
-
140
- // Known intra/inter-day intervals => periods per trading year.
141
- const INTERVAL_PERIODS = {
142
- "1m": TRADING_DAYS * RTH_HOURS * 60,
143
- "2m": TRADING_DAYS * RTH_HOURS * 30,
144
- "5m": TRADING_DAYS * RTH_HOURS * 12,
145
- "15m": TRADING_DAYS * RTH_HOURS * 4,
146
- "30m": TRADING_DAYS * RTH_HOURS * 2,
147
- "1h": TRADING_DAYS * RTH_HOURS,
148
- "60m": TRADING_DAYS * RTH_HOURS,
149
- "1d": TRADING_DAYS,
150
- "1wk": 52,
151
- "1mo": 12,
152
- };
153
-
154
- /**
155
- * Number of bars in one year for the given interval. Used to annualize
156
- * per-bar Sharpe/Sortino. Falls back to estBarMs (assuming a 24/7 clock)
157
- * when the interval string is unknown, then to 252.
158
- */
159
- export function periodsPerYear(interval, estBarMs) {
160
- if (interval && INTERVAL_PERIODS[interval]) return INTERVAL_PERIODS[interval];
161
- if (Number.isFinite(estBarMs) && estBarMs > 0) {
162
- return Math.round(MS_PER_YEAR / estBarMs);
163
- }
164
- return TRADING_DAYS;
165
- }
166
- ```
167
-
168
- - [ ] **Step 4: Run test to verify it passes**
169
-
170
- Run: `node --test test/metrics/annualize.test.js`
171
- Expected: PASS (3 tests).
172
-
173
- - [ ] **Step 5: Commit**
174
-
175
- ```bash
176
- git add src/metrics/annualize.js test/metrics/annualize.test.js
177
- git commit -m "feat: add periodsPerYear annualization helper
178
-
179
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
180
- ```
181
-
182
- ---
183
-
184
- ### Task 3: Benchmark-relative metrics (alpha/beta/IR/correlation)
185
-
186
- **Files:**
187
-
188
- - Create: `src/metrics/benchmark.js`
189
- - Test: `test/metrics/benchmark.test.js`
190
-
191
- - [ ] **Step 1: Write the failing test**
192
-
193
- ```js
194
- // test/metrics/benchmark.test.js
195
- import test from "node:test";
196
- import assert from "node:assert/strict";
197
- import { benchmarkStats } from "../../src/metrics/benchmark.js";
198
-
199
- test("perfectly correlated 2x strategy has beta 2 and ~zero alpha", () => {
200
- const bench = [0.01, -0.02, 0.03, -0.01, 0.02];
201
- const strat = bench.map((r) => r * 2);
202
- const stats = benchmarkStats(strat, bench);
203
- assert.ok(Math.abs(stats.beta - 2) < 1e-9);
204
- assert.ok(Math.abs(stats.alpha) < 1e-9);
205
- assert.ok(Math.abs(stats.correlation - 1) < 1e-9);
206
- });
207
-
208
- test("mismatched lengths return null stats", () => {
209
- assert.equal(benchmarkStats([0.01], [0.01, 0.02]).beta, null);
210
- });
211
-
212
- test("empty inputs return null stats", () => {
213
- assert.equal(benchmarkStats([], []).beta, null);
214
- });
215
- ```
216
-
217
- - [ ] **Step 2: Run test to verify it fails**
218
-
219
- Run: `node --test test/metrics/benchmark.test.js`
220
- Expected: FAIL — cannot find module.
221
-
222
- - [ ] **Step 3: Write minimal implementation**
223
-
224
- ```js
225
- // src/metrics/benchmark.js
226
-
227
- function mean(xs) {
228
- return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0;
229
- }
230
-
231
- /**
232
- * Ordinary least squares of strategy returns on benchmark returns.
233
- * Returns { alpha, beta, correlation, informationRatio, trackingError }.
234
- * `alpha` is per-period excess return (intercept). All null when inputs are
235
- * empty or length-mismatched.
236
- */
237
- export function benchmarkStats(strategyReturns, benchmarkReturns) {
238
- const nullStats = {
239
- alpha: null,
240
- beta: null,
241
- correlation: null,
242
- informationRatio: null,
243
- trackingError: null,
244
- };
245
- if (
246
- !Array.isArray(strategyReturns) ||
247
- !Array.isArray(benchmarkReturns) ||
248
- strategyReturns.length === 0 ||
249
- strategyReturns.length !== benchmarkReturns.length
250
- ) {
251
- return nullStats;
252
- }
253
-
254
- const meanStrat = mean(strategyReturns);
255
- const meanBench = mean(benchmarkReturns);
256
-
257
- let covar = 0;
258
- let varBench = 0;
259
- let varStrat = 0;
260
- for (let i = 0; i < strategyReturns.length; i += 1) {
261
- const ds = strategyReturns[i] - meanStrat;
262
- const db = benchmarkReturns[i] - meanBench;
263
- covar += ds * db;
264
- varBench += db * db;
265
- varStrat += ds * ds;
266
- }
267
-
268
- const beta = varBench === 0 ? 0 : covar / varBench;
269
- const alpha = meanStrat - beta * meanBench;
270
- const denom = Math.sqrt(varStrat * varBench);
271
- const correlation = denom === 0 ? 0 : covar / denom;
272
-
273
- const active = strategyReturns.map((r, i) => r - benchmarkReturns[i]);
274
- const meanActive = mean(active);
275
- const trackingError = Math.sqrt(mean(active.map((a) => (a - meanActive) ** 2)));
276
- const informationRatio = trackingError === 0 ? 0 : meanActive / trackingError;
277
-
278
- return { alpha, beta, correlation, informationRatio, trackingError };
279
- }
280
- ```
281
-
282
- - [ ] **Step 4: Run test to verify it passes**
283
-
284
- Run: `node --test test/metrics/benchmark.test.js`
285
- Expected: PASS (3 tests).
286
-
287
- - [ ] **Step 5: Commit**
288
-
289
- ```bash
290
- git add src/metrics/benchmark.js test/metrics/benchmark.test.js
291
- git commit -m "feat: add benchmarkStats alpha/beta/IR helper
292
-
293
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
294
- ```
295
-
296
- ---
297
-
298
- ### Task 4: Wire annualization + clamping + benchmark into buildMetrics
299
-
300
- **Files:**
301
-
302
- - Modify: `src/metrics/buildMetrics.js`
303
- - Test: `test/metrics/buildMetrics.test.js` (new)
304
-
305
- `buildMetrics` currently has signature
306
- `buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries })`.
307
- Add two optional fields: `interval` and `benchmarkReturns`.
308
-
309
- - [ ] **Step 1: Write the failing test**
310
-
311
- ```js
312
- // test/metrics/buildMetrics.test.js
313
- import test from "node:test";
314
- import assert from "node:assert/strict";
315
- import { buildMetrics } from "../../src/metrics/buildMetrics.js";
316
- import { BIG_NUMBER } from "../../src/metrics/finite.js";
317
-
318
- function leg({ time, pnl, side = "long" }) {
319
- return {
320
- side,
321
- entry: 100,
322
- entryFill: 100,
323
- _initRisk: 1,
324
- openTime: time - 60_000,
325
- exit: { price: 100 + pnl, time, reason: pnl >= 0 ? "TP" : "SL", pnl },
326
- };
327
- }
328
-
329
- test("profitFactor with zero losses is clamped to BIG_NUMBER, not Infinity", () => {
330
- const closed = [leg({ time: 2_000, pnl: 5 }), leg({ time: 3_000, pnl: 7 })];
331
- const m = buildMetrics({
332
- closed,
333
- equityStart: 1000,
334
- equityFinal: 1012,
335
- candles: [
336
- { time: 1_000, close: 100 },
337
- { time: 2_000, close: 100 },
338
- ],
339
- estBarMs: 1000,
340
- eqSeries: [],
341
- interval: "1d",
342
- });
343
- assert.equal(Number.isFinite(m.profitFactor), true);
344
- assert.equal(m.profitFactor, BIG_NUMBER);
345
- // every numeric field survives a JSON round-trip with no Infinity/NaN
346
- const round = JSON.parse(JSON.stringify(m));
347
- for (const v of Object.values(round)) {
348
- if (typeof v === "number") assert.equal(Number.isFinite(v), true);
349
- }
350
- });
351
-
352
- test("sharpeAnnualized scales the per-period daily sharpe by sqrt(periodsPerYear)", () => {
353
- const closed = [
354
- leg({ time: 2 * 86_400_000, pnl: 5 }),
355
- leg({ time: 3 * 86_400_000, pnl: -3 }),
356
- leg({ time: 4 * 86_400_000, pnl: 6 }),
357
- ];
358
- const m = buildMetrics({
359
- closed,
360
- equityStart: 1000,
361
- equityFinal: 1008,
362
- candles: [{ time: 0, close: 100 }],
363
- estBarMs: 86_400_000,
364
- eqSeries: [],
365
- interval: "1d",
366
- });
367
- assert.equal("sharpeAnnualized" in m, true);
368
- if (Number.isFinite(m.sharpe) && m.sharpe !== 0) {
369
- assert.ok(Math.abs(m.sharpeAnnualized - m.sharpe * Math.sqrt(252)) < 1e-6);
370
- }
371
- });
372
-
373
- test("benchmarkReturns produce a benchmark block with beta", () => {
374
- const closed = [leg({ time: 2_000, pnl: 5 }), leg({ time: 3_000, pnl: -2 })];
375
- const m = buildMetrics({
376
- closed,
377
- equityStart: 1000,
378
- equityFinal: 1003,
379
- candles: [{ time: 1_000, close: 100 }],
380
- estBarMs: 1000,
381
- eqSeries: [],
382
- interval: "1d",
383
- benchmarkReturns: [0.01, -0.005],
384
- });
385
- assert.equal(typeof m.benchmark, "object");
386
- assert.equal("beta" in m.benchmark, true);
387
- });
388
- ```
389
-
390
- - [ ] **Step 2: Run test to verify it fails**
391
-
392
- Run: `node --test test/metrics/buildMetrics.test.js`
393
- Expected: FAIL — `sharpeAnnualized`/`benchmark` undefined, `profitFactor` may be a finite cap already (`PROFIT_FACTOR_CAP = 1e6`) but `sortino`/`calmar` can still be `Infinity`.
394
-
395
- - [ ] **Step 3: Edit buildMetrics.js — imports**
396
-
397
- Add at the top of `src/metrics/buildMetrics.js`, after the existing function
398
- declarations block (the file has no imports today; add these as the first lines):
399
-
400
- ```js
401
- import { clampFinite, BIG_NUMBER } from "./finite.js";
402
- import { periodsPerYear } from "./annualize.js";
403
- import { benchmarkStats } from "./benchmark.js";
404
- ```
405
-
406
- - [ ] **Step 4: Edit buildMetrics.js — replace PROFIT_FACTOR_CAP with BIG_NUMBER**
407
-
408
- Find:
409
-
410
- ```js
411
- const PROFIT_FACTOR_CAP = 1_000_000;
412
-
413
- function finiteProfitFactor(grossProfit, grossLoss) {
414
- if (grossLoss === 0) {
415
- return grossProfit > 0 ? PROFIT_FACTOR_CAP : 0;
416
- }
417
- return grossProfit / grossLoss;
418
- }
419
- ```
420
-
421
- Replace with:
422
-
423
- ```js
424
- function finiteProfitFactor(grossProfit, grossLoss) {
425
- if (grossLoss === 0) {
426
- return grossProfit > 0 ? BIG_NUMBER : 0;
427
- }
428
- return grossProfit / grossLoss;
429
- }
430
- ```
431
-
432
- - [ ] **Step 5: Edit buildMetrics.js — accept interval + benchmarkReturns**
433
-
434
- Find the function signature:
435
-
436
- ```js
437
- export function buildMetrics({ closed, equityStart, equityFinal, candles, estBarMs, eqSeries }) {
438
- ```
439
-
440
- Replace with:
441
-
442
- ```js
443
- export function buildMetrics({
444
- closed,
445
- equityStart,
446
- equityFinal,
447
- candles,
448
- estBarMs,
449
- eqSeries,
450
- interval,
451
- benchmarkReturns,
452
- }) {
453
- ```
454
-
455
- - [ ] **Step 6: Edit buildMetrics.js — compute annualized ratios + benchmark before the return**
456
-
457
- Find the line that begins the return object:
458
-
459
- ```js
460
- return {
461
- trades: completedTrades.length,
462
- ```
463
-
464
- Insert immediately ABOVE that `return {`:
465
-
466
- ```js
467
- const periods = periodsPerYear(interval, estBarMs);
468
- const sqrtPeriods = Math.sqrt(periods);
469
- const sharpeAnnualized = clampFinite(sharpeDaily) * sqrtPeriods;
470
- const sortinoAnnualized = clampFinite(sortinoDaily) * sqrtPeriods;
471
- const benchmark = benchmarkStats(dailyReturnsSeries, benchmarkReturns ?? []);
472
- ```
473
-
474
- - [ ] **Step 7: Edit buildMetrics.js — clamp the returned numbers + add new keys**
475
-
476
- In the returned object, replace these specific lines:
477
-
478
- ```js
479
- profitFactor: profitFactorPositions,
480
- ```
481
-
482
- with
483
-
484
- ```js
485
- profitFactor: clampFinite(profitFactorPositions),
486
- ```
487
-
488
- Replace:
489
-
490
- ```js
491
- sharpe: sharpeDaily,
492
- sharpePerTrade,
493
- sortinoPerTrade,
494
- ```
495
-
496
- with
497
-
498
- ```js
499
- sharpe: clampFinite(sharpeDaily),
500
- sharpeAnnualized,
501
- sortinoAnnualized,
502
- sharpePerTrade: clampFinite(sharpePerTrade),
503
- sortinoPerTrade: clampFinite(sortinoPerTrade),
504
- annualizationPeriods: periods,
505
- ```
506
-
507
- Replace:
508
-
509
- ```js
510
- calmar,
511
- ```
512
-
513
- with
514
-
515
- ```js
516
- calmar: clampFinite(calmar),
517
- ```
518
-
519
- Replace:
520
-
521
- ```js
522
- sharpeDaily,
523
- sortinoDaily,
524
- ```
525
-
526
- with
527
-
528
- ```js
529
- sharpeDaily: clampFinite(sharpeDaily),
530
- sortinoDaily: clampFinite(sortinoDaily),
531
- benchmark,
532
- ```
533
-
534
- - [ ] **Step 8: Run the new test + full suite**
535
-
536
- Run: `node --test test/metrics/buildMetrics.test.js`
537
- Expected: PASS (3 tests).
538
-
539
- Run: `node --test`
540
- Expected: PASS — existing `backtest.test.js`, `reporting.test.js` still green
541
- (new keys are additive; `sharpe` is still present and now finite).
542
-
543
- - [ ] **Step 9: Commit**
544
-
545
- ```bash
546
- git add src/metrics/buildMetrics.js test/metrics/buildMetrics.test.js
547
- git commit -m "feat: annualize + clamp metrics and add benchmark block
548
-
549
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
550
- ```
551
-
552
- ---
553
-
554
- ### Task 5: Pass `interval` through every engine call site
555
-
556
- `buildMetrics` now reads `interval`. Each engine already knows its interval; thread it in.
557
-
558
- **Files:**
559
-
560
- - Modify: `src/engine/backtest.js` (the `buildMetrics({...})` call near the end)
561
- - Modify: `src/engine/barSystemRunner.js` (`buildResult()` → `buildMetrics({...})`)
562
- - Modify: `src/engine/backtestTicks.js` (the `buildMetrics({...})` call)
563
- - Modify: `src/engine/walkForward.js` (the final `buildMetrics({...})` call)
564
- - Test: reuse `test/backtest.test.js` (assert annualized field is present)
565
-
566
- - [ ] **Step 1: Write the failing test (append to test/backtest.test.js)**
567
-
568
- ```js
569
- test("backtest result metrics expose sharpeAnnualized", () => {
570
- const candles = buildCandles();
571
- const result = backtest({
572
- candles,
573
- interval: "5m",
574
- warmupBars: 1,
575
- flattenAtClose: false,
576
- signal({ index, bar }) {
577
- if (index !== 1) return null;
578
- return { side: "buy", stop: bar.close - 1, rr: 2 };
579
- },
580
- });
581
- assert.equal("sharpeAnnualized" in result.metrics, true);
582
- assert.equal(result.metrics.annualizationPeriods > 0, true);
583
- });
584
- ```
585
-
586
- - [ ] **Step 2: Run to verify it fails**
587
-
588
- Run: `node --test test/backtest.test.js`
589
- Expected: PASS already for `"sharpeAnnualized" in metrics` (Task 4 added it) BUT
590
- `annualizationPeriods` will reflect a wrong fallback because `interval` is not
591
- passed yet. To make the failure explicit, the assertion `annualizationPeriods > 0`
592
- passes via estBarMs fallback — so instead verify the interval is honored:
593
-
594
- Replace the last assertion with:
595
-
596
- ```js
597
- assert.equal(result.metrics.annualizationPeriods, 252 * 6.5 * 12); // 5m
598
- ```
599
-
600
- Re-run: `node --test test/backtest.test.js`
601
- Expected: FAIL — `annualizationPeriods` came from estBarMs fallback, not "5m".
602
-
603
- - [ ] **Step 3: Edit backtest.js**
604
-
605
- Find:
606
-
607
- ```js
608
- const metrics = buildMetrics({
609
- closed,
610
- equityStart: equity,
611
- equityFinal: currentEquity,
612
- candles,
613
- estBarMs: estimatedBarMs,
614
- eqSeries,
615
- });
616
- ```
617
-
618
- Replace with:
619
-
620
- ```js
621
- const metrics = buildMetrics({
622
- closed,
623
- equityStart: equity,
624
- equityFinal: currentEquity,
625
- candles,
626
- estBarMs: estimatedBarMs,
627
- eqSeries,
628
- interval: options.interval,
629
- });
630
- ```
631
-
632
- - [ ] **Step 4: Edit barSystemRunner.js `buildResult()`**
633
-
634
- Find inside `buildResult()`:
635
-
636
- ```js
637
- const metrics = buildMetrics({
638
- closed: this.closed,
639
- equityStart: this.options.equity,
640
- equityFinal: this.currentEquity,
641
- candles: this.candles,
642
- estBarMs: this.estimatedBarMs,
643
- eqSeries: this.eqSeries,
644
- });
645
- ```
646
-
647
- Replace with:
648
-
649
- ```js
650
- const metrics = buildMetrics({
651
- closed: this.closed,
652
- equityStart: this.options.equity,
653
- equityFinal: this.currentEquity,
654
- candles: this.candles,
655
- estBarMs: this.estimatedBarMs,
656
- eqSeries: this.eqSeries,
657
- interval: this.options.interval,
658
- });
659
- ```
660
-
661
- - [ ] **Step 5: Edit backtestTicks.js**
662
-
663
- Find:
664
-
665
- ```js
666
- const metrics = buildMetrics({
667
- closed: trades,
668
- equityStart: equity,
669
- equityFinal: currentEquity,
670
- candles: normalizedTicks,
671
- estBarMs:
672
- normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
673
- eqSeries,
674
- });
675
- ```
676
-
677
- Replace with (add `interval` — the param already exists in the function signature):
678
-
679
- ```js
680
- const metrics = buildMetrics({
681
- closed: trades,
682
- equityStart: equity,
683
- equityFinal: currentEquity,
684
- candles: normalizedTicks,
685
- estBarMs:
686
- normalizedTicks.length > 1 ? Math.max(1, normalizedTicks[1].time - normalizedTicks[0].time) : 1,
687
- eqSeries,
688
- interval,
689
- });
690
- ```
691
-
692
- - [ ] **Step 6: Edit walkForward.js**
693
-
694
- Find the final `buildMetrics({...})` call (the one after the windows loop):
695
-
696
- ```js
697
- const metrics = buildMetrics({
698
- closed: allTrades,
699
- equityStart: backtestOptions.equity ?? 10_000,
700
- equityFinal: rollingEquity,
701
- candles,
702
- estBarMs: estimateBarMs(candles),
703
- eqSeries,
704
- });
705
- ```
706
-
707
- Replace with:
708
-
709
- ```js
710
- const metrics = buildMetrics({
711
- closed: allTrades,
712
- equityStart: backtestOptions.equity ?? 10_000,
713
- equityFinal: rollingEquity,
714
- candles,
715
- estBarMs: estimateBarMs(candles),
716
- eqSeries,
717
- interval: backtestOptions.interval,
718
- });
719
- ```
720
-
721
- - [ ] **Step 7: Run the test + full suite**
722
-
723
- Run: `node --test test/backtest.test.js`
724
- Expected: PASS — `annualizationPeriods === 252 * 6.5 * 12`.
725
-
726
- Run: `node --test`
727
- Expected: PASS (all suites).
728
-
729
- - [ ] **Step 8: Lint + commit**
730
-
731
- ```bash
732
- npm run lint
733
- git add src/engine/backtest.js src/engine/barSystemRunner.js src/engine/backtestTicks.js src/engine/walkForward.js test/backtest.test.js
734
- git commit -m "feat: thread interval into buildMetrics across all engines
735
-
736
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
737
- ```
738
-
739
- ---
740
-
741
- ### Task 6: Expose benchmark wiring + docs
742
-
743
- **Files:**
744
-
745
- - Modify: `src/engine/backtest.js` (accept `benchmarkReturns` option, forward it)
746
- - Modify: `src/index.js` (export `benchmarkStats`)
747
- - Modify: `docs/backtest-engine.md` (document new metric fields)
748
-
749
- - [ ] **Step 1: Write the failing test (append to test/backtest.test.js)**
750
-
751
- ```js
752
- test("backtest forwards benchmarkReturns into metrics.benchmark", () => {
753
- const candles = buildCandles();
754
- const result = backtest({
755
- candles,
756
- interval: "5m",
757
- warmupBars: 1,
758
- flattenAtClose: false,
759
- benchmarkReturns: Array.from({ length: 40 }, () => 0.001),
760
- signal({ index, bar }) {
761
- if (index !== 1) return null;
762
- return { side: "buy", stop: bar.close - 1, rr: 2 };
763
- },
764
- });
765
- assert.equal(typeof result.metrics.benchmark, "object");
766
- });
767
- ```
768
-
769
- - [ ] **Step 2: Run to verify it fails**
770
-
771
- Run: `node --test test/backtest.test.js`
772
- Expected: FAIL — `benchmarkReturns` is dropped (not in `mergeOptions`), so
773
- `metrics.benchmark.beta` is null but the block exists from Task 4... it WILL be an
774
- object already. To force a real failure, assert the data flows:
775
-
776
- Change the assertion to:
777
-
778
- ```js
779
- assert.equal(result.metrics.benchmark.beta !== null, true);
780
- ```
781
-
782
- Re-run: FAIL — `benchmarkReturns` never reaches `buildMetrics`, so beta is null.
783
-
784
- - [ ] **Step 3: Edit backtest.js mergeOptions**
785
-
786
- In `mergeOptions` (the returned object), add after `collectReplay: ...`:
787
-
788
- ```js
789
- benchmarkReturns: Array.isArray(options.benchmarkReturns) ? options.benchmarkReturns : null,
790
- ```
791
-
792
- Then add `benchmarkReturns` to the destructure at the top of `backtest()` (the
793
- `const { candles, symbol, ... } = options;` block) and pass it into the final
794
- `buildMetrics({...})` call as `benchmarkReturns`.
795
-
796
- Concretely, change the final call to:
797
-
798
- ```js
799
- const metrics = buildMetrics({
800
- closed,
801
- equityStart: equity,
802
- equityFinal: currentEquity,
803
- candles,
804
- estBarMs: estimatedBarMs,
805
- eqSeries,
806
- interval: options.interval,
807
- benchmarkReturns: options.benchmarkReturns,
808
- });
809
- ```
810
-
811
- (Reading from `options.benchmarkReturns` avoids touching the destructure list.)
812
-
813
- - [ ] **Step 4: Edit src/index.js — export benchmarkStats**
814
-
815
- Add to the metrics export line. Find:
816
-
817
- ```js
818
- export { buildMetrics } from "./metrics/buildMetrics.js";
819
- ```
820
-
821
- Add below it:
822
-
823
- ```js
824
- export { benchmarkStats } from "./metrics/benchmark.js";
825
- export { clampFinite, BIG_NUMBER } from "./metrics/finite.js";
826
- export { periodsPerYear } from "./metrics/annualize.js";
827
- ```
828
-
829
- - [ ] **Step 5: Run test + full suite**
830
-
831
- Run: `node --test test/backtest.test.js`
832
- Expected: PASS.
833
-
834
- Run: `node --test`
835
- Expected: PASS.
836
-
837
- - [ ] **Step 6: Document new fields in docs/backtest-engine.md**
838
-
839
- Under the result-shape / metrics section, add a short subsection:
840
-
841
- ```markdown
842
- ### Risk-adjusted metrics
843
-
844
- - `sharpe` / `sortino` are per-period (daily-bucketed).
845
- - `sharpeAnnualized` / `sortinoAnnualized` scale by `sqrt(annualizationPeriods)`,
846
- where `annualizationPeriods` is derived from `interval` (falling back to the
847
- median bar spacing). Use these to compare strategies across timeframes.
848
- - `profitFactor`, `calmar`, and the Sharpe/Sortino family are clamped to a finite
849
- `BIG_NUMBER` (1e9) so `metrics` JSON never contains `Infinity` or `NaN`.
850
- - `benchmark` (`{ alpha, beta, correlation, informationRatio, trackingError }`)
851
- is populated when you pass `benchmarkReturns` (per-day return array aligned to
852
- the strategy's daily equity buckets) to `backtest()`.
853
- ```
854
-
855
- - [ ] **Step 7: Lint, format, commit**
856
-
857
- ```bash
858
- npm run lint && npm run format
859
- git add src/engine/backtest.js src/index.js docs/backtest-engine.md test/backtest.test.js
860
- git commit -m "feat: expose benchmarkReturns option and finite/annualize exports
861
-
862
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
863
- ```
864
-
865
- ---
866
-
867
- ## Self-review checklist
868
-
869
- - [ ] `sharpe` field still exists (dashboards depend on it) — only its value is now clamped. ✔ (Task 4 Step 7)
870
- - [ ] No metric key renamed/removed; only additions (`sharpeAnnualized`, `sortinoAnnualized`, `annualizationPeriods`, `benchmark`). ✔
871
- - [ ] `BIG_NUMBER` replaces the old `PROFIT_FACTOR_CAP` consistently. ✔
872
- - [ ] All 5 `buildMetrics` call sites pass `interval`. ✔ (Tasks 5)
873
- - [ ] JSON round-trip test guarantees no `Infinity`/`NaN` leaks. ✔ (Task 4 Step 1)