canli-backtest-mcp 0.2.0 → 0.4.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/README.md +25 -0
- package/package.json +2 -2
- package/src/arena.mjs +150 -0
- package/src/cpcv.mjs +164 -0
- package/src/engine.mjs +94 -6
- package/src/league.mjs +243 -0
- package/src/proof.mjs +200 -0
- package/src/server.mjs +82 -7
- package/src/sweep-worker.mjs +19 -0
- package/src/sweep.mjs +150 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// One worker of breadth_sweep: reads the panel once, runs its share of the settings, posts each
|
|
2
|
+
// result back. Runs in a worker thread; nothing here writes files.
|
|
3
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
4
|
+
|
|
5
|
+
import { runBreadth } from "./breadth.mjs";
|
|
6
|
+
import { loadInputs } from "./sweep.mjs";
|
|
7
|
+
|
|
8
|
+
const { files, combos, splitDate } = workerData;
|
|
9
|
+
const base = loadInputs(files);
|
|
10
|
+
for (const c of combos) {
|
|
11
|
+
try {
|
|
12
|
+
const r = runBreadth({ ...base, signals: c.signals ?? null, minNames: c.min_names ?? 10, smoothing: c.smoothing ?? null, costBps: c.cost_bps ?? 10, targetVol: c.target_vol ?? 0.1, splitDate });
|
|
13
|
+
const book = r.book ?? {};
|
|
14
|
+
parentPort.postMessage({ k: c.k, ok: true, sleeves: r.sleeves ?? 0, bets: r.effective_bets?.effective_bets ?? null, all: book.netted ?? null, search: book.search?.netted ?? null, test: book.test?.netted ?? null });
|
|
15
|
+
} catch (err) {
|
|
16
|
+
parentPort.postMessage({ k: c.k, ok: false, error: String(err?.message ?? err).slice(0, 200) });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
parentPort.postMessage({ done: true });
|
package/src/sweep.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// breadth_sweep: a grid of breadth-book settings run in parallel on every core of this machine, or
|
|
2
|
+
// split into shards that any machine with the same files can run and that merge back exactly.
|
|
3
|
+
//
|
|
4
|
+
// Every combination is a trial, and the result says so: the best in-sample Sharpe is set against the
|
|
5
|
+
// Sharpe the best of that many trials would reach by luck alone (the expected maximum of N draws,
|
|
6
|
+
// Bailey and Lopez de Prado), and, with split_date, the in-sample winner's Sharpe after the split is
|
|
7
|
+
// reported beside it, the honest check. A shard carries the hash of the inputs and of the grid, so a
|
|
8
|
+
// merge refuses shards run on other data or settings, and refuses gaps and duplicates.
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { availableParallelism } from "node:os";
|
|
12
|
+
import { Worker } from "node:worker_threads";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
import { readPanel } from "./panel-io.mjs";
|
|
16
|
+
|
|
17
|
+
const filePath = z.string().min(1).max(1000);
|
|
18
|
+
export const sweepInput = z.object({
|
|
19
|
+
prices_file: filePath.optional().describe("CSV of prices: date column, one column per ticker (price_panel writes one)."),
|
|
20
|
+
groups_file: filePath.optional().describe("Optional ticker,group CSV."),
|
|
21
|
+
dollar_volume_file: filePath.optional().describe("Optional dollar volume CSV with the same dates."),
|
|
22
|
+
grid: z.object({
|
|
23
|
+
smoothing: z.array(z.number().int().min(1).max(60)).min(1).max(8).optional(),
|
|
24
|
+
target_vol: z.array(z.number().min(0.01).max(0.5)).min(1).max(8).optional(),
|
|
25
|
+
cost_bps: z.array(z.number().min(0).max(200)).min(1).max(8).optional(),
|
|
26
|
+
min_names: z.array(z.number().int().min(3).max(500)).min(1).max(8).optional(),
|
|
27
|
+
signals: z.array(z.array(z.string().min(1).max(40)).min(1).max(40)).min(1).max(12).optional().describe("Signal sets to try, e.g. [[\"momentum_12_1\"], [\"momentum_12_1\", \"low_vol\"]]."),
|
|
28
|
+
}).strict().optional().describe("Values to try for each setting; every combination runs (at most 400)."),
|
|
29
|
+
split_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Choose on dates up to this one; report the chosen settings after it."),
|
|
30
|
+
workers: z.number().int().min(1).max(64).optional().describe("Threads; default one less than this machine's cores."),
|
|
31
|
+
shard: z.object({ index: z.number().int().min(0), of: z.number().int().min(2).max(1000) }).strict().optional().describe("Run only combinations k with k mod of = index, to spread a sweep over machines."),
|
|
32
|
+
out_file: filePath.optional().describe("Write the results (or the shard) as JSON here."),
|
|
33
|
+
merge_files: z.array(filePath).min(2).max(1000).optional().describe("Instead of running: shard files to merge into one result."),
|
|
34
|
+
}).strict();
|
|
35
|
+
|
|
36
|
+
const canonical = (v) => (v === null || typeof v !== "object" ? JSON.stringify(v) : Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`);
|
|
37
|
+
const sha = (b) => createHash("sha256").update(b).digest("hex");
|
|
38
|
+
const asFloat = (values, tickers) => Object.fromEntries(tickers.map((t) => [t, Float64Array.from(values[t], (v) => (v === null ? NaN : v))]));
|
|
39
|
+
|
|
40
|
+
function readGroups(path) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [i, line] of readFileSync(path, "utf8").replace(/^/, "").split(/\r?\n/).filter((l) => l.trim()).entries()) {
|
|
43
|
+
const [t, g] = line.split(",").map((x) => x.trim());
|
|
44
|
+
if (i === 0 && /^ticker$/i.test(t)) continue;
|
|
45
|
+
if (t && g) out[t] = g;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The panel and groups as runBreadth takes them (used by each worker).
|
|
51
|
+
export function loadInputs(files) {
|
|
52
|
+
const prices = readPanel(files.prices_file, "prices_file");
|
|
53
|
+
const dv = files.dollar_volume_file ? readPanel(files.dollar_volume_file, "dollar_volume_file") : null;
|
|
54
|
+
const tickers = prices.tickers.filter((t) => !dv || dv.tickers.includes(t));
|
|
55
|
+
return { dates: prices.dates, tickers, prices: asFloat(prices.values, tickers), dollarVolume: dv ? asFloat(dv.values, tickers) : null, groups: files.groups_file ? readGroups(files.groups_file) : null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Every combination of the grid, numbered.
|
|
59
|
+
export function combinations(grid = {}) {
|
|
60
|
+
const keys = ["signals", "smoothing", "target_vol", "cost_bps", "min_names"].filter((k) => grid[k]?.length);
|
|
61
|
+
let out = [{}];
|
|
62
|
+
for (const k of keys) out = out.flatMap((c) => grid[k].map((v) => ({ ...c, [k]: v })));
|
|
63
|
+
return out.map((c, k) => ({ k, ...c }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The Sharpe the best of n independent trials reaches by luck, given the spread of their Sharpes
|
|
67
|
+
// (Bailey and Lopez de Prado 2014): sqrt(V) * ((1 - g) * Z(1 - 1/n) + g * Z(1 - 1/(n e))).
|
|
68
|
+
export function expectedMaxSharpe(sharpes) {
|
|
69
|
+
const v = sharpes.filter(Number.isFinite), n = v.length;
|
|
70
|
+
if (n < 2) return null;
|
|
71
|
+
const mean = v.reduce((a, b) => a + b, 0) / n, sd = Math.sqrt(v.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1));
|
|
72
|
+
const g = 0.5772156649;
|
|
73
|
+
return sd * ((1 - g) * invNorm(1 - 1 / n) + g * invNorm(1 - 1 / (n * Math.E)));
|
|
74
|
+
}
|
|
75
|
+
// Acklam's inverse normal.
|
|
76
|
+
function invNorm(p) {
|
|
77
|
+
const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239];
|
|
78
|
+
const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572];
|
|
79
|
+
const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783];
|
|
80
|
+
const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
|
|
81
|
+
const q = p < 0.5 ? p : 1 - p;
|
|
82
|
+
if (q < 0.02425) { const r = Math.sqrt(-2 * Math.log(q)); const x = (((((c[0] * r + c[1]) * r + c[2]) * r + c[3]) * r + c[4]) * r + c[5]) / ((((d[0] * r + d[1]) * r + d[2]) * r + d[3]) * r + 1); return p < 0.5 ? x : -x; }
|
|
83
|
+
const r = (q - 0.5) ** 2, s = q - 0.5;
|
|
84
|
+
const x = ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * s) / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
|
|
85
|
+
return p < 0.5 ? x : -x;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runParallel(files, combos, splitDate, workers) {
|
|
89
|
+
const n = Math.max(1, Math.min(workers, combos.length)), parts = Array.from({ length: n }, () => []);
|
|
90
|
+
combos.forEach((c, i) => parts[i % n].push(c));
|
|
91
|
+
return Promise.all(parts.map((part) => new Promise((resolve, reject) => {
|
|
92
|
+
const out = [], w = new Worker(new URL("./sweep-worker.mjs", import.meta.url), { workerData: { files, combos: part, splitDate }, execArgv: [] });
|
|
93
|
+
w.on("message", (m) => { if (m.done) { resolve(out); w.terminate(); } else out.push(m); });
|
|
94
|
+
w.on("error", reject);
|
|
95
|
+
w.on("exit", (code) => { if (code !== 0 && code !== 1) reject(new Error(`a sweep worker stopped (exit ${code})`)); });
|
|
96
|
+
}))).then((xs) => xs.flat());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const r3 = (x) => (Number.isFinite(x) ? Math.round(x * 1000) / 1000 : null);
|
|
100
|
+
|
|
101
|
+
// The summary over a complete set of results.
|
|
102
|
+
export function summarize(results, combos, splitDate) {
|
|
103
|
+
const byK = new Map(results.map((r) => [r.k, r]));
|
|
104
|
+
const rows = combos.map((c) => { const r = byK.get(c.k) ?? {}; const pick = splitDate ? r.search : r.all; return [c.k, c.signals ? c.signals.join("+") : "all", c.smoothing ?? null, c.target_vol ?? null, c.cost_bps ?? null, c.min_names ?? null, r.sleeves ?? null, r.bets ?? null, pick?.sharpe ?? null, splitDate ? r.test?.sharpe ?? null : null, r.ok === false ? r.error : null]; });
|
|
105
|
+
const ranked = [...rows].sort((x, y) => (y[8] ?? -99) - (x[8] ?? -99));
|
|
106
|
+
const best = ranked[0], luck = expectedMaxSharpe(rows.map((r) => r[8]));
|
|
107
|
+
const trials = rows.filter((r) => Number.isFinite(r[8])).length;
|
|
108
|
+
return {
|
|
109
|
+
trials, columns: ["k", "signals", "smoothing", "target_vol", "cost_bps", "min_names", "sleeves", "effective_bets", splitDate ? "sharpe_search" : "sharpe", "sharpe_after_split", "error"],
|
|
110
|
+
rows: ranked,
|
|
111
|
+
best: best ? { k: best[0], settings: combos[best[0]], sharpe: best[8], ...(splitDate ? { sharpe_after_split: best[9] } : {}) } : null,
|
|
112
|
+
luck: { expected_best_sharpe_by_luck: r3(luck), best_minus_luck: best && luck != null && Number.isFinite(best[8]) ? r3(best[8] - luck) : null, note: `The best of ${trials} trials would reach about ${r3(luck)} by luck alone, from the spread of their Sharpes; only the excess above it is evidence. Correlated trials (they share data) make this benchmark conservative.` },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function breadthSweep(session, args) {
|
|
117
|
+
const a = sweepInput.parse(args);
|
|
118
|
+
if (a.merge_files) {
|
|
119
|
+
const shards = a.merge_files.map((f) => JSON.parse(readFileSync(f, "utf8")));
|
|
120
|
+
const id = shards[0].sweep_id;
|
|
121
|
+
for (const [i, s] of shards.entries()) if (s.schema !== "canli.breadth-sweep-shard.v1" || s.sweep_id !== id) throw new RangeError(`${a.merge_files[i]} is not a shard of the same sweep (inputs, grid or split differ).`);
|
|
122
|
+
const results = shards.flatMap((s) => s.results), combos = shards[0].combos;
|
|
123
|
+
const seen = new Map();
|
|
124
|
+
for (const r of results) { if (seen.has(r.k)) throw new RangeError(`combination ${r.k} appears in two shards`); seen.set(r.k, r); }
|
|
125
|
+
const missing = combos.filter((c) => !seen.has(c.k)).map((c) => c.k);
|
|
126
|
+
if (missing.length) throw new RangeError(`shards are missing combinations ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? "..." : ""}`);
|
|
127
|
+
const out = { sweep_id: id, merged_from: shards.length, inputs: shards[0].inputs, ...summarize(results, combos, shards[0].split_date) };
|
|
128
|
+
if (a.out_file) writeFileSync(a.out_file, JSON.stringify(out));
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
if (!a.prices_file) throw new RangeError("breadth_sweep needs prices_file (or merge_files).");
|
|
132
|
+
const files = { prices_file: a.prices_file, groups_file: a.groups_file ?? null, dollar_volume_file: a.dollar_volume_file ?? null };
|
|
133
|
+
const inputs = Object.fromEntries(Object.entries(files).filter(([, p]) => p).map(([k, p]) => [k, sha(readFileSync(p))]));
|
|
134
|
+
const combos = combinations(a.grid);
|
|
135
|
+
if (combos.length > 400) throw new RangeError(`the grid makes ${combos.length} combinations; at most 400`);
|
|
136
|
+
const sweepId = sha(canonical({ inputs, combos, split: a.split_date ?? null })).slice(0, 24);
|
|
137
|
+
const mine = a.shard ? combos.filter((c) => c.k % a.shard.of === a.shard.index) : combos;
|
|
138
|
+
const workers = a.workers ?? Math.max(1, availableParallelism() - 1);
|
|
139
|
+
const t0 = Date.now();
|
|
140
|
+
const results = await runParallel(files, mine, a.split_date ?? null, workers);
|
|
141
|
+
const seconds = Math.round((Date.now() - t0) / 100) / 10;
|
|
142
|
+
if (a.shard) {
|
|
143
|
+
const shard = { schema: "canli.breadth-sweep-shard.v1", sweep_id: sweepId, shard: a.shard, inputs, split_date: a.split_date ?? null, combos, results };
|
|
144
|
+
if (a.out_file) writeFileSync(a.out_file, JSON.stringify(shard));
|
|
145
|
+
return { sweep_id: sweepId, shard: a.shard, ran: mine.length, of: combos.length, seconds, workers, ...(a.out_file ? { shard_file: a.out_file } : { results }), next: "Run the other shards (same files, grid and split_date) on any machine, then breadth_sweep with merge_files." };
|
|
146
|
+
}
|
|
147
|
+
const out = { sweep_id: sweepId, inputs, workers, seconds, ...summarize(results, combos, a.split_date ?? null) };
|
|
148
|
+
if (a.out_file) writeFileSync(a.out_file, JSON.stringify(out));
|
|
149
|
+
return { ...out, limits: ["Trials share the same data, so they are correlated: counting each as an independent trial makes the luck benchmark conservative (it overstates luck).", "Judge the chosen settings on sharpe_after_split only if nothing was chosen on those dates.", "Not investment advice."] };
|
|
150
|
+
}
|