tradelab 1.0.1 → 1.1.0

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 (67) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +75 -12
  3. package/bin/tradelab-mcp.js +7 -0
  4. package/bin/tradelab.js +29 -0
  5. package/dist/cjs/data.cjs +149 -26
  6. package/dist/cjs/index.cjs +1893 -1003
  7. package/dist/cjs/live.cjs +134 -25
  8. package/dist/cjs/ta.cjs +339 -0
  9. package/docs/api-reference.md +46 -0
  10. package/docs/backtest-engine.md +112 -0
  11. package/docs/live-trading.md +51 -0
  12. package/docs/mcp.md +64 -0
  13. package/docs/research.md +103 -0
  14. package/docs/superpowers/plans/2026-00-overview.md +101 -0
  15. package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
  16. package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
  17. package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
  18. package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
  19. package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
  20. package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
  21. package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
  22. package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
  23. package/docs/superpowers/plans/HANDOFF.md +88 -0
  24. package/examples/liveDashboard.js +33 -0
  25. package/examples/llmSignal.js +33 -0
  26. package/examples/optimize.js +25 -0
  27. package/package.json +16 -2
  28. package/src/engine/asyncSignal.js +28 -0
  29. package/src/engine/backtest.js +13 -1
  30. package/src/engine/backtestAsync.js +27 -0
  31. package/src/engine/backtestTicks.js +13 -2
  32. package/src/engine/barSystemRunner.js +96 -41
  33. package/src/engine/execution.js +39 -0
  34. package/src/engine/grid.js +15 -0
  35. package/src/engine/llmSignal.js +84 -0
  36. package/src/engine/optimize.js +86 -0
  37. package/src/engine/optimizeWorker.js +67 -0
  38. package/src/engine/walkForward.js +1 -0
  39. package/src/index.js +9 -0
  40. package/src/live/dashboard/server.js +120 -0
  41. package/src/live/engine/liveEngine.js +2 -2
  42. package/src/live/index.js +1 -0
  43. package/src/mcp/schemas.js +48 -0
  44. package/src/mcp/server.js +31 -0
  45. package/src/mcp/tools.js +142 -0
  46. package/src/metrics/annualize.js +32 -0
  47. package/src/metrics/benchmark.js +55 -0
  48. package/src/metrics/buildMetrics.js +34 -13
  49. package/src/metrics/finite.js +17 -0
  50. package/src/research/combinations.js +18 -0
  51. package/src/research/cpcv.js +47 -0
  52. package/src/research/deflatedSharpe.js +35 -0
  53. package/src/research/index.js +6 -0
  54. package/src/research/monteCarlo.js +88 -0
  55. package/src/research/pbo.js +69 -0
  56. package/src/research/stats.js +78 -0
  57. package/src/strategies/builtins.js +96 -0
  58. package/src/strategies/index.js +30 -0
  59. package/src/ta/channels.js +67 -0
  60. package/src/ta/index.js +16 -0
  61. package/src/ta/oscillators.js +70 -0
  62. package/src/ta/trend.js +78 -0
  63. package/src/utils/random.js +33 -0
  64. package/templates/dashboard.html +174 -0
  65. package/types/index.d.ts +154 -0
  66. package/types/live.d.ts +15 -0
  67. package/types/ta.d.ts +45 -0
