canli-validation-mcp 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -36
- package/package.json +1 -1
- package/src/lab-schemas.mjs +50 -5
- package/src/lab.mjs +149 -7
- package/src/local/js/audit-core.js +329 -0
- package/src/local/js/leakage-core.js +182 -0
- package/src/local/js/null-zoo-v1-sizes.js +40 -0
- package/src/local/js/placebo-core.js +164 -0
- package/src/local/js/validate/overfitting.js +3 -0
- package/src/schemas.mjs +46 -7
- package/src/series-file.mjs +86 -5
- package/src/server.mjs +179 -29
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
|
|
|
@@ -83,7 +85,7 @@ receipt. The current npm/hosted release retains the existing record-only API.
|
|
|
83
85
|
|
|
84
86
|
| Tool | Calls | Key required |
|
|
85
87
|
|---|---|---|
|
|
86
|
-
| `get_key` | `POST /api/v1/keys` | no |
|
|
88
|
+
| `get_key` | `POST /api/v1/keys` (listed only when no key is configured) | no |
|
|
87
89
|
| `validate_deflated_sharpe` | `POST /api/v1/validate/deflated-sharpe` | yes |
|
|
88
90
|
| `validate_overfitting` | `POST /api/v1/validate/overfitting` | yes |
|
|
89
91
|
| `validate_paper_evidence` | `POST /api/v1/validate/paper-evidence` | yes |
|
|
@@ -93,9 +95,8 @@ receipt. The current npm/hosted release retains the existing record-only API.
|
|
|
93
95
|
| `validate_haircut_sharpe` | `POST /api/v1/validate/haircut-sharpe` | yes |
|
|
94
96
|
| `validate_luck_trials` | `POST /api/v1/validate/luck-trials` | yes |
|
|
95
97
|
| `validate_reality_check` | `POST /api/v1/validate/reality-check` (a `matrix_file` is read on your machine and sent as numbers) | yes |
|
|
96
|
-
| `audit_backtest` | the deflated Sharpe
|
|
97
|
-
| `verify_receipt` | `GET /api/v1/receipts/{id}` when given an id; the checks run locally | no |
|
|
98
|
-
| `get_receipt` | `GET /api/v1/receipts/{id}` | no |
|
|
98
|
+
| `audit_backtest` | the deflated Sharpe and track record routes, then the headline: with `variants`, the overfitting and data-snooping routes; without, luck trials. One validation each | yes |
|
|
99
|
+
| `verify_receipt` | `GET /api/v1/receipts/{id}` when given an id (`include_receipt` returns the stored receipt); the checks run locally | no |
|
|
99
100
|
| `service_status` | `GET /api/v1/validate/status` | no |
|
|
100
101
|
| `company_financial_history` | `GET /company-data/{cik}.json` (a ticker resolves through `GET /api/v1/company-tickers.json`) | no |
|
|
101
102
|
|
|
@@ -137,9 +138,9 @@ original SEC response and the record's own boundary sentence: these are accounti
|
|
|
137
138
|
reported to the SEC, not market prices, returns or a recommendation. Companies and concepts
|
|
138
139
|
outside the current release return an error with the available concepts listed.
|
|
139
140
|
|
|
140
|
-
## The lab: backtest, summarize, stress,
|
|
141
|
+
## The lab: backtest, summarize, stress, feasibility, lookahead, placebos
|
|
141
142
|
|
|
142
|
-
|
|
143
|
+
Six tools compute in the server process itself, on your machine or on the hosted endpoint, with
|
|
143
144
|
nothing sent to the API and no receipt stored. Each result names the SHA-256 of exactly the numbers
|
|
144
145
|
it was computed from, and states what it does not establish.
|
|
145
146
|
|
|
@@ -175,6 +176,32 @@ it was computed from, and states what it does not establish.
|
|
|
175
176
|
share of daily volume, square-root market impact (coefficient Y, default 1) with every copy of the
|
|
176
177
|
same strategy counted, and the capital at which costs and impact eat the expected gross return.
|
|
177
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.
|
|
178
205
|
|
|
179
206
|
On your own machine, `prices_file`, `series_file` and `returns_file` take a CSV or JSON path instead
|
|
180
207
|
of the numbers. Only numbers are read, plus an ISO date column when the file has one, so results can
|
|
@@ -182,12 +209,27 @@ name the day a drawdown began.
|
|
|
182
209
|
|
|
183
210
|
## Auditing a backtest in one call
|
|
184
211
|
|
|
185
|
-
`audit_backtest` takes one strategy's return series,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
212
|
+
`audit_backtest` takes one strategy's return series and, ideally, every variant's returns
|
|
213
|
+
(`variants` or `variants_file`, the dropped ones too); without them, the number of variants tried
|
|
214
|
+
and their Sharpe spread. The result opens with:
|
|
215
|
+
|
|
216
|
+
- `headline`: one test chosen because its false-positive rate was measured before anyone looked at
|
|
217
|
+
your numbers. Null Zoo v1 (`scripts/research/null-zoo/v1/` in the repository) drew hundreds of
|
|
218
|
+
thousands of simulated searches from nine return shapes with known ground truth; the bar and the
|
|
219
|
+
rule that picks the test were committed before the run. With variants the headline comes from
|
|
220
|
+
the data-snooping tests (`validate_reality_check`, with its receipt); without, from luck trials
|
|
221
|
+
with Lo's autocorrelation correction. Its sentence quotes the measured false-positive rate for
|
|
222
|
+
returns shaped like yours, and the worst across the nine shapes.
|
|
223
|
+
- `fix_next`: at most five next steps, each `{id, why, next}` with a stable id.
|
|
224
|
+
- `computed`: Lo's adjusted Sharpe, a stationary-bootstrap 95% interval for the Sharpe, the
|
|
225
|
+
haircut Sharpe, the minimum backtest length and, with variants, how many independent trials
|
|
226
|
+
they count as (Li and Ji) and the selected variant's out-of-sample decay.
|
|
227
|
+
|
|
228
|
+
Then the checks: `validate_deflated_sharpe` on the series (an estimate), `validate_track_record`
|
|
229
|
+
on the Sharpe, skew and kurtosis that check derived and, with variants, `validate_overfitting`.
|
|
230
|
+
Each is exactly what its own tool returns, with its own receipt; the deflated Sharpe runs with the
|
|
231
|
+
larger of the declared and counted trials and Sharpe spread, so a search cannot be made to look
|
|
232
|
+
smaller than it was. A check that refuses or cannot be reached is reported as that check's error.
|
|
191
233
|
The audit adds no grade of its own. Through the API it uses one validation per check.
|
|
192
234
|
|
|
193
235
|
When the server runs on your machine, `returns_file` and `variants_file` take the path of the
|
|
@@ -200,10 +242,10 @@ took three audit questions from 4 of 9 to 8 of 9 answered correctly on gpt-5.4-m
|
|
|
200
242
|
|
|
201
243
|
## Prompts, resources and structured results
|
|
202
244
|
|
|
203
|
-
Clients that show MCP prompts offer
|
|
245
|
+
Clients that show MCP prompts offer eight guided workflows: `validate_backtest` (deflated Sharpe, then
|
|
204
246
|
overfitting, then the track record needed, reported with what each number does not establish),
|
|
205
|
-
`track_record_needed`, `backtest_and_validate`, `stress_my_strategy`, `production_check
|
|
206
|
-
`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;
|
|
207
249
|
`canli://sources`, the papers behind each validator and how each is checked against them;
|
|
208
250
|
`canli://strategy-spec`, the JSON Schema of every strategy family and its parameters; and
|
|
209
251
|
`canli://openapi`, the API's OpenAPI 3.1 document. Two resource templates are for writing code
|
|
@@ -327,7 +369,7 @@ equals the hosted one; it names no receipt id because none was made.
|
|
|
327
369
|
claude mcp add canli-local --env CANLI_LOCAL=1 -- npx -y canli-validation-mcp
|
|
328
370
|
```
|
|
329
371
|
|
|
330
|
-
`
|
|
372
|
+
`verify_receipt` by id, `service_status` and `company_financial_history` still read from canlicapital.com;
|
|
331
373
|
they send no series. In the Claude Desktop extension this is the "Private local mode" setting.
|
|
332
374
|
|
|
333
375
|
## Generic stdio client
|
|
@@ -392,35 +434,39 @@ A validation result is the answer (`data`), the sentences above except the quota
|
|
|
392
434
|
receipt's id and URL; an error keeps its error. The rest of the API envelope (schema, endpoint,
|
|
393
435
|
timestamps, claim and capital class, the human page, the source-file hashes and the quota line)
|
|
394
436
|
describes the service rather than the answer, and an agent pays for every token of it on every
|
|
395
|
-
call. It stays in the stored receipt, which `
|
|
437
|
+
call. It stays in the stored receipt, which `verify_receipt` returns in full with `include_receipt`, and in `service_status`.
|
|
396
438
|
On a breadth result this is about half the text. Set `CANLI_FULL_ENVELOPE=1` to receive every field.
|
|
397
439
|
|
|
398
440
|
## Toolsets (tokens)
|
|
399
441
|
|
|
400
442
|
A client sends the model the whole tool list on every turn, and it is most of each turn's prompt:
|
|
401
|
-
a validation result is a few hundred tokens, the list of
|
|
402
|
-
|
|
403
|
-
`?toolsets=` (hosted endpoint). The default is every tool
|
|
443
|
+
a validation result is a few hundred tokens, the list of every tool several thousand. A client
|
|
444
|
+
that needs one kind of tool can list only that kind, with `CANLI_TOOLSETS` (stdio) or
|
|
445
|
+
`?toolsets=` (hosted endpoint). The default is every toolset except `company`, whose one tool
|
|
446
|
+
canli-fundamentals-mcp does better and point in time; `all` adds it. `get_key` is listed only when
|
|
447
|
+
no key is configured.
|
|
404
448
|
|
|
405
449
|
| toolset | tools |
|
|
406
450
|
|---|---|
|
|
407
|
-
| `validate` | `get_key`, the
|
|
408
|
-
| `receipts` | `
|
|
451
|
+
| `validate` | `get_key`, the nine validators, `audit_backtest` |
|
|
452
|
+
| `receipts` | `verify_receipt` |
|
|
409
453
|
| `company` | `company_financial_history` |
|
|
410
454
|
| `status` | `service_status` |
|
|
411
|
-
| `lab` | `backtest_strategy`, `summarize_series`, `stress_test`, `check_feasibility` |
|
|
455
|
+
| `lab` | `backtest_strategy`, `summarize_series`, `stress_test`, `check_feasibility`, `check_leakage`, `placebo_test` |
|
|
412
456
|
|
|
413
457
|
Measured with `bench/tool_list_tokens.py` (tokenizer: tiktoken `o200k_base`; other models'
|
|
414
458
|
tokenizers give different absolute counts), in the shape an OpenAI-style client sends the list:
|
|
415
459
|
|
|
416
460
|
| CANLI_TOOLSETS | tools | tokens per turn | of all |
|
|
417
461
|
|---|---|---|---|
|
|
418
|
-
|
|
|
419
|
-
|
|
|
420
|
-
| `
|
|
421
|
-
| `
|
|
462
|
+
| (default) | 19 | 5,970 | 96% |
|
|
463
|
+
| (default, with CANLI_KEY) | 18 | 5,834 | 93% |
|
|
464
|
+
| `all` | 20 | 6,241 | 100% |
|
|
465
|
+
| `validate` | 11 | 3,650 | 58% |
|
|
466
|
+
| `receipts` | 1 | 182 | 3% |
|
|
467
|
+
| `company` | 1 | 273 | 4% |
|
|
422
468
|
| `status` | 1 | 89 | 1% |
|
|
423
|
-
| `lab` |
|
|
469
|
+
| `lab` | 6 | 2,055 | 33% |
|
|
424
470
|
|
|
425
471
|
Providers cache a tool list that is identical from turn to turn and bill the cached part at a
|
|
426
472
|
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.0",
|
|
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",
|
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
|
|
|
@@ -114,7 +233,6 @@ export const EXAMPLE_ARGS = Object.freeze({
|
|
|
114
233
|
validate_haircut_sharpe: { observed_sharpe_annualized: 1.5, periods_per_year: 252, observations: 1260, tests: 50 },
|
|
115
234
|
validate_luck_trials: { observed_sharpe_annualized: 1.5, periods_per_year: 252, observations: 1260 },
|
|
116
235
|
audit_backtest: { returns_file: "backtest_returns.csv", periods_per_year: 252, effective_independent_trials: 20, cross_trial_sharpe_sd_annualized: 0.5 },
|
|
117
|
-
get_receipt: { id: "0123456789abcdef01234567" },
|
|
118
236
|
verify_receipt: { id: "0123456789abcdef01234567" },
|
|
119
237
|
service_status: {},
|
|
120
238
|
company_financial_history: { ticker: "AAPL", concept: "Assets", limit: 8 },
|
|
@@ -122,6 +240,8 @@ export const EXAMPLE_ARGS = Object.freeze({
|
|
|
122
240
|
summarize_series: { series_file: "prices.csv", series_kind: "prices", name: "My asset" },
|
|
123
241
|
stress_test: { returns_file: "strategy_returns.csv", drawdown_limit: 0.2, paths: 1000, seed: 42 },
|
|
124
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" },
|
|
125
245
|
});
|
|
126
246
|
|
|
127
247
|
const usesFile = (args) => Object.keys(args).some((k) => k.endsWith("_file"));
|
|
@@ -221,7 +341,7 @@ export function registerCodeResources(server, session, catalog) {
|
|
|
221
341
|
(uri, { tool }) => {
|
|
222
342
|
const entry = catalog[tool];
|
|
223
343
|
if (!entry) throw new Error(`No tool ${tool}; tools are ${names.join(", ")}`);
|
|
224
|
-
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" })) };
|
|
225
345
|
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
|
|
226
346
|
},
|
|
227
347
|
);
|
|
@@ -306,4 +426,26 @@ export function registerLabPrompts(server) {
|
|
|
306
426
|
"Point out anything in the data check (stale prices, jumps) that should be fixed before the series is backtested.",
|
|
307
427
|
],
|
|
308
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
|
+
);
|
|
309
451
|
}
|