canli-validation-mcp 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-validation-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Check whether a backtest is real: deflated Sharpe, CSCV probability of backtest overfitting, minimum track record and backtest length, haircut Sharpe and luck-equivalent trials, as an MCP server. Local mode, hosted endpoint, signed receipts.",
5
5
  "private": false,
6
6
  "type": "module",
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
@@ -349,7 +349,7 @@ export async function toolAuditBacktest(session, args) {
349
349
  checks,
350
350
  ...(input.returns_file || input.variants_file
351
351
  ? { source: {
352
- ...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
352
+ ...(returnsRead ? { returns_file: input.returns_file, returns_column_position: returnsRead.column, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
353
353
  ...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
354
354
  } }
355
355
  : {}),
@@ -372,7 +372,21 @@ export async function toolVerifyReceipt(session, args) {
372
372
  data = response.envelope?.data;
373
373
  }
374
374
  const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
375
- const result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
375
+ // A receipt missing a field (output, bindings, input_sha256) cannot be hashed, and that is a
376
+ // failed check, not a tool error: the answer is still "not issued for this content".
377
+ let result;
378
+ try {
379
+ result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
380
+ } catch {
381
+ const missing = ["id", "endpoint", "input_sha256", "output", "bindings", "signature"].filter((k) => data?.[k] === undefined);
382
+ return asText({
383
+ receipt_id: data?.id ?? null,
384
+ valid: false,
385
+ checks: { well_formed: false, id_matches_content: false, signature_valid: false, key_published: false },
386
+ key_id: data?.signature?.key_id ?? null,
387
+ 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.`,
388
+ });
389
+ }
376
390
  return asText({
377
391
  receipt_id: data?.id ?? null,
378
392
  valid: result.valid,
@@ -408,7 +422,7 @@ async function resolveTicker(session, ticker) {
408
422
  try {
409
423
  res = await session.fetchImpl(`${session.base}/api/v1/company-tickers.json`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
410
424
  } 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");
425
+ 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
426
  }
413
427
  if (!res.ok) throw new Error(`company_financial_history: the ticker index returned HTTP ${res.status}`);
414
428
  session.tickerIndex = await res.json();