canli-backtest-mcp 0.3.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 CHANGED
@@ -40,6 +40,20 @@ Three mistakes make most agent-run backtests look better than they are:
40
40
  | `effective_bets` | How many independent bets a set of return series holds (the participation ratio of their correlations), by series and by family |
41
41
  | `annotation_packet`, `annotation_agreement`, `annotation_gold` | Blind labelling packets of figures (or of a draft's figures) with guidelines and an answer key; agreement between labellers (Cohen's kappa); and a gold set with its datasheet |
42
42
 
43
+ ## Size, crises and cross-validation
44
+
45
+ A backtest that assumes any size fills at the close flatters itself, so:
46
+
47
+ - **Size.** Give `backtest_signal` a `capital_usd` and a `dollar_volume_file`. An order fills only up to `max_participation` of recent daily volume (10% unless you say otherwise) and pays square-root impact, with volume and volatility read from the days before each trade. The reply shows the Sharpe at $100k to $1B, and no Sharpe for a size the market could not take. Order-book depth is not modelled: it needs paid data.
48
+ - **Crises.** Every reply holds the result against named crises, from the dot-com bust to the 2023 bank run. A crisis its dates do not fully cover is listed as untested, never as passed. `crisis_replay` does this for any returns file.
49
+ - **Cross-validation.** `cpcv` takes every variant's returns and, for each choice of held-out blocks, chooses the best on the rest, with purge and embargo at the edges, then scores it on what was held out. You get a spread of out-of-sample results, not one path. Splits can run as shards on other machines and merge back.
50
+
51
+ ## A record, a league and a paper market
52
+
53
+ - **Proof ledger.** `proof_filings` writes what companies filed into an append-only file, each line chained to the last and each batch signed, with a line for every figure a later filing changed. `proof_file` adds any file's hash, `proof_verify` checks the whole file and says which line first fails. It shows lines were not altered, not that a figure is right, and no record means only no record. Publish the head somewhere else, or the key holder could rewrite it all. The [published ledger](https://github.com/arhancanli/canlicapital/releases) does that with a public attestation each month.
54
+ - **League.** `league_new`, `league_commit` and `league_score` run a tournament scored after the fact: an entrant signs and commits a hash before a round closes and shows the file afterwards, and anyone with the files can rerun the table. Each record is set against the best luck gives across that many entrants. The top entrants, chosen on earlier rounds only, form a paper book. No prizes, no stakes, nobody's money.
55
+ - **Arena.** `arena_new`, `arena_orders` and `arena_step` are a paper market for agents to trade in, cleared in a call auction, reproducible from a seed. Made-up assets and made-up money: it cannot reach anything real.
56
+
43
57
  ## Quick start
44
58
 
