canli-backtest-mcp 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/docs/AGENT_EXCHANGE.md +45 -0
- package/package.json +2 -1
- package/src/arena.mjs +150 -0
- package/src/consensus.mjs +89 -0
- package/src/cpcv.mjs +164 -0
- package/src/diversifiers.mjs +69 -0
- package/src/engine.mjs +94 -6
- package/src/exchange.mjs +439 -0
- package/src/league.mjs +243 -0
- package/src/proof.mjs +200 -0
- package/src/server.mjs +100 -7
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
|
+
}
|