canli-validation-mcp 0.8.0 → 0.8.2

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/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "canli-validation-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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
+ "keywords": [
6
+ "mcp",
7
+ "mcp-server",
8
+ "model-context-protocol",
9
+ "backtesting",
10
+ "backtest",
11
+ "deflated-sharpe-ratio",
12
+ "probability-of-backtest-overfitting",
13
+ "cscv",
14
+ "sharpe-ratio",
15
+ "overfitting",
16
+ "quantitative-finance",
17
+ "quant",
18
+ "trading"
19
+ ],
5
20
  "private": false,
6
21
  "type": "module",
7
22
  "license": "MIT",
package/src/schemas.mjs CHANGED
@@ -47,7 +47,7 @@ export const FIELD_DESCRIPTIONS = Object.freeze({
47
47
  autocorrelation: "First-order autocorrelation of the returns, -1 to 1; default 0. Corrects the annualized Sharpe as Lo (2002).",
48
48
  other_sharpes: "Annualized Sharpe ratios of the other tests, over the same observations; adds the Holm and BHY haircuts.",
49
49
  lt_trials: "Independent trials tried; adds the chance that the best of them reached this Sharpe by luck.",
50
- skew: "Skewness of the returns; below -0.5 the reading warns that the counts are too generous.",
50
+ lt_skew: "Skewness of the returns; below -0.5 the reading warns that the counts are too generous.",
51
51
  variants: "Optional returns of every variant tried, this one included, as fractions: one row per period, one column per variant. Adds the overfitting check.",
52
52
  returns_file: "Path to a CSV or JSON file of the returns on the machine running this server, instead of returns. Not available on the hosted endpoint.",
53
53
  returns_column: "Header name or 1-based position of the returns column when returns_file has several numeric columns.",
@@ -108,7 +108,7 @@ export const deflatedSharpeToolShape = z
108
108
 
109
109
  export const overfittingInput = z
110
110
  .object({
111
- matrix: z.array(z.array(z.number())).min(2).max(20000).describe(d.matrix),
111
+ matrix: z.array(z.array(z.number()).max(200)).min(2).max(20000).describe(d.matrix),
112
112
  n_splits: z.number().int().positive().optional().describe(d.n_splits),
113
113
  max_combinations: z.number().int().positive().max(2000).optional().describe(d.max_combinations),
114
114
  seed: z.number().optional().describe(d.seed),
@@ -192,7 +192,7 @@ export const luckTrialsInput = z
192
192
  periods_per_year: z.number().min(1).max(10000).describe(d.periods_per_year),
193
193
  observations: z.number().int().min(3).max(1000000).describe(d.hc_observations),
194
194
  effective_independent_trials: z.number().int().min(1).max(1000000000).optional().describe(d.lt_trials),
195
- skew: z.number().min(-100).max(100).optional().describe(d.skew),
195
+ skew: z.number().min(-100).max(100).optional().describe(d.lt_skew),
196
196
  autocorrelation: z.number().gt(-1).lt(1).optional().describe(d.autocorrelation),
197
197
  })
198
198
  .strict();
@@ -212,7 +212,7 @@ export const auditBacktestToolShape = z
212
212
  cross_trial_sharpe_sd_annualized: z.number().min(0).max(10).describe(d.cross_trial_sharpe_sd_annualized),
213
213
  benchmark_sharpe_annualized: z.number().min(-10).max(10).optional().describe(d.benchmark_sharpe_annualized),
214
214
  confidence: z.number().gt(0).lt(1).optional().describe(d.confidence),
215
- variants: z.array(z.array(z.number())).min(2).max(20000).optional().describe(d.variants),
215
+ variants: z.array(z.array(z.number()).max(200)).min(2).max(20000).optional().describe(d.variants),
216
216
  variants_file: z.string().min(1).max(4096).optional().describe(d.variants_file),
217
217
  n_splits: z.number().int().positive().optional().describe(d.n_splits),
218
218
  })
@@ -41,6 +41,26 @@ function readText(path) {
41
41
  }
42
42
 
43
43
  const isNumber = (cell) => cell.trim() !== "" && Number.isFinite(Number(cell.trim()));
44
+ // Cells a spreadsheet or pandas writes for a missing value.
45
+ const isMissing = (cell) => /^(|nan|na|n\/a|#n\/a|null|none|-)$/i.test(cell.trim());
46
+ const DATE = /^(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4})([ T].*)?$/;
47
+ const kindOf = (cell) => (isNumber(cell) ? "number" : isMissing(cell) ? "missing" : DATE.test(cell.trim()) ? "date" : "text");
48
+
49
+ // The first row is a header when one of its cells differs in kind from the cells below it: a label
50
+ // above numbers or dates. A date above dates is data, so a file without a header keeps its first row.
51
+ function hasHeader(first, below) {
52
+ if (!first.some((cell) => !isNumber(cell))) return false;
53
+ if (!below.length) return true;
54
+ return first.some((cell, j) => {
55
+ if (isNumber(cell)) return false;
56
+ // Missing values say nothing about what a column holds, so they do not vote.
57
+ const kinds = below.map((row) => kindOf(row[j] ?? "")).filter((k) => k !== "missing");
58
+ if (!kinds.length) return true;
59
+ const count = (k) => kinds.filter((x) => x === k).length;
60
+ const common = [...new Set(kinds)].sort((a, b) => count(b) - count(a))[0];
61
+ return kindOf(cell) !== common;
62
+ });
63
+ }
44
64
 
