canli-backtest-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,13 +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";
31
+ import { breadthSweep, sweepInput } from "./sweep.mjs";
27
32
  import { FACTORS, PIT_LIMITS_TEXT, buildFactorPanel } from "./pit.mjs";
28
33
 
29
34
  export const SERVER_NAME = "canli-backtest-mcp";
30
35
  export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
31
- 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.";
32
37
  export const SERVER_INFO = Object.freeze({
33
38
  name: SERVER_NAME,
34
39
  version: SERVER_VERSION,
@@ -79,10 +84,20 @@ export const backtestInput = z.object({
79
84
  cost_bps: z.number().min(0).optional().describe("Cost per unit traded, in basis points. Default 10."),
80
85
  min_names: z.number().int().min(2).optional().describe("Skip a rebalance with fewer ranked names. Default 5."),
81
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."),
82
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."),
83
93
  label: z.string().min(1).max(200).optional().describe("The variant's name in the ledger. Default: the settings."),
84
94
  });
85
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
+
86
101
  export const breadthInput = z.object({
87
102
  prices_file: filePath.describe("CSV: a date column and one column of closing (total-return) prices per ticker."),
88
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."),
@@ -109,10 +124,23 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
109
124
  annotation_agreement: "Read filled annotation packets: how often the people agree (Cohen's kappa), how often they agree with the machine, and every disagreement.",
110
125
  annotation_gold: "Merge filled annotation packets and a reviewer's decisions into a gold dataset (JSONL: each item's label, who decided it, the machine label and source) with a datasheet of agreement and hashes.",
111
126
  price_panel: "Build a price panel from free sources for breadth_book or backtest_signal: the tickers you name or an index fund's current holdings, daily adjusted closes and dollar volume, and each company's Fama-French industry from its SEC SIC code, written as CSV files.",
127
+ breadth_sweep: "Run a grid of breadth-book settings (signal sets, smoothing, target volatility, costs, minimum names) in parallel on every core of this machine, or as shards other machines run and merge_files joins. Every combination counts as a trial: the best Sharpe is set against what the best of that many trials reaches by luck, and with split_date the chosen settings are scored after the split.",
112
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.",
113
129
  list_factors: "The point-in-time factors pit_factor builds from SEC filings, and what each measures.",
114
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]}`,
115
- 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]}`,
116
144
  });
117
145
 
118
146
  export async function toolPitFactor(session, args) {
@@ -139,23 +167,57 @@ export async function toolPitFactor(session, args) {
139
167
  });
140
168
  }
141
169
 
170
+ const CAPACITY_LADDER = Object.freeze([1e5, 1e6, 1e7, 1e8, 1e9]);
171
+
142
172
  export async function toolBacktestSignal(session, args) {
143
173
  const a = backtestInput.parse(args);
144
174
  const prices = readPanel(a.prices_file, "prices_file");
145
175
  const signal = readPanel(a.signal_file, "signal_file");
146
176
  const tickers = prices.tickers.filter((t) => signal.tickers.includes(t));
147
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 };
148
- 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);
149
185
  const stats = describe(result);
150
- 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 } : {}) };
151
194
  let search = null;
152
195
  if (a.ledger) {
153
- 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}` : ""}`;
154
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 } });
155
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 };
156
199
  }
157
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.`;
158
- 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.` });
159
221
  }
160
222
 
161
223
  function readGroups(path) {
@@ -222,9 +284,22 @@ export function registerTools(server, session) {
222
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 }));
223
285
  tool("pit_factor", "Build a point-in-time factor", pitInput, { ...WRITES_LOCAL, openWorldHint: true }, (args) => toolPitFactor(session, args));
224
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)));
225
299
  tool("price_panel", "Build a price panel", panelInput, { ...WRITES_LOCAL, openWorldHint: true }, async (args) => text(await buildPanel(session.markets ??= marketsSession(), args)));
226
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 }));
227
301
  tool("breadth_book", "Breadth book of sleeves", breadthInput, WRITES_LOCAL, (args) => toolBreadthBook(session, args));
302
+ tool("breadth_sweep", "Parallel sweep of breadth settings", sweepInput, WRITES_LOCAL, async (args) => text(await breadthSweep(session, args)));
228
303
  tool("effective_bets", "Effective independent bets", betsInput, READ_ONLY, (args) => toolEffectiveBets(args));
229
304
  tool("annotation_packet", "Annotation packets", packetInput, { ...WRITES_LOCAL, openWorldHint: true }, async (args) => text(await annotationPacket(session.markets ??= marketsSession(), args)));
230
305
  tool("annotation_agreement", "Annotation agreement", agreementInput, READ_ONLY, async (args) => text(annotationAgreement(args)));