canli-backtest-mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +2 -2
- package/src/arena.mjs +150 -0
- package/src/cpcv.mjs +164 -0
- package/src/engine.mjs +94 -6
- package/src/league.mjs +243 -0
- package/src/proof.mjs +200 -0
- package/src/server.mjs +82 -7
- package/src/sweep-worker.mjs +19 -0
- package/src/sweep.mjs +150 -0
package/README.md
CHANGED
|
@@ -29,6 +29,31 @@ Three mistakes make most agent-run backtests look better than they are:
|
|
|
29
29
|
| `pit_factor` | A factor (`roa`, `gross_margin`, `operating_margin`, `revenue_growth`, `asset_growth`, `accruals`, or `level:<measure>`) for your tickers, monthly, as known on each date. Writes a signal CSV. |
|
|
30
30
|
| `backtest_signal` | Rank on the signal, long the top quantile (and short the bottom), rebalance weekly, monthly, quarterly or every N days, with `lag_days`, `cost_bps` and an optional `ledger`. |
|
|
31
31
|
|
|
32
|
+
## Breadth lab, sweeps and labelled data
|
|
33
|
+
|
|
34
|
+
| tool | what it does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `price_panel` | A price, dollar-volume and industry-group panel built from a fund's holdings (IVV for the S&P 500), ready for the tools below |
|
|
37
|
+
| `breadth_signals` | The 24 cross-sectional signals the lab builds sleeves from, each with its reference |
|
|
38
|
+
| `breadth_book` | Every signal inside every group of names as a sleeve, traded as one netted account: the book's Sharpe after costs, the netting saved, and its effective independent bets; writes the sleeves as a CSV with their specs |
|
|
39
|
+
| `breadth_sweep` | A grid of book settings run in parallel on every core, or as shards other machines run and merge back (inputs, grid, gaps and duplicates checked); the best Sharpe is set against what the best of that many trials reaches by luck, and with `split_date` the chosen settings are scored after it |
|
|
40
|
+
| `effective_bets` | How many independent bets a set of return series holds (the participation ratio of their correlations), by series and by family |
|
|
41
|
+
| `annotation_packet`, `annotation_agreement`, `annotation_gold` | Blind labelling packets of figures (or of a draft's figures) with guidelines and an answer key; agreement between labellers (Cohen's kappa); and a gold set with its datasheet |
|
|
42
|
+
|
|
43
|
+
## Size, crises and cross-validation
|
|
44
|
+
|
|
45
|
+
A backtest that assumes any size fills at the close flatters itself, so:
|
|
46
|
+
|
|
47
|
+
- **Size.** Give `backtest_signal` a `capital_usd` and a `dollar_volume_file`. An order fills only up to `max_participation` of recent daily volume (10% unless you say otherwise) and pays square-root impact, with volume and volatility read from the days before each trade. The reply shows the Sharpe at $100k to $1B, and no Sharpe for a size the market could not take. Order-book depth is not modelled: it needs paid data.
|
|
48
|
+
- **Crises.** Every reply holds the result against named crises, from the dot-com bust to the 2023 bank run. A crisis its dates do not fully cover is listed as untested, never as passed. `crisis_replay` does this for any returns file.
|
|
49
|
+
- **Cross-validation.** `cpcv` takes every variant's returns and, for each choice of held-out blocks, chooses the best on the rest, with purge and embargo at the edges, then scores it on what was held out. You get a spread of out-of-sample results, not one path. Splits can run as shards on other machines and merge back.
|
|
50
|
+
|
|
51
|
+
## A record, a league and a paper market
|
|
52
|
+
|
|
53
|
+
- **Proof ledger.** `proof_filings` writes what companies filed into an append-only file, each line chained to the last and each batch signed, with a line for every figure a later filing changed. `proof_file` adds any file's hash, `proof_verify` checks the whole file and says which line first fails. It shows lines were not altered, not that a figure is right, and no record means only no record. Publish the head somewhere else, or the key holder could rewrite it all. The [published ledger](https://github.com/arhancanli/canlicapital/releases) does that with a public attestation each month.
|
|
54
|
+
- **League.** `league_new`, `league_commit` and `league_score` run a tournament scored after the fact: an entrant signs and commits a hash before a round closes and shows the file afterwards, and anyone with the files can rerun the table. Each record is set against the best luck gives across that many entrants. The top entrants, chosen on earlier rounds only, form a paper book. No prizes, no stakes, nobody's money.
|
|
55
|
+
- **Arena.** `arena_new`, `arena_orders` and `arena_step` are a paper market for agents to trade in, cleared in a call auction, reproducible from a seed. Made-up assets and made-up money: it cannot reach anything real.
|
|
56
|
+
|
|
32
57
|
## Quick start
|
|
33
58
|
|
|
34
59
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-backtest-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "MCP server for backtests that refuse lookahead: point-in-time factors from SEC filings, cross-sectional backtests of any signal on your own prices, costs on actual trades, and every variant counted in a trial ledger.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/server": "2.3.1",
|
|
23
23
|
"canli-fundamentals-mcp": "^0.7.0",
|
|
24
|
-
"canli-markets-mcp": "^0.
|
|
24
|
+
"canli-markets-mcp": "^0.7.0",
|
|
25
25
|
"canli-validation-mcp": "^0.14.0",
|
|
26
26
|
"zod": "4.6.7"
|
|
27
27
|
},
|
package/src/arena.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The arena: a paper market for AI agents to trade in. Made-up assets and made-up money, cleared in a
|
|
2
|
+
// uniform-price call auction each step, reproducible from a seed. It is here so agents' behaviour
|
|
3
|
+
// together can be studied: who supplies liquidity, who gets picked off, what a crowd does to a price.
|
|
4
|
+
//
|
|
5
|
+
// It touches nothing real. No real security, token or currency is represented, no order leaves the
|
|
6
|
+
// file, and nothing here can be turned into a real market: running one needs registration as an
|
|
7
|
+
// exchange or as a broker-dealer running an alternative trading system.
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
export const ARENA_LIMITS_TEXT = Object.freeze([
|
|
12
|
+
"Paper only: made-up assets and made-up money. Nothing here represents or can reach a real security, token or currency.",
|
|
13
|
+
"A simulated market leaves out most of what moves real ones, so behaviour here is a sketch for study and does not predict results in real markets.",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const file = z.string().min(1).max(1000);
|
|
17
|
+
const assetId = z.string().regex(/^ART[0-9]{1,2}$/, "an arena asset, such as ART1");
|
|
18
|
+
// Names become keys in the state file, so ones every JavaScript object already answers to are refused.
|
|
19
|
+
const agentId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{1,39}$/).refine((n) => !(n in {}), "that name is reserved");
|
|
20
|
+
export const arenaNewInput = z.object({
|
|
21
|
+
arena_file: file.describe("The arena's state file; must not exist yet."),
|
|
22
|
+
seed: z.number().int().min(0).max(2 ** 31 - 2).optional().describe("The same seed and the same orders give the same market. Default 1."),
|
|
23
|
+
assets: z.number().int().min(1).max(20).optional().describe("Made-up assets. Default 3."),
|
|
24
|
+
starting_cash: z.number().positive().max(1e9).optional().describe("Made-up money each agent starts with. Default 100000."),
|
|
25
|
+
bots: z.number().int().min(0).max(50).optional().describe("Scripted traders that keep the market moving: noise, value and momentum in turn. Default 6."),
|
|
26
|
+
}).strict();
|
|
27
|
+
export const arenaOrdersInput = z.object({
|
|
28
|
+
arena_file: file,
|
|
29
|
+
agent: agentId,
|
|
30
|
+
orders: z.array(z.object({ asset: assetId, side: z.enum(["buy", "sell"]), quantity: z.number().int().positive().max(1e7), limit: z.number().positive().max(1e7) }).strict()).max(50).describe("Replaces this agent's orders for the coming step. No short selling and no borrowing."),
|
|
31
|
+
}).strict();
|
|
32
|
+
export const arenaStepInput = z.object({ arena_file: file, steps: z.number().int().min(1).max(1000).optional().describe("Default 1.") }).strict();
|
|
33
|
+
|
|
34
|
+
const SHARES_PER_AGENT = 100;
|
|
35
|
+
const load = (f) => { if (!existsSync(f)) throw new RangeError(`${f} is not an arena yet: call arena_new`); const s = JSON.parse(readFileSync(f, "utf8")); if (s.schema !== "canli.arena.v1") throw new RangeError(`${f} is not an arena file`); return s; };
|
|
36
|
+
// Every order in a step comes from an agent in the file, however the file was written.
|
|
37
|
+
const orderLines = (state) => Object.entries(state.orders).filter(([agent]) => Object.hasOwn(state.agents, agent));
|
|
38
|
+
const save = (f, s) => writeFileSync(f, JSON.stringify(s));
|
|
39
|
+
function rng(state) { state.rng = (state.rng * 16807) % 2147483647; return state.rng / 2147483647; }
|
|
40
|
+
const gauss = (s) => Math.sqrt(-2 * Math.log(rng(s))) * Math.cos(2 * Math.PI * rng(s));
|
|
41
|
+
const newAgent = (s) => ({ cash: s.starting_cash, holdings: Object.fromEntries(s.assets.map((x) => [x.id, SHARES_PER_AGENT])), start_worth: s.starting_cash + s.assets.reduce((n, x) => n + SHARES_PER_AGENT * x.last, 0) });
|
|
42
|
+
const round2 = (x) => Math.round(x * 100) / 100;
|
|
43
|
+
|
|
44
|
+
export function arenaNew(args) {
|
|
45
|
+
const a = arenaNewInput.parse(args);
|
|
46
|
+
if (existsSync(a.arena_file)) throw new RangeError(`${a.arena_file} already exists`);
|
|
47
|
+
const state = { schema: "canli.arena.v1", paper_only: true, step: 0, rng: (a.seed ?? 1) + 1, starting_cash: a.starting_cash ?? 100000, assets: [], agents: {}, orders: {}, history: [] };
|
|
48
|
+
for (let i = 0; i < (a.assets ?? 3); i += 1) { const value = round2(50 + 100 * rng(state)); state.assets.push({ id: `ART${i + 1}`, value, last: value }); }
|
|
49
|
+
const kinds = ["noise", "value", "momentum"];
|
|
50
|
+
state.bots = Array.from({ length: a.bots ?? 6 }, (_, i) => ({ name: `bot-${kinds[i % 3]}-${i}`, kind: kinds[i % 3] }));
|
|
51
|
+
for (const b of state.bots) state.agents[b.name] = newAgent(state);
|
|
52
|
+
save(a.arena_file, state);
|
|
53
|
+
return { arena_file: a.arena_file, assets: state.assets.map((x) => ({ id: x.id, last: x.last })), bots: state.bots.length, plain_reading: "A new paper arena: made-up assets and made-up money. Send orders with arena_orders, then call arena_step.", limits: ARENA_LIMITS_TEXT };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Refused if the agent could not pay for its buys or deliver its sells even if all of them filled.
|
|
57
|
+
export function checkOrders(state, agent, orders) {
|
|
58
|
+
const me = Object.hasOwn(state.agents, agent) ? state.agents[agent] : newAgent(state), known = new Set(state.assets.map((x) => x.id));
|
|
59
|
+
let spend = 0;
|
|
60
|
+
const selling = {};
|
|
61
|
+
for (const o of orders) {
|
|
62
|
+
if (!known.has(o.asset)) throw new RangeError(`no asset ${o.asset} in this arena`);
|
|
63
|
+
if (o.side === "buy") spend += o.quantity * o.limit; else selling[o.asset] = (selling[o.asset] ?? 0) + o.quantity;
|
|
64
|
+
}
|
|
65
|
+
if (spend > me.cash + 1e-9) throw new RangeError(`these buys could cost ${round2(spend)} and ${agent} has ${round2(me.cash)}: no borrowing here`);
|
|
66
|
+
for (const [k, q] of Object.entries(selling)) if (q > (me.holdings[k] ?? 0)) throw new RangeError(`${agent} holds ${me.holdings[k] ?? 0} ${k} and is selling ${q}: no short selling here`);
|
|
67
|
+
}
|
|
68
|
+
export function arenaOrders(args) {
|
|
69
|
+
const a = arenaOrdersInput.parse(args), state = load(a.arena_file);
|
|
70
|
+
if (state.bots.some((b) => b.name === a.agent)) throw new RangeError(`${a.agent} is one of the arena's scripted traders`);
|
|
71
|
+
checkOrders(state, a.agent, a.orders);
|
|
72
|
+
if (!Object.hasOwn(state.agents, a.agent)) state.agents[a.agent] = newAgent(state);
|
|
73
|
+
state.orders[a.agent] = a.orders;
|
|
74
|
+
save(a.arena_file, state);
|
|
75
|
+
return { agent: a.agent, orders_waiting: a.orders.length, step: state.step, plain_reading: `${a.orders.length} paper orders wait for step ${state.step + 1}.`, limits: ARENA_LIMITS_TEXT };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The uniform-price call auction: the price where the most can trade; among those, where the
|
|
79
|
+
// imbalance is smallest, then nearest the last price. Best limits fill first, earliest first at a tie.
|
|
80
|
+
export function clearAuction(orders, last) {
|
|
81
|
+
const buys = orders.filter((o) => o.side === "buy").sort((a, b) => b.limit - a.limit || a.at - b.at);
|
|
82
|
+
const sells = orders.filter((o) => o.side === "sell").sort((a, b) => a.limit - b.limit || a.at - b.at);
|
|
83
|
+
const candidates = [...new Set(orders.map((o) => o.limit))];
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const p of candidates) {
|
|
86
|
+
const demand = buys.filter((o) => o.limit >= p).reduce((n, o) => n + o.quantity, 0), supply = sells.filter((o) => o.limit <= p).reduce((n, o) => n + o.quantity, 0);
|
|
87
|
+
const volume = Math.min(demand, supply), imbalance = Math.abs(demand - supply), gap = Math.abs(p - last);
|
|
88
|
+
if (volume > 0 && (!best || volume > best.volume || (volume === best.volume && (imbalance < best.imbalance || (imbalance === best.imbalance && gap < best.gap))))) best = { price: p, volume, imbalance, gap };
|
|
89
|
+
}
|
|
90
|
+
if (!best) return { price: null, volume: 0, fills: [] };
|
|
91
|
+
const fills = [];
|
|
92
|
+
const take = (list, side) => { let left = best.volume; for (const o of list) { if (left <= 0) break; if (side === "buy" ? o.limit < best.price : o.limit > best.price) continue; const q = Math.min(o.quantity, left); fills.push({ agent: o.agent, side, quantity: q }); left -= q; } };
|
|
93
|
+
take(buys, "buy");
|
|
94
|
+
take(sells, "sell");
|
|
95
|
+
return { price: best.price, volume: best.volume, fills };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function botOrders(state, bot, asset, previous, left) {
|
|
99
|
+
const me = state.agents[bot.name], qty = 1 + Math.floor(rng(state) * 5), noise = 1 + 0.01 * gauss(state);
|
|
100
|
+
let side, limit;
|
|
101
|
+
if (bot.kind === "value") { side = asset.last < asset.value ? "buy" : "sell"; limit = (asset.last + asset.value) / 2; }
|
|
102
|
+
else if (bot.kind === "momentum") { side = previous !== null && asset.last >= previous ? "buy" : "sell"; limit = asset.last * (side === "buy" ? 1.01 : 0.99); }
|
|
103
|
+
else { side = rng(state) < 0.5 ? "buy" : "sell"; limit = asset.last * noise; }
|
|
104
|
+
limit = Math.max(0.01, round2(limit));
|
|
105
|
+
if (side === "buy") { if (left.cash < qty * limit) return null; left.cash -= qty * limit; }
|
|
106
|
+
if (side === "sell" && (me.holdings[asset.id] ?? 0) < qty) return null;
|
|
107
|
+
return { asset: asset.id, side, quantity: qty, limit };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function stepOnce(state) {
|
|
111
|
+
state.step += 1;
|
|
112
|
+
// Buys are held against cash across assets in this step, so no agent can overspend across auctions.
|
|
113
|
+
const all = [];
|
|
114
|
+
let at = 0;
|
|
115
|
+
for (const [agent, orders] of orderLines(state)) for (const o of orders) all.push({ ...o, agent, at: at++ });
|
|
116
|
+
const previous = state.history.at(-1)?.prices_before ?? {};
|
|
117
|
+
// What a bot could pay is held across its auctions in a step, as an agent's buys are at arena_orders.
|
|
118
|
+
for (const bot of state.bots) { const left = { cash: state.agents[bot.name].cash }; for (const asset of state.assets) { const o = botOrders(state, bot, asset, previous[asset.id] ?? null, left); if (o) all.push({ ...o, agent: bot.name, at: at++ }); } }
|
|
119
|
+
const record = { step: state.step, prices_before: Object.fromEntries(state.assets.map((x) => [x.id, x.last])), auctions: {} };
|
|
120
|
+
for (const asset of state.assets) {
|
|
121
|
+
const r = clearAuction(all.filter((o) => o.asset === asset.id), asset.last);
|
|
122
|
+
record.auctions[asset.id] = { price: r.price, volume: r.volume };
|
|
123
|
+
if (r.price !== null) {
|
|
124
|
+
for (const f of r.fills) {
|
|
125
|
+
const me = state.agents[f.agent], cost = f.quantity * r.price;
|
|
126
|
+
if (f.side === "buy") { me.cash -= cost; me.holdings[asset.id] = (me.holdings[asset.id] ?? 0) + f.quantity; }
|
|
127
|
+
else { me.cash += cost; me.holdings[asset.id] -= f.quantity; }
|
|
128
|
+
}
|
|
129
|
+
asset.last = r.price;
|
|
130
|
+
}
|
|
131
|
+
// The made-up value drifts on its own each step: there is no real company behind it.
|
|
132
|
+
asset.value = Math.max(1, round2(asset.value * (1 + 0.01 * gauss(state))));
|
|
133
|
+
}
|
|
134
|
+
state.orders = {};
|
|
135
|
+
state.history.push(record);
|
|
136
|
+
if (state.history.length > 500) state.history.shift();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function standings(state) {
|
|
140
|
+
const worth = (me) => me.cash + state.assets.reduce((n, x) => n + (me.holdings[x.id] ?? 0) * x.last, 0);
|
|
141
|
+
return Object.entries(state.agents).map(([agent, me]) => ({ agent, bot: state.bots.some((b) => b.name === agent), worth: round2(worth(me)), gain: round2(worth(me) - me.start_worth), cash: round2(me.cash), holdings: me.holdings })).sort((a, b) => b.gain - a.gain);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function arenaStep(args) {
|
|
145
|
+
const a = arenaStepInput.parse(args), state = load(a.arena_file);
|
|
146
|
+
for (let i = 0; i < (a.steps ?? 1); i += 1) stepOnce(state);
|
|
147
|
+
save(a.arena_file, state);
|
|
148
|
+
const last = state.history.at(-1);
|
|
149
|
+
return { step: state.step, prices: Object.fromEntries(state.assets.map((x) => [x.id, x.last])), last_auctions: last.auctions, standings: standings(state).slice(0, 25), plain_reading: `Paper step ${state.step} cleared. No real asset or money is involved.`, limits: ARENA_LIMITS_TEXT };
|
|
150
|
+
}
|
package/src/cpcv.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// cpcv: combinatorial purged cross-validation (Lopez de Prado 2018, Advances in Financial Machine
|
|
2
|
+
// Learning, chapter 12) over any set of trial return series. History is cut into N blocks. Every
|
|
3
|
+
// choice of k blocks is a test set; on what remains the best trial is chosen, with days beside the
|
|
4
|
+
// test blocks purged and embargoed so nothing leaks across the edge, and that trial's test returns
|
|
5
|
+
// are kept. The kept pieces stitch into C(N-1, k-1) whole paths, so a search gets a spread of
|
|
6
|
+
// out-of-sample results rather than the one path history happened to take.
|
|
7
|
+
//
|
|
8
|
+
// A split is the unit of work: any machine with the same file can run a shard of them, and a shard
|
|
9
|
+
// carries the hash of the file and settings, so a merge refuses other data, gaps and duplicates.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
import { readPanel } from "./panel-io.mjs";
|
|
15
|
+
|
|
16
|
+
const filePath = z.string().min(1).max(1000);
|
|
17
|
+
export const cpcvInput = z.object({
|
|
18
|
+
returns_file: filePath.optional().describe("CSV: a date column and one column of daily returns per trial (breadth_book's out_file, say). At least two trials."),
|
|
19
|
+
groups: z.number().int().min(3).max(30).optional().describe("Blocks history is cut into. Default 10."),
|
|
20
|
+
test_groups: z.number().int().min(1).max(29).optional().describe("Blocks in each test set. Default 2."),
|
|
21
|
+
purge_days: z.number().int().min(0).max(500).optional().describe("Training days dropped before each test block: at least the horizon the trials' returns look ahead over. Default 5."),
|
|
22
|
+
embargo_days: z.number().int().min(0).max(500).optional().describe("Training days dropped after each test block. Default 5."),
|
|
23
|
+
periods_per_year: z.number().positive().optional().describe("Default 252."),
|
|
24
|
+
shard: z.object({ index: z.number().int().min(0), of: z.number().int().min(2).max(1000) }).strict().optional().describe("Run only splits s with s mod of = index, to spread the work over machines."),
|
|
25
|
+
out_file: filePath.optional().describe("Write the shard as JSON here."),
|
|
26
|
+
merge_files: z.array(filePath).min(2).max(1000).optional().describe("Instead of running: shard files to merge into one result."),
|
|
27
|
+
}).strict();
|
|
28
|
+
|
|
29
|
+
export const CPCV_LIMITS_TEXT = Object.freeze([
|
|
30
|
+
"The trial with the highest training Sharpe is the one chosen in each split; another way of choosing would give other paths.",
|
|
31
|
+
"Paths share days and data, so their spread is not a confidence interval and they are not independent tests.",
|
|
32
|
+
"This covers the search you supply. Variants tried and left out of the file are not counted, and survivorship in the data behind the trials passes straight through.",
|
|
33
|
+
]);
|
|
34
|
+
const MAX_SPLITS = 5000, MAX_CELLS = 100_000_000;
|
|
35
|
+
|
|
36
|
+
export function choose(n, k) {
|
|
37
|
+
let c = 1;
|
|
38
|
+
for (let i = 1; i <= k; i += 1) c = (c * (n - k + i)) / i;
|
|
39
|
+
return Math.round(c);
|
|
40
|
+
}
|
|
41
|
+
export function splitsOf(n, k) {
|
|
42
|
+
const out = [], pick = [];
|
|
43
|
+
const walk = (from) => {
|
|
44
|
+
if (pick.length === k) { out.push([...pick]); return; }
|
|
45
|
+
for (let g = from; g < n; g += 1) { pick.push(g); walk(g + 1); pick.pop(); }
|
|
46
|
+
};
|
|
47
|
+
walk(0);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// Blocks of whole days, as equal as they can be: [first, last] inclusive.
|
|
51
|
+
export function blocksOf(days, n) {
|
|
52
|
+
return Array.from({ length: n }, (_, g) => [Math.floor((g * days) / n), Math.floor(((g + 1) * days) / n) - 1]);
|
|
53
|
+
}
|
|
54
|
+
// 1 for a day training may use: outside the test blocks and their purge and embargo margins.
|
|
55
|
+
export function trainMask(days, blocks, test, purge, embargo) {
|
|
56
|
+
const mask = new Uint8Array(days).fill(1);
|
|
57
|
+
for (const g of test) {
|
|
58
|
+
const [a, b] = blocks[g];
|
|
59
|
+
for (let t = Math.max(0, a - purge); t <= Math.min(days - 1, b + embargo); t += 1) mask[t] = 0;
|
|
60
|
+
}
|
|
61
|
+
return mask;
|
|
62
|
+
}
|
|
63
|
+
const sharpeOf = (values, scale) => {
|
|
64
|
+
let n = 0, mean = 0, s = 0;
|
|
65
|
+
for (const v of values) { if (!Number.isFinite(v)) continue; n += 1; const d = v - mean; mean += d / n; s += d * (v - mean); }
|
|
66
|
+
return n > 1 && s > 0 ? (mean / Math.sqrt(s / (n - 1))) * scale : null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// One split: the best trial on the training days, and its returns on each test block.
|
|
70
|
+
export function runSplit(panel, blocks, test, { purge, embargo, scale }) {
|
|
71
|
+
const days = panel.dates.length, mask = trainMask(days, blocks, test, purge, embargo);
|
|
72
|
+
let best = -1, bestSharpe = -Infinity;
|
|
73
|
+
panel.trials.forEach((name, i) => {
|
|
74
|
+
const r = panel.values[i];
|
|
75
|
+
let n = 0, mean = 0, s = 0;
|
|
76
|
+
for (let t = 0; t < days; t += 1) { if (!mask[t] || !Number.isFinite(r[t])) continue; n += 1; const d = r[t] - mean; mean += d / n; s += d * (r[t] - mean); }
|
|
77
|
+
const sh = n > 1 && s > 0 ? (mean / Math.sqrt(s / (n - 1))) * scale : -Infinity;
|
|
78
|
+
if (sh > bestSharpe) { bestSharpe = sh; best = i; }
|
|
79
|
+
});
|
|
80
|
+
if (best < 0 || !Number.isFinite(bestSharpe)) throw new RangeError(`no trial has enough training days when blocks ${test.join(",")} are the test set; use fewer groups, a shorter purge, or a longer file`);
|
|
81
|
+
// A chosen trial with no value on a day held nothing that day: it earns zero, and the day is counted.
|
|
82
|
+
let missing = 0;
|
|
83
|
+
const pieces = test.map((g) => {
|
|
84
|
+
const [a, b] = blocks[g], out = [];
|
|
85
|
+
for (let t = a; t <= b; t += 1) { const v = panel.values[best][t]; if (Number.isFinite(v)) out.push(v); else { out.push(0); missing += 1; } }
|
|
86
|
+
return [g, out];
|
|
87
|
+
});
|
|
88
|
+
return { chosen: best, train_sharpe: bestSharpe, missing, pieces };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The kept pieces, whole: for path j, block g comes from the j-th split that tested g.
|
|
92
|
+
export function assemble(results, groups) {
|
|
93
|
+
const seenByGroup = Array.from({ length: groups }, () => []);
|
|
94
|
+
for (const r of results.slice().sort((x, y) => x.s - y.s)) for (const [g, piece] of r.pieces) seenByGroup[g].push(piece);
|
|
95
|
+
const paths = seenByGroup[0].length;
|
|
96
|
+
for (const list of seenByGroup) if (list.length !== paths) throw new RangeError("the splits do not cover every block the same number of times");
|
|
97
|
+
return Array.from({ length: paths }, (_, j) => seenByGroup.flatMap((list) => list[j]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function summarize(results, meta) {
|
|
101
|
+
const scale = Math.sqrt(meta.periods_per_year), sorted = (a) => a.slice().sort((x, y) => x - y), median = (a) => { const s = sorted(a), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
102
|
+
const pathReturns = assemble(results, meta.groups);
|
|
103
|
+
const path_sharpes = pathReturns.map((p) => sharpeOf(p, scale)).filter((x) => x !== null);
|
|
104
|
+
const train = results.map((r) => r.train_sharpe);
|
|
105
|
+
const counts = new Map();
|
|
106
|
+
for (const r of results) counts.set(r.chosen, (counts.get(r.chosen) ?? 0) + 1);
|
|
107
|
+
const chosen = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([i, n]) => ({ trial: meta.trials[i], splits: n }));
|
|
108
|
+
const lost = path_sharpes.filter((x) => x <= 0).length;
|
|
109
|
+
const out = {
|
|
110
|
+
first_date: meta.first_date, last_date: meta.last_date, trials: meta.trials.length,
|
|
111
|
+
groups: meta.groups, test_groups: meta.test_groups, purge_days: meta.purge_days, embargo_days: meta.embargo_days,
|
|
112
|
+
splits: results.length, paths: path_sharpes.length, path_sharpes,
|
|
113
|
+
out_of_sample_sharpe: { median: median(path_sharpes), lowest: Math.min(...path_sharpes), highest: Math.max(...path_sharpes), share_of_paths_at_or_below_zero: lost / path_sharpes.length },
|
|
114
|
+
in_sample_sharpe_median: median(train),
|
|
115
|
+
most_chosen: chosen, distinct_trials_chosen: counts.size,
|
|
116
|
+
missing_test_days: results.reduce((n, r) => n + r.missing, 0),
|
|
117
|
+
};
|
|
118
|
+
out.plain_reading = `The best trial chosen on training days showed a median Sharpe of ${out.in_sample_sharpe_median.toFixed(2)} there. On the days held out it showed ${out.out_of_sample_sharpe.median.toFixed(2)}, from ${out.out_of_sample_sharpe.lowest.toFixed(2)} to ${out.out_of_sample_sharpe.highest.toFixed(2)} across ${out.paths} paths, and ${lost} of the paths lost money. ${counts.size} of ${meta.trials.length} trials were chosen at least once.`;
|
|
119
|
+
out.limits = CPCV_LIMITS_TEXT;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loadTrials(file) {
|
|
124
|
+
const p = readPanel(file, "returns_file");
|
|
125
|
+
if (p.tickers.length < 2) throw new RangeError("returns_file needs at least two trials: with one there is nothing to choose between");
|
|
126
|
+
if (p.dates.length * p.tickers.length > MAX_CELLS) throw new RangeError(`returns_file has more than ${MAX_CELLS} values; cut it down`);
|
|
127
|
+
return { dates: p.dates, trials: p.tickers, values: p.tickers.map((t) => Float64Array.from(p.values[t], (v) => (v === null ? NaN : v))) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function cpcv(args) {
|
|
131
|
+
const a = cpcvInput.parse(args);
|
|
132
|
+
if (a.merge_files) {
|
|
133
|
+
const shards = a.merge_files.map((f) => JSON.parse(readFileSync(f, "utf8")));
|
|
134
|
+
const id = shards[0].cpcv_id;
|
|
135
|
+
for (const [i, s] of shards.entries()) if (s.schema !== "canli.cpcv-shard.v1" || s.cpcv_id !== id) throw new RangeError(`${a.merge_files[i]} is not a shard of the same run (file or settings differ)`);
|
|
136
|
+
const results = shards.flatMap((s) => s.results), seen = new Set();
|
|
137
|
+
for (const r of results) { if (seen.has(r.s)) throw new RangeError(`split ${r.s} appears in two shards`); seen.add(r.s); }
|
|
138
|
+
const wanted = shards[0].meta.total_splits, missing = Array.from({ length: wanted }, (_, s) => s).filter((s) => !seen.has(s));
|
|
139
|
+
if (missing.length) throw new RangeError(`shards are missing splits ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? "..." : ""}`);
|
|
140
|
+
return { cpcv_id: id, merged_from: shards.length, ...summarize(results, shards[0].meta) };
|
|
141
|
+
}
|
|
142
|
+
if (!a.returns_file) throw new RangeError("cpcv needs returns_file (or merge_files).");
|
|
143
|
+
const groups = a.groups ?? 10, k = a.test_groups ?? 2;
|
|
144
|
+
if (k >= groups) throw new RangeError("test_groups must be fewer than groups: something has to be left to train on");
|
|
145
|
+
const total = choose(groups, k);
|
|
146
|
+
if (total > MAX_SPLITS) throw new RangeError(`${groups} groups taken ${k} at a time is ${total} splits; the most is ${MAX_SPLITS}`);
|
|
147
|
+
const panel = loadTrials(a.returns_file);
|
|
148
|
+
if (panel.dates.length < groups * 5) throw new RangeError(`${panel.dates.length} days is too short for ${groups} groups`);
|
|
149
|
+
const meta = { groups, test_groups: k, purge_days: a.purge_days ?? 5, embargo_days: a.embargo_days ?? 5, periods_per_year: a.periods_per_year ?? 252, trials: panel.trials, first_date: panel.dates[0], last_date: panel.dates.at(-1), total_splits: total };
|
|
150
|
+
const blocks = blocksOf(panel.dates.length, groups), splits = splitsOf(groups, k), scale = Math.sqrt(meta.periods_per_year);
|
|
151
|
+
const results = [];
|
|
152
|
+
splits.forEach((test, s) => {
|
|
153
|
+
if (a.shard && s % a.shard.of !== a.shard.index) return;
|
|
154
|
+
results.push({ s, ...runSplit(panel, blocks, test, { purge: meta.purge_days, embargo: meta.embargo_days, scale }) });
|
|
155
|
+
});
|
|
156
|
+
if (a.shard) {
|
|
157
|
+
if (a.shard.index >= a.shard.of) throw new RangeError("shard index must be below shard of");
|
|
158
|
+
const cpcv_id = createHash("sha256").update(readFileSync(a.returns_file)).update(JSON.stringify(meta)).digest("hex").slice(0, 24);
|
|
159
|
+
const shard = { schema: "canli.cpcv-shard.v1", cpcv_id, shard: a.shard, meta, results };
|
|
160
|
+
if (a.out_file) writeFileSync(a.out_file, JSON.stringify(shard));
|
|
161
|
+
return { cpcv_id, shard: a.shard, ran: results.length, of: total, ...(a.out_file ? { shard_file: a.out_file } : { shard_data: shard }), next: "Run the other shards with the same file and settings, then call cpcv with merge_files." };
|
|
162
|
+
}
|
|
163
|
+
return summarize(results, meta);
|
|
164
|
+
}
|
package/src/engine.mjs
CHANGED
|
@@ -15,9 +15,65 @@
|
|
|
15
15
|
export const ENGINE_LIMITS_TEXT = Object.freeze([
|
|
16
16
|
"Prices are the caller's: survivorship, splits, dividends and data errors in them pass straight into the result.",
|
|
17
17
|
"A backtest measures one path of history under these costs; it does not establish that the signal will keep working.",
|
|
18
|
-
"Costs are a flat rate per unit traded; market impact, borrow fees for shorts and financing are not charged unless included in cost_bps.",
|
|
18
|
+
"Costs are a flat rate per unit traded; market impact is charged only when capital and dollar volume are given, and borrow fees for shorts and financing are not charged unless included in cost_bps.",
|
|
19
19
|
]);
|
|
20
20
|
|
|
21
|
+
// With capital and dollar volume, size meets the market. Average daily dollar volume and daily
|
|
22
|
+
// volatility are read from the days before each trade, never the trade day. An order fills only up
|
|
23
|
+
// to max participation of that volume (the rest stays unfilled and the old position stays open), and
|
|
24
|
+
// pays square-root impact, the same law check_feasibility uses: Y x daily volatility x sqrt(participation)
|
|
25
|
+
// (Toth et al. 2011). A name with too little volume history cannot trade at all: nothing is assumed.
|
|
26
|
+
const LIQUIDITY_DAYS = 20, MIN_LIQUIDITY_DAYS = 10;
|
|
27
|
+
export const FILL_RATIO_TRADABLE = 0.95;
|
|
28
|
+
|
|
29
|
+
function liquidityAt(prices, volumes, t) {
|
|
30
|
+
let dollars = 0, days = 0;
|
|
31
|
+
const rets = [];
|
|
32
|
+
for (let i = Math.max(1, t - LIQUIDITY_DAYS); i < t; i += 1) {
|
|
33
|
+
if (finite(volumes[i]) && volumes[i] > 0) { dollars += volumes[i]; days += 1; }
|
|
34
|
+
const a = prices[i - 1], b = prices[i];
|
|
35
|
+
if (finite(a) && finite(b) && a > 0) rets.push(b / a - 1);
|
|
36
|
+
}
|
|
37
|
+
if (days < MIN_LIQUIDITY_DAYS || rets.length < MIN_LIQUIDITY_DAYS) return null;
|
|
38
|
+
const mean = rets.reduce((x, y) => x + y, 0) / rets.length;
|
|
39
|
+
return { adv: dollars / days, vol: Math.sqrt(rets.reduce((x, y) => x + (y - mean) ** 2, 0) / (rets.length - 1)) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Whole stretches of market history a strategy can be held against. Dates are peak to trough or the
|
|
43
|
+
// episode's usual bounds; the first eight are the ones stress_worlds drops into its worlds.
|
|
44
|
+
export const CRISES = Object.freeze({
|
|
45
|
+
dotcom_bust: { from: "2000-03-24", to: "2002-10-09", what: "the 2000-02 dot-com bust" },
|
|
46
|
+
bank_crisis: { from: "2008-09-02", to: "2009-03-09", what: "the 2008-09 bank crisis" },
|
|
47
|
+
debt_ceiling_2011: { from: "2011-04-29", to: "2011-10-03", what: "the 2011 US downgrade and euro debt scare" },
|
|
48
|
+
taper_tantrum: { from: "2013-05-22", to: "2013-09-05", what: "the 2013 taper tantrum in bonds" },
|
|
49
|
+
oil_collapse: { from: "2014-06-20", to: "2016-02-11", what: "the 2014-16 oil collapse" },
|
|
50
|
+
dollar_surge: { from: "2014-07-01", to: "2015-03-13", what: "the 2014-15 surge in the dollar" },
|
|
51
|
+
q4_2018: { from: "2018-09-20", to: "2018-12-24", what: "the late 2018 sell-off" },
|
|
52
|
+
pandemic_crash: { from: "2020-02-19", to: "2020-03-23", what: "the February-March 2020 crash" },
|
|
53
|
+
inflation_shock: { from: "2022-01-03", to: "2022-06-16", what: "the 2022 inflation and rate shock" },
|
|
54
|
+
compute_bust: { from: "2022-01-03", to: "2022-10-14", what: "the 2022 fall in chip and compute stocks" },
|
|
55
|
+
regional_bank_run: { from: "2023-03-01", to: "2023-03-24", what: "the March 2023 regional bank run" },
|
|
56
|
+
});
|
|
57
|
+
export const CRISIS_DRAWDOWN_LIMIT = 0.5;
|
|
58
|
+
|
|
59
|
+
// The strategy's own returns inside every crisis its dates fully cover. A crisis the run does not
|
|
60
|
+
// fully cover is listed as untested: not having been there is not the same as having survived it.
|
|
61
|
+
export function crisisReplay({ dates, returns, limit = CRISIS_DRAWDOWN_LIMIT }) {
|
|
62
|
+
if (!(limit > 0 && limit <= 1)) throw new RangeError("the crisis drawdown limit must be above 0 and at most 1");
|
|
63
|
+
const tested = [], untested = [];
|
|
64
|
+
for (const [id, c] of Object.entries(CRISES)) {
|
|
65
|
+
if (!(dates[0] <= c.from && dates.at(-1) >= c.to)) { untested.push({ id, what: c.what, from: c.from, to: c.to }); continue; }
|
|
66
|
+
let wealth = 1, peak = 1, drawdown = 0, days = 0, worst = 0;
|
|
67
|
+
for (let i = 0; i < dates.length; i += 1) {
|
|
68
|
+
if (dates[i] < c.from || dates[i] > c.to) continue;
|
|
69
|
+
wealth *= 1 + returns[i]; peak = Math.max(peak, wealth); drawdown = Math.max(drawdown, 1 - wealth / peak); worst = Math.min(worst, returns[i]); days += 1;
|
|
70
|
+
}
|
|
71
|
+
tested.push({ id, what: c.what, from: c.from, to: c.to, days, return: wealth - 1, drawdown, worst_day: worst, ruptured: drawdown > limit });
|
|
72
|
+
}
|
|
73
|
+
const verdict = !tested.length ? "untested" : tested.some((c) => c.ruptured) ? "ruptured" : "survived_tested";
|
|
74
|
+
return { verdict, drawdown_limit: limit, tested, untested };
|
|
75
|
+
}
|
|
76
|
+
|
|
21
77
|
const REBALANCE = Object.freeze({ weekly: "week", monthly: "month", quarterly: "quarter" });
|
|
22
78
|
const periodKey = (date, every) => {
|
|
23
79
|
const [y, m] = date.split("-").map(Number);
|
|
@@ -70,12 +126,20 @@ function lastRowOnOrBefore(signalDates, bound) {
|
|
|
70
126
|
return found;
|
|
71
127
|
}
|
|
72
128
|
|
|
73
|
-
export function runBacktest({ dates, tickers, prices, signalDates, signals, rebalance = "monthly", lagDays = 1, side = "long_short", quantile = 0.2, costBps = 10, minNames = 5, periodsPerYear = 252 }) {
|
|
129
|
+
export function runBacktest({ dates, tickers, prices, signalDates, signals, rebalance = "monthly", lagDays = 1, side = "long_short", quantile = 0.2, costBps = 10, minNames = 5, periodsPerYear = 252, capital = null, dollarVolumes = null, maxParticipation = 0.1, impactCoefficient = 1 }) {
|
|
74
130
|
checkInputs({ dates, tickers, prices, signalDates, signals });
|
|
75
131
|
if (!Number.isInteger(lagDays) || lagDays < 1) throw new RangeError("lag_days must be a whole number of at least 1: a signal cannot trade on the day it becomes known");
|
|
76
132
|
if (!(quantile > 0 && quantile <= 0.5)) throw new RangeError("quantile must be in (0, 0.5]");
|
|
77
133
|
if (!["long_short", "long_only"].includes(side)) throw new RangeError("side must be long_short or long_only");
|
|
78
134
|
if (!(costBps >= 0)) throw new RangeError("cost_bps cannot be negative");
|
|
135
|
+
const sized = capital !== null;
|
|
136
|
+
if (sized) {
|
|
137
|
+
if (!(capital > 0)) throw new RangeError("capital must be above zero");
|
|
138
|
+
if (!dollarVolumes) throw new RangeError("capital needs dollar volume: without it nothing says how much the market can take");
|
|
139
|
+
if (!(maxParticipation > 0 && maxParticipation <= 1)) throw new RangeError("max participation must be above 0 and at most 1");
|
|
140
|
+
if (!(impactCoefficient >= 0)) throw new RangeError("the impact coefficient cannot be negative");
|
|
141
|
+
}
|
|
142
|
+
const liq = { impact: 0, orders: 0, clipped: 0, blocked: 0, wanted: 0, filled: 0, maxFilled: 0, worst: null };
|
|
79
143
|
|
|
80
144
|
const n = dates.length;
|
|
81
145
|
const rebal = new Set(rebalanceIndices(dates, rebalance, lagDays));
|
|
@@ -116,9 +180,26 @@ export function runBacktest({ dates, tickers, prices, signalDates, signals, reba
|
|
|
116
180
|
const long = ranked.slice(0, k), short = side === "long_short" ? ranked.slice(-k) : [];
|
|
117
181
|
for (const tk of long) target.set(tk, nav / long.length);
|
|
118
182
|
for (const tk of short) target.set(tk, (target.get(tk) ?? 0) - nav / short.length);
|
|
119
|
-
let traded = 0;
|
|
120
|
-
for (const tk of new Set([...pos.keys(), ...target.keys()]))
|
|
121
|
-
|
|
183
|
+
let traded = 0, impact = 0;
|
|
184
|
+
for (const tk of new Set([...pos.keys(), ...target.keys()])) {
|
|
185
|
+
const held = pos.get(tk) ?? 0, want = (target.get(tk) ?? 0) - held;
|
|
186
|
+
if (!sized) { traded += Math.abs(want); continue; }
|
|
187
|
+
if (want === 0) continue;
|
|
188
|
+
const l = liquidityAt(prices[tk], dollarVolumes[tk] ?? [], t);
|
|
189
|
+
const wanted = Math.abs(want);
|
|
190
|
+
liq.orders += 1; liq.wanted += wanted;
|
|
191
|
+
if (!l) { liq.blocked += 1; target.set(tk, held); continue; }
|
|
192
|
+
const room = maxParticipation * l.adv / capital;
|
|
193
|
+
const wantedShare = wanted * capital / l.adv;
|
|
194
|
+
if (!liq.worst || wantedShare > liq.worst.wanted_participation) liq.worst = { ticker: tk, date: dates[t], wanted_participation: wantedShare, average_daily_dollars: l.adv };
|
|
195
|
+
const fill = Math.min(wanted, room);
|
|
196
|
+
if (fill < wanted) { liq.clipped += 1; target.set(tk, held + Math.sign(want) * fill); }
|
|
197
|
+
const share = fill * capital / l.adv;
|
|
198
|
+
impact += fill * impactCoefficient * l.vol * Math.sqrt(share);
|
|
199
|
+
traded += fill; liq.filled += fill; liq.maxFilled = Math.max(liq.maxFilled, share);
|
|
200
|
+
}
|
|
201
|
+
liq.impact += impact;
|
|
202
|
+
const cost = traded * costBps / 1e4 + impact;
|
|
122
203
|
nav -= cost;
|
|
123
204
|
const factor = 1 - cost / (nav + cost);
|
|
124
205
|
if (started && out.returns.length) {
|
|
@@ -133,7 +214,14 @@ export function runBacktest({ dates, tickers, prices, signalDates, signals, reba
|
|
|
133
214
|
started = true;
|
|
134
215
|
}
|
|
135
216
|
if (!rebalances) throw new RangeError("no rebalance had enough names with both a known signal and a price; check the dates overlap and min_names");
|
|
136
|
-
|
|
217
|
+
const fillRatio = liq.wanted > 0 ? liq.filled / liq.wanted : 1;
|
|
218
|
+
const liquidity = sized ? {
|
|
219
|
+
capital_usd: capital, max_participation: maxParticipation, impact_coefficient: impactCoefficient,
|
|
220
|
+
impact_paid: liq.impact, orders: liq.orders, clipped_orders: liq.clipped, blocked_orders: liq.blocked,
|
|
221
|
+
fill_ratio: fillRatio, filled_participation_max: liq.maxFilled, worst: liq.worst,
|
|
222
|
+
verdict: fillRatio >= FILL_RATIO_TRADABLE ? "tradable" : "too_big",
|
|
223
|
+
} : undefined;
|
|
224
|
+
return { ...out, used, nav, rebalances, turnoverSum, costSum, missingDays, longNames, shortNames, periodsPerYear, ...(sized ? { liquidity } : {}) };
|
|
137
225
|
}
|
|
138
226
|
|
|
139
227
|
// Plain statistics of a return series, in the same Sharpe convention as the validators (sample sd).
|