45
65
  // A JSON array (of numbers, or of arrays of numbers), or delimited text: comma, semicolon or tab
46
66
  // separated, one row per line, with an optional header row naming the columns.
@@ -65,7 +85,7 @@ function parseTable(text, path) {
65
85
  const delimiter = [",", ";", "\t"].find((d) => lines[0].includes(d)) ?? ",";
66
86
  const split = (line) => line.split(delimiter).map((cell) => cell.trim());
67
87
  const first = split(lines[0]);
68
- const header = first.some((cell) => !isNumber(cell)) ? first : null;
88
+ const header = hasHeader(first, lines.slice(1, 6).map(split)) ? first : null;
69
89
  const body = header ? lines.slice(1) : lines;
70
90
  const width = (header ?? first).length;
71
91
  const rows = body.map((line, i) => {
@@ -85,40 +105,58 @@ function isRowCounter(name, values) {
85
105
  }
86
106
 
87
107
  // Numeric columns only: a date or label column is dropped, never parsed; a row counter is skipped.
108
+ // A column of numbers with some empty or NaN cells is not dropped: it is reported as incomplete, so
109
+ // a blank cell can never make the reader pick a different column (a benchmark) in its place.
88
110
  function numericColumns({ header, rows }) {
89
111
  const width = rows[0]?.length ?? 0;
90
112
  const columns = [];
113
+ const incomplete = [];
91
114
  const skipped = [];
115
+ const offset = header ? 2 : 1; // file line of the first data row
92
116
  for (let j = 0; j < width; j += 1) {
93
- if (rows.every((row) => typeof row[j] === "number" || isNumber(String(row[j])))) {
94
- const values = rows.map((row) => Number(row[j]));
117
+ const cells = rows.map((row) => (typeof row[j] === "number" ? String(row[j]) : String(row[j])));
118
+ const numeric = cells.filter(isNumber).length;
119
+ if (numeric === cells.length) {
120
+ const values = cells.map(Number);
95
121
  if (isRowCounter(header ? header[j] : null, values)) skipped.push(j + 1);
96
122
  else columns.push({ index: j, name: header ? header[j] : String(j + 1), values });
123
+ } else if (numeric > 0 && numeric * 2 >= cells.length && cells.every((c) => isNumber(c) || isMissing(c))) {
124
+ const gaps = cells.map((c, i) => (isNumber(c) ? null : i + offset)).filter((i) => i !== null);
125
+ incomplete.push({ index: j, name: header ? header[j] : String(j + 1), gaps });
97
126
  }
98
127
  }
99
- return { columns, skipped };
128
+ return { columns, incomplete, skipped };
100
129
  }
101
130
 
131
+ const lineList = (gaps) => (gaps.length > 5 ? `${gaps.slice(0, 5).join(", ")} and ${gaps.length - 5} more` : gaps.join(", "));
132
+ const incompleteError = (path, c) => new Error(`${path}: column ${c.index + 1} holds numbers but is empty or not a number on line${c.gaps.length > 1 ? "s" : ""} ${lineList(c.gaps)}; fill or remove those rows, or pick another column with returns_column`);
133
+
102
134
  // One series. With several numeric columns, `column` (a header name or a 1-based index) picks it.
103
- // Returns the values and the positions of any row-counter columns skipped.
135
+ // Returns the values, the 1-based position of the column read, and the positions of any row-counter
136
+ // columns skipped. Without `column`, any incomplete numeric column is refused, naming its lines.
104
137
  export function readSeriesFile(path, column) {
105
138
  const table = parseTable(readText(path), path);
106
139
  if (!table.rows.length) throw new Error(`${path}: no data rows`);
107
- const { columns, skipped } = numericColumns(table);
108
- if (!columns.length) throw new Error(`${path}: no column holds only numbers`);
140
+ const { columns, incomplete, skipped } = numericColumns(table);
141
+ const match = (list) => list.find((c) => c.name === String(column)) ?? list.find((c) => String(c.index + 1) === String(column));
109
142
  if (column === undefined) {
143
+ if (incomplete.length) throw incompleteError(path, incomplete[0]);
144
+ if (!columns.length) throw new Error(`${path}: no column holds only numbers`);
110
145
  if (columns.length > 1) throw new Error(`${path}: ${columns.length} numeric columns (positions ${columns.map((c) => c.index + 1).join(", ")}); pick one with returns_column, by header name or position`);
111
- return { values: columns[0].values, skipped };
146
+ return { values: columns[0].values, column: columns[0].index + 1, skipped };
112
147
  }
113
- const picked = columns.find((c) => c.name === String(column)) ?? columns.find((c) => String(c.index + 1) === String(column));
114
- if (!picked) throw new Error(`${path}: returns_column matches no numeric column; numeric columns are at positions ${columns.map((c) => c.index + 1).join(", ")}`);
115
- return { values: picked.values, skipped };
148
+ const gappy = match(incomplete);
149
+ if (gappy) throw incompleteError(path, gappy);
150
+ const picked = match(columns);
151
+ if (!picked) throw new Error(`${path}: returns_column matches no numeric column; numeric columns are at positions ${columns.map((c) => c.index + 1).join(", ") || "none"}`);
152
+ return { values: picked.values, column: picked.index + 1, skipped };
116
153
  }
117
154
 
118
- // Every numeric column is one variant; rows are periods.
155
+ // Every numeric column is one variant; rows are periods. An incomplete column is refused.
119
156
  export function readMatrixFile(path) {
120
157
  const table = parseTable(readText(path), path);
121
- const { columns, skipped } = numericColumns(table);
158
+ const { columns, incomplete, skipped } = numericColumns(table);
159
+ if (incomplete.length) throw incompleteError(path, incomplete[0]);
122
160
  if (columns.length < 2) throw new Error(`${path}: needs at least 2 numeric columns, one per variant`);
123
161
  return { matrix: table.rows.map((_, i) => columns.map((c) => c.values[i])), skipped };
124
162
  }
package/src/server.mjs CHANGED
@@ -19,6 +19,18 @@ import { breadthInput, trackRecordInput, auditBacktestInput, verifyReceiptToolSh
19
19
  export const DEFAULT_BASE = "https://canlicapital.com";
20
20
  export const SERVER_NAME = "canlicapital-validation-mcp";
21
21
  export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
22
+ // How the server introduces itself in initialize: a readable title, the page that documents it
23
+ // and its icon, so clients and directories that read serverInfo show more than a package name.
24
+ export const SERVER_INFO = Object.freeze({
25
+ name: SERVER_NAME,
26
+ version: SERVER_VERSION,
27
+ title: "Canli Validation",
28
+ websiteUrl: "https://canlicapital.com/developers#ai-assistant",
29
+ icons: [
30
+ { src: "https://canlicapital.com/icon-512.png", mimeType: "image/png", sizes: ["512x512"] },
31
+ { src: "https://canlicapital.com/favicon.svg", mimeType: "image/svg+xml", sizes: ["any"] },
32
+ ],
33
+ });
22
34
  export const REQUEST_TIMEOUT_MS = 30_000;
23
35
 
24
36
  // One place a value fails a zod schema turns into a short, readable message instead of a raw
@@ -349,7 +361,7 @@ export async function toolAuditBacktest(session, args) {
349
361
  checks,
350
362
  ...(input.returns_file || input.variants_file
351
363
  ? { source: {
352
- ...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
364
+ ...(returnsRead ? { returns_file: input.returns_file, returns_column_position: returnsRead.column, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
353
365
  ...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
354
366
  } }
355
367
  : {}),
@@ -372,7 +384,21 @@ export async function toolVerifyReceipt(session, args) {
372
384
  data = response.envelope?.data;
373
385
  }
374
386
  const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
375
- const result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
387
+ // A receipt missing a field (output, bindings, input_sha256) cannot be hashed, and that is a
388
+ // failed check, not a tool error: the answer is still "not issued for this content".
389
+ let result;
390
+ try {
391
+ result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
392
+ } catch {
393
+ const missing = ["id", "endpoint", "input_sha256", "output", "bindings", "signature"].filter((k) => data?.[k] === undefined);
394
+ return asText({
395
+ receipt_id: data?.id ?? null,
396
+ valid: false,
397
+ checks: { well_formed: false, id_matches_content: false, signature_valid: false, key_published: false },
398
+ key_id: data?.signature?.key_id ?? null,
399
+ meaning: `The receipt is not well formed${missing.length ? `: it has no ${missing.join(", ")}` : ""}, so it cannot be checked. Send the whole receipt object as get_receipt or the validation result returned it, or send its id.`,
400
+ });
401
+ }
376
402
  return asText({
377
403
  receipt_id: data?.id ?? null,
378
404
  valid: result.valid,
@@ -408,7 +434,7 @@ async function resolveTicker(session, ticker) {
408
434
  try {
409
435
  res = await session.fetchImpl(`${session.base}/api/v1/company-tickers.json`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
410
436
  } catch {
411
- throw new Error(signal.aborted ? "company_financial_history: the ticker index request timed out" : "company_financial_history: could not reach the ticker index");
437
+ throw new Error(`company_financial_history: ${signal.aborted ? "the ticker index request timed out" : "could not reach the ticker index"} at ${session.base}. Retry, check service_status, or pass the SEC CIK instead of a ticker.`);
412
438
  }
413
439
  if (!res.ok) throw new Error(`company_financial_history: the ticker index returned HTTP ${res.status}`);
414
440
  session.tickerIndex = await res.json();
@@ -679,7 +705,7 @@ export function registerAll(server, session) {
679
705
  }
680
706
 
681
707
  export function createServer(session = createSession()) {
682
- const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
708
+ const server = new McpServer(SERVER_INFO);
683
709
  registerAll(server, session);
684
710
  return server;
685
711
  }