tradelab 1.0.0 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +75 -12
- package/bin/tradelab-mcp.js +7 -0
- package/bin/tradelab.js +29 -0
- package/dist/cjs/data.cjs +149 -26
- package/dist/cjs/index.cjs +1893 -1003
- package/dist/cjs/live.cjs +134 -25
- package/dist/cjs/ta.cjs +339 -0
- package/docs/api-reference.md +46 -0
- package/docs/backtest-engine.md +112 -0
- package/docs/live-trading.md +51 -0
- package/docs/mcp.md +64 -0
- package/docs/research.md +103 -0
- package/docs/superpowers/plans/2026-00-overview.md +101 -0
- package/docs/superpowers/plans/2026-01-metrics-correctness.md +873 -0
- package/docs/superpowers/plans/2026-02-indicator-library.md +677 -0
- package/docs/superpowers/plans/2026-03-overfitting-toolkit.md +882 -0
- package/docs/superpowers/plans/2026-04-async-signals-seeding.md +981 -0
- package/docs/superpowers/plans/2026-05-mcp-server.md +758 -0
- package/docs/superpowers/plans/2026-06-parallel-param-sweep.md +508 -0
- package/docs/superpowers/plans/2026-07-funding-carry-costs.md +535 -0
- package/docs/superpowers/plans/2026-08-live-dashboard.md +547 -0
- package/docs/superpowers/plans/HANDOFF.md +88 -0
- package/examples/liveDashboard.js +33 -0
- package/examples/llmSignal.js +33 -0
- package/examples/optimize.js +25 -0
- package/package.json +16 -2
- package/src/engine/asyncSignal.js +28 -0
- package/src/engine/backtest.js +13 -1
- package/src/engine/backtestAsync.js +27 -0
- package/src/engine/backtestTicks.js +13 -2
- package/src/engine/barSystemRunner.js +96 -41
- package/src/engine/execution.js +39 -0
- package/src/engine/grid.js +15 -0
- package/src/engine/llmSignal.js +84 -0
- package/src/engine/optimize.js +86 -0
- package/src/engine/optimizeWorker.js +67 -0
- package/src/engine/walkForward.js +1 -0
- package/src/index.js +9 -0
- package/src/live/dashboard/server.js +120 -0
- package/src/live/engine/liveEngine.js +2 -2
- package/src/live/index.js +1 -0
- package/src/mcp/schemas.js +48 -0
- package/src/mcp/server.js +31 -0
- package/src/mcp/tools.js +142 -0
- package/src/metrics/annualize.js +32 -0
- package/src/metrics/benchmark.js +55 -0
- package/src/metrics/buildMetrics.js +34 -13
- package/src/metrics/finite.js +17 -0
- package/src/research/combinations.js +18 -0
- package/src/research/cpcv.js +47 -0
- package/src/research/deflatedSharpe.js +35 -0
- package/src/research/index.js +6 -0
- package/src/research/monteCarlo.js +88 -0
- package/src/research/pbo.js +69 -0
- package/src/research/stats.js +78 -0
- package/src/strategies/builtins.js +96 -0
- package/src/strategies/index.js +30 -0
- package/src/ta/channels.js +67 -0
- package/src/ta/index.js +16 -0
- package/src/ta/oscillators.js +70 -0
- package/src/ta/trend.js +78 -0
- package/src/utils/random.js +33 -0
- package/templates/dashboard.html +174 -0
- package/types/index.d.ts +154 -0
- package/types/live.d.ts +15 -0
- 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)
|