45
59
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-backtest-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
package/src/arena.mjs ADDED
@@ -0,0 +1,150 @@
1
+ // The arena: a paper market for AI agents to trade in. Made-up assets and made-up money, cleared in a
2
+ // uniform-price call auction each step, reproducible from a seed. It is here so agents' behaviour
3
+ // together can be studied: who supplies liquidity, who gets picked off, what a crowd does to a price.
4
+ //
5
+ // It touches nothing real. No real security, token or currency is represented, no order leaves the
6
+ // file, and nothing here can be turned into a real market: running one needs registration as an
7
+ // exchange or as a broker-dealer running an alternative trading system.
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { z } from "zod";
10
+
11
+ export const ARENA_LIMITS_TEXT = Object.freeze([
12
+ "Paper only: made-up assets and made-up money. Nothing here represents or can reach a real security, token or currency.",
13
+ "A simulated market leaves out most of what moves real ones, so behaviour here is a sketch for study and does not predict results in real markets.",
14
+ ]);
15
+
16
+ const file = z.string().min(1).max(1000);
17
+ const assetId = z.string().regex(/^ART[0-9]{1,2}$/, "an arena asset, such as ART1");
18
+ // Names become keys in the state file, so ones every JavaScript object already answers to are refused.
19
+ const agentId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{1,39}$/).refine((n) => !(n in {}), "that name is reserved");
20
+ export const arenaNewInput = z.object({
21
+ arena_file: file.describe("The arena's state file; must not exist yet."),
22
+ seed: z.number().int().min(0).max(2 ** 31 - 2).optional().describe("The same seed and the same orders give the same market. Default 1."),
23
+ assets: z.number().int().min(1).max(20).optional().describe("Made-up assets. Default 3."),
24
+ starting_cash: z.number().positive().max(1e9).optional().describe("Made-up money each agent starts with. Default 100000."),
25
+ bots: z.number().int().min(0).max(50).optional().describe("Scripted traders that keep the market moving: noise, value and momentum in turn. Default 6."),
26
+ }).strict();
27
+ export const arenaOrdersInput = z.object({
28
+ arena_file: file,
29
+ agent: agentId,
30
+ orders: z.array(z.object({ asset: assetId, side: z.enum(["buy", "sell"]), quantity: z.number().int().positive().max(1e7), limit: z.number().positive().max(1e7) }).strict()).max(50).describe("Replaces this agent's orders for the coming step. No short selling and no borrowing."),
31
+ }).strict();
32
+ export const arenaStepInput = z.object({ arena_file: file, steps: z.number().int().min(1).max(1000).optional().describe("Default 1.") }).strict();
33
+
34
+ const SHARES_PER_AGENT = 100;
35
+ const load = (f) => { if (!existsSync(f)) throw new RangeError(`${f} is not an arena yet: call arena_new`); const s = JSON.parse(readFileSync(f, "utf8")); if (s.schema !== "canli.arena.v1") throw new RangeError(`${f} is not an arena file`); return s; };
36
+ // Every order in a step comes from an agent in the file, however the file was written.
37
+ const orderLines = (state) => Object.entries(state.orders).filter(([agent]) => Object.hasOwn(state.agents, agent));
38
+ const save = (f, s) => writeFileSync(f, JSON.stringify(s));
39
+ function rng(state) { state.rng = (state.rng * 16807) % 2147483647; return state.rng / 2147483647; }
40
+ const gauss = (s) => Math.sqrt(-2 * Math.log(rng(s))) * Math.cos(2 * Math.PI * rng(s));
41
+ const newAgent = (s) => ({ cash: s.starting_cash, holdings: Object.fromEntries(s.assets.map((x) => [x.id, SHARES_PER_AGENT])), start_worth: s.starting_cash + s.assets.reduce((n, x) => n + SHARES_PER_AGENT * x.last, 0) });
42
+ const round2 = (x) => Math.round(x * 100) / 100;
43
+
44
+ export function arenaNew(args) {
45
+ const a = arenaNewInput.parse(args);
46
+ if (existsSync(a.arena_file)) throw new RangeError(`${a.arena_file} already exists`);
47
+ const state = { schema: "canli.arena.v1", paper_only: true, step: 0, rng: (a.seed ?? 1) + 1, starting_cash: a.starting_cash ?? 100000, assets: [], agents: {}, orders: {}, history: [] };
48
+ for (let i = 0; i < (a.assets ?? 3); i += 1) { const value = round2(50 + 100 * rng(state)); state.assets.push({ id: `ART${i + 1}`, value, last: value }); }
49
+ const kinds = ["noise", "value", "momentum"];
50
+ state.bots = Array.from({ length: a.bots ?? 6 }, (_, i) => ({ name: `bot-${kinds[i % 3]}-${i}`, kind: kinds[i % 3] }));
51
+ for (const b of state.bots) state.agents[b.name] = newAgent(state);
52
+ save(a.arena_file, state);
53
+ return { arena_file: a.arena_file, assets: state.assets.map((x) => ({ id: x.id, last: x.last })), bots: state.bots.length, plain_reading: "A new paper arena: made-up assets and made-up money. Send orders with arena_orders, then call arena_step.", limits: ARENA_LIMITS_TEXT };
54
+ }
55
+
56
+ // Refused if the agent could not pay for its buys or deliver its sells even if all of them filled.
57
+ export function checkOrders(state, agent, orders) {
58
+ const me = Object.hasOwn(state.agents, agent) ? state.agents[agent] : newAgent(state), known = new Set(state.assets.map((x) => x.id));
59
+ let spend = 0;
60
+ const selling = {};
61
+ for (const o of orders) {
62
+ if (!known.has(o.asset)) throw new RangeError(`no asset ${o.asset} in this arena`);
63
+ if (o.side === "buy") spend += o.quantity * o.limit; else selling[o.asset] = (selling[o.asset] ?? 0) + o.quantity;
64
+ }
65
+ if (spend > me.cash + 1e-9) throw new RangeError(`these buys could cost ${round2(spend)} and ${agent} has ${round2(me.cash)}: no borrowing here`);
66
+ for (const [k, q] of Object.entries(selling)) if (q > (me.holdings[k] ?? 0)) throw new RangeError(`${agent} holds ${me.holdings[k] ?? 0} ${k} and is selling ${q}: no short selling here`);
67
+ }
68
+ export function arenaOrders(args) {
69
+ const a = arenaOrdersInput.parse(args), state = load(a.arena_file);
70
+ if (state.bots.some((b) => b.name === a.agent)) throw new RangeError(`${a.agent} is one of the arena's scripted traders`);
71
+ checkOrders(state, a.agent, a.orders);
72
+ if (!Object.hasOwn(state.agents, a.agent)) state.agents[a.agent] = newAgent(state);
73
+ state.orders[a.agent] = a.orders;
74
+ save(a.arena_file, state);
75
+ return { agent: a.agent, orders_waiting: a.orders.length, step: state.step, plain_reading: `${a.orders.length} paper orders wait for step ${state.step + 1}.`, limits: ARENA_LIMITS_TEXT };
76
+ }
77
+
78
+ // The uniform-price call auction: the price where the most can trade; among those, where the
79
+ // imbalance is smallest, then nearest the last price. Best limits fill first, earliest first at a tie.
80
+ export function clearAuction(orders, last) {
81
+ const buys = orders.filter((o) => o.side === "buy").sort((a, b) => b.limit - a.limit || a.at - b.at);
82
+ const sells = orders.filter((o) => o.side === "sell").sort((a, b) => a.limit - b.limit || a.at - b.at);
83
+ const candidates = [...new Set(orders.map((o) => o.limit))];
84
+ let best = null;
85
+ for (const p of candidates) {
86
+ const demand = buys.filter((o) => o.limit >= p).reduce((n, o) => n + o.quantity, 0), supply = sells.filter((o) => o.limit <= p).reduce((n, o) => n + o.quantity, 0);
87
+ const volume = Math.min(demand, supply), imbalance = Math.abs(demand - supply), gap = Math.abs(p - last);
88
+ if (volume > 0 && (!best || volume > best.volume || (volume === best.volume && (imbalance < best.imbalance || (imbalance === best.imbalance && gap < best.gap))))) best = { price: p, volume, imbalance, gap };
89
+ }
90
+ if (!best) return { price: null, volume: 0, fills: [] };
91
+ const fills = [];
92
+ const take = (list, side) => { let left = best.volume; for (const o of list) { if (left <= 0) break; if (side === "buy" ? o.limit < best.price : o.limit > best.price) continue; const q = Math.min(o.quantity, left); fills.push({ agent: o.agent, side, quantity: q }); left -= q; } };
93
+ take(buys, "buy");
94
+ take(sells, "sell");
95
+ return { price: best.price, volume: best.volume, fills };
96
+ }
97
+
98
+ function botOrders(state, bot, asset, previous, left) {
99
+ const me = state.agents[bot.name], qty = 1 + Math.floor(rng(state) * 5), noise = 1 + 0.01 * gauss(state);
100
+ let side, limit;
101
+ if (bot.kind === "value") { side = asset.last < asset.value ? "buy" : "sell"; limit = (asset.last + asset.value) / 2; }
102
+ else if (bot.kind === "momentum") { side = previous !== null && asset.last >= previous ? "buy" : "sell"; limit = asset.last * (side === "buy" ? 1.01 : 0.99); }
103
+ else { side = rng(state) < 0.5 ? "buy" : "sell"; limit = asset.last * noise; }
104
+ limit = Math.max(0.01, round2(limit));
105
+ if (side === "buy") { if (left.cash < qty * limit) return null; left.cash -= qty * limit; }
106
+ if (side === "sell" && (me.holdings[asset.id] ?? 0) < qty) return null;
107
+ return { asset: asset.id, side, quantity: qty, limit };
108
+ }
109
+
110
+ export function stepOnce(state) {
111
+ state.step += 1;
112
+ // Buys are held against cash across assets in this step, so no agent can overspend across auctions.
113
+ const all = [];
114
+ let at = 0;
115
+ for (const [agent, orders] of orderLines(state)) for (const o of orders) all.push({ ...o, agent, at: at++ });
116
+ const previous = state.history.at(-1)?.prices_before ?? {};
117
+ // What a bot could pay is held across its auctions in a step, as an agent's buys are at arena_orders.
118
+ for (const bot of state.bots) { const left = { cash: state.agents[bot.name].cash }; for (const asset of state.assets) { const o = botOrders(state, bot, asset, previous[asset.id] ?? null, left); if (o) all.push({ ...o, agent: bot.name, at: at++ }); } }
119
+ const record = { step: state.step, prices_before: Object.fromEntries(state.assets.map((x) => [x.id, x.last])), auctions: {} };
120
+ for (const asset of state.assets) {
121
+ const r = clearAuction(all.filter((o) => o.asset === asset.id), asset.last);
122
+ record.auctions[asset.id] = { price: r.price, volume: r.volume };
123
+ if (r.price !== null) {
124
+ for (const f of r.fills) {
125
+ const me = state.agents[f.agent], cost = f.quantity * r.price;
126
+ if (f.side === "buy") { me.cash -= cost; me.holdings[asset.id] = (me.holdings[asset.id] ?? 0) + f.quantity; }
127
+ else { me.cash += cost; me.holdings[asset.id] -= f.quantity; }
128
+ }
129
+ asset.last = r.price;
130
+ }
131
+ // The made-up value drifts on its own each step: there is no real company behind it.
132
+ asset.value = Math.max(1, round2(asset.value * (1 + 0.01 * gauss(state))));
133
+ }
134
+ state.orders = {};
135
+ state.history.push(record);
136
+ if (state.history.length > 500) state.history.shift();
137
+ }
138
+
139
+ export function standings(state) {
140
+ const worth = (me) => me.cash + state.assets.reduce((n, x) => n + (me.holdings[x.id] ?? 0) * x.last, 0);
141
+ return Object.entries(state.agents).map(([agent, me]) => ({ agent, bot: state.bots.some((b) => b.name === agent), worth: round2(worth(me)), gain: round2(worth(me) - me.start_worth), cash: round2(me.cash), holdings: me.holdings })).sort((a, b) => b.gain - a.gain);
142
+ }
143
+
144
+ export function arenaStep(args) {
145
+ const a = arenaStepInput.parse(args), state = load(a.arena_file);
146
+ for (let i = 0; i < (a.steps ?? 1); i += 1) stepOnce(state);
147
+ save(a.arena_file, state);
148
+ const last = state.history.at(-1);
149
+ return { step: state.step, prices: Object.fromEntries(state.assets.map((x) => [x.id, x.last])), last_auctions: last.auctions, standings: standings(state).slice(0, 25), plain_reading: `Paper step ${state.step} cleared. No real asset or money is involved.`, limits: ARENA_LIMITS_TEXT };
150
+ }
package/src/cpcv.mjs ADDED
@@ -0,0 +1,164 @@
1
+ // cpcv: combinatorial purged cross-validation (Lopez de Prado 2018, Advances in Financial Machine
2
+ // Learning, chapter 12) over any set of trial return series. History is cut into N blocks. Every
3
+ // choice of k blocks is a test set; on what remains the best trial is chosen, with days beside the
4
+ // test blocks purged and embargoed so nothing leaks across the edge, and that trial's test returns
5
+ // are kept. The kept pieces stitch into C(N-1, k-1) whole paths, so a search gets a spread of
6
+ // out-of-sample results rather than the one path history happened to take.
7
+ //
8
+ // A split is the unit of work: any machine with the same file can run a shard of them, and a shard
9
+ // carries the hash of the file and settings, so a merge refuses other data, gaps and duplicates.
10
+ import { createHash } from "node:crypto";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
+ import { z } from "zod";
13
+
14
+ import { readPanel } from "./panel-io.mjs";
15
+
16
+ const filePath = z.string().min(1).max(1000);
17
+ export const cpcvInput = z.object({
18
+ returns_file: filePath.optional().describe("CSV: a date column and one column of daily returns per trial (breadth_book's out_file, say). At least two trials."),
19
+ groups: z.number().int().min(3).max(30).optional().describe("Blocks history is cut into. Default 10."),
20
+ test_groups: z.number().int().min(1).max(29).optional().describe("Blocks in each test set. Default 2."),
21
+ purge_days: z.number().int().min(0).max(500).optional().describe("Training days dropped before each test block: at least the horizon the trials' returns look ahead over. Default 5."),
22
+ embargo_days: z.number().int().min(0).max(500).optional().describe("Training days dropped after each test block. Default 5."),
23
+ periods_per_year: z.number().positive().optional().describe("Default 252."),
24
+ shard: z.object({ index: z.number().int().min(0), of: z.number().int().min(2).max(1000) }).strict().optional().describe("Run only splits s with s mod of = index, to spread the work over machines."),
25
+ out_file: filePath.optional().describe("Write the shard as JSON here."),
26
+ merge_files: z.array(filePath).min(2).max(1000).optional().describe("Instead of running: shard files to merge into one result."),
27
+ }).strict();
28
+
29
+ export const CPCV_LIMITS_TEXT = Object.freeze([
30
+ "The trial with the highest training Sharpe is the one chosen in each split; another way of choosing would give other paths.",
31
+ "Paths share days and data, so their spread is not a confidence interval and they are not independent tests.",
32
+ "This covers the search you supply. Variants tried and left out of the file are not counted, and survivorship in the data behind the trials passes straight through.",
33
+ ]);
34
+ const MAX_SPLITS = 5000, MAX_CELLS = 100_000_000;
35
+
36
+ export function choose(n, k) {
37
+ let c = 1;
38
+ for (let i = 1; i <= k; i += 1) c = (c * (n - k + i)) / i;
39
+ return Math.round(c);
40
+ }
41
+ export function splitsOf(n, k) {
42
+ const out = [], pick = [];
43
+ const walk = (from) => {
44
+ if (pick.length === k) { out.push([...pick]); return; }
45
+ for (let g = from; g < n; g += 1) { pick.push(g); walk(g + 1); pick.pop(); }
46
+ };
47
+ walk(0);
48
+ return out;
49
+ }
50
+ // Blocks of whole days, as equal as they can be: [first, last] inclusive.
51
+ export function blocksOf(days, n) {
52
+ return Array.from({ length: n }, (_, g) => [Math.floor((g * days) / n), Math.floor(((g + 1) * days) / n) - 1]);
53
+ }
54
+ // 1 for a day training may use: outside the test blocks and their purge and embargo margins.
55
+ export function trainMask(days, blocks, test, purge, embargo) {
56
+ const mask = new Uint8Array(days).fill(1);
57
+ for (const g of test) {
58
+ const [a, b] = blocks[g];
59
+ for (let t = Math.max(0, a - purge); t <= Math.min(days - 1, b + embargo); t += 1) mask[t] = 0;
60
+ }
61
+ return mask;
62
+ }
63
+ const sharpeOf = (values, scale) => {
64
+ let n = 0, mean = 0, s = 0;
65
+ for (const v of values) { if (!Number.isFinite(v)) continue; n += 1; const d = v - mean; mean += d / n; s += d * (v - mean); }
66
+ return n > 1 && s > 0 ? (mean / Math.sqrt(s / (n - 1))) * scale : null;
67
+ };
68
+
69
+ // One split: the best trial on the training days, and its returns on each test block.
70
+ export function runSplit(panel, blocks, test, { purge, embargo, scale }) {
71
+ const days = panel.dates.length, mask = trainMask(days, blocks, test, purge, embargo);
72
+ let best = -1, bestSharpe = -Infinity;
73
+ panel.trials.forEach((name, i) => {
74
+ const r = panel.values[i];
75
+ let n = 0, mean = 0, s = 0;
76
+ for (let t = 0; t < days; t += 1) { if (!mask[t] || !Number.isFinite(r[t])) continue; n += 1; const d = r[t] - mean; mean += d / n; s += d * (r[t] - mean); }
77
+ const sh = n > 1 && s > 0 ? (mean / Math.sqrt(s / (n - 1))) * scale : -Infinity;
78
+ if (sh > bestSharpe) { bestSharpe = sh; best = i; }
79
+ });
80
+ if (best < 0 || !Number.isFinite(bestSharpe)) throw new RangeError(`no trial has enough training days when blocks ${test.join(",")} are the test set; use fewer groups, a shorter purge, or a longer file`);
81
+ // A chosen trial with no value on a day held nothing that day: it earns zero, and the day is counted.
82
+ let missing = 0;
83
+ const pieces = test.map((g) => {
84
+ const [a, b] = blocks[g], out = [];
85
+ for (let t = a; t <= b; t += 1) { const v = panel.values[best][t]; if (Number.isFinite(v)) out.push(v); else { out.push(0); missing += 1; } }
86
+ return [g, out];
87
+ });
88
+ return { chosen: best, train_sharpe: bestSharpe, missing, pieces };
89
+ }
90
+
91
+ // The kept pieces, whole: for path j, block g comes from the j-th split that tested g.
92
+ export function assemble(results, groups) {
93
+ const seenByGroup = Array.from({ length: groups }, () => []);
94
+ for (const r of results.slice().sort((x, y) => x.s - y.s)) for (const [g, piece] of r.pieces) seenByGroup[g].push(piece);
95
+ const paths = seenByGroup[0].length;
96
+ for (const list of seenByGroup) if (list.length !== paths) throw new RangeError("the splits do not cover every block the same number of times");
97
+ return Array.from({ length: paths }, (_, j) => seenByGroup.flatMap((list) => list[j]));
98
+ }
99
+
100
+ function summarize(results, meta) {
101
+ const scale = Math.sqrt(meta.periods_per_year), sorted = (a) => a.slice().sort((x, y) => x - y), median = (a) => { const s = sorted(a), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
102
+ const pathReturns = assemble(results, meta.groups);
103
+ const path_sharpes = pathReturns.map((p) => sharpeOf(p, scale)).filter((x) => x !== null);
104
+ const train = results.map((r) => r.train_sharpe);
105
+ const counts = new Map();
106
+ for (const r of results) counts.set(r.chosen, (counts.get(r.chosen) ?? 0) + 1);
107
+ const chosen = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([i, n]) => ({ trial: meta.trials[i], splits: n }));
108
+ const lost = path_sharpes.filter((x) => x <= 0).length;
109
+ const out = {
110
+ first_date: meta.first_date, last_date: meta.last_date, trials: meta.trials.length,
111
+ groups: meta.groups, test_groups: meta.test_groups, purge_days: meta.purge_days, embargo_days: meta.embargo_days,
112
+ splits: results.length, paths: path_sharpes.length, path_sharpes,
113
+ out_of_sample_sharpe: { median: median(path_sharpes), lowest: Math.min(...path_sharpes), highest: Math.max(...path_sharpes), share_of_paths_at_or_below_zero: lost / path_sharpes.length },
114
+ in_sample_sharpe_median: median(train),
115
+ most_chosen: chosen, distinct_trials_chosen: counts.size,
116
+ missing_test_days: results.reduce((n, r) => n + r.missing, 0),
117
+ };
118
+ out.plain_reading = `The best trial chosen on training days showed a median Sharpe of ${out.in_sample_sharpe_median.toFixed(2)} there. On the days held out it showed ${out.out_of_sample_sharpe.median.toFixed(2)}, from ${out.out_of_sample_sharpe.lowest.toFixed(2)} to ${out.out_of_sample_sharpe.highest.toFixed(2)} across ${out.paths} paths, and ${lost} of the paths lost money. ${counts.size} of ${meta.trials.length} trials were chosen at least once.`;
119
+ out.limits = CPCV_LIMITS_TEXT;
120
+ return out;
121
+ }
122
+
123
+ export function loadTrials(file) {
124
+ const p = readPanel(file, "returns_file");
125
+ if (p.tickers.length < 2) throw new RangeError("returns_file needs at least two trials: with one there is nothing to choose between");
126
+ if (p.dates.length * p.tickers.length > MAX_CELLS) throw new RangeError(`returns_file has more than ${MAX_CELLS} values; cut it down`);
127
+ return { dates: p.dates, trials: p.tickers, values: p.tickers.map((t) => Float64Array.from(p.values[t], (v) => (v === null ? NaN : v))) };
128
+ }
129
+
130
+ export function cpcv(args) {
131
+ const a = cpcvInput.parse(args);
132
+ if (a.merge_files) {
133
+ const shards = a.merge_files.map((f) => JSON.parse(readFileSync(f, "utf8")));
134
+ const id = shards[0].cpcv_id;
135
+ for (const [i, s] of shards.entries()) if (s.schema !== "canli.cpcv-shard.v1" || s.cpcv_id !== id) throw new RangeError(`${a.merge_files[i]} is not a shard of the same run (file or settings differ)`);
136
+ const results = shards.flatMap((s) => s.results), seen = new Set();
137
+ for (const r of results) { if (seen.has(r.s)) throw new RangeError(`split ${r.s} appears in two shards`); seen.add(r.s); }
138
+ const wanted = shards[0].meta.total_splits, missing = Array.from({ length: wanted }, (_, s) => s).filter((s) => !seen.has(s));
139
+ if (missing.length) throw new RangeError(`shards are missing splits ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? "..." : ""}`);
140
+ return { cpcv_id: id, merged_from: shards.length, ...summarize(results, shards[0].meta) };
141
+ }
142
+ if (!a.returns_file) throw new RangeError("cpcv needs returns_file (or merge_files).");
143
+ const groups = a.groups ?? 10, k = a.test_groups ?? 2;
144
+ if (k >= groups) throw new RangeError("test_groups must be fewer than groups: something has to be left to train on");
145
+ const total = choose(groups, k);
146
+ if (total > MAX_SPLITS) throw new RangeError(`${groups} groups taken ${k} at a time is ${total} splits; the most is ${MAX_SPLITS}`);
147
+ const panel = loadTrials(a.returns_file);
148
+ if (panel.dates.length < groups * 5) throw new RangeError(`${panel.dates.length} days is too short for ${groups} groups`);
149
+ const meta = { groups, test_groups: k, purge_days: a.purge_days ?? 5, embargo_days: a.embargo_days ?? 5, periods_per_year: a.periods_per_year ?? 252, trials: panel.trials, first_date: panel.dates[0], last_date: panel.dates.at(-1), total_splits: total };
150
+ const blocks = blocksOf(panel.dates.length, groups), splits = splitsOf(groups, k), scale = Math.sqrt(meta.periods_per_year);
151
+ const results = [];
152
+ splits.forEach((test, s) => {
153
+ if (a.shard && s % a.shard.of !== a.shard.index) return;
154
+ results.push({ s, ...runSplit(panel, blocks, test, { purge: meta.purge_days, embargo: meta.embargo_days, scale }) });
155
+ });
156
+ if (a.shard) {
157
+ if (a.shard.index >= a.shard.of) throw new RangeError("shard index must be below shard of");
158
+ const cpcv_id = createHash("sha256").update(readFileSync(a.returns_file)).update(JSON.stringify(meta)).digest("hex").slice(0, 24);
159
+ const shard = { schema: "canli.cpcv-shard.v1", cpcv_id, shard: a.shard, meta, results };
160
+ if (a.out_file) writeFileSync(a.out_file, JSON.stringify(shard));
161
+ return { cpcv_id, shard: a.shard, ran: results.length, of: total, ...(a.out_file ? { shard_file: a.out_file } : { shard_data: shard }), next: "Run the other shards with the same file and settings, then call cpcv with merge_files." };
162
+ }
163
+ return summarize(results, meta);
164
+ }
package/src/engine.mjs CHANGED
@@ -15,9 +15,65 @@
15
15
  export const ENGINE_LIMITS_TEXT = Object.freeze([
16
16
  "Prices are the caller's: survivorship, splits, dividends and data errors in them pass straight into the result.",
17
17
  "A backtest measures one path of history under these costs; it does not establish that the signal will keep working.",
18
- "Costs are a flat rate per unit traded; market impact, borrow fees for shorts and financing are not charged unless included in cost_bps.",
18
+ "Costs are a flat rate per unit traded; market impact is charged only when capital and dollar volume are given, and borrow fees for shorts and financing are not charged unless included in cost_bps.",
19
19
  ]);
