canli-validation-mcp 0.3.1 → 0.5.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 +32 -7
- package/package.json +5 -2
- package/src/local/api/_lib/limits.js +30 -0
- package/src/local/js/breadth-core.js +96 -0
- package/src/local/js/dsr-core.js +254 -0
- package/src/local/js/moments-core.js +54 -0
- package/src/local/js/paper-evidence-core.js +167 -0
- package/src/local/js/pbo-core.js +112 -0
- package/src/local/js/selection-risk-core.js +197 -0
- package/src/local/js/validate/breadth.js +24 -0
- package/src/local/js/validate/deflated-sharpe.js +28 -0
- package/src/local/js/validate/overfitting.js +29 -0
- package/src/local/js/validate/paper-evidence.js +11 -0
- package/src/local/js/validate/track-record.js +48 -0
- package/src/local/standards/paper-evidence/schema.json +429 -0
- package/src/local.mjs +39 -0
- package/src/schemas.mjs +34 -4
- package/src/server.mjs +173 -5
package/src/server.mjs
CHANGED
|
@@ -9,16 +9,21 @@
|
|
|
9
9
|
import { readFileSync, realpathSync } from "node:fs";
|
|
10
10
|
import { pathToFileURL } from "node:url";
|
|
11
11
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { computeLocally } from "./local.mjs";
|
|
12
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
15
|
import {
|
|
14
16
|
breadthInput,
|
|
17
|
+
trackRecordInput,
|
|
15
18
|
companyHistoryInput,
|
|
19
|
+
companyHistoryToolShape,
|
|
16
20
|
deflatedSharpeInput,
|
|
17
21
|
deflatedSharpeToolShape,
|
|
18
22
|
emptyInput,
|
|
19
23
|
getKeyInput,
|
|
20
24
|
getReceiptInput,
|
|
21
25
|
overfittingInput,
|
|
26
|
+
LIMITS_SENTENCES,
|
|
22
27
|
paperEvidenceInput,
|
|
23
28
|
TOOL_DESCRIPTIONS,
|
|
24
29
|
} from "./schemas.mjs";
|
|
@@ -46,7 +51,14 @@ export function configuredKey(value) {
|
|
|
46
51
|
return trimmed === "" || /^\$\{[^}]*\}$/.test(trimmed) ? undefined : trimmed;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
|
|
54
|
+
// CANLI_LOCAL=1 (or "true") turns on private local mode: the validators run on this machine and
|
|
55
|
+
// nothing about the submitted series leaves it. An empty or unsubstituted value is off.
|
|
56
|
+
export function configuredLocal(value) {
|
|
57
|
+
const v = configuredKey(value);
|
|
58
|
+
return v === "1" || v?.toLowerCase() === "true";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local } = {}) {
|
|
50
62
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
|
|
51
63
|
return {
|
|
52
64
|
base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
|
|
@@ -57,6 +69,7 @@ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIM
|
|
|
57
69
|
// Set by the hosted endpoint (api/mcp.js): which key a request runs under, so get_key can say
|
|
58
70
|
// so instead of issuing a key the next stateless request would never see.
|
|
59
71
|
hosted: hosted ?? undefined,
|
|
72
|
+
local: local ?? configuredLocal(process.env.CANLI_LOCAL),
|
|
60
73
|
};
|
|
61
74
|
}
|
|
62
75
|
|
|
@@ -89,8 +102,11 @@ async function callApi(session, { path, method = "GET", body }) {
|
|
|
89
102
|
// never reads; every field, boundary sentence and provenance value is kept. Measured on the live
|
|
90
103
|
// Apple StockholdersEquity record (README, "Compact context"): 2,214 -> 1,560 tokens minified,
|
|
91
104
|
// 1,056 with the columnar history below.
|
|
105
|
+
// Every result carries the envelope twice: as text, which every client shows, and as structured
|
|
106
|
+
// content, which a client can read field by field. The two are the same object.
|
|
92
107
|
const asText = (envelope, failed = false) => ({
|
|
93
108
|
content: [{ type: "text", text: JSON.stringify(envelope) }],
|
|
109
|
+
...(envelope && typeof envelope === "object" && !Array.isArray(envelope) ? { structuredContent: envelope } : {}),
|
|
94
110
|
...(failed ? { isError: true } : {}),
|
|
95
111
|
});
|
|
96
112
|
|
|
@@ -111,6 +127,15 @@ export function columnarObservations(observations) {
|
|
|
111
127
|
|
|
112
128
|
export async function toolGetKey(session, args) {
|
|
113
129
|
const { label } = parseOrThrow(getKeyInput, args, "get_key");
|
|
130
|
+
if (session.local) {
|
|
131
|
+
// Local mode computes the validators on this machine; issuing a key would be a network call
|
|
132
|
+
// the user turned local mode on to avoid, and would spend the per-client issuance quota.
|
|
133
|
+
return asText({
|
|
134
|
+
note: "Local mode is on: the validators run on this machine and need no key. No key was issued and no request was sent.",
|
|
135
|
+
key_source: "local",
|
|
136
|
+
key_present: false,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
114
139
|
if (session.hosted) {
|
|
115
140
|
// The hosted endpoint is stateless: a key issued here would be gone by the next request, and
|
|
116
141
|
// every hosted caller shares the platform's egress address and so its per-client issuance quota.
|
|
@@ -146,28 +171,39 @@ export async function toolValidateDeflatedSharpe(session, args) {
|
|
|
146
171
|
"or a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized), never a mix of both and never neither.",
|
|
147
172
|
);
|
|
148
173
|
}
|
|
174
|
+
if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return asText(local.envelope, local.failed); }
|
|
149
175
|
const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
|
|
150
176
|
return asText(response.envelope, response.failed);
|
|
151
177
|
}
|
|
152
178
|
|
|
153
179
|
export async function toolValidateOverfitting(session, args) {
|
|
154
180
|
const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
|
|
181
|
+
if (session.local) { const local = computeLocally("validate_overfitting", body); return asText(local.envelope, local.failed); }
|
|
155
182
|
const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
|
|
156
183
|
return asText(response.envelope, response.failed);
|
|
157
184
|
}
|
|
158
185
|
|
|
159
186
|
export async function toolValidatePaperEvidence(session, args) {
|
|
160
187
|
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); }
|
|
161
189
|
const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
|
|
162
190
|
return asText(response.envelope, response.failed);
|
|
163
191
|
}
|
|
164
192
|
|
|
165
193
|
export async function toolValidateBreadth(session, args) {
|
|
166
194
|
const body = parseOrThrow(breadthInput, args, "validate_breadth");
|
|
195
|
+
if (session.local) { const local = computeLocally("validate_breadth", body); return asText(local.envelope, local.failed); }
|
|
167
196
|
const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
|
|
168
197
|
return asText(response.envelope, response.failed);
|
|
169
198
|
}
|
|
170
199
|
|
|
200
|
+
export async function toolValidateTrackRecord(session, args) {
|
|
201
|
+
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); }
|
|
203
|
+
const response = await callApi(session, { path: "/api/v1/validate/track-record", method: "POST", body });
|
|
204
|
+
return asText(response.envelope, response.failed);
|
|
205
|
+
}
|
|
206
|
+
|
|
171
207
|
export async function toolGetReceipt(session, args) {
|
|
172
208
|
const { id } = parseOrThrow(getReceiptInput, args, "get_receipt");
|
|
173
209
|
const response = await callApi(session, { path: `/api/v1/receipts/${id}` });
|
|
@@ -183,9 +219,37 @@ export async function toolServiceStatus(session) {
|
|
|
183
219
|
// field that says where a value came from (accession, form, filed date, unit, source hash) and
|
|
184
220
|
// the record's own claim_boundary and policy sentences, so an agent cannot quote a number
|
|
185
221
|
// without its provenance or boundary.
|
|
222
|
+
// Ticker -> CIK through the published index of companies in the release, fetched once per session.
|
|
223
|
+
async function resolveTicker(session, ticker) {
|
|
224
|
+
if (!session.tickerIndex) {
|
|
225
|
+
const signal = AbortSignal.timeout(session.timeoutMs);
|
|
226
|
+
let res;
|
|
227
|
+
try {
|
|
228
|
+
res = await session.fetchImpl(`${session.base}/api/v1/company-tickers.json`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
|
|
229
|
+
} catch {
|
|
230
|
+
throw new Error(signal.aborted ? "company_financial_history: the ticker index request timed out" : "company_financial_history: could not reach the ticker index");
|
|
231
|
+
}
|
|
232
|
+
if (!res.ok) throw new Error(`company_financial_history: the ticker index returned HTTP ${res.status}`);
|
|
233
|
+
session.tickerIndex = await res.json();
|
|
234
|
+
}
|
|
235
|
+
const key = ticker.toUpperCase();
|
|
236
|
+
const cik = session.tickerIndex.tickers?.[key];
|
|
237
|
+
if (!cik) {
|
|
238
|
+
return { error: { error: { code: "unknown_ticker", message: `No company in the current company reference release trades as ${key}. Search by name at the page below, or pass the SEC CIK.` }, page: `${session.base}/companies` } };
|
|
239
|
+
}
|
|
240
|
+
return { cik };
|
|
241
|
+
}
|
|
242
|
+
|
|
186
243
|
export async function toolCompanyFinancialHistory(session, args) {
|
|
187
|
-
const { cik, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
|
|
188
|
-
|
|
244
|
+
const { cik, ticker, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
|
|
245
|
+
let id;
|
|
246
|
+
if (ticker !== undefined) {
|
|
247
|
+
const resolved = await resolveTicker(session, ticker);
|
|
248
|
+
if (resolved.error) return asText(resolved.error, true);
|
|
249
|
+
id = resolved.cik;
|
|
250
|
+
} else {
|
|
251
|
+
id = cik.padStart(10, "0");
|
|
252
|
+
}
|
|
189
253
|
const path = `/company-data/${id}.json`;
|
|
190
254
|
const signal = AbortSignal.timeout(session.timeoutMs);
|
|
191
255
|
let res, text;
|
|
@@ -282,6 +346,11 @@ export function registerTools(server, session) {
|
|
|
282
346
|
{ title: "Validate breadth ceiling", annotations: { title: "Validate breadth ceiling", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput },
|
|
283
347
|
(args) => toolValidateBreadth(session, args),
|
|
284
348
|
);
|
|
349
|
+
server.registerTool(
|
|
350
|
+
"validate_track_record",
|
|
351
|
+
{ title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
|
|
352
|
+
(args) => toolValidateTrackRecord(session, args),
|
|
353
|
+
);
|
|
285
354
|
server.registerTool(
|
|
286
355
|
"get_receipt",
|
|
287
356
|
{ title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
|
|
@@ -294,14 +363,113 @@ export function registerTools(server, session) {
|
|
|
294
363
|
);
|
|
295
364
|
server.registerTool(
|
|
296
365
|
"company_financial_history",
|
|
297
|
-
{ title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema:
|
|
366
|
+
{ title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryToolShape },
|
|
298
367
|
(args) => toolCompanyFinancialHistory(session, args),
|
|
299
368
|
);
|
|
300
369
|
}
|
|
301
370
|
|
|
371
|
+
// Guided workflows a user can pick in a client that shows MCP prompts. Arguments are strings, as
|
|
372
|
+
// the protocol defines them; the prompt only writes the instructions, the tools do the work.
|
|
373
|
+
export function registerPrompts(server) {
|
|
374
|
+
server.registerPrompt(
|
|
375
|
+
"validate_backtest",
|
|
376
|
+
{
|
|
377
|
+
title: "Validate a backtest before trusting it",
|
|
378
|
+
description: "Walk through the deflated Sharpe, overfitting and track-record checks for one strategy, and report what the numbers do not establish.",
|
|
379
|
+
argsSchema: {
|
|
380
|
+
strategy: z.string().describe("What the strategy is and what data it was backtested on"),
|
|
381
|
+
variants_tried: z.string().optional().describe("How many variants, parameter sets or ideas were tried before this one"),
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
({ strategy, variants_tried }) => ({
|
|
385
|
+
messages: [{
|
|
386
|
+
role: "user",
|
|
387
|
+
content: {
|
|
388
|
+
type: "text",
|
|
389
|
+
text: [
|
|
390
|
+
`Validate this backtest before I trust it: ${strategy}.`,
|
|
391
|
+
variants_tried ? `Variants tried before settling on it: ${variants_tried}.` : "Ask me how many variants were tried before settling on it; do not assume one.",
|
|
392
|
+
"1. validate_deflated_sharpe with the return series (or the seven contract inputs), counting every variant tried as a trial.",
|
|
393
|
+
"2. If I can share the returns of every variant, validate_overfitting on that matrix.",
|
|
394
|
+
"3. validate_track_record for how long a live record must run before this Sharpe clears a benchmark I care about.",
|
|
395
|
+
"Report each number with the limits sentences its result carries, say plainly what they do not establish, and give me each receipt id.",
|
|
396
|
+
].join("\n"),
|
|
397
|
+
},
|
|
398
|
+
}],
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
server.registerPrompt(
|
|
402
|
+
"track_record_needed",
|
|
403
|
+
{
|
|
404
|
+
title: "How long a track record do I need?",
|
|
405
|
+
description: "The minimum track record length for a Sharpe to clear a benchmark, with the record's own probabilistic Sharpe if its length is known.",
|
|
406
|
+
argsSchema: {
|
|
407
|
+
sharpe: z.string().describe("The observed annualized Sharpe ratio"),
|
|
408
|
+
frequency: z.string().describe("How often returns are measured: daily, weekly or monthly"),
|
|
409
|
+
benchmark: z.string().optional().describe("The annualized Sharpe it must beat; 0 if not given"),
|
|
410
|
+
record_length: z.string().optional().describe("How long the record already is, if known"),
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
({ sharpe, frequency, benchmark, record_length }) => ({
|
|
414
|
+
messages: [{
|
|
415
|
+
role: "user",
|
|
416
|
+
content: {
|
|
417
|
+
type: "text",
|
|
418
|
+
text: [
|
|
419
|
+
`How long a track record does an annualized Sharpe of ${sharpe} on ${frequency} returns need to be believably above ${benchmark ?? "0"}?`,
|
|
420
|
+
"Call validate_track_record with periods_per_year for that frequency (252 daily, 52 weekly, 12 monthly). Ask me for the skewness and kurtosis of the returns; if I do not know them, run it for Normal returns (skew 0, kurtosis 3) and say that fat tails lengthen the answer.",
|
|
421
|
+
record_length ? `The record already runs ${record_length}; convert it to observations and include it, and tell me whether it is long enough.` : "",
|
|
422
|
+
"State that the answer is about sample uncertainty and the shape of the returns, not a forecast.",
|
|
423
|
+
].filter(Boolean).join("\n"),
|
|
424
|
+
},
|
|
425
|
+
}],
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Reference documents a client can read: the boundary language every result carries, and the
|
|
431
|
+
// sources and independent checks behind each validator.
|
|
432
|
+
export const RESOURCE_TEXT = Object.freeze({
|
|
433
|
+
limits: [
|
|
434
|
+
"# What a canlicapital.com validation result does not establish",
|
|
435
|
+
"",
|
|
436
|
+
...Object.values(LIMITS_SENTENCES).map((s) => `- ${s}`),
|
|
437
|
+
].join("\n"),
|
|
438
|
+
sources: [
|
|
439
|
+
"# Sources and independent checks",
|
|
440
|
+
"",
|
|
441
|
+
"- 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
|
+
"- 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.",
|
|
443
|
+
"- 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
|
+
"- Source code: https://github.com/arhancanli/canli-validation-mcp and https://github.com/arhancanli/canlicapital",
|
|
445
|
+
].join("\n"),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
export function registerResources(server) {
|
|
449
|
+
server.registerResource(
|
|
450
|
+
"limits",
|
|
451
|
+
"canli://limits",
|
|
452
|
+
{ title: "What a result does not establish", description: "The boundary sentences every validation result carries.", mimeType: "text/markdown" },
|
|
453
|
+
(uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RESOURCE_TEXT.limits }] }),
|
|
454
|
+
);
|
|
455
|
+
server.registerResource(
|
|
456
|
+
"sources",
|
|
457
|
+
"canli://sources",
|
|
458
|
+
{ title: "Sources and independent checks", description: "The papers behind each validator and how each is checked against them.", mimeType: "text/markdown" },
|
|
459
|
+
(uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RESOURCE_TEXT.sources }] }),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Everything the server exposes: the npm package and the hosted endpoint both call this.
|
|
464
|
+
export function registerAll(server, session) {
|
|
465
|
+
registerTools(server, session);
|
|
466
|
+
registerPrompts(server);
|
|
467
|
+
registerResources(server);
|
|
468
|
+
}
|
|
469
|
+
|
|
302
470
|
export function createServer(session = createSession()) {
|
|
303
471
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
304
|
-
|
|
472
|
+
registerAll(server, session);
|
|
305
473
|
return server;
|
|
306
474
|
}
|
|
307
475
|
|