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 +16 -1
- package/src/schemas.mjs +4 -4
- package/src/series-file.mjs +51 -13
- package/src/server.mjs +30 -4
package/package.json
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-validation-mcp",
|
|
3
|
-
"version": "0.8.
|
|
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
|
-
|
|
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.
|
|
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
|
})
|
package/src/series-file.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
-
|
|
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 ? "
|
|
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(
|
|
708
|
+
const server = new McpServer(SERVER_INFO);
|
|
683
709
|
registerAll(server, session);
|
|
684
710
|
return server;
|
|
685
711
|
}
|