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 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. It
38
- gives a coding agent fifteen tools: issue a free key, run the nine validators (deflated Sharpe,
39
- CSCV overfitting, data-snooping tests (Hansen's SPA, White's Reality Check and Romano-Wolf StepM),
40
- paper-evidence conformance, breadth ceiling, minimum track record length, minimum backtest length,
41
- haircut Sharpe ratio, luck-equivalent trials),
42
- audit one backtest with three of them in a single call, fetch a stored receipt, verify a
43
- receipt's signature offline, read service status, and read a company's reported financial history
44
- from SEC filings. Every validation result carries, beside the number, the sentences that say what
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, track record and, with `variants`, overfitting routes, one validation each | yes |
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, check feasibility
141
+ ## The lab: backtest, summarize, stress, feasibility, lookahead, placebos
141
142
 
142
- Four tools compute in the server process itself, on your machine or on the hosted endpoint, with
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, the number of variants tried and their Sharpe
186
- dispersion, and optionally every variant's returns. It runs `validate_deflated_sharpe` on the
187
- series, then `validate_track_record` on the Sharpe, skew and kurtosis that check derived, then,
188
- with `variants`, `validate_overfitting`. Each check is exactly what its own tool returns, with its
189
- own receipt; the boundary sentences they share are stated once. A check that refuses (a Sharpe
190
- that cannot beat the benchmark has no minimum track record) is reported as that check's error.
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 six guided workflows: `validate_backtest` (deflated Sharpe, then
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` and
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
- `get_receipt`, `service_status` and `company_financial_history` still read from canlicapital.com;
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 `get_receipt` returns in full, and in `service_status`.
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 all nineteen tools several thousand. A
402
- client that needs one kind of tool can list only that kind, with `CANLI_TOOLSETS` (stdio) or
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 eight validators, `audit_backtest` |
408
- | `receipts` | `get_receipt`, `verify_receipt` |
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
- | `all` | 19 | 5,946 | 100% |
419
- | `validate` | 11 | 3,685 | 62% |
420
- | `receipts` | 2 | 312 | 5% |
421
- | `company` | 1 | 273 | 5% |
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` | 4 | 1,595 | 27% |
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.11.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",
@@ -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 check_feasibility. Like the validators' descriptions, each tool description
5
- // carries one sentence of its own result's boundary language verbatim (test/lab.test.mjs checks
6
- // it against the sentences the computation attaches), so an agent reads what the result cannot be
7
- // used to claim before it ever calls the tool. Descriptions stay terse: the tool list is re-sent to
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 { LAB_TOOL_DESCRIPTIONS, backtestInput, backtestOutput, feasibilityInput, feasibilityOutput, stressInput, stressOutput, summarizeInput, summaryOutput } from "./lab-schemas.mjs";
24
- import { readSeriesWithDates } from "./series-file.mjs";
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, ...LAB_ANNOTATIONS }, description: LAB_TOOL_DESCRIPTIONS[name], inputSchema: input, outputSchema: output }, (args) => handler(session, args)];
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
  }