@@ -0,0 +1,882 @@
1
+ # Overfitting & Inference Toolkit 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:** Give tradelab the López de Prado statistical kit — seeded Monte Carlo equity bands, Deflated Sharpe Ratio, Probability of Backtest Overfitting (via CSCV), Combinatorial Purged Cross-Validation splits, and a parameter-sweep haircut — so a result can be defended, not just admired.
6
+
7
+ **Architecture:** A new `src/research/` namespace, exported at top level from `src/index.js` under a `research` object (e.g. `research.monteCarlo`). Pure functions, no engine coupling — they consume trade PnLs, return series, or a performance matrix. Randomness is seeded through a shared `src/utils/random.js` so every run is reproducible.
8
+
9
+ **Tech Stack:** Node ESM, `node:test`. No new dependencies. Depends on Plan 1 only for the `clampFinite` convention (not a hard import).
10
+
11
+ ---
12
+
13
+ ### Task 1: Seeded RNG utility
14
+
15
+ **Files:**
16
+
17
+ - Create: `src/utils/random.js` (skip if it already exists from Plan 4 — verify content matches before reusing)
18
+ - Test: `test/utils/random.test.js`
19
+
20
+ - [ ] **Step 1: Write the failing test**
21
+
22
+ ```js
23
+ // test/utils/random.test.js
24
+ import test from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import { makeRng, randInt } from "../../src/utils/random.js";
27
+
28
+ test("makeRng is deterministic for a given seed", () => {
29
+ const a = makeRng("abc");
30
+ const b = makeRng("abc");
31
+ assert.deepEqual([a(), a(), a()], [b(), b(), b()]);
32
+ });
33
+
34
+ test("different seeds diverge", () => {
35
+ const a = makeRng("abc");
36
+ const b = makeRng("xyz");
37
+ assert.notEqual(a(), b());
38
+ });
39
+
40
+ test("rng output is in [0,1) and randInt in [0,max)", () => {
41
+ const rng = makeRng(7);
42
+ for (let i = 0; i < 100; i += 1) {
43
+ const v = rng();
44
+ assert.ok(v >= 0 && v < 1);
45
+ const n = randInt(rng, 5);
46
+ assert.ok(Number.isInteger(n) && n >= 0 && n < 5);
47
+ }
48
+ });
49
+ ```
50
+
51
+ - [ ] **Step 2: Run to verify it fails**
52
+
53
+ Run: `node --test test/utils/random.test.js`
54
+ Expected: FAIL — cannot find module (unless Plan 4 already created it, in which case this test should PASS and you skip to Step 4).
55
+
56
+ - [ ] **Step 3: Implement src/utils/random.js**
57
+
58
+ ```js
59
+ // src/utils/random.js
60
+
61
+ function xmur3(str) {
62
+ let h = 1779033703 ^ str.length;
63
+ for (let i = 0; i < str.length; i += 1) {
64
+ h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
65
+ h = (h << 13) | (h >>> 19);
66
+ }
67
+ return () => {
68
+ h = Math.imul(h ^ (h >>> 16), 2246822507);
69
+ h = Math.imul(h ^ (h >>> 13), 3266489909);
70
+ return (h ^= h >>> 16) >>> 0;
71
+ };
72
+ }
73
+
74
+ function mulberry32(seed) {
75
+ let state = seed >>> 0;
76
+ return () => {
77
+ state = (state + 0x6d2b79f5) >>> 0;
78
+ let t = Math.imul(state ^ (state >>> 15), state | 1);
79
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
80
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
81
+ };
82
+ }
83
+
84
+ /** Returns a deterministic () => float-in-[0,1) generator seeded by `seed`. */
85
+ export function makeRng(seed = "tradelab") {
86
+ const seedFn = xmur3(String(seed));
87
+ return mulberry32(seedFn());
88
+ }
89
+
90
+ /** Integer in [0, maxExclusive) from an rng produced by makeRng. */
91
+ export function randInt(rng, maxExclusive) {
92
+ return Math.floor(rng() * maxExclusive);
93
+ }
94
+ ```
95
+
96
+ - [ ] **Step 4: Run to verify it passes**
97
+
98
+ Run: `node --test test/utils/random.test.js`
99
+ Expected: PASS (3 tests).
100
+
101
+ - [ ] **Step 5: Commit**
102
+
103
+ ```bash
104
+ git add src/utils/random.js test/utils/random.test.js
105
+ git commit -m "feat: add seeded RNG utility
106
+
107
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
108
+ ```
109
+
110
+ ---
111
+
112
+ ### Task 2: Monte Carlo equity bands (block bootstrap of trade PnLs)
113
+
114
+ **Files:**
115
+
116
+ - Create: `src/research/monteCarlo.js`
117
+ - Test: `test/research/monteCarlo.test.js`
118
+
119
+ - [ ] **Step 1: Write the failing test**
120
+
121
+ ```js
122
+ // test/research/monteCarlo.test.js
123
+ import test from "node:test";
124
+ import assert from "node:assert/strict";
125
+ import { monteCarlo } from "../../src/research/monteCarlo.js";
126
+
127
+ test("monteCarlo returns ordered percentile bands and is seed-deterministic", () => {
128
+ const pnls = [10, -5, 8, -3, 12, -7, 6, -2, 9, -4];
129
+ const a = monteCarlo({ tradePnls: pnls, equityStart: 1000, iterations: 500, seed: 42 });
130
+ const b = monteCarlo({ tradePnls: pnls, equityStart: 1000, iterations: 500, seed: 42 });
131
+
132
+ assert.deepEqual(a.finalEquity, b.finalEquity); // determinism
133
+ assert.ok(a.finalEquity.p5 <= a.finalEquity.p50);
134
+ assert.ok(a.finalEquity.p50 <= a.finalEquity.p95);
135
+ assert.ok(a.maxDrawdown.p95 >= a.maxDrawdown.p50);
136
+ assert.equal(a.iterations, 500);
137
+ });
138
+
139
+ test("monteCarlo with block bootstrap preserves autocorrelation length option", () => {
140
+ const pnls = Array.from({ length: 50 }, (_, i) => (i % 5 === 0 ? -8 : 3));
141
+ const out = monteCarlo({
142
+ tradePnls: pnls,
143
+ equityStart: 1000,
144
+ iterations: 200,
145
+ blockSize: 5,
146
+ seed: 1,
147
+ });
148
+ assert.equal(out.blockSize, 5);
149
+ assert.ok(Number.isFinite(out.finalEquity.p50));
150
+ });
151
+
152
+ test("monteCarlo throws on empty pnls", () => {
153
+ assert.throws(() => monteCarlo({ tradePnls: [], equityStart: 1000 }));
154
+ });
155
+ ```
156
+
157
+ - [ ] **Step 2: Run to verify it fails**
158
+
159
+ Run: `node --test test/research/monteCarlo.test.js`
160
+ Expected: FAIL — cannot find module.
161
+
162
+ - [ ] **Step 3: Implement src/research/monteCarlo.js**
163
+
164
+ ```js
165
+ // src/research/monteCarlo.js
166
+ import { makeRng, randInt } from "../utils/random.js";
167
+
168
+ function percentile(sorted, p) {
169
+ if (!sorted.length) return 0;
170
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p)));
171
+ return sorted[idx];
172
+ }
173
+
174
+ function maxDrawdownOf(equityPath) {
175
+ let peak = equityPath[0];
176
+ let maxDd = 0;
177
+ for (const e of equityPath) {
178
+ if (e > peak) peak = e;
179
+ const dd = peak > 0 ? (peak - e) / peak : 0;
180
+ if (dd > maxDd) maxDd = dd;
181
+ }
182
+ return maxDd;
183
+ }
184
+
185
+ /**
186
+ * Block-bootstrap the trade PnL sequence `iterations` times to produce a
187
+ * distribution of final equity and max drawdown. `blockSize > 1` resamples
188
+ * contiguous blocks to preserve short-run autocorrelation (streaks).
189
+ *
190
+ * Returns percentile bands { p5, p25, p50, p75, p95 } for finalEquity and
191
+ * maxDrawdown, plus pathBands (per-step p5/p50/p95 of the equity curve).
192
+ */
193
+ export function monteCarlo({
194
+ tradePnls,
195
+ equityStart = 10_000,
196
+ iterations = 1000,
197
+ blockSize = 1,
198
+ seed = "tradelab-mc",
199
+ }) {
200
+ if (!Array.isArray(tradePnls) || tradePnls.length === 0) {
201
+ throw new Error("monteCarlo() requires a non-empty tradePnls array");
202
+ }
203
+ const rng = makeRng(seed);
204
+ const n = tradePnls.length;
205
+ const block = Math.max(1, Math.floor(blockSize));
206
+
207
+ const finals = [];
208
+ const drawdowns = [];
209
+ // pathSamples[step] collects equity at that step across iterations
210
+ const pathSamples = Array.from({ length: n + 1 }, () => []);
211
+
212
+ for (let it = 0; it < iterations; it += 1) {
213
+ const path = [equityStart];
214
+ let equity = equityStart;
215
+ let filled = 0;
216
+ while (filled < n) {
217
+ const start = randInt(rng, n);
218
+ for (let k = 0; k < block && filled < n; k += 1) {
219
+ equity += tradePnls[(start + k) % n];
220
+ path.push(equity);
221
+ filled += 1;
222
+ }
223
+ }
224
+ for (let step = 0; step < path.length; step += 1) {
225
+ pathSamples[step].push(path[step]);
226
+ }
227
+ finals.push(equity);
228
+ drawdowns.push(maxDrawdownOf(path));
229
+ }
230
+
231
+ const sortedFinals = [...finals].sort((a, b) => a - b);
232
+ const sortedDd = [...drawdowns].sort((a, b) => a - b);
233
+ const pathBands = pathSamples.map((samples) => {
234
+ const s = [...samples].sort((a, b) => a - b);
235
+ return { p5: percentile(s, 0.05), p50: percentile(s, 0.5), p95: percentile(s, 0.95) };
236
+ });
237
+
238
+ const bands = (sorted) => ({
239
+ p5: percentile(sorted, 0.05),
240
+ p25: percentile(sorted, 0.25),
241
+ p50: percentile(sorted, 0.5),
242
+ p75: percentile(sorted, 0.75),
243
+ p95: percentile(sorted, 0.95),
244
+ });
245
+
246
+ return {
247
+ iterations,
248
+ blockSize: block,
249
+ finalEquity: bands(sortedFinals),
250
+ maxDrawdown: bands(sortedDd),
251
+ pathBands,
252
+ probProfit: finals.filter((f) => f > equityStart).length / iterations,
253
+ };
254
+ }
255
+ ```
256
+
257
+ - [ ] **Step 4: Run to verify it passes**
258
+
259
+ Run: `node --test test/research/monteCarlo.test.js`
260
+ Expected: PASS (3 tests).
261
+
262
+ - [ ] **Step 5: Commit**
263
+
264
+ ```bash
265
+ git add src/research/monteCarlo.js test/research/monteCarlo.test.js
266
+ git commit -m "feat: add seeded Monte Carlo equity bands
267
+
268
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
269
+ ```
270
+
271
+ ---
272
+
273
+ ### Task 3: Normal distribution helpers + Deflated Sharpe Ratio
274
+
275
+ **Files:**
276
+
277
+ - Create: `src/research/stats.js` (normal CDF/PPF, moments)
278
+ - Create: `src/research/deflatedSharpe.js`
279
+ - Test: `test/research/deflatedSharpe.test.js`
280
+
281
+ - [ ] **Step 1: Write the failing test**
282
+
283
+ ```js
284
+ // test/research/deflatedSharpe.test.js
285
+ import test from "node:test";
286
+ import assert from "node:assert/strict";
287
+ import { normalCdf, normalPpf } from "../../src/research/stats.js";
288
+ import { deflatedSharpe, sweepHaircut } from "../../src/research/deflatedSharpe.js";
289
+
290
+ test("normalCdf/normalPpf are consistent inverses", () => {
291
+ assert.ok(Math.abs(normalCdf(0) - 0.5) < 1e-6);
292
+ assert.ok(Math.abs(normalCdf(1.96) - 0.975) < 1e-3);
293
+ assert.ok(Math.abs(normalPpf(0.975) - 1.96) < 1e-2);
294
+ });
295
+
296
+ test("deflatedSharpe falls as the number of trials grows", () => {
297
+ const base = {
298
+ sharpe: 2.0,
299
+ sampleSize: 250,
300
+ skew: 0,
301
+ kurtosis: 3,
302
+ sharpeStd: 0.5,
303
+ };
304
+ const few = deflatedSharpe({ ...base, numTrials: 1 });
305
+ const many = deflatedSharpe({ ...base, numTrials: 100 });
306
+ assert.ok(many < few);
307
+ assert.ok(few >= 0 && few <= 1);
308
+ assert.ok(many >= 0 && many <= 1);
309
+ });
310
+
311
+ test("sweepHaircut returns the expected-max-sharpe threshold under the null", () => {
312
+ const hc = sweepHaircut({ numTrials: 50, sharpeStd: 0.4 });
313
+ assert.ok(hc.expectedMaxSharpe > 0);
314
+ assert.ok(
315
+ hc.expectedMaxSharpe > sweepHaircut({ numTrials: 5, sharpeStd: 0.4 }).expectedMaxSharpe
316
+ );
317
+ });
318
+ ```
319
+
320
+ - [ ] **Step 2: Run to verify it fails**
321
+
322
+ Run: `node --test test/research/deflatedSharpe.test.js`
323
+ Expected: FAIL — cannot find modules.
324
+
325
+ - [ ] **Step 3: Implement src/research/stats.js**
326
+
327
+ ```js
328
+ // src/research/stats.js
329
+
330
+ /** Standard normal CDF via Abramowitz & Stegun 7.1.26 (error < 1.5e-7). */
331
+ export function normalCdf(x) {
332
+ const sign = x < 0 ? -1 : 1;
333
+ const ax = Math.abs(x) / Math.SQRT2;
334
+ const t = 1 / (1 + 0.3275911 * ax);
335
+ const y =
336
+ 1 -
337
+ ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
338
+ t *
339
+ Math.exp(-ax * ax);
340
+ return 0.5 * (1 + sign * y);
341
+ }
342
+
343
+ /** Inverse standard normal CDF (Acklam's algorithm). */
344
+ export function normalPpf(p) {
345
+ if (p <= 0) return -Infinity;
346
+ if (p >= 1) return Infinity;
347
+ const a = [
348
+ -3.969683028665376e1, 2.209460984245205e2, -2.759285104469687e2, 1.38357751867269e2,
349
+ -3.066479806614716e1, 2.506628277459239,
350
+ ];
351
+ const b = [
352
+ -5.447609879822406e1, 1.615858368580409e2, -1.556989798598866e2, 6.680131188771972e1,
353
+ -1.328068155288572e1,
354
+ ];
355
+ const c = [
356
+ -7.784894002430293e-3, -3.223964580411365e-1, -2.400758277161838, -2.549732539343734,
357
+ 4.374664141464968, 2.938163982698783,
358
+ ];
359
+ const d = [7.784695709041462e-3, 3.224671290700398e-1, 2.445134137142996, 3.754408661907416];
360
+ const plow = 0.02425;
361
+ const phigh = 1 - plow;
362
+ let q;
363
+ let r;
364
+ if (p < plow) {
365
+ q = Math.sqrt(-2 * Math.log(p));
366
+ return (
367
+ (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
368
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
369
+ );
370
+ }
371
+ if (p <= phigh) {
372
+ q = p - 0.5;
373
+ r = q * q;
374
+ return (
375
+ ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) /
376
+ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
377
+ );
378
+ }
379
+ q = Math.sqrt(-2 * Math.log(1 - p));
380
+ return (
381
+ -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
382
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
383
+ );
384
+ }
385
+
386
+ /** Sample skewness and excess-aware kurtosis (Pearson, kurtosis includes the +3). */
387
+ export function moments(values) {
388
+ const n = values.length;
389
+ if (n < 2) return { mean: values[0] ?? 0, std: 0, skew: 0, kurtosis: 3 };
390
+ const mean = values.reduce((a, b) => a + b, 0) / n;
391
+ let m2 = 0;
392
+ let m3 = 0;
393
+ let m4 = 0;
394
+ for (const v of values) {
395
+ const d = v - mean;
396
+ m2 += d * d;
397
+ m3 += d * d * d;
398
+ m4 += d * d * d * d;
399
+ }
400
+ m2 /= n;
401
+ m3 /= n;
402
+ m4 /= n;
403
+ const std = Math.sqrt(m2);
404
+ const skew = std === 0 ? 0 : m3 / std ** 3;
405
+ const kurtosis = m2 === 0 ? 3 : m4 / m2 ** 2;
406
+ return { mean, std, skew, kurtosis };
407
+ }
408
+ ```
409
+
410
+ - [ ] **Step 4: Implement src/research/deflatedSharpe.js**
411
+
412
+ ```js
413
+ // src/research/deflatedSharpe.js
414
+ import { normalCdf, normalPpf } from "./stats.js";
415
+
416
+ const EULER_MASCHERONI = 0.5772156649015329;
417
+
418
+ /**
419
+ * Expected maximum Sharpe under the null (no skill), given `numTrials`
420
+ * independent strategy trials whose Sharpe estimates have std `sharpeStd`.
421
+ * López de Prado, "The Deflated Sharpe Ratio" (2014), eq. for E[max].
422
+ */
423
+ export function sweepHaircut({ numTrials, sharpeStd }) {
424
+ const N = Math.max(1, numTrials);
425
+ const a = normalPpf(1 - 1 / N);
426
+ const b = normalPpf(1 - 1 / (N * Math.E));
427
+ const expectedMaxSharpe = sharpeStd * ((1 - EULER_MASCHERONI) * a + EULER_MASCHERONI * b);
428
+ return { expectedMaxSharpe, numTrials: N };
429
+ }
430
+
431
+ /**
432
+ * Deflated Sharpe Ratio: probability the observed `sharpe` (per-period, not
433
+ * annualized) is genuinely > 0 after accounting for `numTrials` selections,
434
+ * non-normal returns (skew, kurtosis), and finite `sampleSize`.
435
+ * Returns a probability in [0,1]; below ~0.95 means "not convincingly real."
436
+ */
437
+ export function deflatedSharpe({
438
+ sharpe,
439
+ sampleSize,
440
+ numTrials = 1,
441
+ sharpeStd = 0,
442
+ skew = 0,
443
+ kurtosis = 3,
444
+ }) {
445
+ const sr0 = sweepHaircut({ numTrials, sharpeStd }).expectedMaxSharpe;
446
+ const denom = Math.sqrt(
447
+ Math.max(1e-12, 1 - skew * sharpe + ((kurtosis - 1) / 4) * sharpe * sharpe)
448
+ );
449
+ const z = ((sharpe - sr0) * Math.sqrt(Math.max(1, sampleSize - 1))) / denom;
450
+ return normalCdf(z);
451
+ }
452
+ ```
453
+
454
+ - [ ] **Step 5: Run to verify it passes**
455
+
456
+ Run: `node --test test/research/deflatedSharpe.test.js`
457
+ Expected: PASS (3 tests).
458
+
459
+ - [ ] **Step 6: Commit**
460
+
461
+ ```bash
462
+ git add src/research/stats.js src/research/deflatedSharpe.js test/research/deflatedSharpe.test.js
463
+ git commit -m "feat: add normal stats, deflated Sharpe, sweep haircut
464
+
465
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
466
+ ```
467
+
468
+ ---
469
+
470
+ ### Task 4: Probability of Backtest Overfitting (CSCV)
471
+
472
+ **Files:**
473
+
474
+ - Create: `src/research/combinations.js`
475
+ - Create: `src/research/pbo.js`
476
+ - Test: `test/research/pbo.test.js`
477
+
478
+ - [ ] **Step 1: Write the failing test**
479
+
480
+ ```js
481
+ // test/research/pbo.test.js
482
+ import test from "node:test";
483
+ import assert from "node:assert/strict";
484
+ import { combinations } from "../../src/research/combinations.js";
485
+ import { probabilityOfBacktestOverfitting } from "../../src/research/pbo.js";
486
+
487
+ test("combinations(4,2) yields 6 unique index pairs", () => {
488
+ const combos = combinations(4, 2);
489
+ assert.equal(combos.length, 6);
490
+ assert.deepEqual(combos[0], [0, 1]);
491
+ });
492
+
493
+ test("a single dominant strategy gives low PBO", () => {
494
+ // strategy 0 always best; 8 observations, matrix [strategy][observation]
495
+ const obs = 8;
496
+ const winner = Array.from({ length: obs }, () => 5);
497
+ const loser1 = Array.from({ length: obs }, (_, i) => (i % 2 ? 1 : -1));
498
+ const loser2 = Array.from({ length: obs }, (_, i) => (i % 3 ? 0.5 : -0.5));
499
+ const out = probabilityOfBacktestOverfitting([winner, loser1, loser2], { groups: 4 });
500
+ assert.ok(out.pbo <= 0.25);
501
+ assert.equal(out.combos > 0, true);
502
+ });
503
+
504
+ test("noise strategies give PBO near 0.5", () => {
505
+ const obs = 12;
506
+ const mk = (seed) => Array.from({ length: obs }, (_, i) => Math.sin(seed * 7.1 + i * 1.3));
507
+ const matrix = [mk(1), mk(2), mk(3), mk(4), mk(5)];
508
+ const out = probabilityOfBacktestOverfitting(matrix, { groups: 6 });
509
+ assert.ok(out.pbo >= 0.2 && out.pbo <= 0.8);
510
+ });
511
+ ```
512
+
513
+ - [ ] **Step 2: Run to verify it fails**
514
+
515
+ Run: `node --test test/research/pbo.test.js`
516
+ Expected: FAIL — cannot find modules.
517
+
518
+ - [ ] **Step 3: Implement src/research/combinations.js**
519
+
520
+ ```js
521
+ // src/research/combinations.js
522
+
523
+ /** All k-sized index combinations of [0..n). Returns arrays of indices. */
524
+ export function combinations(n, k) {
525
+ const result = [];
526
+ const combo = [];
527
+ function recurse(start) {
528
+ if (combo.length === k) {
529
+ result.push([...combo]);
530
+ return;
531
+ }
532
+ for (let i = start; i < n; i += 1) {
533
+ combo.push(i);
534
+ recurse(i + 1);
535
+ combo.pop();
536
+ }
537
+ }
538
+ recurse(0);
539
+ return result;
540
+ }
541
+ ```
542
+
543
+ - [ ] **Step 4: Implement src/research/pbo.js**
544
+
545
+ ```js
546
+ // src/research/pbo.js
547
+ import { combinations } from "./combinations.js";
548
+
549
+ function sharpeOf(returns) {
550
+ const n = returns.length;
551
+ if (n < 2) return 0;
552
+ const mean = returns.reduce((a, b) => a + b, 0) / n;
553
+ let variance = 0;
554
+ for (const r of returns) variance += (r - mean) ** 2;
555
+ variance /= n - 1;
556
+ const std = Math.sqrt(variance);
557
+ return std === 0 ? 0 : mean / std;
558
+ }
559
+
560
+ /**
561
+ * Combinatorially-Symmetric Cross-Validation estimate of the Probability of
562
+ * Backtest Overfitting (Bailey, Borwein, López de Prado, Zhu 2017).
563
+ *
564
+ * `performanceMatrix` is [nStrategies][nObservations] of per-period returns.
565
+ * Observations are split into `groups` equal slices; every way of choosing
566
+ * half the groups forms the in-sample (IS) set, the rest out-of-sample (OS).
567
+ * For each split: pick the best strategy IS (by Sharpe), find its OS rank;
568
+ * PBO = fraction of splits where the IS winner lands in the bottom half OS.
569
+ */
570
+ export function probabilityOfBacktestOverfitting(performanceMatrix, { groups = 16 } = {}) {
571
+ const nStrategies = performanceMatrix.length;
572
+ if (nStrategies < 2) throw new Error("PBO needs at least 2 strategies");
573
+ const nObs = performanceMatrix[0].length;
574
+ const S = Math.min(groups, nObs);
575
+ if (S % 2 !== 0) throw new Error("groups must be even");
576
+
577
+ // Partition observation indices into S contiguous groups.
578
+ const groupIdx = Array.from({ length: S }, () => []);
579
+ for (let i = 0; i < nObs; i += 1) groupIdx[Math.floor((i * S) / nObs)].push(i);
580
+
581
+ const isCombos = combinations(S, S / 2);
582
+ const logits = [];
583
+ let overfitCount = 0;
584
+
585
+ for (const isGroups of isCombos) {
586
+ const isSet = new Set(isGroups);
587
+ const isIndices = [];
588
+ const osIndices = [];
589
+ for (let g = 0; g < S; g += 1) {
590
+ (isSet.has(g) ? isIndices : osIndices).push(...groupIdx[g]);
591
+ }
592
+
593
+ const isScores = performanceMatrix.map((row) => sharpeOf(isIndices.map((i) => row[i])));
594
+ const osScores = performanceMatrix.map((row) => sharpeOf(osIndices.map((i) => row[i])));
595
+
596
+ let bestStrategy = 0;
597
+ for (let s = 1; s < nStrategies; s += 1) {
598
+ if (isScores[s] > isScores[bestStrategy]) bestStrategy = s;
599
+ }
600
+
601
+ // OS rank of the IS winner (1 = worst .. N = best)
602
+ const winnerOs = osScores[bestStrategy];
603
+ let rank = 1;
604
+ for (let s = 0; s < nStrategies; s += 1) {
605
+ if (s !== bestStrategy && osScores[s] < winnerOs) rank += 1;
606
+ }
607
+ const relativeRank = rank / (nStrategies + 1); // in (0,1)
608
+ const logit = Math.log(relativeRank / (1 - relativeRank));
609
+ logits.push(logit);
610
+ if (relativeRank <= 0.5) overfitCount += 1;
611
+ }
612
+
613
+ return {
614
+ pbo: overfitCount / isCombos.length,
615
+ combos: isCombos.length,
616
+ medianLogit: [...logits].sort((a, b) => a - b)[Math.floor(logits.length / 2)],
617
+ };
618
+ }
619
+ ```
620
+
621
+ - [ ] **Step 5: Run to verify it passes**
622
+
623
+ Run: `node --test test/research/pbo.test.js`
624
+ Expected: PASS (3 tests). If the "noise" test is flaky at the boundary, widen the
625
+ band assertion to `[0.15, 0.85]` — PBO of pure noise is ~0.5 in expectation but
626
+ varies with the deterministic sample.
627
+
628
+ - [ ] **Step 6: Commit**
629
+
630
+ ```bash
631
+ git add src/research/combinations.js src/research/pbo.js test/research/pbo.test.js
632
+ git commit -m "feat: add CSCV probability of backtest overfitting
633
+
634
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
635
+ ```
636
+
637
+ ---
638
+
639
+ ### Task 5: Combinatorial Purged Cross-Validation splits
640
+
641
+ **Files:**
642
+
643
+ - Create: `src/research/cpcv.js`
644
+ - Test: `test/research/cpcv.test.js`
645
+
646
+ - [ ] **Step 1: Write the failing test**
647
+
648
+ ```js
649
+ // test/research/cpcv.test.js
650
+ import test from "node:test";
651
+ import assert from "node:assert/strict";
652
+ import { combinatorialPurgedSplits } from "../../src/research/cpcv.js";
653
+
654
+ test("cpcv produces C(nGroups, nTestGroups) splits with disjoint train/test", () => {
655
+ const splits = combinatorialPurgedSplits({
656
+ nObservations: 100,
657
+ nGroups: 6,
658
+ nTestGroups: 2,
659
+ embargo: 0,
660
+ });
661
+ // C(6,2) = 15
662
+ assert.equal(splits.length, 15);
663
+ for (const { train, test: testIdx } of splits) {
664
+ const trainSet = new Set(train);
665
+ for (const t of testIdx) assert.equal(trainSet.has(t), false);
666
+ }
667
+ });
668
+
669
+ test("embargo removes train observations adjacent to test blocks", () => {
670
+ const noEmbargo = combinatorialPurgedSplits({
671
+ nObservations: 60,
672
+ nGroups: 6,
673
+ nTestGroups: 1,
674
+ embargo: 0,
675
+ });
676
+ const withEmbargo = combinatorialPurgedSplits({
677
+ nObservations: 60,
678
+ nGroups: 6,
679
+ nTestGroups: 1,
680
+ embargo: 3,
681
+ });
682
+ // embargo can only shrink (or keep equal) the train set
683
+ assert.ok(withEmbargo[0].train.length <= noEmbargo[0].train.length);
684
+ });
685
+ ```
686
+
687
+ - [ ] **Step 2: Run to verify it fails**
688
+
689
+ Run: `node --test test/research/cpcv.test.js`
690
+ Expected: FAIL — cannot find module.
691
+
692
+ - [ ] **Step 3: Implement src/research/cpcv.js**
693
+
694
+ ```js
695
+ // src/research/cpcv.js
696
+ import { combinations } from "./combinations.js";
697
+
698
+ /**
699
+ * Combinatorial Purged Cross-Validation index splits (López de Prado,
700
+ * "Advances in Financial Machine Learning", ch. 12).
701
+ *
702
+ * Splits [0..nObservations) into `nGroups` contiguous blocks, then forms every
703
+ * combination choosing `nTestGroups` blocks as the test set. Training indices
704
+ * that fall within `embargo` observations of any test block are purged to avoid
705
+ * leakage from overlapping/serially-correlated samples.
706
+ *
707
+ * Returns [{ train: number[], test: number[] }].
708
+ */
709
+ export function combinatorialPurgedSplits({
710
+ nObservations,
711
+ nGroups = 6,
712
+ nTestGroups = 2,
713
+ embargo = 0,
714
+ }) {
715
+ if (!(nObservations > 0)) throw new Error("nObservations must be positive");
716
+ if (nTestGroups >= nGroups) throw new Error("nTestGroups must be < nGroups");
717
+
718
+ const bounds = [];
719
+ for (let g = 0; g < nGroups; g += 1) {
720
+ bounds.push([
721
+ Math.floor((g * nObservations) / nGroups),
722
+ Math.floor(((g + 1) * nObservations) / nGroups),
723
+ ]);
724
+ }
725
+
726
+ const splits = [];
727
+ for (const testGroups of combinations(nGroups, nTestGroups)) {
728
+ const testSet = new Set();
729
+ const purgeZones = [];
730
+ for (const g of testGroups) {
731
+ const [start, end] = bounds[g];
732
+ for (let i = start; i < end; i += 1) testSet.add(i);
733
+ purgeZones.push([start - embargo, end + embargo]);
734
+ }
735
+ const inPurge = (i) => purgeZones.some(([lo, hi]) => i >= lo && i < hi);
736
+
737
+ const train = [];
738
+ const testIdx = [];
739
+ for (let i = 0; i < nObservations; i += 1) {
740
+ if (testSet.has(i)) testIdx.push(i);
741
+ else if (!inPurge(i)) train.push(i);
742
+ }
743
+ splits.push({ train, test: testIdx, testGroups });
744
+ }
745
+ return splits;
746
+ }
747
+ ```
748
+
749
+ - [ ] **Step 4: Run to verify it passes**
750
+
751
+ Run: `node --test test/research/cpcv.test.js`
752
+ Expected: PASS (2 tests).
753
+
754
+ - [ ] **Step 5: Commit**
755
+
756
+ ```bash
757
+ git add src/research/cpcv.js test/research/cpcv.test.js
758
+ git commit -m "feat: add combinatorial purged cross-validation splits
759
+
760
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
761
+ ```
762
+
763
+ ---
764
+
765
+ ### Task 6: Aggregate `research` namespace + export + docs
766
+
767
+ **Files:**
768
+
769
+ - Create: `src/research/index.js`
770
+ - Modify: `src/index.js`
771
+ - Create: `docs/research.md`
772
+ - Modify: `README.md` (link the new guide)
773
+
774
+ - [ ] **Step 1: Write the failing test**
775
+
776
+ ```js
777
+ // test/research/index.test.js
778
+ import test from "node:test";
779
+ import assert from "node:assert/strict";
780
+ import { research } from "../../src/index.js";
781
+
782
+ test("research namespace exposes the full toolkit", () => {
783
+ for (const fn of [
784
+ "monteCarlo",
785
+ "deflatedSharpe",
786
+ "sweepHaircut",
787
+ "probabilityOfBacktestOverfitting",
788
+ "combinatorialPurgedSplits",
789
+ ]) {
790
+ assert.equal(typeof research[fn], "function", `missing research.${fn}`);
791
+ }
792
+ });
793
+ ```
794
+
795
+ - [ ] **Step 2: Run to verify it fails**
796
+
797
+ Run: `node --test test/research/index.test.js`
798
+ Expected: FAIL — `research` is not exported from `src/index.js`.
799
+
800
+ - [ ] **Step 3: Create src/research/index.js**
801
+
802
+ ```js
803
+ // src/research/index.js
804
+ export { monteCarlo } from "./monteCarlo.js";
805
+ export { deflatedSharpe, sweepHaircut } from "./deflatedSharpe.js";
806
+ export { probabilityOfBacktestOverfitting } from "./pbo.js";
807
+ export { combinatorialPurgedSplits } from "./cpcv.js";
808
+ export { combinations } from "./combinations.js";
809
+ export { normalCdf, normalPpf, moments } from "./stats.js";
810
+ ```
811
+
812
+ - [ ] **Step 4: Export from src/index.js**
813
+
814
+ Add near the other exports:
815
+
816
+ ```js
817
+ export * as research from "./research/index.js";
818
+ ```
819
+
820
+ - [ ] **Step 5: Run test + full suite**
821
+
822
+ Run: `node --test test/research/index.test.js`
823
+ Expected: PASS.
824
+
825
+ Run: `node --test`
826
+ Expected: PASS.
827
+
828
+ - [ ] **Step 6: Write docs/research.md**
829
+
830
+ Document each function: signature, inputs, return shape, and a worked example
831
+ that takes a `backtest()` result and runs the kit:
832
+
833
+ ```js
834
+ import { backtest, research } from "tradelab";
835
+
836
+ const result = backtest({ candles, interval: "1d", signal });
837
+ const pnls = result.positions.map((p) => p.exit.pnl);
838
+
839
+ const mc = research.monteCarlo({ tradePnls: pnls, equityStart: 10_000, seed: 1 });
840
+ console.log("5% worst final equity:", mc.finalEquity.p5);
841
+
842
+ const dsr = research.deflatedSharpe({
843
+ sharpe: result.metrics.sharpeDaily,
844
+ sampleSize: result.metrics.trades,
845
+ numTrials: 20, // how many parameter sets you tried
846
+ sharpeStd: 0.5, // dispersion of Sharpe across those trials
847
+ skew: 0,
848
+ kurtosis: 3,
849
+ });
850
+ console.log("Deflated Sharpe prob:", dsr);
851
+ ```
852
+
853
+ Include a paragraph explaining how to feed a parameter-sweep's per-set return
854
+ series into `probabilityOfBacktestOverfitting` (rows = parameter sets, columns =
855
+ per-period returns) and how to read PBO (> 0.5 means the selection process is
856
+ likely overfit).
857
+
858
+ - [ ] **Step 7: Link from README + lint + commit**
859
+
860
+ Add a row to the README documentation table:
861
+
862
+ ```markdown
863
+ | [Research & overfitting](docs/research.md) | Monte Carlo, deflated Sharpe, PBO, CPCV, sweep haircut |
864
+ ```
865
+
866
+ ```bash
867
+ npm run lint && npm run format:check && npm test
868
+ git add src/research/index.js src/index.js docs/research.md README.md test/research/index.test.js
869
+ git commit -m "feat: expose research toolkit namespace and docs
870
+
871
+ Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
872
+ ```
873
+
874
+ ---
875
+
876
+ ## Self-review checklist
877
+
878
+ - [ ] All randomness flows through `makeRng(seed)` — Monte Carlo is reproducible. ✔ (Tasks 1, 2)
879
+ - [ ] `combinations` is shared by both `pbo.js` and `cpcv.js` (DRY). ✔
880
+ - [ ] Function names are consistent: `monteCarlo`, `deflatedSharpe`, `sweepHaircut`, `probabilityOfBacktestOverfitting`, `combinatorialPurgedSplits` appear identically in implementation, index, test, and docs. ✔
881
+ - [ ] No engine files modified — toolkit is pure and decoupled. ✔
882
+ - [ ] `src/utils/random.js` content is byte-identical to Plan 4's version (shared file). ✔ (Task 1 Step 3)