20
20
 
21
+ // With capital and dollar volume, size meets the market. Average daily dollar volume and daily
22
+ // volatility are read from the days before each trade, never the trade day. An order fills only up
23
+ // to max participation of that volume (the rest stays unfilled and the old position stays open), and
24
+ // pays square-root impact, the same law check_feasibility uses: Y x daily volatility x sqrt(participation)
25
+ // (Toth et al. 2011). A name with too little volume history cannot trade at all: nothing is assumed.
26
+ const LIQUIDITY_DAYS = 20, MIN_LIQUIDITY_DAYS = 10;
27
+ export const FILL_RATIO_TRADABLE = 0.95;
28
+
29
+ function liquidityAt(prices, volumes, t) {
30
+ let dollars = 0, days = 0;
31
+ const rets = [];
32
+ for (let i = Math.max(1, t - LIQUIDITY_DAYS); i < t; i += 1) {
33
+ if (finite(volumes[i]) && volumes[i] > 0) { dollars += volumes[i]; days += 1; }
34
+ const a = prices[i - 1], b = prices[i];
35
+ if (finite(a) && finite(b) && a > 0) rets.push(b / a - 1);
36
+ }
37
+ if (days < MIN_LIQUIDITY_DAYS || rets.length < MIN_LIQUIDITY_DAYS) return null;
38
+ const mean = rets.reduce((x, y) => x + y, 0) / rets.length;
39
+ return { adv: dollars / days, vol: Math.sqrt(rets.reduce((x, y) => x + (y - mean) ** 2, 0) / (rets.length - 1)) };
40
+ }
41
+
42
+ // Whole stretches of market history a strategy can be held against. Dates are peak to trough or the
43
+ // episode's usual bounds; the first eight are the ones stress_worlds drops into its worlds.
44
+ export const CRISES = Object.freeze({
45
+ dotcom_bust: { from: "2000-03-24", to: "2002-10-09", what: "the 2000-02 dot-com bust" },
46
+ bank_crisis: { from: "2008-09-02", to: "2009-03-09", what: "the 2008-09 bank crisis" },
47
+ debt_ceiling_2011: { from: "2011-04-29", to: "2011-10-03", what: "the 2011 US downgrade and euro debt scare" },
48
+ taper_tantrum: { from: "2013-05-22", to: "2013-09-05", what: "the 2013 taper tantrum in bonds" },
49
+ oil_collapse: { from: "2014-06-20", to: "2016-02-11", what: "the 2014-16 oil collapse" },
50
+ dollar_surge: { from: "2014-07-01", to: "2015-03-13", what: "the 2014-15 surge in the dollar" },
51
+ q4_2018: { from: "2018-09-20", to: "2018-12-24", what: "the late 2018 sell-off" },
52
+ pandemic_crash: { from: "2020-02-19", to: "2020-03-23", what: "the February-March 2020 crash" },
53
+ inflation_shock: { from: "2022-01-03", to: "2022-06-16", what: "the 2022 inflation and rate shock" },
54
+ compute_bust: { from: "2022-01-03", to: "2022-10-14", what: "the 2022 fall in chip and compute stocks" },
55
+ regional_bank_run: { from: "2023-03-01", to: "2023-03-24", what: "the March 2023 regional bank run" },
56
+ });
57
+ export const CRISIS_DRAWDOWN_LIMIT = 0.5;
58
+
59
+ // The strategy's own returns inside every crisis its dates fully cover. A crisis the run does not
60
+ // fully cover is listed as untested: not having been there is not the same as having survived it.
61
+ export function crisisReplay({ dates, returns, limit = CRISIS_DRAWDOWN_LIMIT }) {
62
+ if (!(limit > 0 && limit <= 1)) throw new RangeError("the crisis drawdown limit must be above 0 and at most 1");
63
+ const tested = [], untested = [];
64
+ for (const [id, c] of Object.entries(CRISES)) {
65
+ if (!(dates[0] <= c.from && dates.at(-1) >= c.to)) { untested.push({ id, what: c.what, from: c.from, to: c.to }); continue; }
66
+ let wealth = 1, peak = 1, drawdown = 0, days = 0, worst = 0;
67
+ for (let i = 0; i < dates.length; i += 1) {
68
+ if (dates[i] < c.from || dates[i] > c.to) continue;
69
+ wealth *= 1 + returns[i]; peak = Math.max(peak, wealth); drawdown = Math.max(drawdown, 1 - wealth / peak); worst = Math.min(worst, returns[i]); days += 1;
70
+ }
71
+ tested.push({ id, what: c.what, from: c.from, to: c.to, days, return: wealth - 1, drawdown, worst_day: worst, ruptured: drawdown > limit });
72
+ }
73
+ const verdict = !tested.length ? "untested" : tested.some((c) => c.ruptured) ? "ruptured" : "survived_tested";
74
+ return { verdict, drawdown_limit: limit, tested, untested };
75
+ }
76
+
21
77
  const REBALANCE = Object.freeze({ weekly: "week", monthly: "month", quarterly: "quarter" });
