canli-validation-mcp 0.5.0 → 0.6.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/src/server.mjs CHANGED
@@ -11,10 +11,18 @@ import { pathToFileURL } from "node:url";
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { z } from "zod";
13
13
  import { computeLocally } from "./local.mjs";
14
+ import { readMatrixFile, readSeriesFile } from "./series-file.mjs";
15
+ import { verifyReceipt } from "./local/js/receipt-statement.js";
14
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
17
  import {
16
18
  breadthInput,
17
19
  trackRecordInput,
20
+ auditBacktestInput,
21
+ verifyReceiptToolShape,
22
+ backtestLengthInput,
23
+ haircutSharpeInput,
24
+ luckTrialsInput,
25
+ auditBacktestToolShape,
18
26
  companyHistoryInput,
19
27
  companyHistoryToolShape,
20
28
  deflatedSharpeInput,
@@ -58,7 +66,7 @@ export function configuredLocal(value) {
58
66
  return v === "1" || v?.toLowerCase() === "true";
59
67
  }
60
68
 
61
- export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local } = {}) {
69
+ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local, fullEnvelope, receiptKeys } = {}) {
62
70
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
63
71
  return {
64
72
  base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
@@ -70,6 +78,9 @@ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIM
70
78
  // so instead of issuing a key the next stateless request would never see.
71
79
  hosted: hosted ?? undefined,
72
80
  local: local ?? configuredLocal(process.env.CANLI_LOCAL),
81
+ fullEnvelope: fullEnvelope ?? configuredFullEnvelope(process.env.CANLI_FULL_ENVELOPE),
82
+ // Tests pass their own keys; everyone else verifies against the bundled published keys.
83
+ receiptKeys: receiptKeys ?? undefined,
73
84
  };
74
85
  }
75
86
 
@@ -110,6 +121,30 @@ const asText = (envelope, failed = false) => ({
110
121
  ...(failed ? { isError: true } : {}),
111
122
  });
112
123
 
124
+ // A validation result as the model reads it: the answer, the sentences saying what it does not
125
+ // establish, and the receipt that holds the rest. Metadata (schema, endpoint, timestamps, claim and
126
+ // capital class, human page), the source hashes and the quota sentence are about the service, not
127
+ // the answer; they stay in the stored receipt (get_receipt) and service_status, and
128
+ // CANLI_FULL_ENVELOPE=1 returns every field. On a result this cuts the text roughly in half.
129
+ const QUOTA_SENTENCE = /^Quotas:/;
130
+
131
+ export function compactEnvelope(envelope) {
132
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return envelope;
133
+ const out = {};
134
+ if (envelope.computed) { out.computed = envelope.computed; out.note = envelope.note; }
135
+ if ("data" in envelope) out.data = envelope.data;
136
+ if (envelope.error) out.error = envelope.error;
137
+ if (Array.isArray(envelope.limits)) out.limits = envelope.limits.filter((s) => !QUOTA_SENTENCE.test(s));
138
+ if ("receipt" in envelope) out.receipt = envelope.receipt ? { id: envelope.receipt.id, url: envelope.receipt.url } : null;
139
+ return out;
140
+ }
141
+
142
+ export function configuredFullEnvelope(value) {
143
+ return value === "1" || value === "true";
144
+ }
145
+
146
+ const validationText = (session, { envelope, failed }) => asText(session.fullEnvelope ? envelope : compactEnvelope(envelope), failed);
147
+
113
148
  // A history's observations as one header and one row each, instead of every field name repeated
114
149
  // on every observation. The column order is fixed so a row can be read without its keys; a unit
115
150
  // shared by every row is stated once.
@@ -171,37 +206,157 @@ export async function toolValidateDeflatedSharpe(session, args) {
171
206
  "or a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized), never a mix of both and never neither.",
172
207
  );
173
208
  }
174
- if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return asText(local.envelope, local.failed); }
209
+ if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return validationText(session, local); }
175
210
  const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
176
- return asText(response.envelope, response.failed);
211
+ return validationText(session, response);
177
212
  }
178
213
 
179
214
  export async function toolValidateOverfitting(session, args) {
180
215
  const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
181
- if (session.local) { const local = computeLocally("validate_overfitting", body); return asText(local.envelope, local.failed); }
216
+ if (session.local) { const local = computeLocally("validate_overfitting", body); return validationText(session, local); }
182
217
  const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
183
- return asText(response.envelope, response.failed);
218
+ return validationText(session, response);
184
219
  }
