canli-backtest-mcp 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arhan Canli / Canli Capital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # canli-backtest-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Backtests an AI agent can trust. An MCP server that builds **point-in-time factors from SEC
4
+ filings**, runs **cross-sectional backtests of any signal on your own prices** with **no lookahead**
5
+ (enforced in code), charges **costs on the actual trades**, and records **every variant in a trial
6
+ ledger**, so the best of a search is judged against the whole search instead of alone.
7
+
8
+ Local stdio only: your prices, signals and ledgers never leave your machine.
9
+
10
+ ## Why
11
+
12
+ Three mistakes make most agent-run backtests look better than they are:
13
+
14
+ 1. **Lookahead.** Using a number before it was public: an annual figure in January that was filed in
15
+ February, or a value that was later restated. `pit_factor` only uses what had been filed with the
16
+ SEC by each date, as it stood then; `backtest_signal` only trades on signal rows dated at least
17
+ `lag_days` trading days earlier, and stops if anything reaches past that.
18
+ 2. **Free trading.** Ignoring turnover. Positions drift with prices between rebalances, and costs are
19
+ charged on the trades from those drifted positions to the new targets.
20
+ 3. **Counting only the winner.** An agent tries 200 variants and reports the best. With `ledger`,
21
+ every run is recorded (in canli-validation-mcp's hash-chained ledger), and each reply gives the
22
+ best run's deflated Sharpe after counting all of them, and the Sharpe that luck alone reaches.
23
+
24
+ ## Tools
25
+
26
+ | tool | what it does |
27
+ |---|---|
28
+ | `list_factors` | The factors `pit_factor` builds and what each measures. |
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
+ | `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
+
32
+ ## Quick start
33
+
34
+ ```bash
35
+ npx -y canli-backtest-mcp
36
+ ```
37
+
38
+ Claude Code:
39
+
40
+ ```bash
41
+ claude mcp add canli-backtest -- npx -y canli-backtest-mcp
42
+ ```
43
+
44
+ Then ask: *"Build roa for these 200 tickers from 2015 to 2024, backtest it monthly on prices.csv with
45
+ 10 bps costs, try quantiles 0.1, 0.2 and 0.3 in the ledger 'roa-search', and tell me whether the best
46
+ one survives the search."*
47
+
48
+ ## Files
49
+
50
+ Both files are CSV with a `date` column (YYYY-MM-DD) and one column per ticker:
51
+
52
+ - **prices**: closing prices, one row per trading day. Bring your own; adjust for splits and dividends
53
+ if you want total returns. The server does not supply prices (vendor licences forbid passing them on).
54
+ - **signal**: one row per date the values became known. `pit_factor` writes this; your own signal works
55
+ too, as long as each row is dated by when it was available.
56
+
57
+ Empty cells are missing values, never zero.
58
+
59
+ ## What a result does not establish
60
+
61
+ - Prices are yours: survivorship, splits, dividends and data errors in them pass straight into the result.
62
+ - A backtest measures one path of history under these costs; it does not establish that the signal keeps working.
63
+ - Costs are a flat rate per unit traded; market impact, short-borrow fees and financing are not charged unless included in `cost_bps`.
64
+ - SEC values are what companies reported in XBRL, as filed; late filers, non-XBRL filers and tag changes can be missing.
65
+
66
+ ## Configuration
67
+
68
+ | variable | default | meaning |
69
+ |---|---|---|
70
+ | `CANLI_LEDGER_DIR` | `~/.canli/ledgers` | where ledgers are kept (shared with canli-validation-mcp) |
71
+ | `CANLI_CACHE_DIR` | `~/.cache/canli-fundamentals` | SEC data cache |
72
+
73
+ ## Part of the Canli Capital MCP family
74
+
75
+ [canli-validation-mcp](https://www.npmjs.com/package/canli-validation-mcp) (validators and the trial
76
+ ledger), [canli-fundamentals-mcp](https://www.npmjs.com/package/canli-fundamentals-mcp) (SEC fundamentals
77
+ point in time), [canli-research-mcp](https://www.npmjs.com/package/canli-research-mcp) (the open research
78
+ record). Source: [github.com/arhancanli/canlicapital](https://github.com/arhancanli/canlicapital/tree/main/mcp-backtest).
79
+ MIT licensed. Built by Arhan Canli.
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "canli-backtest-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "private": false,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "bin": {
9
+ "canli-backtest-mcp": "src/server.mjs"
10
+ },
11
+ "engines": {
12
+ "node": ">=20.10"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test test/*.test.mjs"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/server": "2.3.1",
23
+ "canli-fundamentals-mcp": "^0.5.0",
24
+ "canli-validation-mcp": "file:../mcp",
25
+ "zod": "4.6.5"
26
+ },
27
+ "devDependencies": {
28
+ "@modelcontextprotocol/client": "2.3.1"
29
+ },
30
+ "mcpName": "io.github.arhancanli/canli-backtest-mcp",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/arhancanli/canlicapital.git",
34
+ "directory": "mcp-backtest"
35
+ },
36
+ "homepage": "https://canlicapital.com/mcp-servers",
37
+ "bugs": {
38
+ "url": "https://github.com/arhancanli/canlicapital/issues"
39
+ },
40
+ "keywords": ["mcp", "model-context-protocol", "backtesting", "quant", "quantitative-finance", "point-in-time", "sec", "xbrl", "lookahead-bias", "deflated-sharpe", "overfitting", "factor-investing", "ai-agents"]
41
+ }
package/src/engine.mjs ADDED
@@ -0,0 +1,164 @@
1
+ // mcp-backtest/src/engine.mjs
2
+ //
3
+ // A cross-sectional backtest of any signal against prices the caller supplies, built so the usual
4
+ // ways to fool yourself are refused in code:
5
+ //
6
+ // - Each signal row is dated when it became KNOWN. A trade on day t uses only signal rows dated at
7
+ // least lag_days trading days before t; every rebalance records the signal date it used, and the
8
+ // run stops if one ever reaches past that bound.
9
+ // - Positions drift with prices between rebalances. Turnover and costs are charged on the actual
10
+ // trades from drifted positions to targets, not on an idealized daily rebalance.
11
+ // - A held name with no price on a day earns nothing that day and is counted; nothing is filled in.
12
+ //
13
+ // Pure: no files, no network. Inputs are aligned arrays; series-io.mjs reads CSV files into them.
14
+
15
+ export const ENGINE_LIMITS_TEXT = Object.freeze([
16
+ "Prices are the caller's: survivorship, splits, dividends and data errors in them pass straight into the result.",
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.",
19
+ ]);
20
+
21
+ const REBALANCE = Object.freeze({ weekly: "week", monthly: "month", quarterly: "quarter" });
22
+ const periodKey = (date, every) => {
23
+ const [y, m] = date.split("-").map(Number);
24
+ if (every === "month") return `${y}-${m}`;
25
+ if (every === "quarter") return `${y}-Q${Math.ceil(m / 3)}`;
26
+ const d = new Date(`${date}T00:00:00Z`); // ISO week: Thursday's year and week number
27
+ d.setUTCDate(d.getUTCDate() + 3 - ((d.getUTCDay() + 6) % 7));
28
+ const jan4 = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
29
+ return `${d.getUTCFullYear()}-W${1 + Math.round(((d - jan4) / 86400000 - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7)}`;
30
+ };
31
+
32
+ // Indices of the first trading day of each period (or every N days from the first usable day).
33
+ export function rebalanceIndices(dates, rebalance, start = 0) {
34
+ const out = [];
35
+ if (Number.isInteger(rebalance)) {
36
+ if (rebalance < 1) throw new RangeError("rebalance as a number of days must be at least 1");
37
+ for (let i = start; i < dates.length; i += rebalance) out.push(i);
38
+ return out;
39
+ }
40
+ const every = REBALANCE[rebalance];
41
+ if (!every) throw new RangeError(`rebalance must be weekly, monthly, quarterly or a whole number of trading days; got ${JSON.stringify(rebalance)}`);
42
+ let last = null;
43
+ for (let i = start; i < dates.length; i += 1) {
44
+ const key = periodKey(dates[i], every);
45
+ if (key !== last) { out.push(i); last = key; }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const finite = (x) => typeof x === "number" && Number.isFinite(x);
51
+
52
+ function checkInputs({ dates, tickers, prices, signalDates, signals }) {
53
+ if (!Array.isArray(dates) || dates.length < 3) throw new RangeError("prices need at least 3 dates");
54
+ for (let i = 1; i < dates.length; i += 1) if (!(dates[i] > dates[i - 1])) throw new RangeError(`price dates must be strictly increasing; ${dates[i - 1]} then ${dates[i]}`);
55
+ for (let i = 1; i < signalDates.length; i += 1) if (!(signalDates[i] > signalDates[i - 1])) throw new RangeError(`signal dates must be strictly increasing; ${signalDates[i - 1]} then ${signalDates[i]}`);
56
+ if (!tickers.length) throw new RangeError("no ticker appears in both the prices and the signal");
57
+ for (const t of tickers) {
58
+ if (prices[t]?.length !== dates.length) throw new RangeError(`prices for ${t} are not aligned with the price dates`);
59
+ if (signals[t]?.length !== signalDates.length) throw new RangeError(`signal for ${t} is not aligned with the signal dates`);
60
+ }
61
+ }
62
+
63
+ // The latest signal row dated on or before `bound` (binary search), or -1.
64
+ function lastRowOnOrBefore(signalDates, bound) {
65
+ let lo = 0, hi = signalDates.length - 1, found = -1;
66
+ while (lo <= hi) {
67
+ const mid = (lo + hi) >> 1;
68
+ if (signalDates[mid] <= bound) { found = mid; lo = mid + 1; } else hi = mid - 1;
69
+ }
70
+ return found;
71
+ }
72
+
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 }) {
74
+ checkInputs({ dates, tickers, prices, signalDates, signals });
75
+ 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
+ if (!(quantile > 0 && quantile <= 0.5)) throw new RangeError("quantile must be in (0, 0.5]");
77
+ if (!["long_short", "long_only"].includes(side)) throw new RangeError("side must be long_short or long_only");
78
+ if (!(costBps >= 0)) throw new RangeError("cost_bps cannot be negative");
79
+
80
+ const n = dates.length;
81
+ const rebal = new Set(rebalanceIndices(dates, rebalance, lagDays));
82
+ let nav = 1;
83
+ let pos = new Map(); // ticker -> signed value (fraction of starting capital, drifts with price)
84
+ const out = { dates: [], returns: [] };
85
+ const used = []; // { trade_date, signal_date }
86
+ let pendingCost = 1; // the first rebalance's cost, charged on the first recorded day
87
+ let started = false, turnoverSum = 0, costSum = 0, rebalances = 0, missingDays = 0, longNames = 0, shortNames = 0;
88
+
89
+ for (let t = 1; t < n; t += 1) {
90
+ // 1. Positions earn day t's return.
91
+ if (started) {
92
+ let pnl = 0;
93
+ for (const [tk, v] of pos) {
94
+ const a = prices[tk][t - 1], b = prices[tk][t];
95
+ if (finite(a) && finite(b) && a > 0) { const r = b / a - 1; pnl += v * r; pos.set(tk, v * (1 + r)); }
96
+ else missingDays += 1;
97
+ }
98
+ const before = nav;
99
+ nav += pnl;
100
+ out.dates.push(dates[t]);
101
+ out.returns.push((1 + (before > 0 ? pnl / before : 0)) * pendingCost - 1);
102
+ pendingCost = 1;
103
+ }
104
+ // 2. Rebalance at the close of day t, on signals known lagDays trading days earlier.
105
+ if (!rebal.has(t) || t - lagDays < 0) continue;
106
+ const bound = dates[t - lagDays];
107
+ const row = lastRowOnOrBefore(signalDates, bound);
108
+ if (row < 0) continue;
109
+ if (!(signalDates[row] < dates[t])) throw new Error(`causality violated: a trade on ${dates[t]} would use a signal dated ${signalDates[row]}`);
110
+ const ranked = tickers
111
+ .filter((tk) => finite(signals[tk][row]) && finite(prices[tk][t]) && prices[tk][t] > 0)
112
+ .sort((x, y) => signals[y][row] - signals[x][row] || (x < y ? -1 : 1));
113
+ const k = Math.floor(ranked.length * quantile);
114
+ if (k < 1 || ranked.length < minNames) continue;
115
+ const target = new Map();
116
+ const long = ranked.slice(0, k), short = side === "long_short" ? ranked.slice(-k) : [];
117
+ for (const tk of long) target.set(tk, nav / long.length);
118
+ for (const tk of short) target.set(tk, (target.get(tk) ?? 0) - nav / short.length);
119
+ let traded = 0;
120
+ for (const tk of new Set([...pos.keys(), ...target.keys()])) traded += Math.abs((target.get(tk) ?? 0) - (pos.get(tk) ?? 0));
121
+ const cost = traded * costBps / 1e4;
122
+ nav -= cost;
123
+ const factor = 1 - cost / (nav + cost);
124
+ if (started && out.returns.length) {
125
+ const last = out.returns.length - 1; // cost is charged on the rebalance day's return
126
+ out.returns[last] = (1 + out.returns[last]) * factor - 1;
127
+ } else pendingCost = factor;
128
+ pos = new Map([...target].filter(([, v]) => v !== 0));
129
+ turnoverSum += traded / (nav + cost);
130
+ costSum += cost;
131
+ rebalances += 1; longNames += long.length; shortNames += short.length;
132
+ used.push({ trade_date: dates[t], signal_date: signalDates[row] });
133
+ started = true;
134
+ }
135
+ if (!rebalances) throw new RangeError("no rebalance had enough names with both a known signal and a price; check the dates overlap and min_names");
136
+ return { ...out, used, nav, rebalances, turnoverSum, costSum, missingDays, longNames, shortNames, periodsPerYear };
137
+ }
138
+
139
+ // Plain statistics of a return series, in the same Sharpe convention as the validators (sample sd).
140
+ export function describe(result) {
141
+ const r = result.returns;
142
+ const m = r.length;
143
+ const mean = r.reduce((a, b) => a + b, 0) / m;
144
+ const sd = Math.sqrt(r.reduce((a, b) => a + (b - mean) ** 2, 0) / Math.max(1, m - 1));
145
+ let peak = 1, wealth = 1, maxDd = 0;
146
+ for (const x of r) { wealth *= 1 + x; peak = Math.max(peak, wealth); maxDd = Math.max(maxDd, 1 - wealth / peak); }
147
+ const years = m / result.periodsPerYear;
148
+ const gaps = result.used.map((u) => (Date.parse(u.trade_date) - Date.parse(u.signal_date)) / 86400000);
149
+ return {
150
+ first_date: result.dates[0], last_date: result.dates.at(-1), days: m,
151
+ total_return: wealth - 1,
152
+ annualized_return: years > 0 ? wealth ** (1 / years) - 1 : null,
153
+ annualized_volatility: sd * Math.sqrt(result.periodsPerYear),
154
+ sharpe_annualized: sd > 0 ? (mean / sd) * Math.sqrt(result.periodsPerYear) : null,
155
+ max_drawdown: maxDd,
156
+ rebalances: result.rebalances,
157
+ turnover_per_year: years > 0 ? result.turnoverSum / years : null,
158
+ cost_paid: result.costSum,
159
+ average_long_names: result.longNames / result.rebalances,
160
+ average_short_names: result.shortNames / result.rebalances,
161
+ missing_price_days: result.missingDays,
162
+ causality: { checked_rebalances: result.used.length, min_days_signal_to_trade: Math.min(...gaps), first: result.used.slice(0, 3) },
163
+ };
164
+ }
@@ -0,0 +1,67 @@
1
+ // mcp-backtest/src/panel-io.mjs
2
+ //
3
+ // Reads and writes panels: a CSV with a `date` column (YYYY-MM-DD) and one column per ticker, one
4
+ // row per date. Prices are closes; a signal row is dated when its values became known. Empty cells
5
+ // are missing values (null), never zero. Files are read on this machine; nothing is uploaded.
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+
8
+ export const MAX_PANEL_BYTES = 64 * 1024 * 1024;
9
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
10
+
11
+ function splitLine(line) {
12
+ const out = [];
13
+ let cell = "", quoted = false;
14
+ for (let i = 0; i < line.length; i += 1) {
15
+ const c = line[i];
16
+ if (quoted) {
17
+ if (c === '"' && line[i + 1] === '"') { cell += '"'; i += 1; }
18
+ else if (c === '"') quoted = false;
19
+ else cell += c;
20
+ } else if (c === '"') quoted = true;
21
+ else if (c === ",") { out.push(cell); cell = ""; }
22
+ else cell += c;
23
+ }
24
+ out.push(cell);
25
+ return out.map((s) => s.trim());
26
+ }
27
+
28
+ export function parsePanel(text, name = "panel") {
29
+ const lines = text.replace(/^/, "").split(/\r?\n/).filter((l) => l.trim() !== "");
30
+ if (lines.length < 2) throw new RangeError(`${name} needs a header row and at least one data row`);
31
+ const header = splitLine(lines[0]);
32
+ if (header[0].toLowerCase() !== "date") throw new RangeError(`${name}'s first column must be "date"; got ${JSON.stringify(header[0])}`);
33
+ const tickers = header.slice(1);
34
+ if (!tickers.length) throw new RangeError(`${name} has no ticker columns`);
35
+ const dupe = tickers.find((t, i) => tickers.indexOf(t) !== i);
36
+ if (dupe) throw new RangeError(`${name} names the column ${dupe} twice`);
37
+ const dates = [];
38
+ const values = Object.fromEntries(tickers.map((t) => [t, []]));
39
+ for (let r = 1; r < lines.length; r += 1) {
40
+ const cells = splitLine(lines[r]);
41
+ const date = cells[0];
42
+ if (!DATE.test(date)) throw new RangeError(`${name} row ${r + 1}: date ${JSON.stringify(date)} is not YYYY-MM-DD`);
43
+ if (dates.length && !(date > dates.at(-1))) throw new RangeError(`${name} row ${r + 1}: dates must be strictly increasing (${dates.at(-1)} then ${date})`);
44
+ dates.push(date);
45
+ tickers.forEach((t, i) => {
46
+ const raw = cells[i + 1] ?? "";
47
+ if (raw === "" || /^(na|nan|null)$/i.test(raw)) { values[t].push(null); return; }
48
+ const x = Number(raw);
49
+ if (!Number.isFinite(x)) throw new RangeError(`${name} row ${r + 1}, column ${t}: ${JSON.stringify(raw)} is not a number`);
50
+ values[t].push(x);
51
+ });
52
+ }
53
+ return { dates, tickers, values };
54
+ }
55
+
56
+ // One read, then the size check on the bytes actually read (no check-then-read window).
57
+ export function readPanel(path, name) {
58
+ const bytes = readFileSync(path);
59
+ if (bytes.length > MAX_PANEL_BYTES) throw new RangeError(`${path} is ${bytes.length} bytes; the limit is ${MAX_PANEL_BYTES}`);
60
+ return parsePanel(bytes.toString("utf8"), name ?? path);
61
+ }
62
+
63
+ export function writePanel(path, { dates, tickers, values }) {
64
+ const rows = [["date", ...tickers].join(",")];
65
+ dates.forEach((d, i) => rows.push([d, ...tickers.map((t) => (values[t][i] === null || values[t][i] === undefined ? "" : String(values[t][i])))].join(",")));
66
+ writeFileSync(path, rows.join("\n") + "\n");
67
+ }
package/src/pit.mjs ADDED
@@ -0,0 +1,94 @@
1
+ // mcp-backtest/src/pit.mjs
2
+ //
3
+ // Point-in-time factors from SEC XBRL data: for each company and each date, only values whose
4
+ // filing had reached EDGAR on or before that date, each as it stood then (later restatements are
5
+ // not visible earlier). Data and selection come from canli-fundamentals-mcp (loadCompany,
6
+ // resolveSeries); the rule here is the one its cross_section and known_as_of tools use.
7
+ //
8
+ // A ratio pairs numerator and denominator from the same fiscal period; growth compares a fiscal
9
+ // year with the one before it, both as known on the date. A signal row is dated with the date it
10
+ // was known, so pass it to backtest_signal with lag_days of at least 1.
11
+
12
+ export const FACTORS = Object.freeze({
13
+ roa: { needs: ["net_income", "assets"], meaning: "net income / total assets, same fiscal year" },
14
+ gross_margin: { needs: ["gross_profit", "revenue"], meaning: "gross profit / revenue, same fiscal year" },
15
+ operating_margin: { needs: ["operating_income", "revenue"], meaning: "operating income / revenue, same fiscal year" },
16
+ revenue_growth: { needs: ["revenue"], meaning: "latest fiscal-year revenue / the year before - 1" },
17
+ asset_growth: { needs: ["assets"], meaning: "latest fiscal-year total assets / the year before - 1" },
18
+ accruals: { needs: ["net_income", "operating_cash_flow", "assets"], meaning: "(net income - operating cash flow) / total assets, same fiscal year" },
19
+ });
20
+
21
+ export const PIT_LIMITS_TEXT = Object.freeze([
22
+ "Values are what companies reported to the SEC in XBRL, as filed by each date; a company that files late, files outside XBRL or changes its tags can be missing.",
23
+ "Ticker symbols are resolved to today's SEC assignment; a ticker reused by another company maps to the current holder, so check the matched names.",
24
+ ]);
25
+
26
+ // Annual periods of one series known on `asOf`, newest first: [{ end, val, filed, accn }].
27
+ export function annualAsOf(series, asOf) {
28
+ const out = [];
29
+ for (const p of series.periods) {
30
+ if (p.kind !== "annual") continue;
31
+ let v = null;
32
+ for (const x of p.vintages) if (x.filed <= asOf) v = x; // vintages are oldest first
33
+ if (v) out.push({ end: p.end, val: v.val, filed: v.filed, accn: v.accn });
34
+ }
35
+ return out.sort((a, b) => (a.end < b.end ? 1 : -1));
36
+ }
37
+
38
+ const byEnd = (rows, end) => rows.find((r) => r.end === end) ?? null;
39
+ const ratio = (a, b) => (a && b && b.val !== 0 ? a.val / b.val : null);
40
+
41
+ // series: { name -> resolved series } for one company. Returns the factor value known on asOf.
42
+ export function factorAsOf(factor, series, asOf) {
43
+ if (factor.startsWith("level:")) {
44
+ const s = series[factor.slice(6)];
45
+ return s ? annualAsOf(s, asOf)[0]?.val ?? null : null;
46
+ }
47
+ const known = Object.fromEntries(Object.entries(series).map(([k, s]) => [k, annualAsOf(s, asOf)]));
48
+ switch (factor) {
49
+ case "roa": { const n = known.net_income?.[0]; return n ? ratio(n, byEnd(known.assets ?? [], n.end)) : null; }
50
+ case "gross_margin": { const g = known.gross_profit?.[0]; return g ? ratio(g, byEnd(known.revenue ?? [], g.end)) : null; }
51
+ case "operating_margin": { const o = known.operating_income?.[0]; return o ? ratio(o, byEnd(known.revenue ?? [], o.end)) : null; }
52
+ case "revenue_growth": case "asset_growth": {
53
+ const rows = known[factor === "revenue_growth" ? "revenue" : "assets"] ?? [];
54
+ return rows.length >= 2 && rows[1].val !== 0 ? rows[0].val / rows[1].val - 1 : null;
55
+ }
56
+ case "accruals": {
57
+ const n = known.net_income?.[0];
58
+ if (!n) return null;
59
+ const c = byEnd(known.operating_cash_flow ?? [], n.end), a = byEnd(known.assets ?? [], n.end);
60
+ return c && a && a.val !== 0 ? (n.val - c.val) / a.val : null;
61
+ }
62
+ default: throw new RangeError(`unknown factor ${JSON.stringify(factor)}; choose from ${Object.keys(FACTORS).join(", ")} or level:<measure>`);
63
+ }
64
+ }
65
+
66
+ export function factorNeeds(factor) {
67
+ if (factor.startsWith("level:")) return [factor.slice(6)];
68
+ const f = FACTORS[factor];
69
+ if (!f) throw new RangeError(`unknown factor ${JSON.stringify(factor)}; choose from ${Object.keys(FACTORS).join(", ")} or level:<measure>`);
70
+ return f.needs;
71
+ }
72
+
73
+ // Builds the panel. loadSeries(ticker, names) -> { matchedName, series: { name -> series } } or throws.
74
+ export async function buildFactorPanel({ factor, tickers, dates, loadSeries, parallel = 4 }) {
75
+ const needs = factorNeeds(factor);
76
+ const values = Object.fromEntries(tickers.map((t) => [t, dates.map(() => null)]));
77
+ const matched = {}, missing = {};
78
+ let next = 0;
79
+ const worker = async () => {
80
+ while (next < tickers.length) {
81
+ const t = tickers[next++];
82
+ try {
83
+ const { matchedName, series } = await loadSeries(t, needs);
84
+ matched[t] = matchedName;
85
+ dates.forEach((d, i) => { values[t][i] = factorAsOf(factor, series, d); });
86
+ } catch (err) {
87
+ missing[t] = String(err.message ?? err).split(". ")[0];
88
+ }
89
+ }
90
+ };
91
+ await Promise.all(Array.from({ length: Math.min(parallel, tickers.length) }, worker));
92
+ const coverage = dates.map((d, i) => ({ date: d, companies_with_value: tickers.filter((t) => values[t][i] !== null).length }));
93
+ return { dates, tickers, values, matched, missing, coverage };
94
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ // canli-backtest-mcp: backtests an agent can trust. Point-in-time factors from SEC filings, a
3
+ // cross-sectional backtest of any signal against prices the caller supplies (a trade only ever uses
4
+ // what was known before it), costs and turnover on the actual trades, and every variant recorded in
5
+ // a trial ledger so the best of a search is judged against the whole search.
6
+ //
7
+ // Local stdio only: prices, signals and ledgers stay on this machine. Point-in-time SEC data comes
8
+ // from canli-fundamentals-mcp (cached under ~/.cache/canli-fundamentals); the ledger is
9
+ // canli-validation-mcp's (files under ~/.canli/ledgers), so both servers read the same searches.
10
+ import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import { McpServer } from "@modelcontextprotocol/server";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
17
+ import { z } from "zod";
18
+ import { createSession as fundamentalsSession, loadCompany, resolveSeries } from "canli-fundamentals-mcp/src/server.mjs";
19
+ import { ledgerDir, recordTrial } from "canli-validation-mcp/src/ledger.mjs";
20
+
21
+ import { ENGINE_LIMITS_TEXT, describe, runBacktest } from "./engine.mjs";
22
+ import { readPanel, writePanel } from "./panel-io.mjs";
23
+ import { FACTORS, PIT_LIMITS_TEXT, buildFactorPanel } from "./pit.mjs";
24
+
25
+ export const SERVER_NAME = "canli-backtest-mcp";
26
+ 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.";
28
+ export const SERVER_INFO = Object.freeze({
29
+ name: SERVER_NAME,
30
+ version: SERVER_VERSION,
31
+ title: "Canli Backtest",
32
+ websiteUrl: "https://canlicapital.com/mcp-servers",
33
+ icons: [
34
+ { src: "https://canlicapital.com/icon-512.png", mimeType: "image/png", sizes: ["512x512"] },
35
+ { src: "https://canlicapital.com/favicon.svg", mimeType: "image/svg+xml", sizes: ["any"] },
36
+ ],
37
+ });
38
+
39
+ const text = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
40
+ const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date as YYYY-MM-DD");
41
+
42
+ export function createSession({ fundamentals, ledger, outDir } = {}) {
43
+ return { fundamentals: fundamentals ?? fundamentalsSession(), ledgerDir: ledger ?? ledgerDir(), outDir: outDir ?? null };
44
+ }
45
+
46
+ // First trading day of each month in [start, end], from the weekday calendar (exchange holidays are
47
+ // not modelled; the engine aligns signal rows to whatever price dates exist).
48
+ export function monthStarts(start, end) {
49
+ const out = [];
50
+ const d = new Date(`${start}T00:00:00Z`);
51
+ let month = null;
52
+ while (d.toISOString().slice(0, 10) <= end) {
53
+ const iso = d.toISOString().slice(0, 10);
54
+ if (d.getUTCDay() % 6 !== 0 && iso.slice(0, 7) !== month) { out.push(iso); month = iso.slice(0, 7); }
55
+ d.setUTCDate(d.getUTCDate() + 1);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export const pitInput = z.object({
61
+ factor: z.string().describe(`One of ${Object.keys(FACTORS).join(", ")}, or level:<measure> (revenue, net_income, assets, ...).`),
62
+ tickers: z.array(z.string().min(1).max(20)).min(1).max(1000).describe("Tickers (or CIKs) to compute it for."),
63
+ start: date, end: date,
64
+ out_file: z.string().optional().describe("Where to write the signal CSV; default a new temporary file."),
65
+ });
66
+ export const backtestInput = z.object({
67
+ prices_file: z.string().describe("CSV: a date column (YYYY-MM-DD) and one column of closing prices per ticker."),
68
+ signal_file: z.string().describe("CSV in the same shape; each row dated when its values became known (pit_factor writes this)."),
69
+ rebalance: z.union([z.enum(["weekly", "monthly", "quarterly"]), z.number().int().min(1)]).optional().describe("Default monthly; or every N trading days."),
70
+ lag_days: z.number().int().min(1).optional().describe("Trading days between a signal becoming known and trading on it. Default 1, the minimum."),
71
+ side: z.enum(["long_short", "long_only"]).optional().describe("Default long_short: long the top quantile, short the bottom."),
72
+ quantile: z.number().gt(0).max(0.5).optional().describe("Fraction of ranked names in each leg. Default 0.2."),
73
+ cost_bps: z.number().min(0).optional().describe("Cost per unit traded, in basis points. Default 10."),
74
+ min_names: z.number().int().min(2).optional().describe("Skip a rebalance with fewer ranked names. Default 5."),
75
+ periods_per_year: z.number().positive().optional().describe("Default 252 (daily prices)."),
76
+ ledger: z.string().regex(/^[a-z0-9][a-z0-9-]{2,63}$/).optional().describe("Record this run in a search ledger (created if new) and judge it against every run recorded there."),
77
+ label: z.string().min(1).max(200).optional().describe("The variant's name in the ledger. Default: the settings."),
78
+ });
79
+
80
+ export const TOOL_DESCRIPTIONS = Object.freeze({
81
+ list_factors: "The point-in-time factors pit_factor builds from SEC filings, and what each measures.",
82
+ 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]}`,
83
+ 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]}`,
84
+ });
85
+
86
+ export async function toolPitFactor(session, args) {
87
+ const { factor, tickers, start, end, out_file } = pitInput.parse(args);
88
+ if (!(start < end)) throw new RangeError("start must be before end");
89
+ const dates = monthStarts(start, end);
90
+ const loadSeries = async (ticker, names) => {
91
+ const entry = await loadCompany(session.fundamentals, ticker);
92
+ const series = {};
93
+ for (const n of names) { try { series[n] = resolveSeries(entry, n); } catch { /* measure not reported */ } }
94
+ return { matchedName: entry.name, series };
95
+ };
96
+ const panel = await buildFactorPanel({ factor, tickers, dates, loadSeries });
97
+ const file = out_file ?? join(session.outDir ?? mkdtempSync(join(tmpdir(), "canli-pit-")), `${factor.replace(":", "-")}.csv`);
98
+ writePanel(file, panel);
99
+ const covered = panel.coverage.map((c) => c.companies_with_value);
100
+ return text({
101
+ factor, meaning: FACTORS[factor]?.meaning ?? `annual ${factor.slice(6)} as filed`, file,
102
+ dates: dates.length, tickers: tickers.length, first_date: dates[0], last_date: dates.at(-1),
103
+ coverage: { min: Math.min(...covered), median: covered.slice().sort((a, b) => a - b)[Math.floor(covered.length / 2)], max: Math.max(...covered) },
104
+ matched: panel.matched, ...(Object.keys(panel.missing).length ? { missing: panel.missing } : {}),
105
+ next: `backtest_signal with signal_file ${file} and your prices_file; lag_days 1 or more.`,
106
+ limits: PIT_LIMITS_TEXT,
107
+ });
108
+ }
109
+
110
+ export async function toolBacktestSignal(session, args) {
111
+ const a = backtestInput.parse(args);
112
+ const prices = readPanel(a.prices_file, "prices_file");
113
+ const signal = readPanel(a.signal_file, "signal_file");
114
+ const tickers = prices.tickers.filter((t) => signal.tickers.includes(t));
115
+ const cfg = { rebalance: a.rebalance ?? "monthly", lagDays: a.lag_days ?? 1, side: a.side ?? "long_short", quantile: a.quantile ?? 0.2, costBps: a.cost_bps ?? 10, minNames: a.min_names ?? 5, periodsPerYear: a.periods_per_year ?? 252 };
116
+ const result = runBacktest({ dates: prices.dates, tickers, prices: prices.values, signalDates: signal.dates, signals: signal.values, ...cfg });
117
+ const stats = describe(result);
118
+ const settings = { rebalance: cfg.rebalance, lag_days: cfg.lagDays, side: cfg.side, quantile: cfg.quantile, cost_bps: cfg.costBps, min_names: cfg.minNames };
119
+ let search = null;
120
+ if (a.ledger) {
121
+ const label = a.label ?? `${a.signal_file.split("/").pop()} ${cfg.side} q${cfg.quantile} ${cfg.rebalance} lag${cfg.lagDays} ${cfg.costBps}bps`;
122
+ const r = recordTrial(session.ledgerDir, { ledger: a.ledger, label, returns: result.returns, periods_per_year: cfg.periodsPerYear, params: { ...settings, signal_file: a.signal_file } });
123
+ search = { ledger: r.ledger, trial: r.trial, trials: r.trials, best: r.best, deflated: r.deflated, overfitting: r.overfitting, plain_reading: r.plain_reading };
124
+ }
125
+ const plain = `${stats.first_date} to ${stats.last_date}, ${tickers.length} tickers: annualized Sharpe ${stats.sharpe_annualized?.toFixed(2)}, return ${(100 * (stats.annualized_return ?? 0)).toFixed(1)}% a year, worst drawdown ${(100 * stats.max_drawdown).toFixed(1)}%, turnover ${stats.turnover_per_year?.toFixed(1)}x a year after ${cfg.costBps} bps costs. Every trade used signals at least ${stats.causality.min_days_signal_to_trade} calendar days old.`;
126
+ return text({ settings, tickers: tickers.length, ...stats, plain_reading: plain, ...(search ? { search } : {}), limits: ENGINE_LIMITS_TEXT });
127
+ }
128
+
129
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
130
+ const WRITES_LOCAL = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
131
+
132
+ export function registerTools(server, session) {
133
+ const catalog = {};
134
+ const tool = (name, title, input, annotations, handler) => {
135
+ server.registerTool(name, { title, annotations: { title, ...annotations }, description: TOOL_DESCRIPTIONS[name], inputSchema: input }, handler);
136
+ catalog[name] = { description: TOOL_DESCRIPTIONS[name], inputSchema: input };
137
+ };
138
+ 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 }));
139
+ tool("pit_factor", "Build a point-in-time factor", pitInput, { ...WRITES_LOCAL, openWorldHint: true }, (args) => toolPitFactor(session, args));
140
+ tool("backtest_signal", "Backtest a signal without lookahead", backtestInput, WRITES_LOCAL, (args) => toolBacktestSignal(session, args));
141
+ return catalog;
142
+ }
143
+
144
+ const isMain = (() => {
145
+ try { return realpathSync(process.argv[1] ?? "") === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
146
+ })();
147
+
148
+ if (isMain) {
149
+ const server = new McpServer(SERVER_INFO, { instructions: SERVER_INSTRUCTIONS });
150
+ registerTools(server, createSession());
151
+ await server.connect(new StdioServerTransport());
152
+ }