canli-backtest-mcp 0.1.2 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-backtest-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.2.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",
@@ -20,9 +20,10 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/server": "2.3.1",
23
- "canli-fundamentals-mcp": "^0.5.0",
23
+ "canli-fundamentals-mcp": "^0.7.0",
24
+ "canli-markets-mcp": "^0.6.0",
24
25
  "canli-validation-mcp": "^0.14.0",
25
- "zod": "4.6.5"
26
+ "zod": "4.6.7"
26
27
  },
27
28
  "devDependencies": {
28
29
  "@modelcontextprotocol/client": "2.3.1"
@@ -51,5 +52,8 @@
51
52
  "overfitting",
52
53
  "factor-investing",
53
54
  "ai-agents"
54
- ]
55
+ ],
56
+ "overrides": {
57
+ "zod": "4.6.7"
58
+ }
55
59
  }
@@ -0,0 +1,251 @@
1
+ // Annotation: turn figures into a labelled dataset that people have checked against the filings.
2
+ //
3
+ // annotation_packet takes figures (company, measure, period, value), labels each one by machine
4
+ // against the company's XBRL filings (verify_claims: confirmed, contradicted, not reported or not
5
+ // checkable, with the filed figure and its source), and writes one packet per annotator as a CSV a
6
+ // person fills in, a key with the machine labels, and the guidelines. Packets are blind by default:
7
+ // they carry the source link, not the machine's verdict or the filed figure, so a person's label is
8
+ // their own and can be compared with the machine's afterwards.
9
+ // annotation_agreement reads the filled packets: agreement and Cohen's kappa between annotators,
10
+ // and between the people and the machine, with every disagreement listed.
11
+ // annotation_gold merges them into a gold set (unanimous labels, then a reviewer's decisions),
12
+ // writes it as JSONL with a datasheet, and hashes every input so the set can be traced.
13
+ import { createHash } from "node:crypto";
14
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ import { z } from "zod";
19
+
20
+ import { createSession as marketsSession } from "canli-markets-mcp/src/sources.mjs";
21
+ import { verifyClaims } from "canli-markets-mcp/src/verify.mjs";
22
+
23
+ export const LABELS = Object.freeze(["confirmed", "contradicted", "not_reported", "cannot_find"]);
24
+ const COLUMNS = ["item_id", "company", "measure", "period", "claimed", "unit", "source", "label", "correct_value", "note"];
25
+ const sha = (x) => createHash("sha256").update(x).digest("hex");
26
+ const filePath = z.string().min(1).max(1000).refine((s) => !s.includes("\0"), "a file path cannot contain a NUL character");
27
+
28
+ const item = z.object({
29
+ company: z.string().min(1).max(120),
30
+ measure: z.string().min(2).max(80),
31
+ period: z.string().min(2).max(60),
32
+ value: z.number(),
33
+ unit: z.string().max(30).optional(),
34
+ note: z.string().max(500).optional(),
35
+ }).strict();
36
+
37
+ export const packetInput = z.object({
38
+ task: z.string().regex(/^[a-z0-9][a-z0-9-]{2,63}$/).describe("A name for the task, e.g. q3-revenue-check: the folder the files go in."),
39
+ items: z.array(item).min(1).max(200).optional().describe("Figures to label: company, measure, period, value (and unit), as for verify_claims."),
40
+ items_file: filePath.optional().describe("Or a JSONL file of the same objects, up to 2,000."),
41
+ text: z.string().min(10).max(6000).optional().describe("Or a draft about one company (an AI's answer, say): every figure in it with a measure and a period becomes an item. Needs company."),
42
+ company: z.string().min(1).max(120).optional().describe("With text: the company the draft is about."),
43
+ annotators: z.number().int().min(1).max(5).optional().describe("Packets to write, one per person. Default 2: two people label every item independently."),
44
+ assisted: z.boolean().optional().describe("Show the machine's label and the filed figure in the packets. Default false (blind), so agreement with the machine means something."),
45
+ out_dir: filePath.optional().describe("Folder for the task folder. Default: a new temporary folder."),
46
+ }).strict();
47
+ export const agreementInput = z.object({ task_dir: filePath.describe("The folder annotation_packet wrote, with the filled packets in it.") }).strict();
48
+ export const goldInput = z.object({
49
+ task_dir: filePath.describe("The folder annotation_packet wrote, with the filled packets in it."),
50
+ reviews: z.array(z.object({ item_id: z.string().max(40), label: z.enum(LABELS), correct_value: z.number().optional(), note: z.string().max(500).optional() }).strict()).max(2000).optional()
51
+ .describe("A reviewer's decisions on items the annotators disagreed on."),
52
+ }).strict();
53
+
54
+ // ---- CSV ---------------------------------------------------------------------------------------------
55
+ const quote = (v) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
56
+ export const toCsv = (rows) => rows.map((r) => r.map(quote).join(",")).join("\n") + "\n";
57
+ export function parseCsv(text) {
58
+ const rows = [], s = text.replace(/^/, "");
59
+ let row = [], cell = "", q = false;
60
+ for (let i = 0; i < s.length; i += 1) {
61
+ const ch = s[i];
62
+ if (q) { if (ch === '"') { if (s[i + 1] === '"') { cell += '"'; i += 1; } else q = false; } else cell += ch; continue; }
63
+ if (ch === '"') q = true;
64
+ else if (ch === ",") { row.push(cell); cell = ""; }
65
+ else if (ch === "\n" || ch === "\r") { if (ch === "\r" && s[i + 1] === "\n") i += 1; row.push(cell); cell = ""; if (row.some((x) => x !== "")) rows.push(row); row = []; }
66
+ else cell += ch;
67
+ }
68
+ if (cell !== "" || row.length) { row.push(cell); if (row.some((x) => x !== "")) rows.push(row); }
69
+ return rows;
70
+ }
71
+
72
+ const GUIDELINES = `# Labelling guidelines (canli.annotation.v1)
73
+
74
+ Work alone. Do not compare labels with anyone until every packet is in.
75
+
76
+ For each row, open the link in \`source\` (the filing, or SEC's record of the company's XBRL facts),
77
+ find the figure for the company, measure and period, and set \`label\` to one of:
78
+
79
+ - \`confirmed\`: the filing reports this figure for this period, after its own scaling ("in
80
+ millions") and to the precision the claim is written in (416.2 billion matches 416,161 million).
81
+ - \`contradicted\`: the filing reports a different figure. Put the filed figure, in the claim's unit,
82
+ in \`correct_value\`.
83
+ - \`not_reported\`: the company's filings do not report this measure for this period.
84
+ - \`cannot_find\`: you could not settle it. Say where you looked in \`note\`.
85
+
86
+ A restated figure: label against the latest filing and say in \`note\` that it was restated.
87
+ Non-GAAP or adjusted figures are judged against the company's own reconciliation; say so in \`note\`.
88
+ `;
89
+
90
+ // ---- packets ------------------------------------------------------------------------------------------
91
+ // session: a canli-markets-mcp session (SEC requests, cache, clock); one is made when none is given.
92
+ export async function annotationPacket(session, args) {
93
+ session ??= marketsSession();
94
+ const a = packetInput.parse(args);
95
+ let items = a.items ?? [];
96
+ if (a.items_file) items = items.concat(readFileSync(a.items_file, "utf8").split(/\r?\n/).filter((l) => l.trim()).map((l, i) => {
97
+ try { return item.parse(JSON.parse(l)); } catch (e) { throw new RangeError(`items_file line ${i + 1}: ${e.message}`); }
98
+ }));
99
+ // A draft: verify_claims finds its figures and labels them in the same pass.
100
+ let fromText = null;
101
+ if (a.text) {
102
+ if (!a.company) throw new RangeError("text needs company: the company the draft is about.");
103
+ const r = await verifyClaims(session, { company: a.company, text: a.text });
104
+ const col = (name) => r.columns.indexOf(name);
105
+ fromText = r.rows.map((row) => ({ item: { company: a.company, measure: String(row[col("measure")]), period: String(row[col("period")]), value: Number(row[col("claimed")]), ...(row[col("unit")] ? { unit: String(row[col("unit")]) } : {}), note: String(row[col("claim")] ?? "").slice(0, 500) },
106
+ machine: { verdict: row[col("verdict")] ?? null, filed: row[col("filed")] ?? null, note: row[col("note")] ?? null, source: row[col("source")] ?? null } })).filter((x) => Number.isFinite(x.item.value));
107
+ items = items.concat(fromText.map((x) => x.item));
108
+ }
109
+ if (!items.length) throw new RangeError(a.text ? "The draft has no figure with a measure and a period to label." : "Send items, items_file or text.");
110
+ if (items.length > 2000) throw new RangeError("At most 2,000 items in one task.");
111
+ // Machine labels: verify_claims per company, 25 claims a call.
112
+ const machine = new Array(items.length);
113
+ const byCompany = new Map();
114
+ items.forEach((it, i) => { if (!byCompany.has(it.company)) byCompany.set(it.company, []); byCompany.get(it.company).push(i); });
115
+ // Items from a draft already carry their machine labels.
116
+ const preset = items.length - (fromText?.length ?? 0);
117
+ fromText?.forEach((x, k) => { machine[preset + k] = x.machine; });
118
+ for (const [company, allIdx] of byCompany) {
119
+ const idx = allIdx.filter((i) => i < preset);
120
+ for (let k = 0; k < idx.length; k += 25) {
121
+ const part = idx.slice(k, k + 25);
122
+ try {
123
+ const r = await verifyClaims(session, { company, claims: part.map((i) => ({ measure: items[i].measure, period: items[i].period, value: items[i].value, ...(items[i].unit ? { unit: items[i].unit } : {}) })) });
124
+ const col = (name) => r.columns.indexOf(name);
125
+ part.forEach((i, j) => { const row = r.rows[j] ?? []; machine[i] = { verdict: row[col("verdict")] ?? null, filed: row[col("filed")] ?? null, note: row[col("note")] ?? null, source: row[col("source")] ?? null }; });
126
+ } catch (e) {
127
+ for (const i of part) machine[i] = { verdict: "not_checkable", filed: null, note: `machine check failed: ${String(e.message).slice(0, 200)}`, source: null };
128
+ }
129
+ }
130
+ }
131
+ const ids = items.map((it) => sha(JSON.stringify([it.company, it.measure, it.period, it.value, it.unit ?? null])).slice(0, 12));
132
+ const dir = join(a.out_dir ?? mkdtempSync(join(tmpdir(), "canli-annotation-")), a.task);
133
+ mkdirSync(dir, { recursive: true });
134
+ const n = a.annotators ?? 2, assisted = a.assisted ?? false;
135
+ const packets = [];
136
+ for (let p = 1; p <= n; p += 1) {
137
+ const cols = assisted ? [...COLUMNS.slice(0, 7), "machine_label", "machine_filed", ...COLUMNS.slice(7)] : COLUMNS;
138
+ const rows = items.map((it, i) => [ids[i], it.company, it.measure, it.period, it.value, it.unit ?? "", machine[i].source ?? "", ...(assisted ? [machine[i].verdict, machine[i].filed] : []), "", "", ""]);
139
+ const file = join(dir, `annotator-${p}.csv`);
140
+ writeFileSync(file, toCsv([cols, ...rows]));
141
+ packets.push(file);
142
+ }
143
+ const key = { schema: "canli.annotation-key.v1", task: a.task, created_at: new Date(session.now?.() ?? Date.now()).toISOString(), assisted, annotators: n, labels: LABELS,
144
+ items: items.map((it, i) => ({ item_id: ids[i], ...it, machine: machine[i] })) };
145
+ writeFileSync(join(dir, "key.json"), JSON.stringify(key, null, 1));
146
+ writeFileSync(join(dir, "GUIDELINES.md"), GUIDELINES);
147
+ const counts = {};
148
+ for (const m of machine) counts[m.verdict] = (counts[m.verdict] ?? 0) + 1;
149
+ return {
150
+ task: a.task, task_dir: dir, items: items.length, packets, key: join(dir, "key.json"), guidelines: join(dir, "GUIDELINES.md"),
151
+ machine_labels: counts, blind: !assisted,
152
+ next: `Each person fills the label column of one packet (GUIDELINES.md says how), then annotation_agreement with task_dir ${dir}, then annotation_gold.`,
153
+ };
154
+ }
155
+
156
+ // ---- reading filled packets ----------------------------------------------------------------------------
157
+ function readTask(dir) {
158
+ const key = JSON.parse(readFileSync(join(dir, "key.json"), "utf8"));
159
+ const packets = [];
160
+ for (let p = 1; p <= key.annotators; p += 1) {
161
+ const file = join(dir, `annotator-${p}.csv`), text = readFileSync(file, "utf8"), rows = parseCsv(text);
162
+ const head = rows[0], at = (name) => head.indexOf(name);
163
+ if (at("item_id") < 0 || at("label") < 0) throw new RangeError(`${file}: the header must keep item_id and label`);
164
+ const labels = new Map();
165
+ for (const [r, row] of rows.slice(1).entries()) {
166
+ const label = (row[at("label")] ?? "").trim().toLowerCase();
167
+ if (label && !LABELS.includes(label)) throw new RangeError(`${file} row ${r + 2}: label "${label}" is not one of ${LABELS.join(", ")}`);
168
+ const cv = (row[at("correct_value")] ?? "").trim();
169
+ labels.set(row[at("item_id")], { label: label || null, correct_value: cv === "" ? null : Number(cv), note: (row[at("note")] ?? "").trim() || null });
170
+ }
171
+ packets.push({ file, sha256: sha(text), labels });
172
+ }
173
+ return { key, packets };
174
+ }
175
+
176
+ // The machine's verdict on the annotation scale: not_checkable has no human counterpart.
177
+ const MACHINE = { confirmed: "confirmed", "confirmed as first reported": "confirmed", contradicted: "contradicted", "not reported": "not_reported" };
178
+ const machineLabel = (m) => MACHINE[m?.verdict] ?? null;
179
+
180
+ export function kappa(pairs) {
181
+ const n = pairs.length;
182
+ if (!n) return { items: 0, agreement: null, kappa: null };
183
+ const agree = pairs.filter(([x, y]) => x === y).length / n;
184
+ let pe = 0;
185
+ for (const l of LABELS) pe += (pairs.filter(([x]) => x === l).length / n) * (pairs.filter(([, y]) => y === l).length / n);
186
+ return { items: n, agreement: Math.round(agree * 1e4) / 1e4, kappa: pe < 1 ? Math.round(((agree - pe) / (1 - pe)) * 1e4) / 1e4 : null };
187
+ }
188
+
189
+ export function annotationAgreement(args) {
190
+ const { task_dir } = agreementInput.parse(args);
191
+ const { key, packets } = readTask(task_dir);
192
+ const ids = key.items.map((x) => x.item_id);
193
+ const done = packets.map((p) => ids.filter((id) => p.labels.get(id)?.label).length);
194
+ const pairs = [];
195
+ for (let a = 0; a < packets.length; a += 1) for (let b = a + 1; b < packets.length; b += 1) {
196
+ const both = ids.filter((id) => packets[a].labels.get(id)?.label && packets[b].labels.get(id)?.label).map((id) => [packets[a].labels.get(id).label, packets[b].labels.get(id).label]);
197
+ pairs.push({ annotators: [a + 1, b + 1], ...kappa(both) });
198
+ }
199
+ // The people's majority (a tie has none) against the machine.
200
+ const majority = (id) => {
201
+ const votes = packets.map((p) => p.labels.get(id)?.label).filter(Boolean), c = {};
202
+ for (const v of votes) c[v] = (c[v] ?? 0) + 1;
203
+ const top = Object.entries(c).sort((x, y) => y[1] - x[1]);
204
+ return top.length && (top.length === 1 || top[0][1] > top[1][1]) ? top[0][0] : null;
205
+ };
206
+ const vsMachine = kappa(key.items.map((x) => [majority(x.item_id), machineLabel(x.machine)]).filter(([h, m]) => h && m));
207
+ const disagreements = key.items.filter((x) => new Set(packets.map((p) => p.labels.get(x.item_id)?.label).filter(Boolean)).size > 1)
208
+ .map((x) => [x.item_id, x.company, x.measure, x.period, x.value, ...packets.map((p) => p.labels.get(x.item_id)?.label ?? null), machineLabel(x.machine)]);
209
+ return {
210
+ task: key.task, items: ids.length, labelled: done, between_annotators: pairs, people_vs_machine: vsMachine,
211
+ disagreements: { columns: ["item_id", "company", "measure", "period", "claimed", ...packets.map((_, i) => `annotator_${i + 1}`), "machine"], rows: disagreements.slice(0, 200), count: disagreements.length },
212
+ meaning: "kappa is Cohen's: agreement beyond what the labels' frequencies give by chance (1 perfect, 0 chance; above 0.8 is usually read as strong). people_vs_machine compares the annotators' majority label with the machine's where both exist.",
213
+ next: disagreements.length ? "A reviewer decides the disagreements; pass them to annotation_gold as reviews." : "annotation_gold writes the gold set.",
214
+ };
215
+ }
216
+
217
+ export function annotationGold(args) {
218
+ const { task_dir, reviews = [] } = goldInput.parse(args);
219
+ const { key, packets } = readTask(task_dir);
220
+ const review = new Map(reviews.map((r) => [r.item_id, r]));
221
+ const gold = [], open = [];
222
+ for (const x of key.items) {
223
+ const labels = packets.map((p) => p.labels.get(x.item_id)).filter((l) => l?.label);
224
+ const set = new Set(labels.map((l) => l.label));
225
+ const decided = review.get(x.item_id);
226
+ let final = null, how = null;
227
+ if (decided) { final = decided; how = "reviewer"; }
228
+ else if (labels.length === packets.length && set.size === 1) { final = labels[0]; how = "unanimous"; }
229
+ if (!final) { open.push(x.item_id); continue; }
230
+ const values = labels.map((l) => l.correct_value).filter((v) => v !== null);
231
+ gold.push({ item_id: x.item_id, company: x.company, measure: x.measure, period: x.period, claimed: x.value, unit: x.unit ?? null,
232
+ label: final.label, correct_value: final.correct_value ?? (values.length ? values[0] : null), decided_by: how, annotator_labels: packets.map((p) => p.labels.get(x.item_id)?.label ?? null),
233
+ machine: x.machine, agrees_with_machine: machineLabel(x.machine) ? machineLabel(x.machine) === final.label : null });
234
+ }
235
+ const out = join(task_dir, "gold.jsonl");
236
+ const body = gold.map((g) => JSON.stringify(g)).join("\n") + (gold.length ? "\n" : "");
237
+ writeFileSync(out, body);
238
+ const between = packets.length > 1 ? kappa(key.items.map((x) => [packets[0].labels.get(x.item_id)?.label, packets[1].labels.get(x.item_id)?.label]).filter(([p, q]) => p && q)) : null;
239
+ const datasheet = {
240
+ schema: "canli.annotation-datasheet.v1", task: key.task, items: key.items.length, gold: gold.length, open: open.length,
241
+ labels: Object.fromEntries(LABELS.map((l) => [l, gold.filter((g) => g.label === l).length])),
242
+ decided_by: { unanimous: gold.filter((g) => g.decided_by === "unanimous").length, reviewer: gold.filter((g) => g.decided_by === "reviewer").length },
243
+ agreement_first_two: between, machine_agreement_on_gold: gold.filter((g) => g.agrees_with_machine !== null).length ? Math.round((gold.filter((g) => g.agrees_with_machine).length / gold.filter((g) => g.agrees_with_machine !== null).length) * 1e4) / 1e4 : null,
244
+ blind: !key.assisted, guidelines: "canli.annotation.v1",
245
+ inputs: { key_sha256: sha(readFileSync(join(task_dir, "key.json"))), packets: packets.map((p) => ({ file: p.file, sha256: p.sha256 })), reviews_sha256: sha(JSON.stringify(reviews)) },
246
+ gold_sha256: sha(body),
247
+ };
248
+ writeFileSync(join(task_dir, "datasheet.json"), JSON.stringify(datasheet, null, 1));
249
+ return { gold_file: out, datasheet_file: join(task_dir, "datasheet.json"), ...datasheet, open_items: open.slice(0, 200),
250
+ next: open.length ? `${open.length} items still need a reviewer's decision: pass them as reviews.` : "Every item has a gold label." };
251
+ }
@@ -0,0 +1,413 @@
1
+ // mcp-backtest/src/breadth.mjs
2
+ //
3
+ // The breadth lab: many weakly correlated sleeves from one price panel, traded as one account.
4
+ // Ported from ALPHAC's research/breadth_lab (lab.py, round2.py, round4.py, effective_bets.py,
5
+ // netting_law.py), whose rules it keeps:
6
+ //
7
+ // - A sleeve is one signal inside one group of names (an industry, say): at each rebalance the names
8
+ // are ranked on the signal, the ranks demeaned and scaled to gross 1 within the group, and the mean
9
+ // of the last k rebalances' weights is held (overlapping portfolios cut turnover).
10
+ // - Weights decided at a close trade at the next close and earn from the close after; costs are
11
+ // charged on those trades, plus borrow on shorts. Each sleeve is hedged with its trailing market
12
+ // beta and scaled to a target volatility, both measured up to the day before.
13
+ // - The book holds every sleeve at equal weight and trades the netted positions: one sleeve's sale is
14
+ // another's purchase, so the account pays far less than the sleeves would alone. Both costs are
15
+ // reported (the lab's netting law: about 30% of the summed standalone cost at 10,000 sleeves).
16
+ // - How many independent bets the book holds is counted, not assumed: the participation ratio
17
+ // (trace C)^2 / sum C_ij^2 of the weekly correlation matrix (n for uncorrelated series, 1 for
18
+ // copies), for sleeves and for each signal's family book, and ideas as clusters of family books.
19
+ //
20
+ // Pure: no files, no network. Inputs are aligned arrays; panel-io.mjs reads CSV files into them.
21
+
22
+ import { createHash } from "node:crypto";
23
+
24
+ export const BREADTH_LIMITS_TEXT = Object.freeze([
25
+ "Prices, volumes and groups are the caller's: survivorship, splits, dividends and data errors pass straight into every sleeve.",
26
+ "A sleeve built on many signals and groups is a search: report the number of sleeves, keep a period untouched for the test, and judge the book there, not on the years used to choose it.",
27
+ "Costs are per name per unit traded (tiers by dollar volume when it is given, else cost_bps) plus borrow on shorts; the market hedge is assumed costless, as with a futures overlay.",
28
+ ]);
29
+
30
+ const DAY_YEAR = 252;
31
+
32
+ // ---- rolling statistics over one column (NaN = missing) -------------------------------------------
33
+
34
+ function rolling(x, w, minN, fn) {
35
+ const n = x.length, out = new Float64Array(n).fill(NaN);
36
+ let s1 = 0, s2 = 0, s3 = 0, c = 0;
37
+ for (let i = 0; i < n; i += 1) {
38
+ const v = x[i];
39
+ if (!Number.isNaN(v)) { s1 += v; s2 += v * v; s3 += v * v * v; c += 1; }
40
+ if (i >= w) { const o = x[i - w]; if (!Number.isNaN(o)) { s1 -= o; s2 -= o * o; s3 -= o * o * o; c -= 1; } }
41
+ if (c >= minN) out[i] = fn(s1, s2, s3, c);
42
+ }
43
+ return out;
44
+ }
45
+ const mean = (x, w, m) => rolling(x, w, m, (s1, _s2, _s3, c) => s1 / c);
46
+ const sum = (x, w, m) => rolling(x, w, m, (s1) => s1);
47
+ const std = (x, w, m) => rolling(x, w, m, (s1, s2, _s3, c) => (c > 1 ? Math.sqrt(Math.max(0, (s2 - (s1 * s1) / c) / (c - 1))) : NaN));
48
+ // Sample skewness with pandas' bias correction.
49
+ const skew = (x, w, m) => rolling(x, w, m, (s1, s2, s3, c) => {
50
+ if (c < 3) return NaN;
51
+ const mu = s1 / c, m2 = s2 / c - mu * mu, m3 = s3 / c - 3 * mu * (s2 / c) + 2 * mu ** 3;
52
+ if (m2 <= 1e-14) return NaN;
53
+ return (Math.sqrt(c * (c - 1)) / (c - 2)) * (m3 / m2 ** 1.5);
54
+ });
55
+ function rollMax(x, w, minN) {
56
+ const n = x.length, out = new Float64Array(n).fill(NaN), dq = [];
57
+ let c = 0;
58
+ for (let i = 0; i < n; i += 1) {
59
+ if (!Number.isNaN(x[i])) { while (dq.length && x[dq.at(-1)] <= x[i]) dq.pop(); dq.push(i); c += 1; }
60
+ if (i >= w && !Number.isNaN(x[i - w])) c -= 1;
61
+ while (dq.length && dq[0] <= i - w) dq.shift();
62
+ if (c >= minN && dq.length) out[i] = x[dq[0]];
63
+ }
64
+ return out;
65
+ }
66
+ const shift = (x, k) => { const out = new Float64Array(x.length).fill(NaN); for (let i = k; i < x.length; i += 1) out[i] = x[i - k]; return out; };
67
+ const map2 = (a, b, f) => { const out = new Float64Array(a.length); for (let i = 0; i < a.length; i += 1) out[i] = f(a[i], b[i]); return out; };
68
+ const map1 = (a, f) => { const out = new Float64Array(a.length); for (let i = 0; i < a.length; i += 1) out[i] = f(a[i], i); return out; };
69
+
70
+ // ---- the shared daily context --------------------------------------------------------------------
71
+
72
+ export function context({ dates, tickers, prices, dollarVolume = null, minPrice = 5, minDollarVolume = 5e6, costBps = 10 }) {
73
+ const n = dates.length;
74
+ const r = {}, r0 = {}, ltri = {}, adv = {}, eligible = {}, cost = {};
75
+ for (const t of tickers) {
76
+ const p = prices[t];
77
+ const rt = new Float64Array(n).fill(NaN);
78
+ for (let i = 1; i < n; i += 1) if (p[i] > 0 && p[i - 1] > 0) rt[i] = p[i] / p[i - 1] - 1;
79
+ r[t] = rt;
80
+ r0[t] = map1(rt, (v) => (Number.isNaN(v) ? 0 : v));
81
+ let acc = 0;
82
+ ltri[t] = map1(r0[t], (v) => (acc += Math.log1p(Math.max(v, -0.99))));
83
+ const dv = dollarVolume?.[t] ? map1(dollarVolume[t], (v) => (v > 0 ? v : NaN)) : null;
84
+ adv[t] = dv ? rollMedian(dv, 63, 40) : null;
85
+ eligible[t] = map1(p, (v, i) => (v >= minPrice && (!dv || adv[t][i] >= minDollarVolume) ? 1 : 0));
86
+ cost[t] = dv ? map1(adv[t], (a) => (a >= 5e7 ? 0.001 : a >= 1e7 ? 0.002 : 0.004)) : null;
87
+ }
88
+ // The market: the equal-weighted return of the names eligible yesterday.
89
+ const market = new Float64Array(n);
90
+ for (let i = 1; i < n; i += 1) {
91
+ let s = 0, c = 0;
92
+ for (const t of tickers) if (eligible[t][i - 1] && !Number.isNaN(r[t][i])) { s += r[t][i]; c += 1; }
93
+ market[i] = c ? s / c : 0;
94
+ }
95
+ return { dates, tickers, prices, dollarVolume: dollarVolume ?? null, r, r0, ltri, adv, eligible, cost, flatCost: costBps / 1e4, market, n };
96
+ }
97
+
98
+ function rollMedian(x, w, minN) {
99
+ const out = new Float64Array(x.length).fill(NaN);
100
+ for (let i = 0; i < x.length; i += 1) {
101
+ const win = [];
102
+ for (let k = Math.max(0, i - w + 1); k <= i; k += 1) if (!Number.isNaN(x[k])) win.push(x[k]);
103
+ if (win.length >= minN) { win.sort((a, b) => a - b); const h = win.length >> 1; out[i] = win.length % 2 ? win[h] : (win[h - 1] + win[h]) / 2; }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ // ---- signals: higher = long; each a daily series per name, sampled at the rebalance dates -------------
109
+
110
+ const ret = (c, t, a, b) => map2(shift(c.ltri[t], b), shift(c.ltri[t], a), (x, y) => Math.expm1(x - y));
111
+ function beta(c, t, w) {
112
+ const h = w >> 1, m = c.market;
113
+ const mr = mean(map2(c.r0[t], m, (x, y) => x * y), w, h), rm = mean(c.r0[t], w, h), mm = mean(m, w, h), m2 = mean(map1(m, (x) => x * x), w, h);
114
+ return map1(mr, (v, i) => (v - rm[i] * mm[i]) / (m2[i] - mm[i] * mm[i]));
115
+ }
116
+ const marketMean = (c, f, w, minN) => mean(map1(c.market, f), w, minN);
117
+
118
+ // Each entry: [needs, rebalance, reference, function(c, t)]. needs: "price" or "volume".
119
+ export const SIGNALS = Object.freeze({
120
+ mom_12_1: ["price", "M", "Jegadeesh and Titman (1993): return from 12 months to 1 month ago", (c, t) => ret(c, t, 252, 21)],
121
+ mom_6_1: ["price", "M", "Jegadeesh and Titman (1993): return from 6 months to 1 month ago", (c, t) => ret(c, t, 126, 21)],
122
+ mom_12_7: ["price", "M", "Novy-Marx (2012): intermediate momentum, 12 to 7 months ago", (c, t) => ret(c, t, 252, 147)],
123
+ mom_accel: ["price", "M", "Momentum acceleration: 6-1 month return minus 12-7 month return", (c, t) => map2(ret(c, t, 126, 21), ret(c, t, 252, 147), (a, b) => a - b)],
124
+ rev_36_12: ["price", "M", "De Bondt and Thaler (1985): long-term reversal, 36 to 12 months ago, negated", (c, t) => map1(ret(c, t, 756, 252), (v) => -v)],
125
+ rev_1m: ["price", "M", "Jegadeesh (1990): one-month reversal, negated", (c, t) => map1(ret(c, t, 21, 0), (v) => -v)],
126
+ rev_1w: ["price", "W", "Lehmann (1990): one-week reversal, negated, rebalanced weekly", (c, t) => map1(ret(c, t, 5, 0), (v) => -v)],
127
+ hi52: ["price", "M", "George and Hwang (2004): nearness to the 52-week high (log)", (c, t) => map2(c.ltri[t], rollMax(c.ltri[t], 252, 200), (a, b) => a - b)],
128
+ lowvol_63: ["price", "M", "Ang, Hodrick, Xing and Zhang (2006): low 3-month volatility", (c, t) => map1(std(c.r[t], 63, 40), (v) => -v)],
129
+ lowbeta_252: ["price", "M", "Frazzini and Pedersen (2014): low one-year beta to the equal-weighted market", (c, t) => map1(beta(c, t, 252), (v) => -v)],
130
+ ivol_63: ["price", "M", "Ang et al. (2006): low idiosyncratic volatility, market-model residuals over 3 months", (c, t) => {
131
+ const b = beta(c, t, 252);
132
+ return map1(std(map1(c.r0[t], (v, i) => v - b[i] * c.market[i]), 63, 40), (v) => -v);
133
+ }],
134
+ max_21: ["price", "M", "Bali, Cakici and Whitelaw (2011): low maximum daily return over a month", (c, t) => map1(rollMax(c.r[t], 21, 15), (v) => -v)],
135
+ skew_252: ["price", "M", "Boyer, Mitton and Vorkink (2010): low one-year skewness of daily returns", (c, t) => map1(skew(c.r[t], 252, 200), (v) => -v)],
136
+ zero_days_252: ["price", "M", "Lesmond, Ogden and Trzcinka (1999): share of zero-return days over a year", (c, t) => mean(map1(c.r[t], (v) => (Number.isNaN(v) ? NaN : v === 0 ? 1 : 0)), 252, 200)],
137
+ updays: ["price", "M", "Da, Gurun and Warachka (2014): share of up minus down days, months 12 to 2", (c, t) => mean(shift(map1(c.r[t], (v) => (Number.isNaN(v) ? NaN : Math.sign(v))), 21), 231, 180)],
138
+ resid_mom: ["price", "M", "Blitz, Huij and Martens (2011): residual momentum, months 12 to 2 over residual volatility", (c, t) => {
139
+ const b = shift(beta(c, t, 252), 1);
140
+ const e = shift(map1(c.r0[t], (v, i) => (Number.isNaN(b[i]) ? NaN : v - b[i] * c.market[i])), 21);
141
+ return map2(sum(e, 231, 180), std(e, 231, 180), (s, sd) => s / (sd * Math.sqrt(231)));
142
+ }],
143
+ coskew_252: ["price", "M", "Harvey and Siddique (2000): low coskewness with the market over a year", (c, t) => {
144
+ const w = 252, h = 126, m = c.market, r = c.r[t];
145
+ const mm = marketMean(c, (x) => x, w, h), mv = map2(marketMean(c, (x) => x * x, w, h), mm, (a, b) => a - b * b);
146
+ const mr = mean(r, w, h), r2 = mean(map1(r, (v) => v * v), w, h);
147
+ const rmm = mean(map2(r, m, (v, x) => v * x * x), w, h), rm = mean(map2(r, m, (v, x) => v * x), w, h);
148
+ return map1(rmm, (v, i) => {
149
+ const num = v - 2 * rm[i] * mm[i] + mr[i] * (mm[i] ** 2 - mv[i]);
150
+ return -num / (Math.sqrt(Math.max(r2[i] - mr[i] ** 2, 0)) * mv[i]);
151
+ });
152
+ }],
153
+ dbeta_252: ["price", "M", "Ang, Chen and Xing (2006): downside beta, on days the market fell", (c, t) => {
154
+ const w = 252, h = 126, d = map1(c.market, (x) => (x < 0 ? 1 : 0)), md = map2(c.market, d, (x, y) => x * y);
155
+ const n = mean(d, w, h), erm = mean(map2(c.r0[t], md, (x, y) => x * y), w, h), er = mean(map2(c.r0[t], d, (x, y) => x * y), w, h);
156
+ const em = mean(md, w, h), em2 = mean(map1(md, (x) => x * x), w, h);
157
+ return map1(n, (k, i) => { const a = em[i] / k, b = em2[i] / k; return (erm[i] / k - (er[i] / k) * a) / (b - a * a); });
158
+ }],
159
+ lag_corr_252: ["price", "M", "Hou and Moskowitz (2005): price delay, correlation with yesterday's market", (c, t) => {
160
+ const w = 252, h = 126, ml = shift(c.market, 1), r = c.r0[t];
161
+ const er = mean(r, w, h), eml = mean(ml, w, h), erml = mean(map2(r, ml, (x, y) => x * y), w, h);
162
+ const sm = map2(mean(map1(ml, (x) => x * x), w, h), eml, (a, b) => Math.sqrt(a - b * b)), sr = map2(mean(map1(r, (x) => x * x), w, h), er, (a, b) => Math.sqrt(Math.max(a - b * b, 1e-12)));
163
+ return map1(erml, (v, i) => (v - er[i] * eml[i]) / sm[i] / sr[i]);
164
+ }],
165
+ season: ["price", "M", "Heston and Sadka (2008): mean return of the coming calendar month over the previous five years", (c, t) => seasonality(c, t)],
166
+ vol_shock: ["volume", "M", "Gervais, Kaniel and Mingelgrin (2001): one-month dollar volume against the year's", (c, t) => map2(mean(c.dollarVolume[t], 21, 15), mean(c.dollarVolume[t], 252, 200), (a, b) => Math.log(a / b))],
167
+ amihud_63: ["volume", "M", "Amihud (2002): illiquidity, |return| per dollar traded over 3 months", (c, t) => mean(map2(c.r[t], c.dollarVolume[t], (x, v) => (v > 0 ? Math.abs(x) / v : NaN)), 63, 40)],
168
+ small_63: ["volume", "M", "Banz (1981): small size, proxied by low median dollar volume", (c, t) => map1(c.adv[t], (v) => -Math.log(v))],
169
+ dvol_cv: ["volume", "M", "Chordia, Subrahmanyam and Anshuman (2001): steadier dollar volume", (c, t) => {
170
+ const dv = map1(c.dollarVolume[t], (v) => (v > 0 ? v : NaN));
171
+ return map2(std(dv, 126, 80), mean(dv, 126, 80), (s, m) => -(s / m));
172
+ }],
173
+ });
174
+
175
+ // Mean return of the coming calendar month in each of the previous five years, known at month end.
176
+ function seasonality(c, t) {
177
+ const month = c.dates.map((d) => d.slice(0, 7)), out = new Float64Array(c.n).fill(NaN), monthly = new Map();
178
+ for (let i = 0; i < c.n; i += 1) monthly.set(month[i], (monthly.get(month[i]) ?? 1) * (1 + Math.max(c.r0[t][i], -0.99)));
179
+ for (let i = 0; i < c.n; i += 1) {
180
+ const [y, m] = month[i].split("-").map(Number), next = m === 12 ? 1 : m + 1, ny = m === 12 ? y + 1 : y;
181
+ const past = [1, 2, 3, 4, 5].map((k) => monthly.get(`${ny - k}-${String(next).padStart(2, "0")}`)).filter((v) => v !== undefined);
182
+ if (past.length === 5) out[i] = past.reduce((a, b) => a + b - 1, 0) / 5;
183
+ }
184
+ return out;
185
+ }
186
+
187
+ export function signalCatalog() {
188
+ return Object.entries(SIGNALS).map(([name, [needs, every, reference]]) => ({ name, needs, rebalance: every === "W" ? "weekly" : "monthly", reference }));
189
+ }
190
+
191
+ // ---- sleeves and the netted book -------------------------------------------------------------------
192
+
193
+ function rebalanceDays(dates, every) {
194
+ const out = [];
195
+ for (let i = 0; i < dates.length; i += 1) {
196
+ const next = dates[i + 1];
197
+ if (next === undefined) { out.push(i); continue; }
198
+ if (every === "M" ? next.slice(0, 7) !== dates[i].slice(0, 7) : isoWeek(next) !== isoWeek(dates[i])) out.push(i);
199
+ }
200
+ return out;
201
+ }
202
+ function isoWeek(date) {
203
+ const d = new Date(`${date}T00:00:00Z`);
204
+ d.setUTCDate(d.getUTCDate() + 3 - ((d.getUTCDay() + 6) % 7));
205
+ return `${d.getUTCFullYear()}-${Math.floor((d - Date.UTC(d.getUTCFullYear(), 0, 1)) / 6048e5)}`;
206
+ }
207
+
208
+ // Rank-demeaned weights, gross 1 in each group with at least minNames eligible names.
209
+ function groupWeights(c, values, i, groupOf, minNames) {
210
+ const byGroup = new Map();
211
+ for (const t of c.tickers) {
212
+ const v = values[t][i], g = groupOf(t);
213
+ if (g == null || Number.isNaN(v) || !Number.isFinite(v) || !c.eligible[t][i]) continue;
214
+ if (!byGroup.has(g)) byGroup.set(g, []);
215
+ byGroup.get(g).push([t, v]);
216
+ }
217
+ const w = new Map();
218
+ for (const [g, list] of byGroup) {
219
+ if (list.length < minNames) continue;
220
+ list.sort((a, b) => a[1] - b[1]);
221
+ const ranks = new Map();
222
+ for (let k = 0; k < list.length;) { // ties share their mean rank, as pandas' rank(pct=True)
223
+ let j = k; while (j + 1 < list.length && list[j + 1][1] === list[k][1]) j += 1;
224
+ for (let q = k; q <= j; q += 1) ranks.set(list[q][0], ((k + j) / 2 + 1) / list.length);
225
+ k = j + 1;
226
+ }
227
+ const mu = [...ranks.values()].reduce((a, b) => a + b, 0) / list.length;
228
+ let gross = 0;
229
+ for (const [t] of list) gross += Math.abs(ranks.get(t) - mu);
230
+ if (gross > 0) for (const [t] of list) w.set(t, { g, w: (ranks.get(t) - mu) / gross });
231
+ }
232
+ return w;
233
+ }
234
+
235
+ const statsOf = (x) => {
236
+ const v = [...x].filter((y) => !Number.isNaN(y));
237
+ if (v.length < 60) return { days: v.length, sharpe: null };
238
+ const mu = v.reduce((a, b) => a + b, 0) / v.length, sd = Math.sqrt(v.reduce((a, b) => a + (b - mu) ** 2, 0) / (v.length - 1));
239
+ if (!(sd > 0)) return { days: v.length, sharpe: null };
240
+ let eq = 1, peak = 1, dd = 0;
241
+ for (const y of v) { eq *= 1 + y; peak = Math.max(peak, eq); dd = Math.min(dd, eq / peak - 1); }
242
+ const sr = (mu / sd) * Math.sqrt(DAY_YEAR);
243
+ return { days: v.length, sharpe: round(sr, 3), t: round(sr * Math.sqrt(v.length / DAY_YEAR), 2), ann_return: round(mu * DAY_YEAR, 4), ann_vol: round(sd * Math.sqrt(DAY_YEAR), 4), max_drawdown: round(dd, 4) };
244
+ };
245
+ const round = (x, d) => (Number.isFinite(x) ? Math.round(x * 10 ** d) / 10 ** d : null);
246
+
247
+ export function runBreadth(input) {
248
+ const { dates, tickers, groups = null, signals = null, smoothing = null, minNames = 10, targetVol = 0.1, maxLeverage = 5, borrow = 0.005, splitDate = null } = input;
249
+ const c = context(input);
250
+ const groupOf = groups ? (t) => groups[t] ?? null : () => "all";
251
+ const wanted = signals ?? Object.keys(SIGNALS).filter((s) => SIGNALS[s][0] === "price" || c.dollarVolume);
252
+ for (const s of wanted) {
253
+ if (!SIGNALS[s]) throw new RangeError(`unknown signal ${s}; breadth_signals lists them`);
254
+ if (SIGNALS[s][0] === "volume" && !c.dollarVolume) throw new RangeError(`${s} needs dollar_volume_file`);
255
+ }
256
+ const n = c.n, sleeves = [], netted = Object.fromEntries(tickers.map((t) => [t, new Float64Array(n)]));
257
+ const standaloneCost = new Float64Array(n), active = new Float64Array(n);
258
+ const costOf = (t, i) => (c.cost[t] ? (Number.isNaN(c.cost[t][i]) ? 0.004 : c.cost[t][i]) : c.flatCost);
259
+ const pending = [];
260
+ for (const s of wanted) {
261
+ const [, every, reference, fn] = SIGNALS[s];
262
+ const values = Object.fromEntries(tickers.map((t) => [t, fn(c, t)]));
263
+ const k = smoothing ?? (every === "W" ? 4 : 3);
264
+ // Held weights per name (the mean of the last k rebalances, back to gross 1 per group), and its group.
265
+ const held = Object.fromEntries(tickers.map((t) => [t, new Float64Array(n)]));
266
+ const recent = [];
267
+ let last = new Map();
268
+ const rebal = new Set(rebalanceDays(dates, every));
269
+ for (let i = 0; i < n; i += 1) {
270
+ if (rebal.has(i)) {
271
+ recent.push(groupWeights(c, values, i, groupOf, minNames));
272
+ if (recent.length > k) recent.shift();
273
+ const avg = new Map(), gross = new Map();
274
+ for (const wmap of recent) for (const [t, { g, w }] of wmap) { const a = avg.get(t) ?? { g, w: 0 }; a.w += w / recent.length; avg.set(t, a); }
275
+ for (const [, { g, w }] of avg) gross.set(g, (gross.get(g) ?? 0) + Math.abs(w));
276
+ last = new Map([...avg].map(([t, { g, w }]) => [t, { g, w: gross.get(g) > 0 ? w / gross.get(g) : 0 }]));
277
+ }
278
+ for (const [t, { w }] of last) held[t][i] = w;
279
+ }
280
+ // Per group: gross and cost series, from weights traded a day later and earning a day after that.
281
+ const groupNames = new Map();
282
+ for (const t of tickers) { const g = groupOf(t); if (g != null) { if (!groupNames.has(g)) groupNames.set(g, []); groupNames.get(g).push(t); } }
283
+ for (const [g, names] of groupNames) {
284
+ const gross = new Float64Array(n).fill(NaN), costs = new Float64Array(n), live = new Float64Array(n);
285
+ let any = false;
286
+ for (let i = 2; i < n; i += 1) {
287
+ let gr = 0, short = 0, liveGross = 0, cost = 0;
288
+ for (const t of names) {
289
+ const lv = held[t][i - 2];
290
+ if (lv) { gr += lv * c.r0[t][i]; liveGross += Math.abs(lv); if (lv < 0) short -= lv; }
291
+ const traded = held[t][i - 1] - held[t][i - 2];
292
+ if (traded) cost += Math.abs(traded) * costOf(t, i - 1);
293
+ }
294
+ costs[i] = cost;
295
+ if (liveGross > 0) { gross[i] = gr - short * borrow / DAY_YEAR; live[i] = 1; any = true; }
296
+ }
297
+ if (!any) continue;
298
+ // costs[i] are the trades made at close i-1, charged on day i.
299
+ const net = map1(gross, (v, i) => (Number.isNaN(v) ? NaN : v - costs[i]));
300
+ pending.push({ s, g, every, reference, names, held, gross, net, costs });
301
+ }
302
+ }
303
+ // Hedge each sleeve with its trailing beta and scale to target volatility, both lagged a day.
304
+ const m = c.market, mm = mean(m, 252, 126), mvar = map2(mean(map1(m, (x) => x * x), 252, 126), mm, (a, b) => a - b * b);
305
+ for (const p of pending) {
306
+ const raw = map1(p.net, (v) => (Number.isNaN(v) ? 0 : v)), rawMean = mean(raw, 252, 126);
307
+ const b = map1(mean(map2(raw, m, (x, y) => x * y), 252, 126), (v, i) => (v - rawMean[i] * mm[i]) / mvar[i]);
308
+ const hedged = map1(raw, (v, i) => (i > 0 && Number.isFinite(b[i - 1]) ? v - b[i - 1] * m[i] : NaN));
309
+ const vol = map1(std(hedged, 126, 63), (v) => v * Math.sqrt(DAY_YEAR));
310
+ const lev = map1(vol, (v, i) => (i > 0 && vol[i - 1] > 0 ? Math.min(targetVol / vol[i - 1], maxLeverage) : NaN));
311
+ p.returns = map1(hedged, (v, i) => (Number.isNaN(p.net[i]) || !Number.isFinite(lev[i]) ? NaN : v * lev[i]));
312
+ p.lev = lev;
313
+ for (let i = 2; i < n; i += 1) {
314
+ if (Number.isNaN(p.returns[i])) continue;
315
+ active[i] += 1;
316
+ standaloneCost[i] += p.costs[i] * lev[i];
317
+ for (const t of p.names) netted[t][i - 1] += p.held[t][i - 1] * lev[i];
318
+ }
319
+ }
320
+ // The book: equal weight across the sleeves live each day; the account trades the netted weights.
321
+ // Standalone cost is each sleeve's own trades at its leverage; the netted account also pays for
322
+ // changes in leverage, so the netting ratio errs against netting.
323
+ const bookGross = new Float64Array(n).fill(NaN), nettedCost = new Float64Array(n), sCost = new Float64Array(n);
324
+ for (let i = 2; i < n; i += 1) {
325
+ if (!active[i]) continue;
326
+ let s = 0;
327
+ for (const p of pending) if (!Number.isNaN(p.returns[i])) s += p.returns[i] + p.costs[i] * p.lev[i];
328
+ bookGross[i] = s / active[i];
329
+ let nc = 0;
330
+ for (const t of tickers) { const d = netted[t][i - 1] / active[i] - (active[i - 1] ? netted[t][i - 2] / active[i - 1] : 0); if (d) nc += Math.abs(d) * costOf(t, i - 1); }
331
+ nettedCost[i] = nc; sCost[i] = standaloneCost[i] / active[i];
332
+ }
333
+ const netBook = map1(bookGross, (v, i) => v - nettedCost[i]), soloBook = map1(bookGross, (v, i) => v - sCost[i]);
334
+ const ids = pending.map((p) => `${p.s}@${p.g}`), family = pending.map((p) => p.s);
335
+ const bets = effectiveBets(dates, pending.map((p) => p.returns), family);
336
+ const window = (x, after) => (splitDate ? map1(x, (v, i) => ((dates[i] > splitDate) === after ? v : NaN)) : x);
337
+ const sumOf = (x) => [...x].reduce((a, b) => a + (Number.isNaN(b) ? 0 : b), 0);
338
+ return {
339
+ sleeves: pending.length, signals: wanted.length, groups: new Set(pending.map((p) => p.g)).size,
340
+ book: {
341
+ netted: statsOf(netBook), standalone: statsOf(soloBook), before_costs: statsOf(bookGross),
342
+ cost_per_year_netted: round((sumOf(nettedCost) / Math.max(1, [...active].filter(Boolean).length)) * DAY_YEAR, 4),
343
+ cost_per_year_standalone: round((sumOf(sCost) / Math.max(1, [...active].filter(Boolean).length)) * DAY_YEAR, 4),
344
+ netting_ratio: round(sumOf(nettedCost) / sumOf(sCost), 3),
345
+ ...(splitDate ? { search: { netted: statsOf(window(netBook, false)) }, test: { netted: statsOf(window(netBook, true)), note: `After ${splitDate}: judge the book here only if nothing was chosen on these dates.` } } : {}),
346
+ },
347
+ effective_bets: bets,
348
+ sleeve_columns: ["id", "signal", "group", "names", "sharpe_after_costs", "sharpe_before_costs_unhedged"],
349
+ sleeve_rows: pending.map((p, j) => [ids[j], p.s, p.g, p.names.length, statsOf(p.returns).sharpe, statsOf(p.gross).sharpe]),
350
+ series: { ids, family, returns: pending.map((p) => p.returns), book: netBook },
351
+ specs: pending.map((p, j) => ({ id: ids[j], signal: p.s, group: p.g, rebalance: p.every === "W" ? "weekly" : "monthly", smoothing: smoothing ?? (p.every === "W" ? 4 : 3), min_names: minNames, target_vol: targetVol, reference: p.reference, spec_sha256: createHash("sha256").update(JSON.stringify([p.s, p.g, p.every, smoothing, minNames, targetVol, maxLeverage, borrow])).digest("hex") })),
352
+ };
353
+ }
354
+
355
+ // ---- effective independent bets ---------------------------------------------------------------------
356
+
357
+ // Weekly sums (at least 3 days), then correlations over weeks both series have (at least minWeeks).
358
+ function weekly(dates, series) {
359
+ const keys = dates.map(isoWeek), order = [...new Set(keys)];
360
+ const at = new Map(order.map((k, i) => [k, i]));
361
+ return series.map((x) => {
362
+ const s = new Float64Array(order.length), c = new Uint16Array(order.length);
363
+ for (let i = 0; i < x.length; i += 1) if (!Number.isNaN(x[i])) { s[at.get(keys[i])] += x[i]; c[at.get(keys[i])] += 1; }
364
+ return map1(s, (v, i) => (c[i] >= 3 ? v : NaN));
365
+ });
366
+ }
367
+ function corr(a, b, minN) {
368
+ let n = 0, sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0;
369
+ for (let i = 0; i < a.length; i += 1) {
370
+ if (Number.isNaN(a[i]) || Number.isNaN(b[i])) continue;
371
+ n += 1; sa += a[i]; sb += b[i]; saa += a[i] * a[i]; sbb += b[i] * b[i]; sab += a[i] * b[i];
372
+ }
373
+ if (n < minN) return NaN;
374
+ const v = (saa - (sa * sa) / n) * (sbb - (sb * sb) / n);
375
+ return v > 0 ? (sab - (sa * sb) / n) / Math.sqrt(v) : NaN;
376
+ }
377
+ // (trace C)^2 / sum of squared entries = (sum of eigenvalues)^2 / sum of squared eigenvalues.
378
+ function participation(cm) {
379
+ const k = cm.length;
380
+ let s2 = 0;
381
+ for (let i = 0; i < k; i += 1) for (let j = 0; j < k; j += 1) { const v = i === j ? 1 : Number.isNaN(cm[i][j]) ? 0 : cm[i][j]; s2 += v * v; }
382
+ return k ? (k * k) / s2 : 0;
383
+ }
384
+ const matrix = (ws, minWeeks) => ws.map((a, i) => ws.map((b, j) => (i === j ? 1 : j < i ? NaN : corr(a, b, minWeeks)))).map((row, i, all) => row.map((v, j) => (j < i ? all[j][i] : v)));
385
+
386
+ export function effectiveBets(dates, series, family = null, { threshold = 0.5, minWeeks = 104 } = {}) {
387
+ const ws = weekly(dates, series), cm = matrix(ws, minWeeks), k = series.length;
388
+ const off = [];
389
+ for (let i = 0; i < k; i += 1) for (let j = i + 1; j < k; j += 1) if (!Number.isNaN(cm[i][j])) off.push(cm[i][j]);
390
+ const out = { series: k, avg_pairwise_corr: off.length ? round(off.reduce((a, b) => a + b, 0) / off.length, 4) : null, effective_bets: round(participation(cm), 1) };
391
+ if (!family) return out;
392
+ const names = [...new Set(family)];
393
+ const books = names.map((f) => {
394
+ const members = series.filter((_, i) => family[i] === f);
395
+ return map1(members[0], (_, t) => { let s = 0, c = 0; for (const x of members) if (!Number.isNaN(x[t])) { s += x[t]; c += 1; } return c ? s / c : NaN; });
396
+ });
397
+ const bm = matrix(weekly(dates, books), minWeeks);
398
+ // Ideas: average linkage on |corr| of the family books, joined while the link is at least threshold.
399
+ let clusters = names.map((_, i) => [i]);
400
+ for (;;) {
401
+ let best = -1, pair = null;
402
+ for (let i = 0; i < clusters.length; i += 1) for (let j = i + 1; j < clusters.length; j += 1) {
403
+ let s = 0, c = 0;
404
+ for (const a of clusters[i]) for (const b of clusters[j]) { const v = Math.abs(bm[a][b]); s += Number.isNaN(v) ? 0 : v; c += 1; }
405
+ if (s / c > best) { best = s / c; pair = [i, j]; }
406
+ }
407
+ if (!pair || best < threshold) break;
408
+ clusters[pair[0]] = clusters[pair[0]].concat(clusters[pair[1]]);
409
+ clusters.splice(pair[1], 1);
410
+ }
411
+ clusters = clusters.map((cl) => cl.map((i) => names[i])).sort((a, b) => b.length - a.length);
412
+ return { ...out, families: names.length, effective_bets_families: round(participation(bm), 1), ideas: clusters.length, idea_threshold_abs_corr: threshold, idea_members: clusters };
413
+ }
package/src/panel.mjs ADDED
@@ -0,0 +1,123 @@
1
+ // A price panel for the breadth lab and backtest_signal, built from free sources: the tickers you
2
+ // name or an index fund's current holdings (its latest SEC N-PORT), each ticker's daily adjusted
3
+ // closes and volume from canli-markets-mcp's price_history, and an industry for each company from
4
+ // its SEC SIC code, grouped into the twelve Fama-French industries. Writes prices, dollar volume and
5
+ // groups as CSV files breadth_book reads directly.
6
+ import { mkdtempSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { fundPortfolio } from "canli-markets-mcp/src/funds.mjs";
11
+ import { submissions, tickerTable } from "canli-markets-mcp/src/sources.mjs";
12
+ import { priceHistory } from "canli-markets-mcp/src/tools.mjs";
13
+ import { z } from "zod";
14
+
15
+ import { writePanel } from "./panel-io.mjs";
16
+
17
+ const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date as YYYY-MM-DD");
18
+ export const panelInput = z.object({
19
+ tickers: z.array(z.string().min(1).max(12).regex(/^[A-Za-z0-9.\-]+$/)).min(2).max(600).optional().describe("US tickers to include."),
20
+ index_fund: z.string().min(2).max(10).optional().describe("Or an index fund or ETF whose current holdings make the universe: IVV (S&P 500), IJH (mid caps), IJR (small caps), QQQ, VTI..."),
21
+ max_names: z.number().int().min(2).max(600).optional().describe("With index_fund: the largest holdings to take. Default 500."),
22
+ start: date.describe("First date."),
23
+ end: date.optional().describe("Last date; default today."),
24
+ out_dir: z.string().min(1).max(1000).optional().describe("Folder for prices.csv, dollar_volume.csv and groups.csv. Default: a new temporary folder."),
25
+ }).strict();
26
+
27
+ // The twelve Fama-French industries by four-digit SIC (Kenneth French's definitions).
28
+ const FF12 = [
29
+ ["NoDur", [[100, 999], [2000, 2399], [2700, 2749], [2770, 2799], [3100, 3199], [3940, 3989]]],
30
+ ["Durbl", [[2500, 2519], [2590, 2599], [3630, 3659], [3710, 3711], [3714, 3714], [3716, 3716], [3750, 3751], [3792, 3792], [3900, 3939], [3990, 3999]]],
31
+ ["Manuf", [[2520, 2589], [2600, 2699], [2750, 2769], [3000, 3099], [3200, 3569], [3580, 3629], [3700, 3709], [3712, 3713], [3715, 3715], [3717, 3749], [3752, 3791], [3793, 3799], [3830, 3839], [3860, 3899]]],
32
+ ["Enrgy", [[1200, 1399], [2900, 2999]]],
33
+ ["Chems", [[2800, 2829], [2840, 2899]]],
34
+ ["BusEq", [[3570, 3579], [3660, 3692], [3694, 3699], [3810, 3829], [7370, 7379]]],
35
+ ["Telcm", [[4800, 4899]]],
36
+ ["Utils", [[4900, 4949]]],
37
+ ["Shops", [[5000, 5999], [7200, 7299], [7600, 7699]]],
38
+ ["Hlth", [[2830, 2839], [3693, 3693], [3840, 3859], [8000, 8099]]],
39
+ ["Money", [[6000, 6999]]],
40
+ ];
41
+ export function ff12(sic) {
42
+ const s = Number(sic);
43
+ if (!Number.isFinite(s) || s <= 0) return null;
44
+ for (const [name, ranges] of FF12) if (ranges.some(([lo, hi]) => s >= lo && s <= hi)) return name;
45
+ return "Other";
46
+ }
47
+
48
+ const legal = /\b(inc|incorporated|corp|corporation|co|company|ltd|limited|plc|holdings?|group|class [a-c]|cl [a-c]|the|sa|nv|ag|se|lp|llc|new|del|de)\b/g;
49
+ const key = (name) => String(name ?? "").toLowerCase().replace(/&/g, " and ").replace(/[^a-z0-9 ]+/g, " ").replace(legal, " ").replace(/\s+/g, " ").trim();
50
+
51
+ // The holdings of an index fund as tickers, matched to SEC's ticker table by name (a holding whose
52
+ // name matches no listed company, or several, is left out and counted).
53
+ async function fundTickers(session, fund, max) {
54
+ const p = await fundPortfolio(session, { fund, top: Math.min(max * 2, 500), compare: false });
55
+ const byName = new Map();
56
+ for (const r of Object.values(await tickerTable(session))) {
57
+ const k = key(r.title);
58
+ if (!byName.has(k)) byName.set(k, []);
59
+ byName.get(k).push(r);
60
+ }
61
+ const at = (c) => p.columns.indexOf(c);
62
+ const tickers = [], unmatched = [], seen = new Set();
63
+ for (const row of p.rows) {
64
+ if (row[at("asset_category")] !== "EC") continue; // equities only
65
+ const hits = byName.get(key(row[at("name")])) ?? [];
66
+ const cik = new Set(hits.map((h) => h.cik_str));
67
+ if (cik.size !== 1) { unmatched.push(row[at("name")]); continue; }
68
+ if (seen.has(hits[0].cik_str)) continue;
69
+ seen.add(hits[0].cik_str);
70
+ tickers.push(hits[0].ticker);
71
+ if (tickers.length >= max) break;
72
+ }
73
+ return { tickers, unmatched, fund: p.fund, period: p.period };
74
+ }
75
+
76
+ export async function buildPanel(session, args) {
77
+ const a = panelInput.parse(args);
78
+ if (!a.tickers === !a.index_fund) throw new RangeError("Send tickers or index_fund.");
79
+ const end = a.end ?? new Date(session.now()).toISOString().slice(0, 10);
80
+ if (!(a.start < end)) throw new RangeError("start must be before end.");
81
+ let universe = a.tickers?.map((t) => t.toUpperCase()) ?? null, from = null;
82
+ if (a.index_fund) { from = await fundTickers(session, a.index_fund.toUpperCase(), a.max_names ?? 500); universe = from.tickers; }
83
+ const cikOf = new Map(Object.values(await tickerTable(session)).map((r) => [r.ticker, r.cik_str]));
84
+ const series = new Map(), groups = {}, failed = [];
85
+ for (const t of universe) {
86
+ try {
87
+ const p = await priceHistory(session, { symbol: t, start: a.start, end, adjusted: true });
88
+ series.set(t, p);
89
+ const cik = cikOf.get(t.replace(/\./g, "-"));
90
+ const sub = cik ? await submissions(session, cik).catch(() => null) : null;
91
+ groups[t] = ff12(sub?.sic) ?? "Other";
92
+ } catch (err) { failed.push([t, String(err.message).slice(0, 120)]); }
93
+ }
94
+ const tickers = [...series.keys()];
95
+ if (tickers.length < 2) throw new RangeError(`Prices came back for ${tickers.length} ticker(s); ${failed.slice(0, 5).map((f) => f.join(": ")).join("; ")}`);
96
+ const dates = [...new Set(tickers.flatMap((t) => series.get(t).dates))].sort();
97
+ const idx = new Map(dates.map((d, i) => [d, i]));
98
+ const prices = {}, dollars = {};
99
+ for (const t of tickers) {
100
+ const p = series.get(t), px = new Array(dates.length).fill(null), dv = new Array(dates.length).fill(null);
101
+ p.dates.forEach((d, i) => { px[idx.get(d)] = p.close[i]; dv[idx.get(d)] = p.volume?.[i] != null && p.close[i] != null ? Math.round(p.volume[i] * p.close[i]) : null; });
102
+ prices[t] = px; dollars[t] = dv;
103
+ }
104
+ const dir = a.out_dir ?? mkdtempSync(join(tmpdir(), "canli-panel-"));
105
+ const files = { prices_file: join(dir, "prices.csv"), dollar_volume_file: join(dir, "dollar_volume.csv"), groups_file: join(dir, "groups.csv") };
106
+ writePanel(files.prices_file, { dates, tickers, values: prices });
107
+ writePanel(files.dollar_volume_file, { dates, tickers, values: dollars });
108
+ writeFileSync(files.groups_file, `ticker,group\n${tickers.map((t) => `${t},${groups[t]}`).join("\n")}\n`);
109
+ const count = {};
110
+ for (const g of Object.values(groups)) count[g] = (count[g] ?? 0) + 1;
111
+ return {
112
+ ...files, tickers: tickers.length, dates: dates.length, first_date: dates[0], last_date: dates.at(-1),
113
+ groups: count, source: [...new Set(tickers.map((t) => series.get(t).source))],
114
+ ...(from ? { universe: { fund: from.fund, period: from.period, unmatched_holdings: from.unmatched.length } } : {}),
115
+ ...(failed.length ? { failed: { count: failed.length, examples: failed.slice(0, 10) } } : {}),
116
+ next: `breadth_book with prices_file ${files.prices_file}, groups_file ${files.groups_file} and dollar_volume_file ${files.dollar_volume_file}.`,
117
+ limits: [
118
+ "An index fund's current holdings are today's survivors: companies that left the index or failed before today are missing, which flatters a backtest over past years (survivorship bias).",
119
+ "Prices are price_history's provider's (Yahoo's public chart data unless you set an Alpaca or Tiingo key): adjusted for splits and dividends by the provider.",
120
+ "Industries come from each company's SEC SIC code, grouped into the twelve Fama-French industries; a SIC code describes the filer's main line of business as SEC records it.",
121
+ ],
122
+ };
123
+ }
package/src/server.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // Local stdio only: prices, signals and ledgers stay on this machine. Point-in-time SEC data comes
8
8
  // from canli-fundamentals-mcp (cached under ~/.cache/canli-fundamentals); the ledger is
9
9
  // canli-validation-mcp's (files under ~/.canli/ledgers), so both servers read the same searches.
10
- import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
10
+ import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
@@ -16,15 +16,19 @@ import { McpServer } from "@modelcontextprotocol/server";
16
16
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
17
17
  import { z } from "zod";
18
18
  import { createSession as fundamentalsSession, loadCompany, resolveSeries } from "canli-fundamentals-mcp/src/server.mjs";
19
+ import { createSession as marketsSession } from "canli-markets-mcp/src/sources.mjs";
19
20
  import { ledgerDir, recordTrial } from "canli-validation-mcp/src/ledger.mjs";
20
21
 
22
+ import { agreementInput, annotationAgreement, annotationGold, annotationPacket, goldInput, packetInput } from "./annotate.mjs";
23
+ import { BREADTH_LIMITS_TEXT, effectiveBets, runBreadth, signalCatalog } from "./breadth.mjs";
24
+ import { buildPanel, panelInput } from "./panel.mjs";
21
25
  import { ENGINE_LIMITS_TEXT, describe, runBacktest } from "./engine.mjs";
22
26
  import { readPanel, writePanel } from "./panel-io.mjs";
23
27
  import { FACTORS, PIT_LIMITS_TEXT, buildFactorPanel } from "./pit.mjs";
24
28
 
25
29
  export const SERVER_NAME = "canli-backtest-mcp";
26
30
  export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
27
- 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.";
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.";
28
32
  export const SERVER_INFO = Object.freeze({
29
33
  name: SERVER_NAME,
30
34
  version: SERVER_VERSION,
@@ -79,7 +83,33 @@ export const backtestInput = z.object({
79
83
  label: z.string().min(1).max(200).optional().describe("The variant's name in the ledger. Default: the settings."),
80
84
  });
81
85
 
86
+ export const breadthInput = z.object({
87
+ prices_file: filePath.describe("CSV: a date column and one column of closing (total-return) prices per ticker."),
88
+ 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."),
89
+ dollar_volume_file: filePath.optional().describe("CSV like prices_file with daily dollar volume: adds the liquidity signals, a $5m liquidity floor and cost tiers by volume."),
90
+ signals: z.array(z.string().min(1).max(40)).min(1).max(40).optional().describe("Signals from breadth_signals. Default: every one the data allows."),
91
+ min_names: z.number().int().min(3).max(200).optional().describe("Fewest eligible names a group needs at a rebalance. Default 10."),
92
+ smoothing: z.number().int().min(1).max(12).optional().describe("Rebalances whose weights are averaged (overlapping portfolios). Default 3 (monthly), 4 (weekly)."),
93
+ cost_bps: z.number().min(0).max(500).optional().describe("Cost per unit traded when no dollar volume is given. Default 10."),
94
+ target_vol: z.number().gt(0).max(1).optional().describe("Each sleeve's volatility target. Default 0.10."),
95
+ split_date: date.optional().describe("Report the book before and after this date: choose on the first part, judge on the second."),
96
+ out_file: filePath.optional().describe("Where to write every sleeve's daily returns (CSV, one column per sleeve): a dataset to train or select on. Its specs go beside it as .specs.json. Default: a new temporary file."),
97
+ ledger: z.string().regex(/^[a-z0-9][a-z0-9-]{2,63}$/).optional().describe("Record the netted book in a search ledger and judge it against every run there."),
98
+ }).strict();
99
+ export const betsInput = z.object({
100
+ returns_file: filePath.describe("CSV: a date column and one column of daily returns per series (breadth_book's out_file, say)."),
101
+ family_separator: z.string().min(1).max(3).optional().describe("Columns named family<sep>member are grouped into families, e.g. \"@\" for breadth_book's signal@group. Default: no families."),
102
+ threshold: z.number().gt(0).lt(1).optional().describe("Families whose books correlate at least this much (in absolute value, average linkage) count as one idea. Default 0.5."),
103
+ }).strict();
104
+
82
105
  export const TOOL_DESCRIPTIONS = Object.freeze({
106
+ breadth_signals: "The cross-sectional signals breadth_book builds sleeves from: momentum, reversal, low volatility and beta, idiosyncratic risk, lottery demand, skewness, price delay, seasonality and liquidity, each with its formula's source and the data it needs.",
107
+ breadth_book: `Build many weakly correlated sleeves (every signal inside every group of names) from your price panel and trade them as one netted account, ALPHAC's breadth lab: rank-weighted within each group, overlapping portfolios, hedged and scaled to a volatility target, costs on actual trades. Returns the book netted and standalone, the netting ratio, effective independent bets and ideas, and writes every sleeve's returns as a training dataset. ${BREADTH_LIMITS_TEXT[1]}`,
108
+ annotation_packet: "Start a labelled dataset: figures (company, measure, period, value) are checked by machine against the SEC filings and written as blind CSV packets for two or more people to label, with guidelines and a key holding the machine labels.",
109
+ 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
+ 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
+ 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.",
112
+ 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.",
83
113
  list_factors: "The point-in-time factors pit_factor builds from SEC filings, and what each measures.",
84
114
  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]}`,
85
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]}`,
@@ -128,6 +158,58 @@ export async function toolBacktestSignal(session, args) {
128
158
  return text({ settings, tickers: tickers.length, ...stats, plain_reading: plain, ...(search ? { search } : {}), limits: ENGINE_LIMITS_TEXT });
129
159
  }
130
160
 
161
+ function readGroups(path) {
162
+ const lines = readFileSync(path, "utf8").replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim());
163
+ const out = {};
164
+ for (const [i, line] of lines.entries()) {
165
+ const [t, g] = line.split(",").map((x) => x.trim());
166
+ if (i === 0 && /^ticker$/i.test(t)) continue;
167
+ if (!t || !g) throw new RangeError(`groups_file line ${i + 1}: expected ticker,group`);
168
+ out[t] = g;
169
+ }
170
+ return out;
171
+ }
172
+ const asFloat = (values, tickers) => Object.fromEntries(tickers.map((t) => [t, Float64Array.from(values[t], (v) => (v === null ? NaN : v))]));
173
+
174
+ export async function toolBreadthBook(session, args) {
175
+ const a = breadthInput.parse(args);
176
+ const prices = readPanel(a.prices_file, "prices_file");
177
+ const dv = a.dollar_volume_file ? readPanel(a.dollar_volume_file, "dollar_volume_file") : null;
178
+ if (dv && dv.dates.join() !== prices.dates.join()) throw new RangeError("dollar_volume_file must have the same dates as prices_file");
179
+ const tickers = prices.tickers.filter((t) => !dv || dv.tickers.includes(t));
180
+ const r = runBreadth({ dates: prices.dates, tickers, prices: asFloat(prices.values, tickers), dollarVolume: dv ? asFloat(dv.values, tickers) : null,
181
+ groups: a.groups_file ? readGroups(a.groups_file) : null, signals: a.signals ?? null, minNames: a.min_names ?? 10, smoothing: a.smoothing ?? null,
182
+ costBps: a.cost_bps ?? 10, targetVol: a.target_vol ?? 0.1, splitDate: a.split_date ?? null });
183
+ if (!r.sleeves) throw new RangeError("No sleeve had enough eligible names: lower min_names, add names to the groups, or check that prices are at least 5.");
184
+ const file = a.out_file ?? join(session.outDir ?? mkdtempSync(join(tmpdir(), "canli-breadth-")), "sleeves.csv");
185
+ const cell = (x) => (Number.isNaN(x) ? null : Math.round(x * 1e8) / 1e8);
186
+ writePanel(file, { dates: prices.dates, tickers: [...r.series.ids, "book_netted"], values: Object.fromEntries([...r.series.ids.map((id, j) => [id, Array.from(r.series.returns[j], cell)]), ["book_netted", Array.from(r.series.book, cell)]]) });
187
+ writeFileSync(`${file}.specs.json`, JSON.stringify({ schema: "canli.breadth-sleeves.v1", prices_file: a.prices_file, settings: { min_names: a.min_names ?? 10, smoothing: a.smoothing ?? null, cost_bps: a.cost_bps ?? 10, target_vol: a.target_vol ?? 0.1 }, sleeves: r.specs }, null, 1));
188
+ let search = null;
189
+ if (a.ledger) {
190
+ const res = recordTrial(session.ledgerDir, { ledger: a.ledger, label: `breadth ${r.signals} signals x ${r.groups} groups`, returns: Array.from(r.series.book).filter((x) => !Number.isNaN(x)), periods_per_year: 252, params: { signals: a.signals ?? "all", groups_file: a.groups_file ?? null, min_names: a.min_names ?? 10 } });
191
+ search = { ledger: res.ledger, trial: res.trial, trials: res.trials, deflated: res.deflated, plain_reading: res.plain_reading };
192
+ }
193
+ const b = r.book;
194
+ const top = [...r.sleeve_rows].sort((x, y) => (y[4] ?? -9) - (x[4] ?? -9));
195
+ return text({
196
+ sleeves: r.sleeves, signals: r.signals, groups: r.groups, book: b, effective_bets: r.effective_bets,
197
+ sleeve_columns: r.sleeve_columns, best_sleeves: top.slice(0, 10), worst_sleeves: top.slice(-5),
198
+ dataset: { file, specs: `${file}.specs.json`, columns: r.sleeves + 1, rows: prices.dates.length },
199
+ plain_reading: `${r.sleeves} sleeves (${r.signals} signals x ${r.groups} groups) hold ${r.effective_bets.effective_bets} independent bets at sleeve level${r.effective_bets.effective_bets_families ? `, ${r.effective_bets.effective_bets_families} across signal families and ${r.effective_bets.ideas} ideas` : ""}. Traded as one account the book's Sharpe after costs is ${b.netted.sharpe ?? "n/a"} (${b.standalone.sharpe ?? "n/a"} if each sleeve paid its own costs); netting cuts costs to ${b.netting_ratio} of the standalone total.`,
200
+ ...(search ? { search } : {}),
201
+ limits: BREADTH_LIMITS_TEXT,
202
+ });
203
+ }
204
+
205
+ export async function toolEffectiveBets(args) {
206
+ const a = betsInput.parse(args);
207
+ const p = readPanel(a.returns_file, "returns_file");
208
+ const series = p.tickers.map((t) => Float64Array.from(p.values[t], (v) => (v === null ? NaN : v)));
209
+ const family = a.family_separator ? p.tickers.map((t) => t.split(a.family_separator)[0]) : null;
210
+ return text({ ...effectiveBets(p.dates, series, family, { threshold: a.threshold ?? 0.5 }), meaning: "effective_bets is (sum of eigenvalues)^2 / sum of squared eigenvalues of the weekly correlation matrix: Sharpe grows with the square root of independent bets, not of series. A sign flip does not make a new bet." });
211
+ }
212
+
131
213
  const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
132
214
  const WRITES_LOCAL = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
133
215
 
@@ -140,6 +222,13 @@ export function registerTools(server, session) {
140
222
  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 }));
141
223
  tool("pit_factor", "Build a point-in-time factor", pitInput, { ...WRITES_LOCAL, openWorldHint: true }, (args) => toolPitFactor(session, args));
142
224
  tool("backtest_signal", "Backtest a signal without lookahead", backtestInput, WRITES_LOCAL, (args) => toolBacktestSignal(session, args));
225
+ tool("price_panel", "Build a price panel", panelInput, { ...WRITES_LOCAL, openWorldHint: true }, async (args) => text(await buildPanel(session.markets ??= marketsSession(), args)));
226
+ 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
+ tool("breadth_book", "Breadth book of sleeves", breadthInput, WRITES_LOCAL, (args) => toolBreadthBook(session, args));
228
+ tool("effective_bets", "Effective independent bets", betsInput, READ_ONLY, (args) => toolEffectiveBets(args));
229
+ tool("annotation_packet", "Annotation packets", packetInput, { ...WRITES_LOCAL, openWorldHint: true }, async (args) => text(await annotationPacket(session.markets ??= marketsSession(), args)));
230
+ tool("annotation_agreement", "Annotation agreement", agreementInput, READ_ONLY, async (args) => text(annotationAgreement(args)));
231
+ tool("annotation_gold", "Annotation gold set", goldInput, WRITES_LOCAL, async (args) => text(annotationGold(args)));
143
232
  return catalog;
144
233
  }
145
234