185
220
 
186
221
  export async function toolValidatePaperEvidence(session, args) {
187
222
  const body = parseOrThrow(paperEvidenceInput, args, "validate_paper_evidence");
188
- if (session.local) { const local = computeLocally("validate_paper_evidence", body); return asText(local.envelope, local.failed); }
223
+ if (session.local) { const local = computeLocally("validate_paper_evidence", body); return validationText(session, local); }
189
224
  const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
190
- return asText(response.envelope, response.failed);
225
+ return validationText(session, response);
191
226
  }
192
227
 
193
228
  export async function toolValidateBreadth(session, args) {
194
229
  const body = parseOrThrow(breadthInput, args, "validate_breadth");
195
- if (session.local) { const local = computeLocally("validate_breadth", body); return asText(local.envelope, local.failed); }
230
+ if (session.local) { const local = computeLocally("validate_breadth", body); return validationText(session, local); }
196
231
  const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
197
- return asText(response.envelope, response.failed);
232
+ return validationText(session, response);
198
233
  }
199
234
 
200
235
  export async function toolValidateTrackRecord(session, args) {
201
236
  const body = parseOrThrow(trackRecordInput, args, "validate_track_record");
202
- if (session.local) { const local = computeLocally("validate_track_record", body); return asText(local.envelope, local.failed); }
237
+ if (session.local) { const local = computeLocally("validate_track_record", body); return validationText(session, local); }
203
238
  const response = await callApi(session, { path: "/api/v1/validate/track-record", method: "POST", body });
204
- return asText(response.envelope, response.failed);
239
+ return validationText(session, response);
240
+ }
241
+
242
+ export async function toolValidateBacktestLength(session, args) {
243
+ const body = parseOrThrow(backtestLengthInput, args, "validate_backtest_length");
244
+ if (session.local) { const local = computeLocally("validate_backtest_length", body); return validationText(session, local); }
245
+ const response = await callApi(session, { path: "/api/v1/validate/backtest-length", method: "POST", body });
246
+ return validationText(session, response);
247
+ }
248
+
249
+ export async function toolValidateHaircutSharpe(session, args) {
250
+ const body = parseOrThrow(haircutSharpeInput, args, "validate_haircut_sharpe");
251
+ if (session.local) { const local = computeLocally("validate_haircut_sharpe", body); return validationText(session, local); }
252
+ const response = await callApi(session, { path: "/api/v1/validate/haircut-sharpe", method: "POST", body });
253
+ return validationText(session, response);
254
+ }
255
+
256
+ export async function toolValidateLuckTrials(session, args) {
257
+ const body = parseOrThrow(luckTrialsInput, args, "validate_luck_trials");
258
+ if (session.local) { const local = computeLocally("validate_luck_trials", body); return validationText(session, local); }
259
+ const response = await callApi(session, { path: "/api/v1/validate/luck-trials", method: "POST", body });
260
+ return validationText(session, response);
261
+ }
262
+
263
+ // One validator, run the same way its own tool runs it: on this machine in local mode, otherwise
264
+ // through the API (one validation of quota, one receipt).
265
+ async function runValidator(session, tool, path, body) {
266
+ if (session.local) return computeLocally(tool, body);
267
+ return callApi(session, { path, method: "POST", body });
268
+ }
269
+
270
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
271
+
272
+ // audit_backtest: deflated Sharpe from the series, then the minimum track record length from the
273
+ // Sharpe, skew and kurtosis that check derived, then (with variants) CSCV overfitting. Each check
274
+ // keeps its envelope and receipt; the limits every envelope repeats are stated once at the top.
275
+ // A refused later check (for example a Sharpe that does not exceed the benchmark) is reported as
276
+ // that check's error, not as a failed audit; the audit fails only when the first check does.
277
+ export async function toolAuditBacktest(session, args) {
278
+ const input = parseOrThrow(auditBacktestInput, args, "audit_backtest");
279
+ if ((input.returns_file || input.variants_file) && session.hosted) {
280
+ throw new Error("audit_backtest: the hosted endpoint cannot read files on your machine; send returns (and variants) as numbers, or run the server locally with npx -y canli-validation-mcp.");
281
+ }
282
+ const returnsRead = input.returns_file ? readSeriesFile(input.returns_file, input.returns_column) : null;
283
+ const variantsRead = input.variants_file ? readMatrixFile(input.variants_file) : null;
284
+ const returns = input.returns ?? returnsRead.values;
285
+ const variants = input.variants ?? variantsRead?.matrix;
286
+ const { periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized } = input;
287
+ const dsr = await runValidator(session, "validate_deflated_sharpe", "/api/v1/validate/deflated-sharpe", {
288
+ returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized,
289
+ });
290
+ if (dsr.failed) return validationText(session, { envelope: dsr.envelope, failed: true });
291
+ const derived = dsr.envelope?.data?.derived_inputs ?? {};
292
+ const trackBody = {
293
+ observed_sharpe_annualized: derived.observed_sharpe_annualized,
294
+ periods_per_year,
295
+ skew: derived.skew,
296
+ non_excess_kurtosis: derived.non_excess_kurtosis,
297
+ observations: derived.observations,
298
+ ...(input.benchmark_sharpe_annualized !== undefined ? { benchmark_sharpe_annualized: input.benchmark_sharpe_annualized } : {}),
299
+ ...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
300
+ };
301
+ const track = await runValidator(session, "validate_track_record", "/api/v1/validate/track-record", trackBody);
302
+ const overfit = variants
303
+ ? await runValidator(session, "validate_overfitting", "/api/v1/validate/overfitting", {
304
+ matrix: variants,
305
+ ...(input.n_splits !== undefined ? { n_splits: input.n_splits } : {}),
306
+ })
307
+ : null;
308
+ const shape = (e) => (session.fullEnvelope ? e : compactEnvelope(e));
309
+ const envelopes = { deflated_sharpe: shape(dsr.envelope), track_record: shape(track.envelope), ...(overfit ? { overfitting: shape(overfit.envelope) } : {}) };
310
+ const limits = envelopes.deflated_sharpe?.limits;
311
+ const shared = Object.values(envelopes).every((e) => sameJson(e?.limits, limits));
312
+ const checks = Object.fromEntries(
313
+ Object.entries(envelopes).map(([name, e]) => [name, shared ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== "limits")) : e]),
314
+ );
315
+ const readings = Object.fromEntries(
316
+ Object.entries(envelopes).map(([name, e]) => [name, e?.data?.plain_reading ?? e?.error?.message ?? null]),
317
+ );
318
+ return asText({
319
+ schema: "canli.audit.v1",
320
+ note: "Each check is its validator's own result and receipt, side by side. The audit does not grade the strategy.",
321
+ ...(shared ? { limits } : {}),
322
+ readings,
323
+ checks,
324
+ ...(input.returns_file || input.variants_file
325
+ ? { source: {
326
+ ...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
327
+ ...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
328
+ } }
329
+ : {}),
330
+ ...(variants ? {} : { not_run: { overfitting: "Send variants or variants_file (every variant's returns) to add the overfitting check." } }),
331
+ });
332
+ }
333
+
334
+ // The public keys canlicapital.com signs receipts with, as published at
335
+ // /.well-known/canli-receipt-keys.json when this package was built; a test keeps the two identical.
336
+ const RECEIPT_KEYS = JSON.parse(readFileSync(new URL("./receipt-keys.json", import.meta.url), "utf8"));
337
+
338
+ // verify_receipt: every check is local; only fetching a receipt by id touches the network.
339
+ export async function toolVerifyReceipt(session, args) {
340
+ const input = parseOrThrow(verifyReceiptToolShape, args, "verify_receipt");
341
+ if ((input.id === undefined) === (input.receipt === undefined)) throw new Error("verify_receipt: send exactly one of id or receipt");
342
+ let data = input.receipt;
343
+ if (input.id !== undefined) {
344
+ const response = await callApi(session, { path: `/api/v1/receipts/${input.id}` });
345
+ if (response.failed) return asText(response.envelope, true);
346
+ data = response.envelope?.data;
347
+ }
348
+ const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
349
+ const result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
350
+ return asText({
351
+ receipt_id: data?.id ?? null,
352
+ valid: result.valid,
353
+ checks: result.checks,
354
+ key_id: result.key_id,
355
+ keys: "bundled with this package; published at https://canlicapital.com/.well-known/canli-receipt-keys.json",
356
+ meaning: result.valid
357
+ ? "canlicapital.com signed this exact output for this exact input, computed by the source files whose hashes the receipt lists. It says nothing about how the input series was built."
358
+ : "At least one check failed: do not treat this receipt as issued by canlicapital.com for this content.",
359
+ });
205
360
  }
