canli-validation-mcp 0.12.0 → 0.13.1
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 +55 -26
- package/package.json +3 -3
- package/src/lab-schemas.mjs +50 -5
- package/src/lab.mjs +149 -6
- package/src/local/js/leakage-core.js +182 -0
- package/src/local/js/placebo-core.js +164 -0
- package/src/schemas.mjs +52 -1
- package/src/series-file.mjs +86 -5
- package/src/server.mjs +10 -5
package/README.md
CHANGED
|
@@ -34,14 +34,16 @@ other stdio client (Cursor, VS Code) run the same `npx` command; see "Claude Des
|
|
|
34
34
|
|
|
35
35
|
## What it does
|
|
36
36
|
|
|
37
|
-
An MCP (Model Context Protocol) server over canlicapital.com's free, keyed validation API
|
|
38
|
-
gives a coding agent
|
|
39
|
-
CSCV overfitting, data-snooping tests (Hansen's SPA, White's
|
|
40
|
-
paper-evidence conformance, breadth ceiling, minimum track
|
|
41
|
-
haircut Sharpe ratio, luck-equivalent trials),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
An MCP (Model Context Protocol) server over canlicapital.com's free, keyed validation API, with a
|
|
38
|
+
lab that computes on your machine. It gives a coding agent twenty tools: issue a free key, run the
|
|
39
|
+
nine validators (deflated Sharpe, CSCV overfitting, data-snooping tests (Hansen's SPA, White's
|
|
40
|
+
Reality Check and Romano-Wolf StepM), paper-evidence conformance, breadth ceiling, minimum track
|
|
41
|
+
record length, minimum backtest length, haircut Sharpe ratio, luck-equivalent trials), audit one
|
|
42
|
+
backtest with several of them in a single call, verify a receipt's signature offline, read service
|
|
43
|
+
status, read a company's reported financial history from SEC filings, and six lab tools: backtest
|
|
44
|
+
a rule over a parameter grid, summarize a long series, stress-test a strategy's returns, check a
|
|
45
|
+
plan against a broker's limits, check a signal for lookahead, and test a whole research pipeline
|
|
46
|
+
on placebo data. Every validation result carries, beside the number, the sentences that say what
|
|
45
47
|
it does not establish and the receipt that records it, success or error, so the agent cannot see a
|
|
46
48
|
number without its limits.
|
|
47
49
|
|
|
@@ -136,9 +138,9 @@ original SEC response and the record's own boundary sentence: these are accounti
|
|
|
136
138
|
reported to the SEC, not market prices, returns or a recommendation. Companies and concepts
|
|
137
139
|
outside the current release return an error with the available concepts listed.
|
|
138
140
|
|
|
139
|
-
## The lab: backtest, summarize, stress,
|
|
141
|
+
## The lab: backtest, summarize, stress, feasibility, lookahead, placebos
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
Six tools compute in the server process itself, on your machine or on the hosted endpoint, with
|
|
142
144
|
nothing sent to the API and no receipt stored. Each result names the SHA-256 of exactly the numbers
|
|
143
145
|
it was computed from, and states what it does not establish.
|
|
144
146
|
|
|
@@ -174,6 +176,32 @@ it was computed from, and states what it does not establish.
|
|
|
174
176
|
share of daily volume, square-root market impact (coefficient Y, default 1) with every copy of the
|
|
175
177
|
same strategy counted, and the capital at which costs and impact eat the expected gross return.
|
|
176
178
|
Every broker fact carries the date it was checked and its source.
|
|
179
|
+
- **`check_leakage`** finds lookahead in a signal without seeing its code. A causal signal computed
|
|
180
|
+
on the first n rows gives exactly the values the full run gives for those rows. `plan` picks the
|
|
181
|
+
cuts (seeded, between 40% and 95% of the series); you rerun your own code on the rows before each
|
|
182
|
+
cut, and `compare` checks every column: a value that changed when the later rows were removed
|
|
183
|
+
used them. On your machine, `columns_file` takes the path of a JSON file `{cuts, columns}` your
|
|
184
|
+
script wrote (Python's `NaN` reads as missing) instead of thousands of pasted numbers. The result names the pattern: future rows with their horizon (a `shift(-5)` reads as
|
|
185
|
+
five rows), the full sample (a normalization, rank, scaling or fit over the whole series), or
|
|
186
|
+
scattered rows (filling gaps backwards). On a fixed zoo of 22 pandas and statsmodels signals
|
|
187
|
+
(`config/research/leakage-zoo.json`, from `scripts/research/leakage/`), all 11 leaks are found
|
|
188
|
+
with the right pattern and none of the 11 honest indicators is flagged. A pass covers only the
|
|
189
|
+
cuts tested: survivorship, vendor revisions or a leak confined to the first rows leave no trace.
|
|
190
|
+
- **`placebo_test`** tests a whole research pipeline at once. `plan` writes your panel (`real.csv`)
|
|
191
|
+
and 19 placebos to a new folder in the system's temporary directory: the same returns with the
|
|
192
|
+
periods in a random order, the same order for every asset, so each asset keeps its returns and each
|
|
193
|
+
day its cross-section, but nothing can be predicted. Run the pipeline unchanged on each, every
|
|
194
|
+
search, filter and parameter choice included, and `compare` ranks its real result among the
|
|
195
|
+
placebo results. Every choice the pipeline makes is counted, because it makes them again on every
|
|
196
|
+
placebo. A pre-registered study chose the reordering (`scripts/research/placebo/v1/`,
|
|
197
|
+
`config/research/placebo-v1-evaluation.json`): in all 30 combinations of simulated market and
|
|
198
|
+
pipeline where nothing can be predicted (10,000 tests each; five return shapes, flat,
|
|
199
|
+
drifting and with assets that differ only in their constant means; SMA, time-series and
|
|
200
|
+
cross-sectional momentum grids), its false-positive rate at a nominal 5% was 4.6% to 5.5%.
|
|
201
|
+
On the same markets a t-test on the best rule found an edge 10.9% to 78.9% of the time,
|
|
202
|
+
and Bonferroni's correction 8.0% to 10.3% wherever the market drifted and 58.5% to
|
|
203
|
+
59.7% where assets only differed in their means. With 19 placebos the smallest p is 0.05; 99
|
|
204
|
+
give a finer one.
|
|
177
205
|
|
|
178
206
|
On your own machine, `prices_file`, `series_file` and `returns_file` take a CSV or JSON path instead
|
|
179
207
|
of the numbers. Only numbers are read, plus an ISO date column when the file has one, so results can
|
|
@@ -214,10 +242,10 @@ took three audit questions from 4 of 9 to 8 of 9 answered correctly on gpt-5.4-m
|
|
|
214
242
|
|
|
215
243
|
## Prompts, resources and structured results
|
|
216
244
|
|
|
217
|
-
Clients that show MCP prompts offer
|
|
245
|
+
Clients that show MCP prompts offer eight guided workflows: `validate_backtest` (deflated Sharpe, then
|
|
218
246
|
overfitting, then the track record needed, reported with what each number does not establish),
|
|
219
|
-
`track_record_needed`, `backtest_and_validate`, `stress_my_strategy`, `production_check
|
|
220
|
-
`summarize_market_series`. Resources: `canli://limits`, the boundary sentences every result carries;
|
|
247
|
+
`track_record_needed`, `backtest_and_validate`, `stress_my_strategy`, `production_check`,
|
|
248
|
+
`summarize_market_series`, `check_signal_for_lookahead` and `test_pipeline_on_placebos`. Resources: `canli://limits`, the boundary sentences every result carries;
|
|
221
249
|
`canli://sources`, the papers behind each validator and how each is checked against them;
|
|
222
250
|
`canli://strategy-spec`, the JSON Schema of every strategy family and its parameters; and
|
|
223
251
|
`canli://openapi`, the API's OpenAPI 3.1 document. Two resource templates are for writing code
|
|
@@ -392,10 +420,11 @@ Every envelope this server returns carries these sentences, verbatim, from the A
|
|
|
392
420
|
its costs, survivorship, or any lookahead in how the series was built.
|
|
393
421
|
- A deflated Sharpe or overfitting probability above or below any threshold is not admission to
|
|
394
422
|
anything and is not a forecast.
|
|
395
|
-
- The receipt is content-hashed
|
|
396
|
-
|
|
423
|
+
- The receipt is content-hashed, reproducible from the open-source core it names, and signed with
|
|
424
|
+
Ed25519 by a key published at https://canlicapital.com/.well-known/canli-receipt-keys.json.
|
|
397
425
|
- Quotas: 1000 validations per key per UTC day, 5 keys per client per UTC day, 1048576 bytes per
|
|
398
|
-
request, 20000 observations per series, 200
|
|
426
|
+
validation request, 1024 bytes per key revocation request, 20000 observations per series, 200
|
|
427
|
+
variants per matrix.
|
|
399
428
|
|
|
400
429
|
Each tool's description also states one of these sentences, so an agent sees the boundary before
|
|
401
430
|
it calls the tool, not only after.
|
|
@@ -420,25 +449,25 @@ no key is configured.
|
|
|
420
449
|
|
|
421
450
|
| toolset | tools |
|
|
422
451
|
|---|---|
|
|
423
|
-
| `validate` | `get_key`, the
|
|
452
|
+
| `validate` | `get_key`, the nine validators, `audit_backtest` |
|
|
424
453
|
| `receipts` | `verify_receipt` |
|
|
425
454
|
| `company` | `company_financial_history` |
|
|
426
455
|
| `status` | `service_status` |
|
|
427
|
-
| `lab` | `backtest_strategy`, `summarize_series`, `stress_test`, `check_feasibility` |
|
|
456
|
+
| `lab` | `backtest_strategy`, `summarize_series`, `stress_test`, `check_feasibility`, `check_leakage`, `placebo_test` |
|
|
428
457
|
|
|
429
458
|
Measured with `bench/tool_list_tokens.py` (tokenizer: tiktoken `o200k_base`; other models'
|
|
430
459
|
tokenizers give different absolute counts), in the shape an OpenAI-style client sends the list:
|
|
431
460
|
|
|
432
461
|
| CANLI_TOOLSETS | tools | tokens per turn | of all |
|
|
433
462
|
|---|---|---|---|
|
|
434
|
-
| (default) |
|
|
435
|
-
| (default, with CANLI_KEY) |
|
|
436
|
-
| `all` |
|
|
437
|
-
| `validate` | 11 | 3,
|
|
438
|
-
| `receipts` | 1 |
|
|
439
|
-
| `company` | 1 | 273 |
|
|
440
|
-
| `status` | 1 | 89 |
|
|
441
|
-
| `lab` |
|
|
463
|
+
| (default) | 19 | 5,978 | 96% |
|
|
464
|
+
| (default, with CANLI_KEY) | 18 | 5,842 | 93% |
|
|
465
|
+
| `all` | 20 | 6,249 | 100% |
|
|
466
|
+
| `validate` | 11 | 3,658 | 59% |
|
|
467
|
+
| `receipts` | 1 | 182 | 3% |
|
|
468
|
+
| `company` | 1 | 273 | 4% |
|
|
469
|
+
| `status` | 1 | 89 | 1% |
|
|
470
|
+
| `lab` | 6 | 2,055 | 33% |
|
|
442
471
|
|
|
443
472
|
Providers cache a tool list that is identical from turn to turn and bill the cached part at a
|
|
444
473
|
fraction of the price (`test/tool-list-stable.test.mjs` keeps each list byte-stable); a smaller list
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-validation-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Check whether a backtest is real: deflated Sharpe, CSCV probability of backtest overfitting, minimum track record and backtest length, haircut Sharpe and luck-equivalent trials, as an MCP server. Local mode, hosted endpoint, signed receipts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"test:package": "node test/package-smoke.mjs"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/server": "2.1
|
|
41
|
+
"@modelcontextprotocol/server": "2.3.1",
|
|
42
42
|
"zod": "4.6.5"
|
|
43
43
|
},
|
|
44
44
|
"mcpName": "io.github.arhancanli/canli-validation-mcp",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"url": "https://github.com/arhancanli/canlicapital/issues"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@modelcontextprotocol/client": "2.1
|
|
55
|
+
"@modelcontextprotocol/client": "2.3.1",
|
|
56
56
|
"fast-check": "4.10.2"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/src/lab-schemas.mjs
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
// mcp/src/lab-schemas.mjs
|
|
2
2
|
//
|
|
3
3
|
// Input and output schemas for the lab tools (src/lab.mjs): backtest_strategy, summarize_series,
|
|
4
|
-
// stress_test and
|
|
5
|
-
// carries one sentence of its own result's boundary language
|
|
6
|
-
// it against the sentences the computation attaches), so an
|
|
7
|
-
// used to claim before it ever calls the tool. Descriptions
|
|
8
|
-
// the model on every turn.
|
|
4
|
+
// stress_test, check_feasibility, check_leakage and placebo_test. Like the validators'
|
|
5
|
+
// descriptions, each tool description carries one sentence of its own result's boundary language
|
|
6
|
+
// verbatim (test/lab.test.mjs checks it against the sentences the computation attaches), so an
|
|
7
|
+
// agent reads what the result cannot be used to claim before it ever calls the tool. Descriptions
|
|
8
|
+
// stay terse: the tool list is re-sent to the model on every turn.
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { BACKTEST_LIMITS_TEXT, FAMILIES } from "./local/js/backtest-core.js";
|
|
11
11
|
import { FEASIBILITY_LIMITS_TEXT } from "./local/js/feasibility-core.js";
|
|
12
12
|
import { SUMMARY_LIMITS_TEXT } from "./local/js/series-summary-core.js";
|
|
13
13
|
import { STRESS_LIMITS_TEXT } from "./local/js/stress-core.js";
|
|
14
|
+
import { LEAKAGE_LIMITS_TEXT } from "./local/js/leakage-core.js";
|
|
15
|
+
import { PLACEBO_LIMITS_TEXT, PLACEBO_METHODS } from "./local/js/placebo-core.js";
|
|
14
16
|
|
|
15
17
|
export const LAB_BOUNDARY = Object.freeze({
|
|
16
18
|
backtest_strategy: BACKTEST_LIMITS_TEXT[3],
|
|
17
19
|
summarize_series: SUMMARY_LIMITS_TEXT[2],
|
|
18
20
|
stress_test: STRESS_LIMITS_TEXT[2],
|
|
19
21
|
check_feasibility: FEASIBILITY_LIMITS_TEXT[0],
|
|
22
|
+
check_leakage: LEAKAGE_LIMITS_TEXT[0],
|
|
23
|
+
placebo_test: PLACEBO_LIMITS_TEXT[0],
|
|
20
24
|
});
|
|
21
25
|
|
|
22
26
|
export const LAB_TOOL_DESCRIPTIONS = Object.freeze({
|
|
@@ -24,6 +28,8 @@ export const LAB_TOOL_DESCRIPTIONS = Object.freeze({
|
|
|
24
28
|
summarize_series: `A long price or return series in about 100 words plus fields: growth, risk, dated drawdowns, trend, volatility regime, tails, jumps, stale data. Use instead of reading raw bars. ${LAB_BOUNDARY.summarize_series}`,
|
|
25
29
|
stress_test: `Stress a strategy's returns: bootstrap histories (how often the drawdown limit breaks or Sharpe turns negative) and named crash, volatility, repeat and stuck-position scenarios, with a fragility share. ${LAB_BOUNDARY.stress_test}`,
|
|
26
30
|
check_feasibility: `Check a trading plan before it trades: broker order-rate and minimum-order limits, 2026 US day-trading rules, square-root market impact with crowding, and the capital where costs eat the return. ${LAB_BOUNDARY.check_feasibility}`,
|
|
31
|
+
check_leakage: `Does a signal look ahead? plan gives cuts; rerun your code on the first cut rows for each, then compare. Rows that changed used later rows; the result names the pattern and horizon. No code is sent. ${LAB_BOUNDARY.check_leakage}`,
|
|
32
|
+
placebo_test: `Does your pipeline find edges in noise? plan writes placebo files (your data's returns in random orders, so nothing is predictable); run the whole pipeline on real.csv and on each, then compare for a p-value that counts every choice it makes. ${LAB_BOUNDARY.placebo_test}`,
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
const FAMILY_NAMES = Object.keys(FAMILIES);
|
|
@@ -102,6 +108,37 @@ export const feasibilityInput = z
|
|
|
102
108
|
})
|
|
103
109
|
.strict();
|
|
104
110
|
|
|
111
|
+
export const leakageInput = z
|
|
112
|
+
.object({
|
|
113
|
+
action: z.enum(["plan", "compare"]),
|
|
114
|
+
observations: z.number().int().optional().describe("plan: rows in the series."),
|
|
115
|
+
prefixes: z.number().int().optional().describe("plan: cuts, default 5."),
|
|
116
|
+
seed: z.number().int().optional(),
|
|
117
|
+
cuts: z.array(z.number().int()).optional().describe("compare: plan's cuts."),
|
|
118
|
+
// Shapes and lengths are checked by the computation (js/leakage-core.js) with messages that
|
|
119
|
+
// say what to send; the advertised schema stays small because it is re-sent every turn.
|
|
120
|
+
columns: z.record(z.string(), z.looseObject({})).optional().describe("compare: {name: {full: [all rows], prefixes: [[first cuts[i] rows], ...]}}, null for missing."),
|
|
121
|
+
columns_file: z.string().optional().describe("compare, on your machine: a JSON file {cuts, columns} instead."),
|
|
122
|
+
timestamps: z.array(z.string()).optional(),
|
|
123
|
+
tolerance: z.number().optional().describe("Default 1e-9."),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
|
|
127
|
+
export const placeboInput = z
|
|
128
|
+
.object({
|
|
129
|
+
action: z.enum(["plan", "compare"]),
|
|
130
|
+
data_file: z.string().optional().describe("plan, on your machine: CSV or JSON, a column per asset, dates optional."),
|
|
131
|
+
columns: z.record(z.string(), z.array(z.number())).optional().describe("plan: {asset: [values]} instead of data_file."),
|
|
132
|
+
kind: z.enum(["prices", "returns"]).optional().describe("plan: default prices."),
|
|
133
|
+
placebos: z.number().int().optional().describe("plan: default 19, up to 199."),
|
|
134
|
+
method: z.enum(PLACEBO_METHODS).optional(),
|
|
135
|
+
seed: z.number().int().optional(),
|
|
136
|
+
real: z.number().optional().describe("compare: the pipeline's result on the real data."),
|
|
137
|
+
placebo_results: z.array(z.number()).optional().describe("compare: its result on each placebo, in file order."),
|
|
138
|
+
lower_is_better: z.boolean().optional(),
|
|
139
|
+
})
|
|
140
|
+
.strict();
|
|
141
|
+
|
|
105
142
|
const loose = z.looseObject({}).optional();
|
|
106
143
|
const sentences = z.array(z.string()).optional();
|
|
107
144
|
|
|
@@ -117,6 +154,14 @@ export const stressOutput = z
|
|
|
117
154
|
.looseObject({ baseline: loose, resampled: loose, scenarios: z.array(z.unknown()).optional(), fragility: loose, plain_reading: z.string().optional(), limits: sentences, source: loose })
|
|
118
155
|
.describe("resampled holds bootstrap percentiles and breach frequencies; scenarios each named stress and its rule; fragility the share of histories breaking the limit or losing.");
|
|
119
156
|
|
|
157
|
+
export const leakageOutput = z
|
|
158
|
+
.looseObject({ verdict: z.string().optional(), cuts: z.array(z.number()).optional(), seed: z.number().optional(), columns: loose, flagged_columns: z.array(z.string()).optional(), plain_reading: z.string().optional(), limits: sentences, source: loose })
|
|
159
|
+
.describe("plan: cuts and seed. compare: verdict over all columns, flagged_columns, and per column its pattern, sentence and per-cut changes.");
|
|
160
|
+
|
|
161
|
+
export const placeboOutput = z
|
|
162
|
+
.looseObject({ verdict: z.string().optional(), p_value: z.number().optional(), dir: z.string().optional(), files: z.array(z.string()).optional(), placebo: loose, plain_reading: z.string().optional(), limits: sentences, source: loose })
|
|
163
|
+
.describe("plan: dir, real.csv and the placebo files, method and seed. compare: verdict, p_value, and what the pipeline found on the placebos.");
|
|
164
|
+
|
|
120
165
|
export const feasibilityOutput = z
|
|
121
166
|
.looseObject({ verdict: z.string().optional(), checks: z.array(z.unknown()).optional(), to_change: z.array(z.string()).optional(), sources: loose, plain_reading: z.string().optional(), limits: sentences })
|
|
122
167
|
.describe("verdict summarizes the checks; each check has a status (ok, warning, blocking, unknown), its finding and numbers; to_change lists what to fix.");
|
package/src/lab.mjs
CHANGED
|
@@ -13,17 +13,23 @@
|
|
|
13
13
|
//
|
|
14
14
|
// Also here: the code-generation resources (every tool's exact JSON Schemas, the strategy spec,
|
|
15
15
|
// runnable client examples, the API's OpenAPI document) and the lab's guided prompts.
|
|
16
|
-
import { createHash } from "node:crypto";
|
|
16
|
+
import { createHash, randomInt } from "node:crypto";
|
|
17
|
+
import { mkdtempSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
17
20
|
import { ResourceTemplate } from "@modelcontextprotocol/server";
|
|
18
21
|
import { z } from "zod";
|
|
19
22
|
import { BACKTEST_LIMITS, BACKTEST_LIMITS_TEXT, FAMILIES, runBacktest } from "./local/js/backtest-core.js";
|
|
20
23
|
import { FEASIBILITY_LIMITS_TEXT, checkFeasibility } from "./local/js/feasibility-core.js";
|
|
21
24
|
import { SUMMARY_LIMITS_TEXT, summarizeSeries } from "./local/js/series-summary-core.js";
|
|
22
25
|
import { STRESS_LIMITS_TEXT, stressTest } from "./local/js/stress-core.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
26
|
+
import { LEAKAGE_LIMITS, LEAKAGE_LIMITS_TEXT, checkLeakage, planPrefixes } from "./local/js/leakage-core.js";
|
|
27
|
+
import { PLACEBO_DEFAULT_METHOD, PLACEBO_LIMITS, PLACEBO_LIMITS_TEXT, checkPanel, placeboPValue, placeboPanels } from "./local/js/placebo-core.js";
|
|
28
|
+
import { LAB_TOOL_DESCRIPTIONS, backtestInput, backtestOutput, feasibilityInput, feasibilityOutput, leakageInput, leakageOutput, placeboInput, placeboOutput, stressInput, stressOutput, summarizeInput, summaryOutput } from "./lab-schemas.mjs";
|
|
29
|
+
import { readColumnsFile, readPanelFile, readSeriesWithDates, writePanelFile } from "./series-file.mjs";
|
|
30
|
+
import { leanJsonSchema } from "./schemas.mjs";
|
|
25
31
|
|
|
26
|
-
export const LAB_TOOLS = Object.freeze(["backtest_strategy", "summarize_series", "stress_test", "check_feasibility"]);
|
|
32
|
+
export const LAB_TOOLS = Object.freeze(["backtest_strategy", "summarize_series", "stress_test", "check_feasibility", "check_leakage", "placebo_test"]);
|
|
27
33
|
|
|
28
34
|
// Same shape as every other tool's result: the object as text, and as structured content.
|
|
29
35
|
const labText = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
|
|
@@ -83,16 +89,129 @@ export async function toolCheckFeasibility(session, args) {
|
|
|
83
89
|
return labText({ ...checkFeasibility(input), limits: FEASIBILITY_LIMITS_TEXT });
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
// check_leakage: plan picks the cut points (a fresh seed unless one is sent, so the caller does not
|
|
93
|
+
// choose them); compare reads the caller's outputs and reports, per column, what changed and why.
|
|
94
|
+
export async function toolCheckLeakage(session, args) {
|
|
95
|
+
const input = parse(leakageInput, args, "check_leakage");
|
|
96
|
+
if (input.action === "plan") {
|
|
97
|
+
if (input.observations === undefined) throw new Error("check_leakage: plan needs observations, the number of rows in the full series");
|
|
98
|
+
const seed = input.seed ?? randomInt(1, 2 ** 31);
|
|
99
|
+
const cuts = planPrefixes(input.observations, { prefixes: input.prefixes ?? LEAKAGE_LIMITS.default_prefixes, seed });
|
|
100
|
+
return labText({
|
|
101
|
+
schema: "canli.leakage-plan.v1", observations: input.observations, cuts, seed,
|
|
102
|
+
plain_reading: `Run your signal code ${cuts.length + 1} times: on all ${input.observations} rows, and on the first ${cuts.join(", ")} rows. Send each output column with action compare and these cuts; prefixes[i] holds the run on the first cuts[i] rows.`,
|
|
103
|
+
limits: LEAKAGE_LIMITS_TEXT,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
let { cuts, columns, timestamps } = input;
|
|
107
|
+
if (input.columns_file !== undefined) {
|
|
108
|
+
if (columns !== undefined) throw new Error("check_leakage: send columns or columns_file, not both");
|
|
109
|
+
if (session.hosted) throw new Error("check_leakage: the hosted endpoint cannot read files on your machine; send columns as numbers, or run the server locally with npx -y canli-validation-mcp.");
|
|
110
|
+
const file = readColumnsFile(input.columns_file);
|
|
111
|
+
if (cuts !== undefined && file.cuts !== undefined && JSON.stringify(cuts) !== JSON.stringify(file.cuts)) throw new Error("check_leakage: the cuts sent differ from the cuts in columns_file");
|
|
112
|
+
cuts ??= file.cuts;
|
|
113
|
+
columns = file.columns;
|
|
114
|
+
timestamps ??= file.timestamps;
|
|
115
|
+
}
|
|
116
|
+
if (cuts === undefined || columns === undefined) throw new Error("check_leakage: compare needs cuts (from plan) and columns, sent or in a columns_file");
|
|
117
|
+
const result = checkLeakage({ cuts, columns, tolerance: input.tolerance, timestamps: labels(timestamps) });
|
|
118
|
+
// Per cut, only what a reader needs: where the changes start and how large they are.
|
|
119
|
+
const report = Object.fromEntries(Object.entries(result.columns).map(([name, c]) => [name, {
|
|
120
|
+
verdict: c.verdict, pattern: c.pattern, sentence: c.sentence,
|
|
121
|
+
...(c.horizon_rows !== undefined ? { horizon_rows: c.horizon_rows } : {}),
|
|
122
|
+
...(c.share_changed !== undefined ? { share_changed: c.share_changed } : {}),
|
|
123
|
+
...(c.first_changed_at !== undefined ? { first_changed_at: c.first_changed_at } : {}),
|
|
124
|
+
per_cut: c.prefixes.map((p) => ({ cut: p.cut, changed: p.changed, first_changed: p.first_changed, max_abs_difference: p.max_abs_difference })),
|
|
125
|
+
}]));
|
|
126
|
+
const flagged = result.flagged_columns;
|
|
127
|
+
return labText({
|
|
128
|
+
schema: "canli.leakage.v1", verdict: result.verdict, flagged_columns: flagged, cuts: result.cuts, columns: report,
|
|
129
|
+
source: { ...(input.columns_file !== undefined ? { columns_file: input.columns_file } : {}), input_sha256: digest({ cuts, columns }) },
|
|
130
|
+
plain_reading: flagged.length
|
|
131
|
+
? `Lookahead in ${flagged.length} of ${Object.keys(report).length} column${Object.keys(report).length === 1 ? "" : "s"} (${flagged.join(", ")}). ${flagged.map((n) => `${n}: ${report[n].sentence}`).join(" ")}`
|
|
132
|
+
: `No value changed in ${Object.keys(report).length} column${Object.keys(report).length === 1 ? "" : "s"} at ${result.cuts.length} cuts: these prefixes found no lookahead.`,
|
|
133
|
+
limits: LEAKAGE_LIMITS_TEXT,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// placebo_test: plan reorders the periods of the caller's panel, the same order for every column,
|
|
138
|
+
// and writes the real panel and each placebo as CSV to a new folder in the system's temporary
|
|
139
|
+
// directory (the hosted endpoint, which writes nothing, returns small placebos inline); compare
|
|
140
|
+
// ranks the pipeline's real result among its placebo results.
|
|
141
|
+
const PLACEBO_INLINE_MAX = 50000;
|
|
142
|
+
const short = (x) => Number(x.toPrecision(4));
|
|
143
|
+
|
|
144
|
+
export async function toolPlaceboTest(session, args) {
|
|
145
|
+
const input = parse(placeboInput, args, "placebo_test");
|
|
146
|
+
if (input.action === "compare") {
|
|
147
|
+
if (input.real === undefined || input.placebo_results === undefined) throw new Error("placebo_test: compare needs real and placebo_results, from the pipeline's runs on real.csv and the placebo files");
|
|
148
|
+
const r = placeboPValue(input.real, input.placebo_results, { higherIsBetter: !input.lower_is_better });
|
|
149
|
+
const beats = r.as_good_as_real === 0;
|
|
150
|
+
const luck = `On the placebos, where nothing can be predicted, it found a median of ${short(r.placebo.median)} and at best ${short(r.placebo.best)}.`;
|
|
151
|
+
return labText({
|
|
152
|
+
schema: "canli.placebo.v1", verdict: r.p_value <= 0.05 ? "beats_placebos" : "within_placebo_range", ...r,
|
|
153
|
+
source: { input_sha256: digest({ real: input.real, placebo_results: input.placebo_results }) },
|
|
154
|
+
plain_reading: beats
|
|
155
|
+
? `The pipeline's real result, ${short(r.real)}, beat all ${r.placebos} placebos: p = ${short(r.p_value)}, the smallest ${r.placebos} placebos can give${r.placebos < 99 ? "; 99 placebos give a finer p" : ""}. ${luck}`
|
|
156
|
+
: `${r.as_good_as_real} of ${r.placebos} placebos gave a result at least as good as the real one, ${short(r.real)}: p = ${short(r.p_value)}. ${luck}`,
|
|
157
|
+
limits: PLACEBO_LIMITS_TEXT,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if ((input.data_file === undefined) === (input.columns === undefined)) throw new Error("placebo_test: plan needs exactly one of data_file or columns");
|
|
161
|
+
const kind = input.kind ?? "prices";
|
|
162
|
+
const method = input.method ?? PLACEBO_DEFAULT_METHOD;
|
|
163
|
+
const placebos = input.placebos ?? PLACEBO_LIMITS.default_placebos;
|
|
164
|
+
const seed = input.seed ?? randomInt(1, 2 ** 31);
|
|
165
|
+
let panel;
|
|
166
|
+
if (input.data_file !== undefined) {
|
|
167
|
+
if (session.hosted) throw new Error("placebo_test: the hosted endpoint cannot read files on your machine; send columns as numbers, or run the server locally with npx -y canli-validation-mcp.");
|
|
168
|
+
panel = readPanelFile(input.data_file);
|
|
169
|
+
} else {
|
|
170
|
+
const names = Object.keys(input.columns);
|
|
171
|
+
panel = { columns: names.map((n) => input.columns[n]), names, dates: null, date_name: null, skipped: [], format: "csv" };
|
|
172
|
+
}
|
|
173
|
+
checkPanel(panel.columns, kind);
|
|
174
|
+
const source = {
|
|
175
|
+
...(input.data_file !== undefined ? { data_file: input.data_file, column_positions: panel.positions, ...(panel.date_column ? { date_column_position: panel.date_column } : {}), ...(panel.skipped.length ? { skipped_row_counter_columns: panel.skipped } : {}) } : {}),
|
|
176
|
+
columns: panel.columns.length, rows: panel.columns[0].length, input_sha256: digest(panel.columns),
|
|
177
|
+
};
|
|
178
|
+
const made = placeboPanels(panel.columns, { kind, method, placebos, seed });
|
|
179
|
+
const plan = { schema: "canli.placebo-plan.v1", kind, method, placebos, seed };
|
|
180
|
+
if (session.hosted) {
|
|
181
|
+
if (placebos * panel.columns.length * panel.columns[0].length > PLACEBO_INLINE_MAX) throw new Error(`placebo_test: the hosted endpoint returns at most ${PLACEBO_INLINE_MAX} placebo numbers; send fewer rows or columns, or run the server locally with npx -y canli-validation-mcp, which writes the placebos to files`);
|
|
182
|
+
const names = panel.names;
|
|
183
|
+
return labText({ ...plan, placebo_columns: [...made].map((p) => Object.fromEntries(names.map((n, i) => [n, p[i]]))), plain_reading: `Run the pipeline unchanged on the real columns and on each of the ${placebos} placebos, then send its results to compare in this order.`, limits: PLACEBO_LIMITS_TEXT, source });
|
|
184
|
+
}
|
|
185
|
+
const dir = mkdtempSync(join(tmpdir(), "canli-placebo-"));
|
|
186
|
+
const layout = { names: panel.names, dates: panel.dates, dateName: panel.date_name, format: panel.format };
|
|
187
|
+
const real = `real.${panel.format}`;
|
|
188
|
+
writePanelFile(join(dir, real), { ...layout, columns: panel.columns });
|
|
189
|
+
const files = [];
|
|
190
|
+
for (const columns of made) {
|
|
191
|
+
const name = `placebo_${String(files.length + 1).padStart(3, "0")}.${panel.format}`;
|
|
192
|
+
writePanelFile(join(dir, name), { ...layout, columns });
|
|
193
|
+
files.push(name);
|
|
194
|
+
}
|
|
195
|
+
return labText({
|
|
196
|
+
...plan, dir, real, files,
|
|
197
|
+
plain_reading: `Run the pipeline unchanged, every search and choice included, on ${real} and on each of the ${placebos} placebo files in ${dir}, record the one number it reports (such as its best Sharpe), and send them to compare in file order.`,
|
|
198
|
+
limits: PLACEBO_LIMITS_TEXT, source,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
86
202
|
// The lab computes locally and changes nothing anywhere: read-only, closed world, idempotent.
|
|
87
203
|
const LAB_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
88
204
|
|
|
89
205
|
export function labToolSpecs(session) {
|
|
90
|
-
const spec = (name, title, input, output, handler) => [name, { title, annotations: { title, ...
|
|
206
|
+
const spec = (name, title, input, output, handler, annotations = LAB_ANNOTATIONS) => [name, { title, annotations: { title, ...annotations }, description: LAB_TOOL_DESCRIPTIONS[name], inputSchema: input, outputSchema: output }, (args) => handler(session, args)];
|
|
91
207
|
return [
|
|
92
208
|
spec("backtest_strategy", "Backtest a rule over a parameter grid, validated", backtestInput, backtestOutput, toolBacktestStrategy),
|
|
93
209
|
spec("summarize_series", "Summarize a price or return series", summarizeInput, summaryOutput, toolSummarizeSeries),
|
|
94
210
|
spec("stress_test", "Stress-test a strategy's returns", stressInput, stressOutput, toolStressTest),
|
|
95
211
|
spec("check_feasibility", "Check a plan against broker limits and impact", feasibilityInput, feasibilityOutput, toolCheckFeasibility),
|
|
212
|
+
spec("check_leakage", "Check a signal for lookahead", leakageInput, leakageOutput, toolCheckLeakage),
|
|
213
|
+
// plan writes files, each call to a new temporary folder: not read-only, not idempotent.
|
|
214
|
+
spec("placebo_test", "Test a research pipeline on placebo data", placeboInput, placeboOutput, toolPlaceboTest, { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }),
|
|
96
215
|
];
|
|
97
216
|
}
|
|
98
217
|
|
|
@@ -121,6 +240,8 @@ export const EXAMPLE_ARGS = Object.freeze({
|
|
|
121
240
|
summarize_series: { series_file: "prices.csv", series_kind: "prices", name: "My asset" },
|
|
122
241
|
stress_test: { returns_file: "strategy_returns.csv", drawdown_limit: 0.2, paths: 1000, seed: 42 },
|
|
123
242
|
check_feasibility: { broker: "alpaca", asset_class: "us_equity", account: "margin", capital_usd: 250000, orders_per_rebalance: 40, rebalances_per_year: 52, turnover_per_year: 8, adv_usd: 20000000, daily_volatility: 0.02, copies: 1, expected_gross_return: 0.12, spread_and_fees_bps: 3 },
|
|
243
|
+
check_leakage: { action: "plan", observations: 2520, prefixes: 5 },
|
|
244
|
+
placebo_test: { action: "plan", data_file: "prices.csv" },
|
|
124
245
|
});
|
|
125
246
|
|
|
126
247
|
const usesFile = (args) => Object.keys(args).some((k) => k.endsWith("_file"));
|
|
@@ -220,7 +341,7 @@ export function registerCodeResources(server, session, catalog) {
|
|
|
220
341
|
(uri, { tool }) => {
|
|
221
342
|
const entry = catalog[tool];
|
|
222
343
|
if (!entry) throw new Error(`No tool ${tool}; tools are ${names.join(", ")}`);
|
|
223
|
-
const body = { tool, description: entry.description, input_schema: z.toJSONSchema(entry.inputSchema, { io: "input" }), output_schema: z.toJSONSchema(entry.outputSchema, { io: "output" }) };
|
|
344
|
+
const body = { tool, description: entry.description, input_schema: leanJsonSchema(z.toJSONSchema(entry.inputSchema, { io: "input" })), output_schema: leanJsonSchema(z.toJSONSchema(entry.outputSchema, { io: "output" })) };
|
|
224
345
|
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
|
|
225
346
|
},
|
|
226
347
|
);
|
|
@@ -305,4 +426,26 @@ export function registerLabPrompts(server) {
|
|
|
305
426
|
"Point out anything in the data check (stale prices, jumps) that should be fixed before the series is backtested.",
|
|
306
427
|
],
|
|
307
428
|
);
|
|
429
|
+
prompt(
|
|
430
|
+
"check_signal_for_lookahead",
|
|
431
|
+
"Does my signal look ahead?",
|
|
432
|
+
"Rerun your own signal code on prefixes the server picks, and find any value that depends on later rows.",
|
|
433
|
+
{ rows: z.string().describe("Rows in the full series the signal is computed on") },
|
|
434
|
+
({ rows }) => [
|
|
435
|
+
`Call check_leakage with action plan and observations ${rows}.`,
|
|
436
|
+
"Run your own signal and feature code once on all rows and once on the first cut rows for each cut it returns, changing nothing else, and send every output column with action compare. On this machine, write them to a JSON file {cuts, columns} and send its path as columns_file instead of pasting numbers.",
|
|
437
|
+
"For each flagged column, explain the pattern (future rows with their horizon, full-sample statistics, filled gaps), find the line of code that causes it, fix it, and run the check again. Say that a pass covers only these cuts.",
|
|
438
|
+
],
|
|
439
|
+
);
|
|
440
|
+
prompt(
|
|
441
|
+
"test_pipeline_on_placebos",
|
|
442
|
+
"Does my pipeline find edges in noise?",
|
|
443
|
+
"Run your whole research pipeline on placebo data, where nothing can be predicted, and see whether its real result beats what it finds there.",
|
|
444
|
+
{ data_file: z.string().describe("Path to the CSV or JSON the pipeline reads: a column per asset, dates optional") },
|
|
445
|
+
({ data_file }) => [
|
|
446
|
+
`Call placebo_test with action plan and data_file ${data_file}.`,
|
|
447
|
+
"Run the research pipeline unchanged, every search, filter and parameter choice included, on real.csv and on each placebo file in the folder it returns, and record the one number the pipeline reports, such as its best Sharpe.",
|
|
448
|
+
"Call placebo_test with action compare, the real number as real and the placebo numbers in file order as placebo_results. Explain the p-value and what the pipeline finds on data where nothing can be predicted, and say that only this pipeline's search is counted, not pipelines tried before it.",
|
|
449
|
+
],
|
|
450
|
+
);
|
|
308
451
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// leakage-core.js
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// A lookahead check that needs no code. A causal signal's value at row t depends only on rows 0
|
|
5
|
+
// to t, so computing it on the first n rows gives exactly the values the full run gives for rows
|
|
6
|
+
// 0 to n - 1. The server picks the cut points; the caller reruns its own code on each prefix and
|
|
7
|
+
// sends every column back; a row whose value changed when the rows after the cut were removed
|
|
8
|
+
// used those rows.
|
|
9
|
+
//
|
|
10
|
+
// planPrefixes the cut points (prefix lengths) for a series, seeded so a plan reproduces
|
|
11
|
+
// compareColumn for each prefix: rows compared, rows that changed, the first and last changed
|
|
12
|
+
// row, the largest change, and how far before the cut the changes start
|
|
13
|
+
// diagnose what the pattern of changes says: future rows (a fixed horizon before every
|
|
14
|
+
// cut), the full sample (most of every prefix changed: a full-sample
|
|
15
|
+
// normalization, rank or fit), or sparse rows (typical of filling gaps backwards)
|
|
16
|
+
//
|
|
17
|
+
// A pass shows only that these prefixes found no lookahead: the caller supplies the columns, and
|
|
18
|
+
// survivorship, vendor revisions or a leak that only shows in rows never cut leave no trace here.
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
import { makeRandom } from "./selection-risk-core.js";
|
|
22
|
+
|
|
23
|
+
export const LEAKAGE_LIMITS = Object.freeze({
|
|
24
|
+
min_observations: 30,
|
|
25
|
+
max_observations: 100000,
|
|
26
|
+
min_prefixes: 2,
|
|
27
|
+
max_prefixes: 20,
|
|
28
|
+
default_prefixes: 5,
|
|
29
|
+
max_columns: 20,
|
|
30
|
+
default_tolerance: 1e-9,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const LEAKAGE_LIMITS_TEXT = Object.freeze([
|
|
34
|
+
"A pass covers only these cuts: the columns are the caller's, and survivorship, vendor revisions or a leak no cut reaches leave no trace.",
|
|
35
|
+
"Cut points fall between 40% and 95% of the series, so a leak confined to its first rows is not tested.",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prefix lengths for a series of `observations` rows: `prefixes` distinct cuts between 40% and 95%
|
|
40
|
+
* of the series, one in each equal slot of that range at a seeded position, sorted.
|
|
41
|
+
*/
|
|
42
|
+
export function planPrefixes(observations, { prefixes = LEAKAGE_LIMITS.default_prefixes, seed = 1 } = {}) {
|
|
43
|
+
const n = Number(observations);
|
|
44
|
+
const k = Number(prefixes);
|
|
45
|
+
if (!Number.isInteger(n) || n < LEAKAGE_LIMITS.min_observations || n > LEAKAGE_LIMITS.max_observations) {
|
|
46
|
+
throw new RangeError(`observations must be an integer from ${LEAKAGE_LIMITS.min_observations} to ${LEAKAGE_LIMITS.max_observations}`);
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isInteger(k) || k < LEAKAGE_LIMITS.min_prefixes || k > LEAKAGE_LIMITS.max_prefixes) {
|
|
49
|
+
throw new RangeError(`prefixes must be an integer from ${LEAKAGE_LIMITS.min_prefixes} to ${LEAKAGE_LIMITS.max_prefixes}`);
|
|
50
|
+
}
|
|
51
|
+
const lo = Math.ceil(0.4 * n);
|
|
52
|
+
const hi = Math.floor(0.95 * n);
|
|
53
|
+
if (hi - lo + 1 < k) throw new RangeError(`a series of ${n} rows has room for at most ${hi - lo + 1} cuts`);
|
|
54
|
+
const next = makeRandom(Number(seed) >>> 0 || 1);
|
|
55
|
+
const width = (hi - lo + 1) / k;
|
|
56
|
+
const cuts = new Set();
|
|
57
|
+
for (let i = 0; i < k; i++) {
|
|
58
|
+
const start = lo + Math.floor(i * width);
|
|
59
|
+
const end = Math.max(start, lo + Math.floor((i + 1) * width) - 1);
|
|
60
|
+
let cut = start + Math.floor(next() * (end - start + 1));
|
|
61
|
+
while (cuts.has(cut)) cut += 1;
|
|
62
|
+
cuts.add(cut);
|
|
63
|
+
}
|
|
64
|
+
return [...cuts].sort((a, b) => a - b);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const missing = (v) => v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
|
|
68
|
+
|
|
69
|
+
function readValue(v, where) {
|
|
70
|
+
if (missing(v)) return null;
|
|
71
|
+
const x = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
|
|
72
|
+
if (!Number.isFinite(x)) throw new RangeError(`${where} is ${JSON.stringify(v)}, not a number or null`);
|
|
73
|
+
return x;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compares one column's full run with its prefix runs. `prefixes[i]` must hold exactly `cuts[i]`
|
|
78
|
+
* values: the caller's code run on rows 0 to cuts[i] - 1. Missing values (null) match only missing
|
|
79
|
+
* values; numbers match within `tolerance` relative to max(1, |a|, |b|).
|
|
80
|
+
*/
|
|
81
|
+
export function compareColumn(full, prefixes, cuts, { tolerance = LEAKAGE_LIMITS.default_tolerance, name = "column" } = {}) {
|
|
82
|
+
if (!Array.isArray(full)) throw new RangeError(`${name}.full must be an array`);
|
|
83
|
+
if (!Array.isArray(prefixes) || prefixes.length !== cuts.length) throw new RangeError(`${name}.prefixes must hold ${cuts.length} runs, one per cut`);
|
|
84
|
+
const f = full.map((v, i) => readValue(v, `${name}.full[${i}]`));
|
|
85
|
+
return cuts.map((cut, p) => {
|
|
86
|
+
const run = prefixes[p];
|
|
87
|
+
if (!Array.isArray(run) || run.length !== cut) {
|
|
88
|
+
throw new RangeError(`${name}.prefixes[${p}] has ${Array.isArray(run) ? run.length : "no"} values; its cut is ${cut}: send your code's output on exactly the first ${cut} rows`);
|
|
89
|
+
}
|
|
90
|
+
if (cut > f.length) throw new RangeError(`${name}: cut ${cut} is beyond the ${f.length} rows of the full run`);
|
|
91
|
+
let changed = 0, first = -1, last = -1, maxDiff = 0;
|
|
92
|
+
for (let t = 0; t < cut; t++) {
|
|
93
|
+
const a = f[t];
|
|
94
|
+
const b = readValue(run[t], `${name}.prefixes[${p}][${t}]`);
|
|
95
|
+
let same;
|
|
96
|
+
if (a === null || b === null) same = a === b;
|
|
97
|
+
else {
|
|
98
|
+
const d = Math.abs(a - b);
|
|
99
|
+
same = d <= tolerance * Math.max(1, Math.abs(a), Math.abs(b));
|
|
100
|
+
if (!same && d > maxDiff) maxDiff = d;
|
|
101
|
+
}
|
|
102
|
+
if (!same) {
|
|
103
|
+
changed += 1;
|
|
104
|
+
if (first < 0) first = t;
|
|
105
|
+
last = t;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
cut,
|
|
110
|
+
compared: cut,
|
|
111
|
+
changed,
|
|
112
|
+
share_changed: changed / cut,
|
|
113
|
+
first_changed: first < 0 ? null : first,
|
|
114
|
+
last_changed: last < 0 ? null : last,
|
|
115
|
+
rows_before_cut: first < 0 ? null : cut - first,
|
|
116
|
+
max_abs_difference: changed ? maxDiff : 0,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What a column's changes say. future_rows: in every prefix the changed rows sit in a block that
|
|
123
|
+
* ends at the cut and is no wider than a tenth of the shortest prefix, so the value at row t uses
|
|
124
|
+
* rows up to t + horizon. full_sample: most rows of some prefix changed. sparse: anything else.
|
|
125
|
+
*/
|
|
126
|
+
export function diagnose(results) {
|
|
127
|
+
const touched = results.filter((r) => r.changed > 0);
|
|
128
|
+
if (!touched.length) {
|
|
129
|
+
return { verdict: "no_lookahead_found", pattern: "none", sentence: `No value changed in ${results.length} prefixes: these cuts found no lookahead.` };
|
|
130
|
+
}
|
|
131
|
+
const shortest = Math.min(...results.map((r) => r.cut));
|
|
132
|
+
const horizon = Math.max(...touched.map((r) => r.rows_before_cut));
|
|
133
|
+
const endsAtCut = touched.every((r) => r.last_changed === r.cut - 1 && r.changed === r.rows_before_cut);
|
|
134
|
+
if (endsAtCut && horizon <= Math.max(1, Math.floor(shortest / 10))) {
|
|
135
|
+
return {
|
|
136
|
+
verdict: "lookahead_found",
|
|
137
|
+
pattern: "future_rows",
|
|
138
|
+
horizon_rows: horizon,
|
|
139
|
+
sentence: `Up to ${horizon} row${horizon === 1 ? "" : "s"} before each cut changed when the later rows were removed: the value at row t uses at least the next ${horizon} row${horizon === 1 ? "" : "s"}, as shift(-${horizon}), a centered window or a forward return would.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const widest = Math.max(...touched.map((r) => r.share_changed));
|
|
143
|
+
if (widest > 0.5) {
|
|
144
|
+
return {
|
|
145
|
+
verdict: "lookahead_found",
|
|
146
|
+
pattern: "full_sample",
|
|
147
|
+
share_changed: widest,
|
|
148
|
+
sentence: `Up to ${(widest * 100).toFixed(0)}% of a prefix's rows changed when later rows were removed: values depend on the whole sample, as a full-sample normalization, rank, scaling or model fit would.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const first = Math.min(...touched.map((r) => r.first_changed));
|
|
152
|
+
return {
|
|
153
|
+
verdict: "lookahead_found",
|
|
154
|
+
pattern: "sparse",
|
|
155
|
+
first_changed: first,
|
|
156
|
+
sentence: `Scattered rows changed when later rows were removed (the earliest at row ${first}): typical of filling gaps backwards (bfill) or a lookup that reaches forward.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The whole check: every column compared at every cut, and one verdict for the set. */
|
|
161
|
+
export function checkLeakage({ cuts, columns, tolerance = LEAKAGE_LIMITS.default_tolerance, timestamps }) {
|
|
162
|
+
if (!Array.isArray(cuts) || cuts.length < LEAKAGE_LIMITS.min_prefixes || cuts.length > LEAKAGE_LIMITS.max_prefixes) {
|
|
163
|
+
throw new RangeError(`cuts must list ${LEAKAGE_LIMITS.min_prefixes} to ${LEAKAGE_LIMITS.max_prefixes} prefix lengths, as check_leakage's plan returned them`);
|
|
164
|
+
}
|
|
165
|
+
const names = Object.keys(columns ?? {});
|
|
166
|
+
if (!names.length || names.length > LEAKAGE_LIMITS.max_columns) throw new RangeError(`send 1 to ${LEAKAGE_LIMITS.max_columns} columns`);
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
const results = compareColumn(columns[name].full, columns[name].prefixes, cuts, { tolerance, name });
|
|
170
|
+
const d = diagnose(results);
|
|
171
|
+
const label = (i) => (timestamps && i !== null && i !== undefined && timestamps[i] !== undefined ? timestamps[i] : undefined);
|
|
172
|
+
out[name] = { ...d, ...(d.pattern === "sparse" && label(d.first_changed) ? { first_changed_at: label(d.first_changed) } : {}), prefixes: results };
|
|
173
|
+
}
|
|
174
|
+
const flagged = names.filter((n) => out[n].verdict === "lookahead_found");
|
|
175
|
+
return {
|
|
176
|
+
verdict: flagged.length ? "lookahead_found" : "no_lookahead_found",
|
|
177
|
+
flagged_columns: flagged,
|
|
178
|
+
cuts,
|
|
179
|
+
tolerance,
|
|
180
|
+
columns: out,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// placebo-core.js
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// The pipeline placebo. A research pipeline searches data for a strategy and reports a result,
|
|
5
|
+
// usually the best Sharpe it found. How good a result does the same pipeline find in data where
|
|
6
|
+
// nothing can be predicted? A placebo dataset holds the real returns with their periods reordered,
|
|
7
|
+
// the same reordering for every column: each column keeps its returns (mean, volatility, fat
|
|
8
|
+
// tails) and each period keeps its cross-section, but no period's position says anything about
|
|
9
|
+
// the next. The caller runs its own pipeline on the real data and on every placebo; the rank of the
|
|
10
|
+
// real result among the placebo results is the p-value. The pipeline's code never leaves the
|
|
11
|
+
// caller, and every choice the pipeline makes is counted, because it makes them again on every
|
|
12
|
+
// placebo.
|
|
13
|
+
//
|
|
14
|
+
// placeboOrder one reordering of n periods: a permutation, a permutation of blocks, or a
|
|
15
|
+
// stationary bootstrap (Politis and Romano 1994)
|
|
16
|
+
// placeboPanel a panel of prices or returns, reordered: prices are rebuilt from the
|
|
17
|
+
// reordered returns, starting from the real first prices
|
|
18
|
+
// placeboPanels K placebos from one seed, one at a time
|
|
19
|
+
// placeboPValue the real result's rank among the placebo results, as a p-value
|
|
20
|
+
//
|
|
21
|
+
// When the periods are exchangeable (independent and identically distributed rows), the real data
|
|
22
|
+
// and each permutation of it are equally likely, so the real result's rank is uniform and the
|
|
23
|
+
// permutation p-value is exact. Volatility clustering breaks exchangeability; how far that moves
|
|
24
|
+
// the p-value was measured before a method was chosen (scripts/research/placebo/v1/).
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
import { makeRandom } from "./selection-risk-core.js";
|
|
28
|
+
import { defaultBlock, stationaryIndices } from "./snooping-core.js";
|
|
29
|
+
|
|
30
|
+
export const PLACEBO_METHODS = Object.freeze(["permute", "block_permute", "stationary_bootstrap"]);
|
|
31
|
+
// placebo_test's default: the method the pre-registered study selected
|
|
32
|
+
// (config/research/placebo-v1-evaluation.json; a test keeps the two equal).
|
|
33
|
+
export const PLACEBO_DEFAULT_METHOD = "permute";
|
|
34
|
+
|
|
35
|
+
export const PLACEBO_LIMITS = Object.freeze({
|
|
36
|
+
min_rows: 30,
|
|
37
|
+
max_rows: 100000,
|
|
38
|
+
max_columns: 500,
|
|
39
|
+
max_cells: 5000000,
|
|
40
|
+
min_placebos: 19,
|
|
41
|
+
max_placebos: 199,
|
|
42
|
+
default_placebos: 19,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const PLACEBO_LIMITS_TEXT = Object.freeze([
|
|
46
|
+
"Beating the placebos shows the pipeline finds more in the real order of the returns than in random orders of the same returns; it does not show the edge survives costs, capacity or the future.",
|
|
47
|
+
"Only the search this pipeline runs is counted: pipelines tried and dropped before it are not.",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
/** A uniform integer in [0, k) from a uniform generator in [0, 1). */
|
|
51
|
+
const below = (next, k) => Math.min(k - 1, Math.floor(next() * k));
|
|
52
|
+
|
|
53
|
+
/** Fisher-Yates over an Int32Array in place. */
|
|
54
|
+
function shuffle(values, next) {
|
|
55
|
+
for (let i = values.length - 1; i > 0; i--) {
|
|
56
|
+
const j = below(next, i + 1);
|
|
57
|
+
const v = values[i];
|
|
58
|
+
values[i] = values[j];
|
|
59
|
+
values[j] = v;
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One reordering of n periods, as the index of the real period each placebo period takes.
|
|
66
|
+
* permute: every order equally likely. block_permute: the periods cut into consecutive blocks of
|
|
67
|
+
* `block` (the last may be shorter) and the blocks shuffled, so structure inside a block survives.
|
|
68
|
+
* stationary_bootstrap: blocks of random length with mean `block`, drawn with replacement.
|
|
69
|
+
*/
|
|
70
|
+
export function placeboOrder(n, { method = PLACEBO_DEFAULT_METHOD, block, next }) {
|
|
71
|
+
if (!PLACEBO_METHODS.includes(method)) throw new RangeError(`method must be one of ${PLACEBO_METHODS.join(", ")}`);
|
|
72
|
+
if (method === "permute") return shuffle(Int32Array.from({ length: n }, (_, i) => i), next);
|
|
73
|
+
const b = block ?? defaultBlock(n);
|
|
74
|
+
if (!Number.isInteger(b) || b < 1 || b > n) throw new RangeError(`block must be a whole number from 1 to ${n}`);
|
|
75
|
+
if (method === "stationary_bootstrap") return stationaryIndices(n, b, next);
|
|
76
|
+
const starts = shuffle(Int32Array.from({ length: Math.ceil(n / b) }, (_, k) => k * b), next);
|
|
77
|
+
const out = new Int32Array(n);
|
|
78
|
+
let at = 0;
|
|
79
|
+
for (const start of starts) for (let t = start; t < Math.min(n, start + b); t++) out[at++] = t;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Checks a panel (columns of equal length) and returns the number of periods the placebo reorders:
|
|
85
|
+
* the rows of a returns panel, or the returns between rows of a prices panel.
|
|
86
|
+
*/
|
|
87
|
+
export function checkPanel(columns, kind = "prices") {
|
|
88
|
+
if (kind !== "prices" && kind !== "returns") throw new RangeError('kind must be "prices" or "returns"');
|
|
89
|
+
if (!Array.isArray(columns) || !columns.length || columns.length > PLACEBO_LIMITS.max_columns) {
|
|
90
|
+
throw new RangeError(`send 1 to ${PLACEBO_LIMITS.max_columns} columns`);
|
|
91
|
+
}
|
|
92
|
+
const rows = columns[0]?.length;
|
|
93
|
+
if (!Number.isInteger(rows) || rows < PLACEBO_LIMITS.min_rows || rows > PLACEBO_LIMITS.max_rows) {
|
|
94
|
+
throw new RangeError(`each column needs ${PLACEBO_LIMITS.min_rows} to ${PLACEBO_LIMITS.max_rows} rows`);
|
|
95
|
+
}
|
|
96
|
+
if (rows * columns.length > PLACEBO_LIMITS.max_cells) throw new RangeError(`at most ${PLACEBO_LIMITS.max_cells} numbers in all`);
|
|
97
|
+
columns.forEach((column, c) => {
|
|
98
|
+
if (!Array.isArray(column) && !ArrayBuffer.isView(column)) throw new RangeError(`column ${c + 1} is not a list of numbers`);
|
|
99
|
+
if (column.length !== rows) throw new RangeError(`column ${c + 1} has ${column.length} rows; column 1 has ${rows}`);
|
|
100
|
+
for (let t = 0; t < rows; t++) {
|
|
101
|
+
const v = column[t];
|
|
102
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new RangeError(`column ${c + 1}, row ${t + 1} is not a finite number: the placebo needs a complete panel`);
|
|
103
|
+
if (kind === "prices" && v <= 0) throw new RangeError(`column ${c + 1}, row ${t + 1} is not a positive price`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return kind === "prices" ? rows - 1 : rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A panel reordered by `order`. Returns: placebo row t is real row order[t]. Prices: the placebo
|
|
111
|
+
* keeps the real first prices, and its return into row t + 1 is the real return into row
|
|
112
|
+
* order[t] + 1, so a permutation ends every column at its real last price.
|
|
113
|
+
*/
|
|
114
|
+
export function placeboPanel(columns, { kind = "prices", order }) {
|
|
115
|
+
if (kind === "returns") return columns.map((column) => Array.from(order, (i) => column[i]));
|
|
116
|
+
return columns.map((column) => {
|
|
117
|
+
const out = new Array(column.length);
|
|
118
|
+
out[0] = column[0];
|
|
119
|
+
for (let t = 1; t < column.length; t++) {
|
|
120
|
+
const i = order[t - 1] + 1;
|
|
121
|
+
out[t] = out[t - 1] * (column[i] / column[i - 1]);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `placebos` placebo panels from one seed, in order, one at a time. */
|
|
128
|
+
export function* placeboPanels(columns, { kind = "prices", method = PLACEBO_DEFAULT_METHOD, block, placebos = PLACEBO_LIMITS.default_placebos, seed = 1 } = {}) {
|
|
129
|
+
const n = checkPanel(columns, kind);
|
|
130
|
+
if (!Number.isInteger(placebos) || placebos < PLACEBO_LIMITS.min_placebos || placebos > PLACEBO_LIMITS.max_placebos) {
|
|
131
|
+
throw new RangeError(`placebos must be a whole number from ${PLACEBO_LIMITS.min_placebos} to ${PLACEBO_LIMITS.max_placebos}`);
|
|
132
|
+
}
|
|
133
|
+
const next = makeRandom(Number(seed) >>> 0 || 1);
|
|
134
|
+
for (let k = 0; k < placebos; k++) yield placeboPanel(columns, { kind, order: placeboOrder(n, { method, block, next }) });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The value at nearest rank q (0 < q <= 1) of an ascending list. */
|
|
138
|
+
const nearestRank = (sorted, q) => sorted[Math.max(0, Math.ceil(q * sorted.length) - 1)];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The real result's rank among the placebo results. A placebo result as good as the real one counts
|
|
142
|
+
* against it, so ties make the p-value larger, never smaller. With K placebos the smallest p-value
|
|
143
|
+
* is 1 / (K + 1).
|
|
144
|
+
*/
|
|
145
|
+
export function placeboPValue(real, results, { higherIsBetter = true } = {}) {
|
|
146
|
+
if (typeof real !== "number" || !Number.isFinite(real)) throw new RangeError("real must be a finite number: the pipeline's result on the real data");
|
|
147
|
+
if (!Array.isArray(results) || results.length < PLACEBO_LIMITS.min_placebos || results.length > PLACEBO_LIMITS.max_placebos) {
|
|
148
|
+
throw new RangeError(`send ${PLACEBO_LIMITS.min_placebos} to ${PLACEBO_LIMITS.max_placebos} placebo results, one per placebo`);
|
|
149
|
+
}
|
|
150
|
+
results.forEach((x, k) => {
|
|
151
|
+
if (typeof x !== "number" || !Number.isFinite(x)) throw new RangeError(`placebo_results[${k}] is not a finite number: rerun that placebo, or record what the pipeline reports when it finds nothing`);
|
|
152
|
+
});
|
|
153
|
+
const atLeast = results.filter((x) => (higherIsBetter ? x >= real : x <= real)).length;
|
|
154
|
+
const sorted = [...results].sort((a, b) => a - b);
|
|
155
|
+
const best = higherIsBetter ? sorted[sorted.length - 1] : sorted[0];
|
|
156
|
+
return {
|
|
157
|
+
p_value: (1 + atLeast) / (results.length + 1),
|
|
158
|
+
smallest_possible_p: 1 / (results.length + 1),
|
|
159
|
+
placebos: results.length,
|
|
160
|
+
as_good_as_real: atLeast,
|
|
161
|
+
real,
|
|
162
|
+
placebo: { min: sorted[0], median: nearestRank(sorted, 0.5), q90: nearestRank(sorted, 0.9), max: sorted[sorted.length - 1], best },
|
|
163
|
+
};
|
|
164
|
+
}
|
package/src/schemas.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// docs/superpowers/specs/2026-09-05-developer-key-validation-api-design.md section 4 for the
|
|
8
8
|
// validator semantics these shapes mirror.
|
|
9
9
|
import { z } from "zod";
|
|
10
|
+
import { NULL_ZOO_V1 } from "./local/js/null-zoo-v1-sizes.js";
|
|
10
11
|
|
|
11
12
|
// One description per input field, shared by every shape that uses the field, so a client reads
|
|
12
13
|
// the same meaning, unit and example wherever the field appears. Agents choose arguments from
|
|
@@ -331,13 +332,26 @@ export const LIMITS_SENTENCES = Object.freeze({
|
|
|
331
332
|
export const REGISTRY_LIMITS_CLAUSE = "is not admission to anything and is not a forecast.";
|
|
332
333
|
export const REGISTRY_DESCRIPTION_MAX = 100;
|
|
333
334
|
|
|
335
|
+
// The headline tests audit_backtest can run, by the names the shipped size table uses. The
|
|
336
|
+
// description reads the chosen ones from that table, so it cannot name a test the audit does not run.
|
|
337
|
+
export const HEADLINE_TEST_NAMES = Object.freeze({
|
|
338
|
+
reality_check: "White's Reality Check",
|
|
339
|
+
spa_consistent: "Hansen's SPA",
|
|
340
|
+
spa_upper: "Hansen's SPA (upper p-value)",
|
|
341
|
+
luck_trials_lo: "luck trials with Lo's correction",
|
|
342
|
+
});
|
|
343
|
+
const headlineName = (test) => {
|
|
344
|
+
if (!HEADLINE_TEST_NAMES[test]) throw new Error(`No description name for the headline test ${test}`);
|
|
345
|
+
return HEADLINE_TEST_NAMES[test];
|
|
346
|
+
};
|
|
347
|
+
|
|
334
348
|
// One tool description per tool, each stating (in one sentence lifted from the boundary language
|
|
335
349
|
// above) what the tool's result cannot be used to claim, so an agent sees this before it ever
|
|
336
350
|
// calls the tool, not only inside the returned envelope.
|
|
337
351
|
export const TOOL_DESCRIPTIONS = Object.freeze({
|
|
338
352
|
get_key: `Issue a free validation key for this session. Rarely needed: the first validation issues one itself unless CANLI_KEY or local mode is set, and the read tools need none. ${LIMITS_SENTENCES.quotas}`,
|
|
339
353
|
validate_deflated_sharpe: `Deflated Sharpe ratio: the probability (0 to 1) that the selected strategy's Sharpe beats the best that luck gives across the variants tried, with the probabilistic Sharpe and that luck benchmark. Send the seven statistics or a return series. With every variant's returns use validate_overfitting; luck as a trial count, validate_luck_trials; a multiple-testing haircut, validate_haircut_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
340
|
-
audit_backtest: `One-call audit of a strategy's returns. Headline: one test whose false-positive rate was measured on nine return shapes (
|
|
354
|
+
audit_backtest: `One-call audit of a strategy's returns. Headline: one test whose false-positive rate was measured on nine return shapes (${headlineName(NULL_ZOO_V1.with_variants.test)} with variants, ${headlineName(NULL_ZOO_V1.single_series.test)} without). Then deflated Sharpe, minimum track record and, with variants, overfitting and out-of-sample decay, each with its receipt, plus fix_next. Send every variant tried (variants_file) so trials are counted; point returns_file at a CSV instead of pasting. ${LIMITS_SENTENCES.notAdmission}`,
|
|
341
355
|
validate_overfitting: `Probability of backtest overfitting (0 to 1) by CSCV: how often the in-sample best variant falls below the out-of-sample median. Needs every variant's returns (periods by variants); with summary statistics only, use validate_deflated_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
342
356
|
validate_reality_check: `Data-snooping tests on every variant a search tried: Hansen's SPA p-value that the best beat the benchmark only by luck, White's Reality Check, and the variants Romano-Wolf StepM finds better. Send all variants tried, not only the winners. ${LIMITS_SENTENCES.notAdmission}`,
|
|
343
357
|
validate_paper_evidence: `Checks paper-evidence.v0 structure and disclosures. With CANLI_LOCAL=1, record_file reads an export bundle and journal_file verifies signatures, source hashes and recomputed claims. Without a journal, source facts and signatures are unchecked. Local files are never uploaded. ${LIMITS_SENTENCES.scope}`,
|
|
@@ -394,3 +408,40 @@ export const statusOutput = z
|
|
|
394
408
|
export const companyHistoryOutput = z
|
|
395
409
|
.looseObject({ company: loose, claim_boundary: z.string().optional(), source: loose, histories: z.array(z.unknown()).optional(), history: loose, error: refusal })
|
|
396
410
|
.describe("Without a concept, histories lists what is available; with one, history holds the observations newest first as columns and rows, each with its filing. Values are as reported to the SEC.");
|
|
411
|
+
|
|
412
|
+
// zod writes keywords that constrain nothing a caller sends, and the tool list carrying them is
|
|
413
|
+
// re-sent to the model every turn, so the schemas the server lists leave them out:
|
|
414
|
+
// - JavaScript's safe-integer range (minimum -9007199254740991, maximum 9007199254740991) on every
|
|
415
|
+
// .int() field, about nine tokens a field;
|
|
416
|
+
// - propertyNames {type: "string"}, which every JSON object key already is;
|
|
417
|
+
// - an empty additionalProperties ({}), which allows anything, as its absence does.
|
|
418
|
+
// A real bound stays, and zod still validates every call. Only subschemas are walked, so a field
|
|
419
|
+
// that happens to be named "minimum" or "additionalProperties" is never touched.
|
|
420
|
+
const SUBSCHEMA_MAPS = ["properties", "patternProperties", "$defs", "dependentSchemas"];
|
|
421
|
+
const SUBSCHEMA_LISTS = ["anyOf", "oneOf", "allOf", "prefixItems"];
|
|
422
|
+
const SUBSCHEMAS = ["items", "additionalProperties", "propertyNames", "contains", "not", "if", "then", "else", "unevaluatedItems", "unevaluatedProperties"];
|
|
423
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
424
|
+
|
|
425
|
+
export function leanJsonSchema(schema) {
|
|
426
|
+
if (!isPlainObject(schema)) return schema;
|
|
427
|
+
const out = { ...schema };
|
|
428
|
+
for (const key of SUBSCHEMA_MAPS) {
|
|
429
|
+
if (isPlainObject(out[key])) out[key] = Object.fromEntries(Object.entries(out[key]).map(([name, sub]) => [name, leanJsonSchema(sub)]));
|
|
430
|
+
}
|
|
431
|
+
for (const key of SUBSCHEMA_LISTS) if (Array.isArray(out[key])) out[key] = out[key].map(leanJsonSchema);
|
|
432
|
+
for (const key of SUBSCHEMAS) if (key in out) out[key] = leanJsonSchema(out[key]);
|
|
433
|
+
const integer = out.type === "integer" || (Array.isArray(out.type) && out.type.includes("integer"));
|
|
434
|
+
if (integer && out.minimum === Number.MIN_SAFE_INTEGER) delete out.minimum;
|
|
435
|
+
if (integer && out.maximum === Number.MAX_SAFE_INTEGER) delete out.maximum;
|
|
436
|
+
if (isPlainObject(out.propertyNames) && Object.keys(out.propertyNames).length === 1 && out.propertyNames.type === "string") delete out.propertyNames;
|
|
437
|
+
if (isPlainObject(out.additionalProperties) && Object.keys(out.additionalProperties).length === 0) delete out.additionalProperties;
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// The schema a tool is registered with: zod's own validation, and its JSON Schema made lean.
|
|
442
|
+
export function listedSchema(schema) {
|
|
443
|
+
if (!schema) return schema;
|
|
444
|
+
const std = schema["~standard"];
|
|
445
|
+
const lean = (io) => (options) => leanJsonSchema(std.jsonSchema[io](options));
|
|
446
|
+
return { "~standard": { ...std, jsonSchema: { input: lean("input"), output: lean("output") } } };
|
|
447
|
+
}
|
package/src/series-file.mjs
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
// Only numbers leave this module. Error messages name rows and columns by position, never by a
|
|
8
8
|
// cell's content or a header's text, so a path pointed at the wrong file cannot echo that file back
|
|
9
9
|
// into the conversation. The hosted endpoint never reads files (see toolAuditBacktest).
|
|
10
|
-
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
10
|
+
import { closeSync, fstatSync, openSync, readSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
12
|
|
|
13
13
|
export const MAX_SERIES_FILE_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
// check_leakage's columns and placebo_test's panels hold many series at once: a larger cap.
|
|
15
|
+
export const MAX_LARGE_FILE_BYTES = 50 * 1024 * 1024;
|
|
14
16
|
|
|
15
17
|
// One open file descriptor for the check and the read, so the file checked is the file read: a
|
|
16
18
|
// path swapped between a separate stat and read could otherwise pass the size and type checks as
|
|
17
19
|
// one file and be read as another. The read stops one byte past the cap, whatever the file grows to.
|
|
18
|
-
function readText(path) {
|
|
20
|
+
function readText(path, maxBytes = MAX_SERIES_FILE_BYTES) {
|
|
19
21
|
let fd;
|
|
20
22
|
try {
|
|
21
23
|
fd = openSync(resolve(path), "r");
|
|
@@ -25,14 +27,14 @@ function readText(path) {
|
|
|
25
27
|
try {
|
|
26
28
|
const stat = fstatSync(fd);
|
|
27
29
|
if (!stat.isFile()) throw new Error(`${path}: not a regular file`);
|
|
28
|
-
if (stat.size >
|
|
29
|
-
const buffer = Buffer.alloc(
|
|
30
|
+
if (stat.size > maxBytes) throw new Error(`${path}: larger than ${maxBytes} bytes`);
|
|
31
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
30
32
|
let length = 0;
|
|
31
33
|
for (;;) {
|
|
32
34
|
const read = readSync(fd, buffer, length, buffer.length - length, null);
|
|
33
35
|
if (read === 0) break;
|
|
34
36
|
length += read;
|
|
35
|
-
if (length >
|
|
37
|
+
if (length > maxBytes) throw new Error(`${path}: larger than ${maxBytes} bytes`);
|
|
36
38
|
}
|
|
37
39
|
return buffer.toString("utf8", 0, length);
|
|
38
40
|
} finally {
|
|
@@ -178,3 +180,82 @@ export function readMatrixFile(path) {
|
|
|
178
180
|
if (columns.length < 2) throw new Error(`${path}: needs at least 2 numeric columns, one per variant`);
|
|
179
181
|
return { matrix: table.rows.map((_, i) => columns.map((c) => c.values[i])), skipped };
|
|
180
182
|
}
|
|
183
|
+
|
|
184
|
+
// check_leakage's columns as the caller's script wrote them: {"columns": {name: {"full": [...],
|
|
185
|
+
// "prefixes": [[...], ...]}}}, with "cuts" and "timestamps" if the script has them. Python's
|
|
186
|
+
// json.dump writes a missing value as NaN, which is not JSON, so NaN reads as null. Every problem
|
|
187
|
+
// is named by position; names leave this module only once every column has the right shape.
|
|
188
|
+
export function readColumnsFile(path) {
|
|
189
|
+
const text = readText(path, MAX_LARGE_FILE_BYTES).replace(/(?<=[[,:]\s*)NaN(?=\s*[,\]}])/g, "null");
|
|
190
|
+
let value;
|
|
191
|
+
try {
|
|
192
|
+
value = JSON.parse(text);
|
|
193
|
+
} catch {
|
|
194
|
+
throw new Error(`${path}: does not parse as JSON (numbers, null for a missing value)`);
|
|
195
|
+
}
|
|
196
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
197
|
+
if (!isObject(value) || !isObject(value.columns)) throw new Error(`${path}: must be a JSON object {"columns": {name: {"full": [...], "prefixes": [[...], ...]}}}`);
|
|
198
|
+
Object.entries(value.columns).forEach(([name, column], i) => {
|
|
199
|
+
if (name.length > 100) throw new Error(`${path}: column ${i + 1} has a name longer than 100 characters`);
|
|
200
|
+
if (!isObject(column) || !Array.isArray(column.full) || !Array.isArray(column.prefixes) || !column.prefixes.every(Array.isArray)) {
|
|
201
|
+
throw new Error(`${path}: column ${i + 1} is not {"full": [...], "prefixes": [[...], ...]}`);
|
|
202
|
+
}
|
|
203
|
+
[column.full, ...column.prefixes].forEach((run, r) => run.forEach((cell, t) => {
|
|
204
|
+
if (cell !== null && !(typeof cell === "number" && Number.isFinite(cell))) {
|
|
205
|
+
throw new Error(`${path}: column ${i + 1}, ${r === 0 ? "full" : `prefixes[${r - 1}]`}[${t}] is not a finite number or null`);
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
});
|
|
209
|
+
if (value.cuts !== undefined && !(Array.isArray(value.cuts) && value.cuts.every(Number.isInteger))) throw new Error(`${path}: cuts must be a list of whole numbers`);
|
|
210
|
+
if (value.timestamps !== undefined && !(Array.isArray(value.timestamps) && value.timestamps.every((v) => typeof v === "string" || typeof v === "number"))) {
|
|
211
|
+
throw new Error(`${path}: timestamps must be a list of dates or numbers`);
|
|
212
|
+
}
|
|
213
|
+
return { columns: value.columns, cuts: value.cuts, timestamps: value.timestamps?.map(String) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A panel for placebo_test: every numeric column (one per asset) and, when the file has one, its
|
|
217
|
+
// ISO date column. A column with a gap is refused, naming its lines: the placebo reorders whole
|
|
218
|
+
// periods. Header names leave this module only into the placebo files written beside the data,
|
|
219
|
+
// never into an error message.
|
|
220
|
+
export function readPanelFile(path) {
|
|
221
|
+
const text = readText(path, MAX_LARGE_FILE_BYTES);
|
|
222
|
+
const table = parseTable(text, path);
|
|
223
|
+
if (!table.rows.length) throw new Error(`${path}: no data rows`);
|
|
224
|
+
const { columns, incomplete, skipped } = numericColumns(table);
|
|
225
|
+
if (incomplete.length) {
|
|
226
|
+
const c = incomplete[0];
|
|
227
|
+
throw new Error(`${path}: column ${c.index + 1} holds numbers but is empty or not a number on line${c.gaps.length > 1 ? "s" : ""} ${lineList(c.gaps)}; the placebo reorders whole periods, so fill or remove those rows`);
|
|
228
|
+
}
|
|
229
|
+
if (!columns.length) throw new Error(`${path}: no column holds only numbers`);
|
|
230
|
+
let dateColumn = null;
|
|
231
|
+
if (typeof table.rows[0][0] !== "number") {
|
|
232
|
+
for (let j = 0; j < table.rows[0].length && dateColumn === null; j += 1) {
|
|
233
|
+
if (table.rows.every((row) => ISO_DATE.test(String(row[j]).trim()))) dateColumn = j;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
columns: columns.map((c) => c.values),
|
|
238
|
+
positions: columns.map((c) => c.index + 1),
|
|
239
|
+
names: table.header ? columns.map((c) => table.header[c.index]) : null,
|
|
240
|
+
dates: dateColumn === null ? null : table.rows.map((row) => String(row[dateColumn]).trim()),
|
|
241
|
+
date_name: dateColumn !== null && table.header ? table.header[dateColumn] : null,
|
|
242
|
+
date_column: dateColumn === null ? null : dateColumn + 1,
|
|
243
|
+
skipped,
|
|
244
|
+
format: text.trim().startsWith("[") ? "json" : "csv",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Writes a panel in the format it was read from. CSV: a header row when the source had one, then
|
|
249
|
+
// one row per period, its date first when there is one. JSON: a list of numbers for one column, a
|
|
250
|
+
// list of rows for several. Numbers keep every digit. An existing file is never overwritten.
|
|
251
|
+
export function writePanelFile(path, { columns, names, dates, dateName, format = "csv" }) {
|
|
252
|
+
if (format === "json") {
|
|
253
|
+
const body = columns.length === 1 ? columns[0] : columns[0].map((_, t) => columns.map((c) => c[t]));
|
|
254
|
+
writeFileSync(path, `${JSON.stringify(Array.from(body, (v) => (ArrayBuffer.isView(v) ? Array.from(v) : v)))}\n`, { flag: "wx" });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const lines = [];
|
|
258
|
+
if (names) lines.push([...(dates ? [dateName ?? "date"] : []), ...names].join(","));
|
|
259
|
+
for (let t = 0; t < columns[0].length; t += 1) lines.push([...(dates ? [dates[t]] : []), ...columns.map((c) => String(c[t]))].join(","));
|
|
260
|
+
writeFileSync(path, `${lines.join("\n")}\n`, { flag: "wx" });
|
|
261
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -22,14 +22,14 @@ import { bestOfTrialsProbability } from "./local/js/luck-core.js";
|
|
|
22
22
|
import { minimumBacktestLength } from "./local/js/dsr-core.js";
|
|
23
23
|
import { studentTUpper } from "./local/js/student-t.js";
|
|
24
24
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
25
|
-
import { breadthInput, trackRecordInput, auditBacktestInput, verifyReceiptToolShape, backtestLengthInput, haircutSharpeInput, luckTrialsInput, auditBacktestToolShape, companyHistoryInput, companyHistoryToolShape, deflatedSharpeInput, deflatedSharpeToolShape, emptyInput, getKeyInput, getReceiptInput, overfittingInput, realityCheckInput, realityCheckToolShape, LIMITS_SENTENCES, paperEvidenceInput, paperEvidenceToolShape, TOOL_DESCRIPTIONS, validationOutput, auditOutput, keyOutput, receiptOutput, verifyReceiptOutput, statusOutput, companyHistoryOutput } from "./schemas.mjs";
|
|
25
|
+
import { breadthInput, trackRecordInput, auditBacktestInput, verifyReceiptToolShape, backtestLengthInput, haircutSharpeInput, luckTrialsInput, auditBacktestToolShape, companyHistoryInput, companyHistoryToolShape, deflatedSharpeInput, deflatedSharpeToolShape, emptyInput, getKeyInput, getReceiptInput, overfittingInput, realityCheckInput, realityCheckToolShape, LIMITS_SENTENCES, paperEvidenceInput, paperEvidenceToolShape, TOOL_DESCRIPTIONS, validationOutput, auditOutput, keyOutput, receiptOutput, verifyReceiptOutput, statusOutput, companyHistoryOutput, listedSchema } from "./schemas.mjs";
|
|
26
26
|
|
|
27
27
|
export const DEFAULT_BASE = "https://canlicapital.com";
|
|
28
28
|
export const SERVER_NAME = "canlicapital-validation-mcp";
|
|
29
29
|
export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
30
30
|
// Sent once in initialize; clients such as Claude Code put it in the system prompt, so the model
|
|
31
31
|
// knows the first call to make even when tool definitions are deferred. Byte-stable across runs.
|
|
32
|
-
export const SERVER_INSTRUCTIONS = "Checks whether a backtest's result is real. For one strategy's returns, call audit_backtest, pointing returns_file at the backtest's CSV instead of pasting long series. For a Sharpe found by a search, validate_deflated_sharpe needs how many independent variants were tried and how their Sharpes spread; with every variant's returns, validate_overfitting gives the probability of backtest overfitting. To test a rule on prices, backtest_strategy runs a whole parameter grid and validates the best with the count actually run; summarize_series reads a long series in a hundred words; stress_test and check_feasibility ask whether it survives bad markets and a real broker. Set CANLI_LOCAL=1 to compute on this machine with no network. Every result says what it does not establish; quote those limits with the number.";
|
|
32
|
+
export const SERVER_INSTRUCTIONS = "Checks whether a backtest's result is real. For one strategy's returns, call audit_backtest, pointing returns_file at the backtest's CSV instead of pasting long series. For a Sharpe found by a search, validate_deflated_sharpe needs how many independent variants were tried and how their Sharpes spread; with every variant's returns, validate_overfitting gives the probability of backtest overfitting. To test a rule on prices, backtest_strategy runs a whole parameter grid and validates the best with the count actually run; summarize_series reads a long series in a hundred words; stress_test and check_feasibility ask whether it survives bad markets and a real broker. Before trusting a research pipeline, check_leakage finds lookahead in its signals and placebo_test runs it on data where nothing can be predicted. Set CANLI_LOCAL=1 to compute on this machine with no network. Every result says what it does not establish; quote those limits with the number.";
|
|
33
33
|
|
|
34
34
|
// How the server introduces itself in initialize: a readable title, the page that documents it
|
|
35
35
|
// and its icon, so clients and directories that read serverInfo show more than a package name.
|
|
@@ -339,8 +339,9 @@ async function runValidator(session, tool, path, body) {
|
|
|
339
339
|
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
340
340
|
|
|
341
341
|
// audit_backtest: one calibrated test first, then the checks behind it.
|
|
342
|
-
// headline with every variant's returns,
|
|
343
|
-
//
|
|
342
|
+
// headline with every variant's returns, the test Null Zoo v1 chose from validate_reality_check's
|
|
343
|
+
// result (White's Reality Check since 0.12.0), with its receipt; with one series, the
|
|
344
|
+
// single-series test it chose. Either way the result quotes
|
|
344
345
|
// that test's measured false-positive rate on returns shaped like these
|
|
345
346
|
// (local/js/null-zoo-v1-sizes.js) and the worst across the nine families.
|
|
346
347
|
// checks the deflated Sharpe (an estimate), the minimum track record and, with variants, CSCV
|
|
@@ -881,7 +882,11 @@ export function registerResources(server) {
|
|
|
881
882
|
|
|
882
883
|
// Everything the server exposes: the npm package and the hosted endpoint both call this.
|
|
883
884
|
export function registerAll(server, session) {
|
|
884
|
-
|
|
885
|
+
// The SDK lists the schemas it is given, so the server registers lean ones (listedSchema), while
|
|
886
|
+
// registerTools itself hands out the zod schemas: the website's MCP pages read those to check
|
|
887
|
+
// their example arguments.
|
|
888
|
+
const listing = { registerTool: (name, config, handler) => server.registerTool(name, { ...config, inputSchema: listedSchema(config.inputSchema), outputSchema: listedSchema(config.outputSchema) }, handler) };
|
|
889
|
+
const catalog = registerTools(listing, session);
|
|
885
890
|
registerPrompts(server);
|
|
886
891
|
registerLabPrompts(server);
|
|
887
892
|
registerResources(server);
|