canli-backtest-mcp 0.5.1 → 0.6.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 +2 -0
- package/package.json +9 -4
- package/src/panel-io.mjs +6 -0
- package/src/sweep-worker.mjs +4 -2
- package/src/sweep.mjs +33 -7
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ ledger**, so the best of a search is judged against the whole search instead of
|
|
|
7
7
|
|
|
8
8
|
Local stdio only: your prices, signals and ledgers never leave your machine.
|
|
9
9
|
|
|
10
|
+
**[canli-backtest-mcp on canlicapital.com](https://canlicapital.com/mcp-servers/backtest)** · [All Canli Capital MCP servers](https://canlicapital.com/mcp-servers). Free, MIT-licensed. For every server behind one install, use [canli-mcp](https://www.npmjs.com/package/canli-mcp).
|
|
11
|
+
|
|
10
12
|
## Why
|
|
11
13
|
|
|
12
14
|
Three mistakes make most agent-run backtests look better than they are:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-backtest-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "MCP server for backtests that refuse lookahead: point-in-time factors from SEC filings, cross-sectional backtests of any signal on your own prices, costs on actual trades, and every variant counted in a trial ledger.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"url": "git+https://github.com/arhancanli/canlicapital.git",
|
|
36
36
|
"directory": "mcp-backtest"
|
|
37
37
|
},
|
|
38
|
-
"homepage": "https://canlicapital.com/mcp-servers",
|
|
38
|
+
"homepage": "https://canlicapital.com/mcp-servers/backtest",
|
|
39
39
|
"bugs": {
|
|
40
40
|
"url": "https://github.com/arhancanli/canlicapital/issues"
|
|
41
41
|
},
|
|
@@ -52,8 +52,13 @@
|
|
|
52
52
|
"deflated-sharpe",
|
|
53
53
|
"overfitting",
|
|
54
54
|
"factor-investing",
|
|
55
|
-
"ai-agents"
|
|
56
|
-
|
|
55
|
+
"ai-agents",
|
|
56
|
+
"backtest",
|
|
57
|
+
"algorithmic-trading",
|
|
58
|
+
"sec-filings",
|
|
59
|
+
"mcp-server",
|
|
60
|
+
"claude",
|
|
61
|
+
"cursor"],
|
|
57
62
|
"overrides": {
|
|
58
63
|
"zod": "4.6.7"
|
|
59
64
|
}
|
package/src/panel-io.mjs
CHANGED
|
@@ -60,6 +60,12 @@ export function readPanel(path, name) {
|
|
|
60
60
|
return parsePanel(bytes.toString("utf8"), name ?? path);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// A panel's dates alone (its first column, as readPanel would read them), without parsing every price.
|
|
64
|
+
export function readPanelDates(path) {
|
|
65
|
+
const lines = readFileSync(path, "utf8").replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
66
|
+
return lines.slice(1).map((l) => { const c = l.indexOf(","); return (c < 0 ? l : l.slice(0, c)).trim().replace(/^"(.*)"$/, "$1"); });
|
|
67
|
+
}
|
|
68
|
+
|
|
63
69
|
export function writePanel(path, { dates, tickers, values }) {
|
|
64
70
|
const rows = [["date", ...tickers].join(",")];
|
|
65
71
|
dates.forEach((d, i) => rows.push([d, ...tickers.map((t) => (values[t][i] === null || values[t][i] === undefined ? "" : String(values[t][i])))].join(",")));
|
package/src/sweep-worker.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// One worker of breadth_sweep: reads the panel once, runs its share of the settings, posts each
|
|
2
|
-
// result back
|
|
2
|
+
// result back with the book's daily returns (a flat day as 0), moved rather than copied. Runs in a
|
|
3
|
+
// worker thread; nothing here writes files.
|
|
3
4
|
import { parentPort, workerData } from "node:worker_threads";
|
|
4
5
|
|
|
5
6
|
import { runBreadth } from "./breadth.mjs";
|
|
@@ -11,7 +12,8 @@ for (const c of combos) {
|
|
|
11
12
|
try {
|
|
12
13
|
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
14
|
const book = r.book ?? {};
|
|
14
|
-
|
|
15
|
+
const daily = Float64Array.from(r.series?.book ?? [], (v) => (Number.isNaN(v) ? 0 : v));
|
|
16
|
+
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, daily }, [daily.buffer]);
|
|
15
17
|
} catch (err) {
|
|
16
18
|
parentPort.postMessage({ k: c.k, ok: false, error: String(err?.message ?? err).slice(0, 200) });
|
|
17
19
|
}
|
package/src/sweep.mjs
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
// Sharpe the best of that many trials would reach by luck alone (the expected maximum of N draws,
|
|
6
6
|
// Bailey and Lopez de Prado), and, with split_date, the in-sample winner's Sharpe after the split is
|
|
7
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.
|
|
8
|
+
// merge refuses shards run on other data or settings, and refuses gaps and duplicates. Each variant's
|
|
9
|
+
// daily book returns go back beside the answer, under a symbol key that JSON leaves out, for a server
|
|
10
|
+
// that runs this package in its own process (canli-mcp) to measure overfitting across a session.
|
|
9
11
|
import { createHash } from "node:crypto";
|
|
10
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
13
|
import { availableParallelism } from "node:os";
|
|
12
14
|
import { Worker } from "node:worker_threads";
|
|
13
15
|
import { z } from "zod";
|
|
14
16
|
|
|
15
|
-
import { readPanel } from "./panel-io.mjs";
|
|
17
|
+
import { readPanel, readPanelDates } from "./panel-io.mjs";
|
|
16
18
|
|
|
17
19
|
const filePath = z.string().min(1).max(1000);
|
|
18
20
|
export const sweepInput = z.object({
|
|
@@ -97,19 +99,36 @@ function runParallel(files, combos, splitDate, workers) {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
const r3 = (x) => (Number.isFinite(x) ? Math.round(x * 1000) / 1000 : null);
|
|
102
|
+
const DAY_YEAR = 252; // breadth books are daily (breadth.mjs)
|
|
100
103
|
|
|
101
|
-
//
|
|
104
|
+
// Each variant's daily book returns over every date of the prices file (a flat day as 0, so variants with longer
|
|
105
|
+
// warm-ups line up), in every_trial's order. The key is canli-quant-mcp's: JSON leaves it out, so the answer, a shard
|
|
106
|
+
// file and out_file never carry them. With split_date too, since the table shows every variant after the split.
|
|
107
|
+
export const TRIAL_RETURNS = Symbol.for("canli.trial_returns");
|
|
108
|
+
// And the date of each of those days, the prices file's first column, under canli-mcp's key, so a server can set the
|
|
109
|
+
// returns against other strategies over the same months.
|
|
110
|
+
export const TRIAL_DATES = Symbol.for("canli.trial_dates");
|
|
111
|
+
const handOver = (daily, every, dates) => ({ [TRIAL_RETURNS]: every.k.map((k) => daily.get(k)), ...(dates ? { [TRIAL_DATES]: dates } : {}) });
|
|
112
|
+
|
|
113
|
+
// The summary over a complete set of results. Every variant's Sharpe, best first, goes back with its combination k and
|
|
114
|
+
// the days it was measured on (every_trial: one number when all variants share them, else one per variant), so a
|
|
115
|
+
// server that counts the strategies tried in a session counts each one and not only the best, as with
|
|
116
|
+
// canli-quant-mcp's strategy_sweep, and knows a variant again by its sweep_id and k. With split_date that is the
|
|
117
|
+
// Sharpe the settings were chosen on, before the split.
|
|
102
118
|
export function summarize(results, combos, splitDate) {
|
|
103
119
|
const byK = new Map(results.map((r) => [r.k, r]));
|
|
104
|
-
const
|
|
120
|
+
const pickOf = (k) => { const r = byK.get(k) ?? {}; return splitDate ? r.search : r.all; };
|
|
121
|
+
const rows = combos.map((c) => { const r = byK.get(c.k) ?? {}; 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, pickOf(c.k)?.sharpe ?? null, splitDate ? r.test?.sharpe ?? null : null, r.ok === false ? r.error : null]; });
|
|
105
122
|
const ranked = [...rows].sort((x, y) => (y[8] ?? -99) - (x[8] ?? -99));
|
|
106
123
|
const best = ranked[0], luck = expectedMaxSharpe(rows.map((r) => r[8]));
|
|
107
|
-
const
|
|
124
|
+
const tried = ranked.filter((r) => Number.isFinite(r[8])), days = tried.map((r) => pickOf(r[0])?.days ?? null);
|
|
125
|
+
const trials = tried.length;
|
|
108
126
|
return {
|
|
109
127
|
trials, columns: ["k", "signals", "smoothing", "target_vol", "cost_bps", "min_names", "sleeves", "effective_bets", splitDate ? "sharpe_search" : "sharpe", "sharpe_after_split", "error"],
|
|
110
128
|
rows: ranked,
|
|
111
129
|
best: best ? { k: best[0], settings: combos[best[0]], sharpe: best[8], ...(splitDate ? { sharpe_after_split: best[9] } : {}) } : null,
|
|
112
130
|
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.` },
|
|
131
|
+
every_trial: { periods: trials && days.every((d) => d === days[0]) ? days[0] : days, periods_per_year: DAY_YEAR, k: tried.map((r) => r[0]), sharpes: tried.map((r) => r[8]) },
|
|
113
132
|
};
|
|
114
133
|
}
|
|
115
134
|
|
|
@@ -139,12 +158,19 @@ export async function breadthSweep(session, args) {
|
|
|
139
158
|
const t0 = Date.now();
|
|
140
159
|
const results = await runParallel(files, mine, a.split_date ?? null, workers);
|
|
141
160
|
const seconds = Math.round((Date.now() - t0) / 100) / 10;
|
|
161
|
+
// The returns leave the results, which a shard file and a shard's answer carry; a merge has none to hand over.
|
|
162
|
+
const daily = new Map();
|
|
163
|
+
for (const r of results) if (r.daily) { daily.set(r.k, r.daily); delete r.daily; }
|
|
164
|
+
const dates = daily.size ? readPanelDates(files.prices_file) : null;
|
|
142
165
|
if (a.shard) {
|
|
143
166
|
const shard = { schema: "canli.breadth-sweep-shard.v1", sweep_id: sweepId, shard: a.shard, inputs, split_date: a.split_date ?? null, combos, results };
|
|
144
167
|
if (a.out_file) writeFileSync(a.out_file, JSON.stringify(shard));
|
|
145
|
-
|
|
168
|
+
// The shard's own variants, as the whole sweep lists them, so a server that counts tries counts them as they run
|
|
169
|
+
// and not first at the merge, which then adds only the variants that server has not seen.
|
|
170
|
+
const { every_trial } = summarize(results, mine, a.split_date ?? null);
|
|
171
|
+
return { sweep_id: sweepId, shard: a.shard, ran: mine.length, of: combos.length, seconds, workers, ...(a.out_file ? { shard_file: a.out_file } : { results }), every_trial, next: "Run the other shards (same files, grid and split_date) on any machine, then breadth_sweep with merge_files.", ...handOver(daily, every_trial, dates) };
|
|
146
172
|
}
|
|
147
173
|
const out = { sweep_id: sweepId, inputs, workers, seconds, ...summarize(results, combos, a.split_date ?? null) };
|
|
148
174
|
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."] };
|
|
175
|
+
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."], ...handOver(daily, out.every_trial, dates) };
|
|
150
176
|
}
|