206
361
 
207
362
  export async function toolGetReceipt(session, args) {
@@ -351,11 +506,36 @@ export function registerTools(server, session) {
351
506
  { title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
352
507
  (args) => toolValidateTrackRecord(session, args),
353
508
  );
509
+ server.registerTool(
510
+ "validate_backtest_length",
511
+ { title: "Minimum backtest length", annotations: { title: "Minimum backtest length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_backtest_length, inputSchema: backtestLengthInput },
512
+ (args) => toolValidateBacktestLength(session, args),
513
+ );
514
+ server.registerTool(
515
+ "validate_haircut_sharpe",
516
+ { title: "Haircut Sharpe ratio", annotations: { title: "Haircut Sharpe ratio", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_haircut_sharpe, inputSchema: haircutSharpeInput },
517
+ (args) => toolValidateHaircutSharpe(session, args),
518
+ );
519
+ server.registerTool(
520
+ "validate_luck_trials",
521
+ { title: "Luck-equivalent trials", annotations: { title: "Luck-equivalent trials", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_luck_trials, inputSchema: luckTrialsInput },
522
+ (args) => toolValidateLuckTrials(session, args),
523
+ );
524
+ server.registerTool(
525
+ "audit_backtest",
526
+ { title: "Audit a backtest", annotations: { title: "Audit a backtest", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.audit_backtest, inputSchema: auditBacktestToolShape },
527
+ (args) => toolAuditBacktest(session, args),
528
+ );
354
529
  server.registerTool(
355
530
  "get_receipt",
356
531
  { title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
357
532
  (args) => toolGetReceipt(session, args),
358
533
  );
534
+ server.registerTool(
535
+ "verify_receipt",
536
+ { title: "Verify a receipt", annotations: { title: "Verify a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.verify_receipt, inputSchema: verifyReceiptToolShape },
537
+ (args) => toolVerifyReceipt(session, args),
538
+ );
359
539
  server.registerTool(
360
540
  "service_status",
361
541
  { title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
@@ -440,6 +620,9 @@ export const RESOURCE_TEXT = Object.freeze({
440
620
  "",
441
621
  "- Deflated Sharpe ratio: Bailey and López de Prado, \"The Deflated Sharpe Ratio\", Journal of Portfolio Management, 2014. Reproduces the paper's worked example (pages 9 and 10) to its four printed decimals, checked in CI.",
442
622
  "- Minimum track record length and probabilistic Sharpe against a benchmark: Bailey and López de Prado, \"The Sharpe Ratio Efficient Frontier\", Journal of Risk, 2012. Reproduces the paper's worked examples (page 11), checked in CI.",
623
+ "- Minimum backtest length: Bailey, Borwein, López de Prado and Zhu, \"Pseudo-Mathematics and Financial Charlatanism\", Notices of the American Mathematical Society, 2014. Reproduces the paper's statements exactly (the best of 10 trials at 1.57; 5 years allow at most 45 trials, 2 years at most 7), checked in CI.",
624
+ "- Haircut Sharpe ratio: Harvey and Liu, \"Backtesting\", Journal of Portfolio Management, 2015. Agrees with the authors' own Haircut_SR.m on every deterministic (Bonferroni) output to 1e-9; Holm and BHY agree with R's p.adjust; the Student t with R's pt and qt. Checked in CI.",
625
+ "- Luck-equivalent trials: Canli Capital's statistic, built from the Student t null of the Sharpe's t-statistic, the Sidak best-of-N probability and the expected maximum of Bailey, Borwein, López de Prado and Zhu (2014). Calibrated by Monte Carlo in CI: correct size for normal and Student t4 returns; too generous for negatively skewed returns (with 252 observations, a nominal 5 percent test rejected 10.8 percent of skill-less searches at skew -1.3).",
443
626
  "- Probability of backtest overfitting by CSCV: Bailey, Borwein, López de Prado and Zhu, \"The Probability of Backtest Overfitting\", Journal of Computational Finance, 2017. Agrees with the CRAN package pbo on PBO and on every logit, after its documented rank convention, checked in CI.",
444
627
  "- Source code: https://github.com/arhancanli/canli-validation-mcp and https://github.com/arhancanli/canlicapital",
445
628
  ].join("\n"),