22
78
  const periodKey = (date, every) => {
23
79
  const [y, m] = date.split("-").map(Number);
@@ -70,12 +126,20 @@ function lastRowOnOrBefore(signalDates, bound) {
70
126
  return found;
71
127
  }
72
128
 
73
- export function runBacktest({ dates, tickers, prices, signalDates, signals, rebalance = "monthly", lagDays = 1, side = "long_short", quantile = 0.2, costBps = 10, minNames = 5, periodsPerYear = 252 }) {
129
+ export function runBacktest({ dates, tickers, prices, signalDates, signals, rebalance = "monthly", lagDays = 1, side = "long_short", quantile = 0.2, costBps = 10, minNames = 5, periodsPerYear = 252, capital = null, dollarVolumes = null, maxParticipation = 0.1, impactCoefficient = 1 }) {
74
130
  checkInputs({ dates, tickers, prices, signalDates, signals });
75
131
  if (!Number.isInteger(lagDays) || lagDays < 1) throw new RangeError("lag_days must be a whole number of at least 1: a signal cannot trade on the day it becomes known");
76
132
  if (!(quantile > 0 && quantile <= 0.5)) throw new RangeError("quantile must be in (0, 0.5]");
77
133
  if (!["long_short", "long_only"].includes(side)) throw new RangeError("side must be long_short or long_only");
78
134
  if (!(costBps >= 0)) throw new RangeError("cost_bps cannot be negative");
135
+ const sized = capital !== null;
136
+ if (sized) {
137
+ if (!(capital > 0)) throw new RangeError("capital must be above zero");
138
+ if (!dollarVolumes) throw new RangeError("capital needs dollar volume: without it nothing says how much the market can take");
139
+ if (!(maxParticipation > 0 && maxParticipation <= 1)) throw new RangeError("max participation must be above 0 and at most 1");
140
+ if (!(impactCoefficient >= 0)) throw new RangeError("the impact coefficient cannot be negative");
141
+ }
142
+ const liq = { impact: 0, orders: 0, clipped: 0, blocked: 0, wanted: 0, filled: 0, maxFilled: 0, worst: null };
79
143
 
80
144
  const n = dates.length;
81
145
  const rebal = new Set(rebalanceIndices(dates, rebalance, lagDays));
@@ -116,9 +180,26 @@ export function runBacktest({ dates, tickers, prices, signalDates, signals, reba
116
180
  const long = ranked.slice(0, k), short = side === "long_short" ? ranked.slice(-k) : [];
117
181
  for (const tk of long) target.set(tk, nav / long.length);
118
182
  for (const tk of short) target.set(tk, (target.get(tk) ?? 0) - nav / short.length);
119
- let traded = 0;
120
- for (const tk of new Set([...pos.keys(), ...target.keys()])) traded += Math.abs((target.get(tk) ?? 0) - (pos.get(tk) ?? 0));
121
- const cost = traded * costBps / 1e4;
183
+ let traded = 0, impact = 0;
184
+ for (const tk of new Set([...pos.keys(), ...target.keys()])) {
185
+ const held = pos.get(tk) ?? 0, want = (target.get(tk) ?? 0) - held;
186
+ if (!sized) { traded += Math.abs(want); continue; }
187
+ if (want === 0) continue;
188
+ const l = liquidityAt(prices[tk], dollarVolumes[tk] ?? [], t);
189
+ const wanted = Math.abs(want);
190
+ liq.orders += 1; liq.wanted += wanted;
191
+ if (!l) { liq.blocked += 1; target.set(tk, held); continue; }
192
+ const room = maxParticipation * l.adv / capital;
193
+ const wantedShare = wanted * capital / l.adv;
194
+ if (!liq.worst || wantedShare > liq.worst.wanted_participation) liq.worst = { ticker: tk, date: dates[t], wanted_participation: wantedShare, average_daily_dollars: l.adv };
195
+ const fill = Math.min(wanted, room);
196
+ if (fill < wanted) { liq.clipped += 1; target.set(tk, held + Math.sign(want) * fill); }
197
+ const share = fill * capital / l.adv;
198
+ impact += fill * impactCoefficient * l.vol * Math.sqrt(share);
199
+ traded += fill; liq.filled += fill; liq.maxFilled = Math.max(liq.maxFilled, share);
200
+ }
201
+ liq.impact += impact;
202
+ const cost = traded * costBps / 1e4 + impact;
122
203
  nav -= cost;
123
204
  const factor = 1 - cost / (nav + cost);
124
205
  if (started && out.returns.length) {
@@ -133,7 +214,14 @@ export function runBacktest({ dates, tickers, prices, signalDates, signals, reba
133
214
  started = true;
134
215
  }
135
216
  if (!rebalances) throw new RangeError("no rebalance had enough names with both a known signal and a price; check the dates overlap and min_names");
136
- return { ...out, used, nav, rebalances, turnoverSum, costSum, missingDays, longNames, shortNames, periodsPerYear };
217
+ const fillRatio = liq.wanted > 0 ? liq.filled / liq.wanted : 1;
218
+ const liquidity = sized ? {
219
+ capital_usd: capital, max_participation: maxParticipation, impact_coefficient: impactCoefficient,
220
+ impact_paid: liq.impact, orders: liq.orders, clipped_orders: liq.clipped, blocked_orders: liq.blocked,
221
+ fill_ratio: fillRatio, filled_participation_max: liq.maxFilled, worst: liq.worst,
222
+ verdict: fillRatio >= FILL_RATIO_TRADABLE ? "tradable" : "too_big",
223
+ } : undefined;
224
+ return { ...out, used, nav, rebalances, turnoverSum, costSum, missingDays, longNames, shortNames, periodsPerYear, ...(sized ? { liquidity } : {}) };
137
225
  }
138
226
 
139
227
  // Plain statistics of a return series, in the same Sharpe convention as the validators (sample sd).
package/src/league.mjs ADDED
@@ -0,0 +1,243 @@
1
+ // The league: anyone's model, scored on what happens after it committed. An entrant commits the hash
2
+ // of a CSV of scores before a round closes, the hash goes in a proof ledger, and the file is shown
3
+ // only after the round. Scoring is open: the same files and prices give the same table on any
4
+ // machine. An entry's rank correlation with what followed is its score, the table sets each
5
+ // entrant's record against the best that luck gives across that many entrants, and the top entrants
6
+ // are combined into a paper book, so the league shows what a pool of them would have done.
7
+ //
8
+ // Paper only. There are no prizes, no stakes and nobody's money in it.
9
+ import { createHash, createPublicKey, sign, verify } from "node:crypto";
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { z } from "zod";
13
+
14
+ import { readPanel } from "./panel-io.mjs";
15
+ import { appendRecords, loadKey, readLines, verifyLedger } from "./proof.mjs";
16
+
17
+ export const LEAGUE_LIMITS_TEXT = Object.freeze([
18
+ "Paper only: no prizes, no stakes, and nobody's money is in the league or its paper book.",
19
+ "A commit's time is the clock of the machine that wrote the ledger. For a public round, keep the ledger in a public repository or service so the time can be checked from outside.",
20
+ "A place in the table is a record over these rounds, not a forecast. With few rounds, luck and skill cannot be told apart, which the deflated figure tries to show.",
21
+ "One person can enter under many names and nothing here stops it. Every name counts toward the luck bar, so that raises the bar for everyone including them.",
22
+ ]);
23
+
24
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
25
+ const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
26
+ const dir = z.string().min(1).max(1000);
27
+
28
+ export const leagueNewInput = z.object({
29
+ league_dir: dir.describe("A folder for the league's rules, ledger and entries."),
30
+ name: z.string().min(1).max(120),
31
+ first_close: date.describe("When entries for the first round close; scoring starts the next trading day."),
32
+ rounds: z.number().int().min(1).max(520).describe("Number of rounds."),
33
+ days_per_round: z.number().int().min(1).max(90).optional().describe("Calendar days from one close to the next, and each round's scoring span. Default 7."),
34
+ min_names: z.number().int().min(5).max(5000).optional().describe("Fewest scored names an entry needs. Default 20."),
35
+ }).strict();
36
+ export function leagueNew(args) {
37
+ const a = leagueNewInput.parse(args);
38
+ const file = join(a.league_dir, "league.json");
39
+ const step = a.days_per_round ?? 7, day = (d, n) => { const x = new Date(`${d}T00:00:00Z`); x.setUTCDate(x.getUTCDate() + n); return x.toISOString().slice(0, 10); };
40
+ const rounds = Array.from({ length: a.rounds }, (_, i) => { const closes = day(a.first_close, i * step); return { id: `r${String(i + 1).padStart(3, "0")}`, closes, ends: day(closes, step) }; });
41
+ mkdirSync(a.league_dir, { recursive: true });
42
+ const rules = { schema: "canli.league.v1", name: a.name, min_names: a.min_names ?? 20, rounds };
43
+ try { writeFileSync(file, `${JSON.stringify(rules, null, 1)}\n`, { flag: "wx" }); } catch (err) {
44
+ if (err.code === "EEXIST") throw new RangeError(`${file} already exists; a league's rules do not change after it starts`);
45
+ throw err;
46
+ }
47
+ return { league_dir: a.league_dir, rules_sha256: sha256(readFileSync(file)), rounds: rounds.length, first_round: rounds[0], last_round: rounds.at(-1), plain_reading: `${a.name}: ${rounds.length} rounds. Publish the rules' hash before the first close, so the schedule cannot quietly change.`, limits: LEAGUE_LIMITS_TEXT };
48
+ }
49
+
50
+ const readRules = (leagueDir) => {
51
+ const file = join(leagueDir, "league.json");
52
+ if (!existsSync(file)) throw new RangeError(`${leagueDir} is not a league: no league.json`);
53
+ return JSON.parse(readFileSync(file, "utf8"));
54
+ };
55
+ // An entry: rows of ticker,score. Every score a finite number, no ticker twice.
56
+ export function parseEntry(text, minNames = 20) {
57
+ const lines = text.replace(/^/, "").split(/\r?\n/).filter((l) => l.trim());
58
+ const scores = new Map();
59
+ for (const [i, line] of lines.entries()) {
60
+ const [t, s] = line.split(",").map((x) => x.trim());
61
+ if (i === 0 && /^ticker$/i.test(t)) continue;
62
+ if (!t || s === undefined || s === "" || !Number.isFinite(Number(s))) throw new RangeError(`entry line ${i + 1}: expected ticker,score with a number`);
63
+ if (scores.has(t)) throw new RangeError(`entry line ${i + 1}: ${t} appears twice`);
64
+ scores.set(t, Number(s));
65
+ }
66
+ if (scores.size < minNames) throw new RangeError(`an entry needs at least ${minNames} names; this has ${scores.size}`);
67
+ return scores;
68
+ }
69
+
70
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{1,39}$/;
71
+ const entrantName = z.string().regex(NAME, "2 to 40 letters, digits, _ or -");
72
+ export const leagueCommitInput = z.object({
73
+ league_dir: dir,
74
+ round: z.string().regex(/^r\d{3}$/),
75
+ entrant: entrantName,
76
+ file: dir.describe("The entry: a CSV of ticker,score, higher meaning expected to do better. Only its hash is recorded now."),
77
+ entrant_key_file: dir.describe("The entrant's own signing key (PEM), created if new. A name belongs to the key that first committed under it, so nobody else can replace an entrant's entry."),
78
+ key_file: dir.optional().describe("The league's signing key for its ledger (PEM); created if new."),
79
+ }).strict();
80
+ // What an entrant signs: which round, which name, which file, so a signature cannot be replayed elsewhere.
81
+ const commitMessage = (round, entrant, digest) => Buffer.from(`canli.league.commit.v1|${round}|${entrant}|${digest}`);
82
+ function commitIsSigned(c) {
83
+ try { return verify(null, commitMessage(c.round, c.entrant, c.sha256), createPublicKey(c.entrant_public_key), Buffer.from(c.entrant_signature, "base64")); } catch { return false; }
84
+ }
85
+ export function leagueCommit(args, now = () => new Date()) {
86
+ const a = leagueCommitInput.parse(args);
87
+ const rules = readRules(a.league_dir), round = rules.rounds.find((r) => r.id === a.round);
88
+ if (!round) throw new RangeError(`no round ${a.round} in this league`);
89
+ const today = now().toISOString().slice(0, 10);
90
+ if (today >= round.closes) throw new RangeError(`round ${a.round} closed on ${round.closes}: an entry must be committed before the day it closes`);
91
+ const bytes = readFileSync(a.file);
92
+ parseEntry(bytes.toString("utf8"), rules.min_names);
93
+ const digest = sha256(bytes);
94
+ const ledger = join(a.league_dir, "ledger.jsonl"), { key, publicPem } = loadKey(a.entrant_key_file);
95
+ const holder = existsSync(ledger) ? readLines(ledger).find((e) => e.kind === "file" && e.body?.kind === "league_entry" && e.body.entrant === a.entrant) : null;
96
+ if (holder && holder.body.entrant_public_key !== publicPem) throw new RangeError(`the name ${a.entrant} belongs to the key that first committed under it; this is another key`);
97
+ const r = appendRecords(ledger, [{ kind: "file", body: { id: `league|${a.round}|${a.entrant}|${digest}`, sha256: digest, label: `league entry ${a.round} ${a.entrant}`, kind: "league_entry", round: a.round, entrant: a.entrant, entrant_public_key: publicPem, entrant_signature: sign(null, commitMessage(a.round, a.entrant, digest), key).toString("base64") } }], { keyFile: a.key_file, now });
98
+ return { round: a.round, entrant: a.entrant, sha256: digest, ...r, plain_reading: `Committed ${a.entrant}'s entry for ${a.round}. Keep the file unchanged and reveal it after ${round.ends}: put it at ${join(a.league_dir, a.round, `${a.entrant}.csv`)}.`, limits: LEAGUE_LIMITS_TEXT };
99
+ }
100
+
101
+ // Average ranks, ties shared, for Spearman's correlation.
102
+ export function ranks(values) {
103
+ const order = values.map((v, i) => [v, i]).sort((x, y) => x[0] - y[0]), out = new Array(values.length);
104
+ for (let i = 0; i < order.length;) {
105
+ let j = i;
106
+ while (j + 1 < order.length && order[j + 1][0] === order[i][0]) j += 1;
107
+ for (let k = i; k <= j; k += 1) out[order[k][1]] = (i + j) / 2;
108
+ i = j + 1;
109
+ }
110
+ return out;
111
+ }
112
+ export function pearson(x, y) {
113
+ const n = x.length, mx = x.reduce((a, b) => a + b, 0) / n, my = y.reduce((a, b) => a + b, 0) / n;
114
+ let sxy = 0, sxx = 0, syy = 0;
115
+ for (let i = 0; i < n; i += 1) { sxy += (x[i] - mx) * (y[i] - my); sxx += (x[i] - mx) ** 2; syy += (y[i] - my) ** 2; }
116
+ return sxx > 0 && syy > 0 ? sxy / Math.sqrt(sxx * syy) : null;
117
+ }
118
+ export const spearman = (x, y) => pearson(ranks(x), ranks(y));
119
+
120
+ // The return of each name from the last close on or before `from` to the last close on or before `to`.
121
+ function forwardReturns(prices, from, to) {
122
+ const last = (d) => { let k = -1; for (let i = 0; i < prices.dates.length && prices.dates[i] <= d; i += 1) k = i; return k; };
123
+ const a = last(from), b = last(to), out = new Map();
124
+ if (a < 0 || b <= a) return out;
125
+ for (const t of prices.tickers) { const p0 = prices.values[t][a], p1 = prices.values[t][b]; if (p0 > 0 && p1 > 0) out.set(t, p1 / p0 - 1); }
126
+ return out;
127
+ }
128
+ function scoreEntry(scores, returns, minNames) {
129
+ const names = [...scores.keys()].filter((t) => returns.has(t));
130
+ if (names.length < minNames) return null;
131
+ const s = names.map((t) => scores.get(t)), r = names.map((t) => returns.get(t));
132
+ const corr = spearman(s, r);
133
+ if (corr === null) return null;
134
+ const k = Math.floor(names.length / 5), sorted = names.map((_, i) => i).sort((p, q) => s[q] - s[p]);
135
+ const mean = (idx) => idx.reduce((n, i) => n + r[i], 0) / idx.length;
136
+ return { names: names.length, corr, top_minus_bottom: mean(sorted.slice(0, k)) - mean(sorted.slice(-k)) };
137
+ }
138
+
139
+ const GAMMA = 0.5772156649015329;
140
+ function normInv(p) {
141
+ // Acklam's rational approximation, enough for a table's luck bar.
142
+ const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239], b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572], c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783], d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416];
143
+ const tail = (q) => (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
144
+ if (p < 0.02425) return tail(Math.sqrt(-2 * Math.log(p)));
145
+ if (p > 1 - 0.02425) return -tail(Math.sqrt(-2 * Math.log(1 - p)));
146
+ const q = p - 0.5, r = q * q;
147
+ return ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
148
+ }
149
+ const normCdf = (x) => { const z = Math.abs(x) / Math.SQRT2, t = 1 / (1 + 0.5 * z), e = t * Math.exp(-z * z - 1.26551223 + t * (1.00002368 + t * (0.37409196 + t * (0.09678418 + t * (-0.18628806 + t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 + t * (-0.82215223 + t * 0.17087277))))))))); return x >= 0 ? 1 - e / 2 : e / 2; };
150
+ // The t-statistic the best of n entrants reaches by luck alone (expected maximum of n standard normals).
151
+ export const luckT = (n) => (n < 2 ? 0 : (1 - GAMMA) * normInv(1 - 1 / n) + GAMMA * normInv(1 - 1 / (n * Math.E)));
152
+
153
+ export const leagueScoreInput = z.object({
154
+ league_dir: dir,
155
+ prices_file: dir.describe("CSV: a date column and one column of closing prices per ticker. Everyone re-running the table needs the same file."),
156
+ book_size: z.number().int().min(1).max(100).optional().describe("How many top entrants the paper book combines. Default 5."),
157
+ }).strict();
158
+ export function leagueScore(args) {
159
+ const a = leagueScoreInput.parse(args);
160
+ const rules = readRules(a.league_dir), prices = readPanel(a.prices_file, "prices_file");
161
+ const ledger = join(a.league_dir, "ledger.jsonl"), lastDate = prices.dates.at(-1);
162
+ const results = [], problems = [], perRound = new Map();
163
+ const check = existsSync(ledger) ? verifyLedger(ledger) : null;
164
+ if (check && !check.valid) throw new RangeError(`the league's ledger does not verify: ${check.problems[0]}`);
165
+ const commitLines = existsSync(ledger) ? readLines(ledger).filter((e) => e.kind === "file" && e.body?.kind === "league_entry").map((e) => ({ ...e.body, at: e.at })) : [];
166
+ // A name belongs to the first validly signed commit's key.
167
+ const owners = new Map();
168
+ for (const c of commitLines) if (commitIsSigned(c) && !owners.has(c.entrant)) owners.set(c.entrant, c.entrant_public_key);
169
+ let open = 0;
170
+ for (const round of rules.rounds) {
171
+ if (round.ends > lastDate) { open += 1; continue; }
172
+ const returns = forwardReturns(prices, round.closes, round.ends);
173
+ // Only an entrant's latest commit before the close counts, so committing several files and
174
+ // revealing whichever did best is refused: a reveal must match that one.
175
+ const latest = new Map();
176
+ for (const c of commitLines) {
177
+ if (c.round !== round.id) continue;
178
+ if (!NAME.test(c.entrant ?? "")) { problems.push(`A commit for ${round.id} has an unusable entrant name, not scored.`); continue; }
179
+ if (!commitIsSigned(c) || owners.get(c.entrant) !== c.entrant_public_key) { problems.push(`${c.entrant} ${round.id}: a commit not signed by the key that holds this name, not scored.`); continue; }
180
+ if (String(c.at).slice(0, 10) >= round.closes) { problems.push(`${c.entrant} ${round.id}: committed on or after the close, not scored.`); continue; }
181
+ latest.set(c.entrant, c);
182
+ }
183
+ const entries = new Map();
184
+ for (const c of latest.values()) {
185
+ const file = join(a.league_dir, round.id, `${c.entrant}.csv`);
186
+ if (!existsSync(file)) { problems.push(`${c.entrant} ${round.id}: committed but not revealed, not scored.`); continue; }
187
+ const bytes = readFileSync(file);
188
+ if (sha256(bytes) !== c.sha256) { problems.push(`${c.entrant} ${round.id}: the revealed file is not the one committed, not scored.`); continue; }
189
+ entries.set(c.entrant, parseEntry(bytes.toString("utf8"), rules.min_names));
190
+ }
191
+ perRound.set(round.id, { round, returns, entries });
192
+ for (const [entrant, scores] of entries) {
193
+ const s = scoreEntry(scores, returns, rules.min_names);
194
+ if (s) results.push({ round: round.id, entrant, ...s }); else problems.push(`${entrant} ${round.id}: too few of its names have prices for the round, not scored.`);
195
+ }
196
+ }
197
+ const byEntrant = new Map();
198
+ for (const r of results) (byEntrant.get(r.entrant) ?? byEntrant.set(r.entrant, []).get(r.entrant)).push(r);
199
+ const bar = luckT(byEntrant.size);
200
+ const table = [...byEntrant].map(([entrant, list]) => {
201
+ const n = list.length, mean = list.reduce((x, r) => x + r.corr, 0) / n, sd = n > 1 ? Math.sqrt(list.reduce((x, r) => x + (r.corr - mean) ** 2, 0) / (n - 1)) : null;
202
+ const t = sd > 0 ? mean / (sd / Math.sqrt(n)) : null;
203
+ return { entrant, rounds: n, mean_score: mean, score_sd: sd, t_stat: t, chance_it_beats_luck: t === null ? null : normCdf(t - bar) };
204
+ }).sort((x, y) => (y.chance_it_beats_luck ?? -1) - (x.chance_it_beats_luck ?? -1) || y.mean_score - x.mean_score);
205
+ // How different each entrant is from the rest: its average correlation with the others' scores.
206
+ for (const row of table) {
207
+ const cs = [];
208
+ for (const { entries } of perRound.values()) {
209
+ const mine = entries.get(row.entrant);
210
+ if (!mine) continue;
211
+ for (const [other, theirs] of entries) {
212
+ if (other === row.entrant) continue;
213
+ const names = [...mine.keys()].filter((t) => theirs.has(t));
214
+ if (names.length >= 5) { const c = spearman(names.map((t) => mine.get(t)), names.map((t) => theirs.get(t))); if (c !== null) cs.push(c); }
215
+ }
216
+ }
217
+ row.average_correlation_with_others = cs.length ? cs.reduce((x, y) => x + y, 0) / cs.length : null;
218
+ }
219
+ // The paper book: in each round, the entrants ranked best on rounds before it only, their ranks averaged.
220
+ const size = a.book_size ?? 5, book = [];
221
+ for (const { round, returns, entries } of perRound.values()) {
222
+ const before = results.filter((r) => r.round < round.id), tally = new Map();
223
+ for (const r of before) { const t = tally.get(r.entrant) ?? { sum: 0, n: 0 }; t.sum += r.corr; t.n += 1; tally.set(r.entrant, t); }
224
+ const chosen = [...tally].filter(([e]) => entries.has(e)).sort((x, y) => y[1].sum / y[1].n - x[1].sum / x[1].n).slice(0, size).map(([e]) => e);
225
+ if (!chosen.length) continue;
226
+ const combined = new Map();
227
+ for (const e of chosen) {
228
+ const s = entries.get(e), names = [...s.keys()], rk = ranks(names.map((t) => s.get(t)));
229
+ names.forEach((t, i) => { const c = combined.get(t) ?? { sum: 0, n: 0 }; c.sum += rk[i] / (names.length - 1 || 1); c.n += 1; combined.set(t, c); });
230
+ }
231
+ const scores = new Map([...combined].map(([t, c]) => [t, c.sum / c.n]));
232
+ const s = scoreEntry(scores, returns, rules.min_names);
233
+ if (s) book.push({ round: round.id, entrants: chosen, ...s });
234
+ }
235
+ const bookMean = book.length ? book.reduce((x, r) => x + r.top_minus_bottom, 0) / book.length : null;
236
+ const inputs = { rules_sha256: sha256(readFileSync(join(a.league_dir, "league.json"))), prices_sha256: sha256(readFileSync(a.prices_file)), ledger_head: check?.head ?? null };
237
+ return {
238
+ name: rules.name, rounds_scored: perRound.size, rounds_still_open: open, entrants: table.length, luck_bar_t: bar, table,
239
+ paper_book: { rounds: book.length, average_round_return: bookMean, chosen_on_earlier_rounds_only: true, by_round: book.slice(-52) }, problems, inputs,
240
+ plain_reading: table.length ? `${table.length} ${table.length === 1 ? "entrant" : "entrants"} over ${perRound.size} rounds. With this many entrants the best reaches a t of about ${bar.toFixed(2)} by luck alone; ${table.filter((r) => (r.chance_it_beats_luck ?? 0) > 0.95).length} clear that with more than 95%.${book.length ? ` The paper book of the top ${size} made ${(100 * bookMean).toFixed(2)}% a round, top fifth minus bottom fifth, before costs.` : ""}` : "No entry has been scored yet.",
241
+ limits: LEAGUE_LIMITS_TEXT,
242
+ };
243
+ }
package/src/proof.mjs ADDED
@@ -0,0 +1,200 @@
1
+ // The proof ledger: an append-only file where each line carries the hash of the line before it and
2
+ // each batch ends in a seal signed with Ed25519. Into it go what companies filed (each figure with
3
+ // its filing, and a restatement line whenever a later filing changes one) and the hash of any file,
4
+ // a backtest's result or a league entry, so anyone can later check it existed unchanged.
5
+ //
6
+ // What this proves is narrow and worth stating: the lines have not been altered since they were
7
+ // sealed. It does not prove a figure is right or a strategy is good, and an absent record proves
8
+ // nothing about whatever is missing. Whoever holds the key could rewrite the whole chain, unless a
9
+ // head was copied somewhere else first, so heads should be published and passed back as anchors.
10
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { z } from "zod";
15
+
16
+ import { getJson, pad10, resolveEntity } from "canli-markets-mcp/src/sources.mjs";
17
+
18
+ export const SCHEMA = "canli.proof-ledger.v1";
19
+ export const PROOF_LIMITS_TEXT = Object.freeze([
20
+ "A ledger shows these lines have not changed since they were sealed. It does not show a figure is right or a strategy is any good.",
21
+ "No record means no record. It says nothing about the filing, file or strategy that is missing.",
22
+ "Whoever holds the signing key could rewrite the whole chain unless a head was copied elsewhere first: publish heads, and check them with anchors.",
23
+ ]);
24
+
25
+ export const canonical = (v) => (v === null || typeof v !== "object" ? JSON.stringify(v) : Array.isArray(v) ? `[${v.map(canonical).join(",")}]` : `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`);
26
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
27
+ export const entryHash = (e) => sha256(canonical({ seq: e.seq, at: e.at, kind: e.kind, body: e.body, prev: e.prev }));
28
+ const defaultKeyFile = () => join(process.env.CANLI_HOME || join(homedir(), ".canli"), "proof-key.pem");
29
+
30
+ export function loadKey(file = defaultKeyFile()) {
31
+ let pem;
32
+ try { pem = readFileSync(file, "utf8"); } catch (err) {
33
+ if (err.code !== "ENOENT") throw err;
34
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
35
+ pem = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" });
36
+ // wx: if another process made the key first, that one is the key, and this one is dropped.
37
+ try { writeFileSync(file, pem, { flag: "wx", mode: 0o600 }); } catch (e) { if (e.code !== "EEXIST") throw e; pem = readFileSync(file, "utf8"); }
38
+ }
39
+ const key = createPrivateKey(pem);
40
+ return { key, publicPem: createPublicKey(key).export({ type: "spki", format: "pem" }) };
41
+ }
42
+
43
+ export function readLines(file) {
44
+ if (!existsSync(file)) return [];
45
+ return readFileSync(file, "utf8").split("\n").filter((l) => l.trim()).map((l, i) => {
46
+ try { return JSON.parse(l); } catch { throw new RangeError(`line ${i + 1} of the ledger is not JSON`); }
47
+ });
48
+ }
49
+
50
+ // Add records to the ledger and seal them. Records already there (by their id) are left out.
51
+ export function appendRecords(file, records, { keyFile, now = () => new Date() } = {}) {
52
+ const { key, publicPem } = loadKey(keyFile);
53
+ const lines = readLines(file), at = now().toISOString();
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ let head = lines.at(-1) ?? null;
56
+ const add = (kind, body) => {
57
+ const e = { seq: head ? head.seq + 1 : 0, at, kind, body, prev: head ? head.hash : null };
58
+ e.hash = entryHash(e);
59
+ head = e;
60
+ return e;
61
+ };
62
+ const out = [];
63
+ if (!lines.length) out.push(add("header", { schema: SCHEMA, public_key: publicPem }));
64
+ else if (lines[0].body?.public_key !== publicPem) throw new RangeError("this key did not start this ledger; use the key it was started with");
65
+ const known = new Set(lines.filter((l) => l.body?.id).map((l) => l.body.id));
66
+ let added = 0;
67
+ for (const r of records) {
68
+ if (r.body.id && known.has(r.body.id)) continue;
69
+ if (r.body.id) known.add(r.body.id);
70
+ out.push(add(r.kind, r.body));
71
+ added += 1;
72
+ }
73
+ if (added) {
74
+ const seal = add("seal", { over: head.hash });
75
+ seal.signature = sign(null, Buffer.from(seal.hash, "hex"), key).toString("base64");
76
+ out.push(seal);
77
+ }
78
+ if (out.length) appendFileSync(file, `${out.map((e) => JSON.stringify(e)).join("\n")}\n`);
79
+ return { added, skipped: records.length - added, head: head ? { seq: head.seq, hash: head.hash } : null };
80
+ }
81
+
82
+ // Recompute the chain from the file alone. Stops at the first line that does not hold.
83
+ export function verifyLedger(file, anchors = []) {
84
+ const lines = readLines(file), problems = [];
85
+ if (!lines.length) return { valid: false, entries: 0, problems: ["The ledger is empty or missing."] };
86
+ const header = lines[0];
87
+ if (header.kind !== "header" || header.body?.schema !== SCHEMA) problems.push("The first line is not this ledger's header.");
88
+ let publicKey = null;
89
+ try { publicKey = createPublicKey(header.body.public_key); } catch { problems.push("The header's public key cannot be read."); }
90
+ let prev = null, seals = 0, sealedTo = -1;
91
+ for (const [i, e] of lines.entries()) {
92
+ if (e.seq !== i) { problems.push(`Line ${i + 1} has sequence ${e.seq}: a line was added, removed or moved.`); break; }
93
+ if (e.prev !== prev) { problems.push(`Line ${i + 1} does not follow the one before it.`); break; }
94
+ if (entryHash(e) !== e.hash) { problems.push(`Line ${i + 1} was changed: its hash does not match its content.`); break; }
95
+ if (e.kind === "seal") {
96
+ if (!publicKey || !e.signature || !verify(null, Buffer.from(e.hash, "hex"), publicKey, Buffer.from(e.signature, "base64"))) { problems.push(`Line ${i + 1} is a seal whose signature does not verify.`); break; }
97
+ seals += 1; sealedTo = i;
98
+ }
99
+ prev = e.hash;
100
+ }
101
+ for (const a of anchors) if (lines[a.seq]?.hash !== a.hash) problems.push(`The published head at line ${a.seq + 1} does not match this file, so history was rewritten or cut.`);
102
+ const tail = lines.length - 1 - sealedTo;
103
+ if (!problems.length && tail > 0) problems.push(`${tail} ${tail === 1 ? "line comes" : "lines come"} after the last seal and ${tail === 1 ? "is" : "are"} unsigned.`);
104
+ const head = lines.at(-1);
105
+ return {
106
+ valid: problems.length === 0, entries: lines.length, seals, anchors_checked: anchors.length,
107
+ head: { seq: head.seq, hash: head.hash, at: head.at }, key_fingerprint: publicKey ? sha256(publicKey.export({ type: "spki", format: "der" })).slice(0, 16) : null,
108
+ problems, limits: PROOF_LIMITS_TEXT,
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------------------------
113
+ // What companies filed, and what later filings changed
114
+ // ---------------------------------------------------------------------------------------------
115
+ const CONCEPTS = Object.freeze(["RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues", "SalesRevenueNet", "NetIncomeLoss", "OperatingIncomeLoss", "GrossProfit", "Assets", "Liabilities", "StockholdersEquity", "CashAndCashEquivalentsAtCarryingValue", "NetCashProvidedByUsedInOperatingActivities", "EarningsPerShareDiluted"]);
116
+
117
+ // Rows from a concept's facts: one record per value per filing, and a restatement where a period's value changes.
118
+ export function filingRecords(cik, name, concept, unit, rows) {
119
+ const byPeriod = new Map();
120
+ for (const r of rows) if (r.accn && Number.isFinite(r.val)) { const p = `${r.start ?? ""}|${r.end}`; (byPeriod.get(p) ?? byPeriod.set(p, []).get(p)).push(r); }
121
+ const out = [];
122
+ for (const list of byPeriod.values()) {
123
+ list.sort((a, b) => String(a.filed).localeCompare(String(b.filed)) || String(a.accn).localeCompare(String(b.accn)));
124
+ let last = null;
125
+ for (const r of list) {
126
+ if (last === null || r.val !== last.val) {
127
+ const period = { ...(r.start ? { start: r.start } : {}), end: r.end };
128
+ const base = { cik, company: name, concept, unit, ...period };
129
+ out.push({ kind: "figure", body: { id: `figure|${cik}|${concept}|${unit}|${r.start ?? ""}|${r.end}|${r.val}|${r.accn}`, ...base, value: r.val, form: r.form, accession: r.accn, filed: r.filed } });
130
+ if (last !== null) out.push({ kind: "restatement", body: { id: `restatement|${cik}|${concept}|${unit}|${r.start ?? ""}|${r.end}|${last.accn}|${r.accn}`, ...base, from: { value: last.val, accession: last.accn, filed: last.filed }, to: { value: r.val, accession: r.accn, filed: r.filed }, change: last.val !== 0 ? (r.val - last.val) / Math.abs(last.val) : null } });
131
+ last = r;
132
+ }
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ export const proofFilingsInput = z.object({
139
+ ledger_file: z.string().min(1).max(1000).describe("The ledger file; started if new."),
140
+ companies: z.array(z.string().min(1).max(120)).min(1).max(600).describe("Tickers, CIKs or names."),
141
+ key_file: z.string().min(1).max(1000).optional().describe("Signing key (PEM); created if new. Default ~/.canli/proof-key.pem."),
142
+ }).strict();
143
+ export async function proofFilings(session, args) {
144
+ const a = proofFilingsInput.parse(args);
145
+ const records = [], read = [], failed = [];
146
+ for (const c of a.companies) {
147
+ try {
148
+ const e = await resolveEntity(session, c);
149
+ const facts = await getJson(session, `https://data.sec.gov/api/xbrl/companyfacts/CIK${pad10(e.cik)}.json`, { maxBytes: 96 * 1024 * 1024 });
150
+ for (const concept of CONCEPTS) for (const [unit, rows] of Object.entries(facts?.facts?.["us-gaap"]?.[concept]?.units ?? {})) records.push(...filingRecords(e.cik, e.name ?? facts.entityName ?? c, concept, unit, rows));
151
+ read.push(c);
152
+ } catch (err) { failed.push({ company: c, why: String(err.message).slice(0, 200) }); }
153
+ }
154
+ const r = appendRecords(a.ledger_file, records, { keyFile: a.key_file });
155
+ const restatements = records.filter((x) => x.kind === "restatement").length;
156
+ return { ledger_file: a.ledger_file, companies_read: read.length, ...(failed.length ? { failed } : {}), figures_and_restatements_read: records.length, restatements_seen: restatements, ...r, plain_reading: `Read ${read.length} ${read.length === 1 ? "company" : "companies"}: ${r.added} new lines sealed, ${r.skipped} already in the ledger. ${restatements} changes between filings seen. A change can be a correction, a reclassification or a new way of reporting; the lines do not say which.`, limits: PROOF_LIMITS_TEXT };
157
+ }
158
+
159
+ export const proofFileInput = z.object({
160
+ ledger_file: z.string().min(1).max(1000).describe("The ledger file; started if new."),
161
+ file: z.string().min(1).max(1000).describe("The file to record: a backtest's result, a returns file, a league entry. Only its hash goes in the ledger."),
162
+ label: z.string().min(1).max(200).describe("What this file is."),
163
+ kind: z.enum(["backtest", "returns", "league_entry", "other"]).optional().describe("Default other."),
164
+ key_file: z.string().min(1).max(1000).optional().describe("Signing key (PEM); created if new."),
165
+ }).strict();
166
+ export function proofFile(args) {
167
+ const a = proofFileInput.parse(args);
168
+ const digest = sha256(readFileSync(a.file));
169
+ const r = appendRecords(a.ledger_file, [{ kind: "file", body: { id: `file|${digest}`, sha256: digest, label: a.label, kind: a.kind ?? "other" } }], { keyFile: a.key_file });
170
+ return { ledger_file: a.ledger_file, sha256: digest, ...r, plain_reading: r.added ? `Recorded the hash of ${a.file}: this exact file existed when this line was sealed. Nothing else about it is claimed.` : "This exact file is already in the ledger.", limits: PROOF_LIMITS_TEXT };
171
+ }
172
+
173
+ export const proofVerifyInput = z.object({
174
+ ledger_file: z.string().min(1).max(1000),
175
+ anchors: z.array(z.object({ seq: z.number().int().min(0), hash: z.string().regex(/^[0-9a-f]{64}$/) }).strict()).max(1000).optional().describe("Heads published elsewhere: each must still be in the file."),
176
+ }).strict();
177
+
178
+ export const proofFindInput = z.object({
179
+ ledger_file: z.string().min(1).max(1000),
180
+ sha256: z.string().regex(/^[0-9a-f]{64}$/).optional().describe("A file's hash."),
181
+ file: z.string().min(1).max(1000).optional().describe("Or the file itself, hashed here."),
182
+ company: z.string().min(1).max(120).optional().describe("Company name or CIK as recorded."),
183
+ concept: z.string().min(1).max(160).optional(),
184
+ kind: z.enum(["figure", "restatement", "file"]).optional(),
185
+ limit: z.number().int().min(1).max(500).optional().describe("Default 50."),
186
+ }).strict();
187
+ export function proofFind(args) {
188
+ const a = proofFindInput.parse(args);
189
+ const want = a.file ? sha256(readFileSync(a.file)) : a.sha256;
190
+ const who = a.company?.toLowerCase();
191
+ const hits = readLines(a.ledger_file).filter((e) => {
192
+ if (!["figure", "restatement", "file"].includes(e.kind)) return false;
193
+ if (a.kind && e.kind !== a.kind) return false;
194
+ if (want && e.body.sha256 !== want) return false;
195
+ if (who && !(String(e.body.company ?? "").toLowerCase().includes(who) || String(e.body.cik) === a.company)) return false;
196
+ return !(a.concept && e.body.concept !== a.concept);
197
+ });
198
+ const limit = a.limit ?? 50;
199
+ return { found: hits.length, records: hits.slice(-limit).map((e) => ({ seq: e.seq, at: e.at, kind: e.kind, ...e.body, id: undefined, hash: e.hash })), plain_reading: hits.length ? `${hits.length} ${hits.length === 1 ? "record" : "records"} found.` : "No record found. That says only that this ledger holds none: it does not mean the file, figure or strategy is wrong or made up.", limits: PROOF_LIMITS_TEXT };
200
+ }
package/src/server.mjs CHANGED
@@ -22,14 +22,18 @@ import { ledgerDir, recordTrial } from "canli-validation-mcp/src/ledger.mjs";
22
22
  import { agreementInput, annotationAgreement, annotationGold, annotationPacket, goldInput, packetInput } from "./annotate.mjs";
23
23
  import { BREADTH_LIMITS_TEXT, effectiveBets, runBreadth, signalCatalog } from "./breadth.mjs";
24
24
  import { buildPanel, panelInput } from "./panel.mjs";
25
- import { ENGINE_LIMITS_TEXT, describe, runBacktest } from "./engine.mjs";
25
+ import { CRISES, crisisReplay, ENGINE_LIMITS_TEXT, describe, runBacktest } from "./engine.mjs";
26
+ import { cpcv, cpcvInput, CPCV_LIMITS_TEXT } from "./cpcv.mjs";
27
+ import { arenaNew, arenaNewInput, arenaOrders, arenaOrdersInput, arenaStep, arenaStepInput } from "./arena.mjs";
28
+ import { leagueCommit, leagueCommitInput, leagueNew, leagueNewInput, leagueScore, leagueScoreInput } from "./league.mjs";
29
+ import { proofFile, proofFileInput, proofFilings, proofFilingsInput, proofFind, proofFindInput, proofVerifyInput, verifyLedger } from "./proof.mjs";
26
30
  import { readPanel, writePanel } from "./panel-io.mjs";
27
31
  import { breadthSweep, sweepInput } from "./sweep.mjs";
28
32
  import { FACTORS, PIT_LIMITS_TEXT, buildFactorPanel } from "./pit.mjs";
29
33
 
30
34
  export const SERVER_NAME = "canli-backtest-mcp";
31
35
  export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
32
- export const SERVER_INSTRUCTIONS = "Backtests that refuse lookahead. Build a point-in-time factor from SEC filings with pit_factor (or bring your own signal CSV, each row dated when it was known), then run backtest_signal against your own prices CSV. Pass the same ledger name to every backtest_signal call in one search: the reply judges the best variant against all of them. For breadth, price_panel builds a free price panel (an index fund's holdings, prices, dollar volume and industries), breadth_book builds a sleeve for every signal in every industry from one price panel and trades them as one netted account; effective_bets counts the independent bets in any set of return series. For datasets, annotation_packet, annotation_agreement and annotation_gold turn figures into a gold set people have checked against the filings.";
36
+ export const SERVER_INSTRUCTIONS = "Backtests that refuse lookahead. Build a point-in-time factor from SEC filings with pit_factor (or bring your own signal CSV, each row dated when it was known), then run backtest_signal against your own prices CSV. Pass the same ledger name to every backtest_signal call in one search: the reply judges the best variant against all of them. For breadth, price_panel builds a free price panel (an index fund's holdings, prices, dollar volume and industries), breadth_book builds a sleeve for every signal in every industry from one price panel and trades them as one netted account; effective_bets counts the independent bets in any set of return series. For a search, cpcv scores the chosen variant on held-out blocks across many paths, and crisis_replay holds any returns against named crises. proof_filings and proof_file keep a signed, append-only record; league_new, league_commit and league_score run a paper league scored after the fact; arena_new, arena_orders and arena_step are a paper market for agents, with made-up assets and money. For datasets, annotation_packet, annotation_agreement and annotation_gold turn figures into a gold set people have checked against the filings.";
33
37
  export const SERVER_INFO = Object.freeze({
34
38
  name: SERVER_NAME,
35
39
  version: SERVER_VERSION,
@@ -80,10 +84,20 @@ export const backtestInput = z.object({
80
84
  cost_bps: z.number().min(0).optional().describe("Cost per unit traded, in basis points. Default 10."),
81
85
  min_names: z.number().int().min(2).optional().describe("Skip a rebalance with fewer ranked names. Default 5."),
82
86
  periods_per_year: z.number().positive().optional().describe("Default 252 (daily prices)."),
87
+ capital_usd: z.number().positive().optional().describe("Size to trade at, in dollars. Needs dollar_volume_file. Orders fill only up to max_participation of recent volume and pay square-root impact, and the reply adds a capacity curve."),
88
+ dollar_volume_file: filePath.optional().describe("CSV like prices_file with daily dollar volume. price_panel writes one from adjusted closes, which understates older volume by the dividends paid since."),
89
+ max_participation: z.number().gt(0).max(1).optional().describe("Largest share of a name's average daily dollar volume one order can take. Default 0.1."),
90
+ impact_coefficient: z.number().min(0).max(5).optional().describe("Y in impact = Y x daily volatility x sqrt(participation). Default 1."),
91
+ crisis_drawdown_limit: z.number().gt(0).max(1).optional().describe("A drawdown beyond this inside one crisis counts as a rupture. Default 0.5."),
83
92
  ledger: z.string().regex(/^[a-z0-9][a-z0-9-]{2,63}$/).optional().describe("Record this run in a search ledger (created if new) and judge it against every run recorded there."),
84
93
  label: z.string().min(1).max(200).optional().describe("The variant's name in the ledger. Default: the settings."),
85
94
  });
86
95
 
96
+ export const crisisInput = z.object({
97
+ returns_file: filePath.describe("CSV: a date column (YYYY-MM-DD) and one column of daily returns per strategy, at most 50."),
98
+ drawdown_limit: z.number().gt(0).max(1).optional().describe("A drawdown beyond this inside one crisis counts as a rupture. Default 0.5."),
99
+ }).strict();
100
+
87
101
  export const breadthInput = z.object({
88
102
  prices_file: filePath.describe("CSV: a date column and one column of closing (total-return) prices per ticker."),
89
103
  groups_file: filePath.optional().describe("CSV with columns ticker,group (an industry, say): one sleeve per signal per group. Default: one group, the whole panel."),
@@ -114,7 +128,19 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
114
128
  effective_bets: "How many independent bets a set of return series holds: the participation ratio of their weekly correlations (n for unrelated series, 1 for copies), and with families, the families' bets and the ideas they cluster into.",
115
129
  list_factors: "The point-in-time factors pit_factor builds from SEC filings, and what each measures.",
116
130
  pit_factor: `Build a factor from SEC filings for a list of tickers, monthly from start to end, using only what had been filed by each date (restatements invisible until filed). Writes a signal CSV for backtest_signal. ${PIT_LIMITS_TEXT[0]}`,
117
- backtest_signal: `Backtest a signal against your prices: rank, long and short quantiles, rebalance, trading only on what was known lag_days earlier, costs on actual trades. Pass ledger to judge the best of a search against all of it. ${ENGINE_LIMITS_TEXT[1]}`,
131
+ proof_filings: "Add what companies filed to a signed, append-only ledger: each figure with its filing, and a restatement line whenever a later filing changes one. Re-running adds only what is new. It shows the lines were not altered afterwards, not that a figure is right.",
132
+ proof_file: "Record a file's hash in a signed, append-only ledger, a backtest result or a returns file, so anyone can later check this exact file existed by then. Nothing else about the file is claimed.",
133
+ proof_verify: "Check a proof ledger from the file alone: every hash, order and signature, and any heads published elsewhere. Says which line first fails.",
134
+ proof_find: "Look up a file, company or concept in a proof ledger. No record means only that this ledger holds none, not that anything is wrong.",
135
+ league_new: "Start a paper league: a schedule of rounds and rules with a hash to publish. No prizes, no stakes and nobody's money.",
136
+ league_commit: "Commit a league entry before its round closes: only the hash of its CSV of ticker,score goes in the ledger, and the file is shown after the round. Only the latest commit counts, so revealing whichever of several did best is refused.",
137
+ league_score: "Score a paper league from its files and prices, repeatable by anyone: each entrant's rank correlation with what followed, set against the best luck gives across that many entrants, how different each is from the rest, and a paper book of the top entrants chosen on earlier rounds only.",
138
+ arena_new: "Start a paper market for AI agents to trade in: made-up assets and made-up money, with scripted traders, reproducible from a seed. Nothing here represents or reaches a real security, token or currency.",
139
+ arena_orders: "Send paper orders for an agent in an arena, for the coming auction. No short selling and no borrowing.",
140
+ arena_step: "Clear a paper arena's next call auctions at one price per asset, and show prices and standings. Paper only.",
141
+ crisis_replay: `Hold any strategy's daily returns against named market crises (${Object.values(CRISES).map((c) => c.what.replace(/^the /, "")).slice(0, 4).join(", ")} and others): its return, drawdown and worst day in each one its dates fully cover, and the crises it was not there for, listed as untested and never as passed.`,
142
+ cpcv: `Combinatorial purged cross-validation of a search: choose the best trial on training days, score it on held-out blocks, with purge and embargo at the edges, for every choice of blocks, stitched into whole paths. Returns the spread of out-of-sample Sharpes against the in-sample one. Splits can run as shards on other machines and merge back. ${CPCV_LIMITS_TEXT[1]}`,
143
+ backtest_signal: `Backtest a signal against your prices: rank, long and short quantiles, rebalance, trading only on what was known lag_days earlier, costs on actual trades. Every reply holds the result against the crises its dates cover and lists the ones it does not. With capital_usd and dollar_volume_file, orders fill only as far as the market traded, pay square-root impact, and the reply shows the Sharpe at each size the market can take. Pass ledger to judge the best of a search against all of it. ${ENGINE_LIMITS_TEXT[1]}`,
118
144
  });
119
145
 
120
146
  export async function toolPitFactor(session, args) {
@@ -141,23 +167,57 @@ export async function toolPitFactor(session, args) {
141
167
  });
142
168
  }
143
169
 
170
+ const CAPACITY_LADDER = Object.freeze([1e5, 1e6, 1e7, 1e8, 1e9]);
171
+
144
172
  export async function toolBacktestSignal(session, args) {
145
173
  const a = backtestInput.parse(args);
146
174
  const prices = readPanel(a.prices_file, "prices_file");
147
175
  const signal = readPanel(a.signal_file, "signal_file");
148
176
  const tickers = prices.tickers.filter((t) => signal.tickers.includes(t));
149
177
  const cfg = { rebalance: a.rebalance ?? "monthly", lagDays: a.lag_days ?? 1, side: a.side ?? "long_short", quantile: a.quantile ?? 0.2, costBps: a.cost_bps ?? 10, minNames: a.min_names ?? 5, periodsPerYear: a.periods_per_year ?? 252 };
150
- const result = runBacktest({ dates: prices.dates, tickers, prices: prices.values, signalDates: signal.dates, signals: signal.values, ...cfg });
178
+ if (a.capital_usd && !a.dollar_volume_file) throw new RangeError("capital_usd needs dollar_volume_file: without volume nothing says how much the market can take");
179
+ if (a.dollar_volume_file && !a.capital_usd) throw new RangeError("dollar_volume_file needs capital_usd: volume only matters at a size");
180
+ const volumes = a.dollar_volume_file ? readPanel(a.dollar_volume_file, "dollar_volume_file") : null;
181
+ if (volumes && volumes.dates.join() !== prices.dates.join()) throw new RangeError("dollar_volume_file must have the same dates as prices_file");
182
+ const size = volumes ? { dollarVolumes: volumes.values, maxParticipation: a.max_participation ?? 0.1, impactCoefficient: a.impact_coefficient ?? 1 } : {};
183
+ const run = (capital) => runBacktest({ dates: prices.dates, tickers, prices: prices.values, signalDates: signal.dates, signals: signal.values, ...cfg, ...size, ...(capital ? { capital } : {}) });
184
+ const result = run(a.capital_usd);
151
185
  const stats = describe(result);
152
- const settings = { rebalance: cfg.rebalance, lag_days: cfg.lagDays, side: cfg.side, quantile: cfg.quantile, cost_bps: cfg.costBps, min_names: cfg.minNames };
186
+ const crises = crisisReplay({ dates: result.dates, returns: result.returns, ...(a.crisis_drawdown_limit ? { limit: a.crisis_drawdown_limit } : {}) });
187
+ const capacity = a.capital_usd ? CAPACITY_LADDER.map((capital) => {
188
+ const r = run(capital), d = describe(r);
189
+ // A book that cannot fill holds stale positions, and its Sharpe can even rise: not a figure to choose a size by.
190
+ const tradable = r.liquidity.verdict === "tradable";
191
+ return { capital_usd: capital, sharpe_annualized: tradable ? d.sharpe_annualized : null, annualized_return: tradable ? d.annualized_return : null, fill_ratio: r.liquidity.fill_ratio, impact_paid: r.liquidity.impact_paid, verdict: r.liquidity.verdict };
192
+ }) : null;
193
+ const settings = { rebalance: cfg.rebalance, lag_days: cfg.lagDays, side: cfg.side, quantile: cfg.quantile, cost_bps: cfg.costBps, min_names: cfg.minNames, ...(a.capital_usd ? { capital_usd: a.capital_usd, max_participation: size.maxParticipation, impact_coefficient: size.impactCoefficient } : {}) };
153
194
  let search = null;
154
195
  if (a.ledger) {
155
- const label = a.label ?? `${a.signal_file.split("/").pop()} ${cfg.side} q${cfg.quantile} ${cfg.rebalance} lag${cfg.lagDays} ${cfg.costBps}bps`;
196
+ const label = a.label ?? `${a.signal_file.split("/").pop()} ${cfg.side} q${cfg.quantile} ${cfg.rebalance} lag${cfg.lagDays} ${cfg.costBps}bps${a.capital_usd ? ` $${a.capital_usd}` : ""}`;
156
197
  const r = recordTrial(session.ledgerDir, { ledger: a.ledger, label, returns: result.returns, periods_per_year: cfg.periodsPerYear, params: { ...settings, signal_file: a.signal_file } });
157
198
  search = { ledger: r.ledger, trial: r.trial, trials: r.trials, best: r.best, deflated: r.deflated, overfitting: r.overfitting, plain_reading: r.plain_reading };
158
199
  }
159
200
  const plain = `${stats.first_date} to ${stats.last_date}, ${tickers.length} tickers: annualized Sharpe ${stats.sharpe_annualized?.toFixed(2)}, return ${(100 * (stats.annualized_return ?? 0)).toFixed(1)}% a year, worst drawdown ${(100 * stats.max_drawdown).toFixed(1)}%, turnover ${stats.turnover_per_year?.toFixed(1)}x a year after ${cfg.costBps} bps costs. Every trade used signals at least ${stats.causality.min_days_signal_to_trade} calendar days old.`;
160
- return text({ settings, tickers: tickers.length, ...stats, plain_reading: plain, ...(search ? { search } : {}), limits: ENGINE_LIMITS_TEXT });
201
+ const fails = [
202
+ ...(result.liquidity?.verdict === "too_big" ? [`Too big to trade: at $${a.capital_usd.toLocaleString("en-US")} only ${(100 * result.liquidity.fill_ratio).toFixed(0)}% of the wanted trading could fill; the figures below are for the smaller book that did.`] : []),
203
+ ...crises.tested.filter((c) => c.ruptured).map((c) => `Ruptured in ${c.what}: a ${(100 * c.drawdown).toFixed(0)}% drawdown against a ${(100 * crises.drawdown_limit).toFixed(0)}% limit.`),
204
+ ];
205
+ const coverage = `Crises: ${crises.tested.length} covered by these dates, ${crises.untested.length} untested${crises.untested.length ? ` (not having been there is not surviving them)` : ""}.`;
206
+ return text({ settings, tickers: tickers.length, fails, ...stats, crises, ...(result.liquidity ? { liquidity: result.liquidity, capacity } : {}), plain_reading: [...fails, plain, coverage].join(" "), ...(search ? { search } : {}), limits: ENGINE_LIMITS_TEXT });
207
+ }
208
+
209
+ export async function toolCrisisReplay(args) {
210
+ const a = crisisInput.parse(args);
211
+ const p = readPanel(a.returns_file, "returns_file");
212
+ if (p.tickers.length > 50) throw new RangeError("returns_file has more than 50 columns; crisis_replay takes at most 50");
213
+ const series = p.tickers.map((name) => {
214
+ // A series counts as there from its first value to its last; blank days in between earn nothing.
215
+ const v = p.values[name], first = v.findIndex((x) => x !== null), last = v.findLastIndex((x) => x !== null);
216
+ if (first < 0) return { name, verdict: "untested", tested: [], untested: Object.entries(CRISES).map(([id, c]) => ({ id, what: c.what, from: c.from, to: c.to })) };
217
+ return { name, ...crisisReplay({ dates: p.dates.slice(first, last + 1), returns: v.slice(first, last + 1).map((x) => x ?? 0), ...(a.drawdown_limit ? { limit: a.drawdown_limit } : {}) }) };
218
+ });
219
+ const ruptured = series.filter((s) => s.verdict === "ruptured");
220
+ return text({ series, plain_reading: `${series.length} ${series.length === 1 ? "strategy" : "strategies"} held against ${Object.keys(CRISES).length} crises: ${ruptured.length} ruptured${ruptured.length ? ` (${ruptured.slice(0, 5).map((s) => s.name).join(", ")})` : ""}. A crisis outside a strategy's dates is untested, not passed; surviving past crises says nothing about the next one.` });
161
221
  }
162
222
 
163
223
  function readGroups(path) {
@@ -224,6 +284,18 @@ export function registerTools(server, session) {
224
284
  tool("list_factors", "Point-in-time factors", z.object({}).strict(), READ_ONLY, async () => text({ factors: Object.fromEntries(Object.entries(FACTORS).map(([k, v]) => [k, v.meaning])), plus: "level:<measure> for any annual measure (revenue, net_income, assets, equity, operating_cash_flow, ...)", limits: PIT_LIMITS_TEXT }));
225
285
  tool("pit_factor", "Build a point-in-time factor", pitInput, { ...WRITES_LOCAL, openWorldHint: true }, (args) => toolPitFactor(session, args));
226
286
  tool("backtest_signal", "Backtest a signal without lookahead", backtestInput, WRITES_LOCAL, (args) => toolBacktestSignal(session, args));
287
+ tool("proof_filings", "Add company filings to a proof ledger", proofFilingsInput, { ...WRITES_LOCAL, openWorldHint: true }, async (args) => text(await proofFilings(session.markets ??= marketsSession(), args)));
288
+ tool("proof_file", "Record a file in a proof ledger", proofFileInput, WRITES_LOCAL, async (args) => text(proofFile(args)));
289
+ tool("proof_verify", "Verify a proof ledger", proofVerifyInput, READ_ONLY, async (args) => { const a = proofVerifyInput.parse(args); return text(verifyLedger(a.ledger_file, a.anchors ?? [])); });
290
+ tool("proof_find", "Look up a proof ledger", proofFindInput, READ_ONLY, async (args) => text(proofFind(args)));
291
+ tool("league_new", "Start a paper league", leagueNewInput, WRITES_LOCAL, async (args) => text(leagueNew(args)));
292
+ tool("league_commit", "Commit a league entry", leagueCommitInput, WRITES_LOCAL, async (args) => text(leagueCommit(args)));
293
+ tool("league_score", "Score a paper league", leagueScoreInput, READ_ONLY, async (args) => text(leagueScore(args)));
294
+ tool("arena_new", "Start a paper arena", arenaNewInput, WRITES_LOCAL, async (args) => text(arenaNew(args)));
295
+ tool("arena_orders", "Send paper orders to an arena", arenaOrdersInput, WRITES_LOCAL, async (args) => text(arenaOrders(args)));
296
+ tool("arena_step", "Clear a paper arena", arenaStepInput, WRITES_LOCAL, async (args) => text(arenaStep(args)));
297
+ tool("crisis_replay", "Replay strategies through named crises", crisisInput, READ_ONLY, (args) => toolCrisisReplay(args));
298
+ tool("cpcv", "Combinatorial purged cross-validation", cpcvInput, WRITES_LOCAL, async (args) => text(cpcv(args)));
227
299
  tool("price_panel", "Build a price panel", panelInput, { ...WRITES_LOCAL, openWorldHint: true }, async (args) => text(await buildPanel(session.markets ??= marketsSession(), args)));
228
300
  tool("breadth_signals", "Breadth lab signals", z.object({}).strict(), READ_ONLY, async () => text({ signals: signalCatalog(), next: "breadth_book with your prices_file (and groups_file, dollar_volume_file) builds a sleeve per signal per group.", limits: BREADTH_LIMITS_TEXT }));
229
301
  tool("breadth_book", "Breadth book of sleeves", breadthInput, WRITES_LOCAL, (args) => toolBreadthBook(session, args));