canli-backtest-mcp 0.5.0 → 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 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.5.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
  }
@@ -7,8 +7,8 @@ import { z } from "zod";
7
7
  import { readPanel } from "./panel-io.mjs";
8
8
 
9
9
  export const diversifiersInput = z.object({
10
- book_file: z.string().min(1).max(1000).describe("A CSV of the book's daily returns: date, then one column per strategy or one for the whole book."),
11
- candidates_file: z.string().min(1).max(1000).describe("A CSV of candidates' daily returns: date, then one column each."),
10
+ book_file: z.string().min(1).max(1000).regex(/^[^\0]+$/, "a file path").describe("A CSV of the book's daily returns: date, then one column per strategy or one for the whole book."),
11
+ candidates_file: z.string().min(1).max(1000).regex(/^[^\0]+$/, "a file path").describe("A CSV of candidates' daily returns: date, then one column each."),
12
12
  limit: z.number().int().min(1).max(100).optional().describe("How many to return; default 10."),
13
13
  min_days: z.number().int().min(60).max(100000).optional().describe("Days a candidate must share with the book; default 252."),
14
14
  }).strict();
package/src/exchange.mjs CHANGED
@@ -19,7 +19,7 @@ export const EXCHANGE_LIMITS_TEXT = Object.freeze([
19
19
  "Personas are simple scripts with parameters someone chose. Different parameters give different cascades, so a result here is a question to ask of a real system, not an answer about it.",
20
20
  ]);
21
21
 
22
- const file = z.string().min(1).max(1000);
22
+ const file = z.string().min(1).max(1000).regex(/^[^\0]+$/, "a file path");
23
23
  const agentId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{1,39}$/).refine((n) => !(n in {}), "that name is reserved");
24
24
  const assetId = z.string().regex(/^ART[0-9]{1,2}$/, "an exchange asset, such as ART1");
25
25
  const PERSONAS = ["market_maker", "value", "momentum", "noise", "deleverager", "stop_loss", "levered_long"];
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(",")));
@@ -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. Runs in a worker thread; nothing here writes files.
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
- 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
+ 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
- // The summary over a complete set of results.
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 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]; });
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 trials = rows.filter((r) => Number.isFinite(r[8])).length;
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
- 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." };
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
  }