canli-validation-mcp 0.5.0 → 0.7.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,29 @@ 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
+ // Toolsets: which tools the server lists. The tool list is re-sent to the model on every turn and
70
+ // is most of each turn's prompt (README, "Toolsets"; bench/tool_list_tokens.py measures it), so a
71
+ // client that needs one kind of tool can load only that kind. Default: all.
72
+ export const TOOLSETS = Object.freeze({
73
+ validate: Object.freeze(["get_key", "validate_deflated_sharpe", "validate_overfitting", "validate_paper_evidence", "validate_breadth", "validate_track_record", "validate_backtest_length", "validate_haircut_sharpe", "validate_luck_trials", "audit_backtest"]),
74
+ receipts: Object.freeze(["get_receipt", "verify_receipt"]),
75
+ company: Object.freeze(["company_financial_history"]),
76
+ status: Object.freeze(["service_status"]),
77
+ });
78
+
79
+ // CANLI_TOOLSETS=validate,company (or ?toolsets= on the hosted endpoint): a comma-separated list of
80
+ // TOOLSETS names, or "all". Empty or unsubstituted means all; an unknown name is refused, so a typo
81
+ // never silently leaves a client without the tools it asked for.
82
+ export function configuredToolsets(value) {
83
+ const v = configuredKey(value);
84
+ if (!v || v.trim().toLowerCase() === "all") return Object.keys(TOOLSETS);
85
+ const names = [...new Set(v.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean))];
86
+ const unknown = names.filter((n) => !Object.hasOwn(TOOLSETS, n));
87
+ if (unknown.length || !names.length) throw new Error(`Unknown toolset ${unknown.join(", ") || "(none)"}; choose from ${Object.keys(TOOLSETS).join(", ")} or all`);
88
+ return names;
89
+ }
90
+
91
+ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local, fullEnvelope, receiptKeys, toolsets } = {}) {
62
92
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
63
93
  return {
64
94
  base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
@@ -70,6 +100,10 @@ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIM
70
100
  // so instead of issuing a key the next stateless request would never see.
71
101
  hosted: hosted ?? undefined,
72
102
  local: local ?? configuredLocal(process.env.CANLI_LOCAL),
103
+ fullEnvelope: fullEnvelope ?? configuredFullEnvelope(process.env.CANLI_FULL_ENVELOPE),
104
+ // Tests pass their own keys; everyone else verifies against the bundled published keys.
105
+ receiptKeys: receiptKeys ?? undefined,
106
+ toolsets: toolsets ?? configuredToolsets(process.env.CANLI_TOOLSETS),
73
107
  };
74
108
  }
75
109
 
@@ -110,6 +144,30 @@ const asText = (envelope, failed = false) => ({
110
144
  ...(failed ? { isError: true } : {}),
111
145
  });
112
146
 
147
+ // A validation result as the model reads it: the answer, the sentences saying what it does not
148
+ // establish, and the receipt that holds the rest. Metadata (schema, endpoint, timestamps, claim and
149
+ // capital class, human page), the source hashes and the quota sentence are about the service, not
150
+ // the answer; they stay in the stored receipt (get_receipt) and service_status, and
151
+ // CANLI_FULL_ENVELOPE=1 returns every field. On a result this cuts the text roughly in half.
152
+ const QUOTA_SENTENCE = /^Quotas:/;
153
+
154
+ export function compactEnvelope(envelope) {
155
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return envelope;
156
+ const out = {};
157
+ if (envelope.computed) { out.computed = envelope.computed; out.note = envelope.note; }
158
+ if ("data" in envelope) out.data = envelope.data;
159
+ if (envelope.error) out.error = envelope.error;
160
+ if (Array.isArray(envelope.limits)) out.limits = envelope.limits.filter((s) => !QUOTA_SENTENCE.test(s));
161
+ if ("receipt" in envelope) out.receipt = envelope.receipt ? { id: envelope.receipt.id, url: envelope.receipt.url } : null;
162
+ return out;
163
+ }
164
+
165
+ export function configuredFullEnvelope(value) {
166
+ return value === "1" || value === "true";
167
+ }
168
+
169
+ const validationText = (session, { envelope, failed }) => asText(session.fullEnvelope ? envelope : compactEnvelope(envelope), failed);
170
+
113
171
  // A history's observations as one header and one row each, instead of every field name repeated
114
172
  // on every observation. The column order is fixed so a row can be read without its keys; a unit
115
173
  // shared by every row is stated once.
@@ -171,37 +229,157 @@ export async function toolValidateDeflatedSharpe(session, args) {
171
229
  "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
230
  );
173
231
  }
174
- if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return asText(local.envelope, local.failed); }
232
+ if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return validationText(session, local); }
175
233
  const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
176
- return asText(response.envelope, response.failed);
234
+ return validationText(session, response);
177
235
  }
178
236
 
179
237
  export async function toolValidateOverfitting(session, args) {
180
238
  const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
181
- if (session.local) { const local = computeLocally("validate_overfitting", body); return asText(local.envelope, local.failed); }
239
+ if (session.local) { const local = computeLocally("validate_overfitting", body); return validationText(session, local); }
182
240
  const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
183
- return asText(response.envelope, response.failed);
241
+ return validationText(session, response);
184
242
  }
185
243
 
186
244
  export async function toolValidatePaperEvidence(session, args) {
187
245
  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); }
246
+ if (session.local) { const local = computeLocally("validate_paper_evidence", body); return validationText(session, local); }
189
247
  const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
190
- return asText(response.envelope, response.failed);
248
+ return validationText(session, response);
191
249
  }
192
250
 
193
251
  export async function toolValidateBreadth(session, args) {
194
252
  const body = parseOrThrow(breadthInput, args, "validate_breadth");
195
- if (session.local) { const local = computeLocally("validate_breadth", body); return asText(local.envelope, local.failed); }
253
+ if (session.local) { const local = computeLocally("validate_breadth", body); return validationText(session, local); }
196
254
  const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
197
- return asText(response.envelope, response.failed);
255
+ return validationText(session, response);
198
256
  }
199
257
 
200
258
  export async function toolValidateTrackRecord(session, args) {
201
259
  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); }
260
+ if (session.local) { const local = computeLocally("validate_track_record", body); return validationText(session, local); }
203
261
  const response = await callApi(session, { path: "/api/v1/validate/track-record", method: "POST", body });
204
- return asText(response.envelope, response.failed);
262
+ return validationText(session, response);
263
+ }
264
+
265
+ export async function toolValidateBacktestLength(session, args) {
266
+ const body = parseOrThrow(backtestLengthInput, args, "validate_backtest_length");
267
+ if (session.local) { const local = computeLocally("validate_backtest_length", body); return validationText(session, local); }
268
+ const response = await callApi(session, { path: "/api/v1/validate/backtest-length", method: "POST", body });
269
+ return validationText(session, response);
270
+ }
271
+
272
+ export async function toolValidateHaircutSharpe(session, args) {
273
+ const body = parseOrThrow(haircutSharpeInput, args, "validate_haircut_sharpe");
274
+ if (session.local) { const local = computeLocally("validate_haircut_sharpe", body); return validationText(session, local); }
275
+ const response = await callApi(session, { path: "/api/v1/validate/haircut-sharpe", method: "POST", body });
276
+ return validationText(session, response);
277
+ }
278
+
279
+ export async function toolValidateLuckTrials(session, args) {
280
+ const body = parseOrThrow(luckTrialsInput, args, "validate_luck_trials");
281
+ if (session.local) { const local = computeLocally("validate_luck_trials", body); return validationText(session, local); }
282
+ const response = await callApi(session, { path: "/api/v1/validate/luck-trials", method: "POST", body });
283
+ return validationText(session, response);
284
+ }
285
+
286
+ // One validator, run the same way its own tool runs it: on this machine in local mode, otherwise
287
+ // through the API (one validation of quota, one receipt).
288
+ async function runValidator(session, tool, path, body) {
289
+ if (session.local) return computeLocally(tool, body);
290
+ return callApi(session, { path, method: "POST", body });
291
+ }
292
+
293
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
294
+
295
+ // audit_backtest: deflated Sharpe from the series, then the minimum track record length from the
296
+ // Sharpe, skew and kurtosis that check derived, then (with variants) CSCV overfitting. Each check
297
+ // keeps its envelope and receipt; the limits every envelope repeats are stated once at the top.
298
+ // A refused later check (for example a Sharpe that does not exceed the benchmark) is reported as
299
+ // that check's error, not as a failed audit; the audit fails only when the first check does.
300
+ export async function toolAuditBacktest(session, args) {
301
+ const input = parseOrThrow(auditBacktestInput, args, "audit_backtest");
302
+ if ((input.returns_file || input.variants_file) && session.hosted) {
303
+ 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.");
304
+ }
305
+ const returnsRead = input.returns_file ? readSeriesFile(input.returns_file, input.returns_column) : null;
306
+ const variantsRead = input.variants_file ? readMatrixFile(input.variants_file) : null;
307
+ const returns = input.returns ?? returnsRead.values;
308
+ const variants = input.variants ?? variantsRead?.matrix;
309
+ const { periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized } = input;
310
+ const dsr = await runValidator(session, "validate_deflated_sharpe", "/api/v1/validate/deflated-sharpe", {
311
+ returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized,
312
+ });
313
+ if (dsr.failed) return validationText(session, { envelope: dsr.envelope, failed: true });
314
+ const derived = dsr.envelope?.data?.derived_inputs ?? {};
315
+ const trackBody = {
316
+ observed_sharpe_annualized: derived.observed_sharpe_annualized,
317
+ periods_per_year,
318
+ skew: derived.skew,
319
+ non_excess_kurtosis: derived.non_excess_kurtosis,
320
+ observations: derived.observations,
321
+ ...(input.benchmark_sharpe_annualized !== undefined ? { benchmark_sharpe_annualized: input.benchmark_sharpe_annualized } : {}),
322
+ ...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
323
+ };
324
+ const track = await runValidator(session, "validate_track_record", "/api/v1/validate/track-record", trackBody);
325
+ const overfit = variants
326
+ ? await runValidator(session, "validate_overfitting", "/api/v1/validate/overfitting", {
327
+ matrix: variants,
328
+ ...(input.n_splits !== undefined ? { n_splits: input.n_splits } : {}),
329
+ })
330
+ : null;
331
+ const shape = (e) => (session.fullEnvelope ? e : compactEnvelope(e));
332
+ const envelopes = { deflated_sharpe: shape(dsr.envelope), track_record: shape(track.envelope), ...(overfit ? { overfitting: shape(overfit.envelope) } : {}) };
333
+ const limits = envelopes.deflated_sharpe?.limits;
334
+ const shared = Object.values(envelopes).every((e) => sameJson(e?.limits, limits));
335
+ const checks = Object.fromEntries(
336
+ Object.entries(envelopes).map(([name, e]) => [name, shared ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== "limits")) : e]),
337
+ );
338
+ const readings = Object.fromEntries(
339
+ Object.entries(envelopes).map(([name, e]) => [name, e?.data?.plain_reading ?? e?.error?.message ?? null]),
340
+ );
341
+ return asText({
342
+ schema: "canli.audit.v1",
343
+ note: "Each check is its validator's own result and receipt, side by side. The audit does not grade the strategy.",
344
+ ...(shared ? { limits } : {}),
345
+ readings,
346
+ checks,
347
+ ...(input.returns_file || input.variants_file
348
+ ? { source: {
349
+ ...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
350
+ ...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
351
+ } }
352
+ : {}),
353
+ ...(variants ? {} : { not_run: { overfitting: "Send variants or variants_file (every variant's returns) to add the overfitting check." } }),
354
+ });
355
+ }
356
+
357
+ // The public keys canlicapital.com signs receipts with, as published at
358
+ // /.well-known/canli-receipt-keys.json when this package was built; a test keeps the two identical.
359
+ const RECEIPT_KEYS = JSON.parse(readFileSync(new URL("./receipt-keys.json", import.meta.url), "utf8"));
360
+
361
+ // verify_receipt: every check is local; only fetching a receipt by id touches the network.
362
+ export async function toolVerifyReceipt(session, args) {
363
+ const input = parseOrThrow(verifyReceiptToolShape, args, "verify_receipt");
364
+ if ((input.id === undefined) === (input.receipt === undefined)) throw new Error("verify_receipt: send exactly one of id or receipt");
365
+ let data = input.receipt;
366
+ if (input.id !== undefined) {
367
+ const response = await callApi(session, { path: `/api/v1/receipts/${input.id}` });
368
+ if (response.failed) return asText(response.envelope, true);
369
+ data = response.envelope?.data;
370
+ }
371
+ const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
372
+ const result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
373
+ return asText({
374
+ receipt_id: data?.id ?? null,
375
+ valid: result.valid,
376
+ checks: result.checks,
377
+ key_id: result.key_id,
378
+ keys: "bundled with this package; published at https://canlicapital.com/.well-known/canli-receipt-keys.json",
379
+ meaning: result.valid
380
+ ? "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."
381
+ : "At least one check failed: do not treat this receipt as issued by canlicapital.com for this content.",
382
+ });
205
383
  }
206
384
 
207
385
  export async function toolGetReceipt(session, args) {
@@ -321,47 +499,74 @@ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, openWorldHint: t
321
499
  const WRITES_RECEIPT = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
322
500
 
323
501
  export function registerTools(server, session) {
324
- server.registerTool(
502
+ const enabled = new Set((session.toolsets ?? Object.keys(TOOLSETS)).flatMap((name) => TOOLSETS[name]));
503
+ const register = (name, ...rest) => { if (enabled.has(name)) server.registerTool(name, ...rest); };
504
+ register(
325
505
  "get_key",
326
506
  { title: "Get a free validation key", annotations: { title: "Get a free validation key", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, description: TOOL_DESCRIPTIONS.get_key, inputSchema: getKeyInput },
327
507
  (args) => toolGetKey(session, args),
328
508
  );
329
- server.registerTool(
509
+ register(
330
510
  "validate_deflated_sharpe",
331
511
  { title: "Validate deflated Sharpe", annotations: { title: "Validate deflated Sharpe", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_deflated_sharpe, inputSchema: deflatedSharpeToolShape },
332
512
  (args) => toolValidateDeflatedSharpe(session, args),
333
513
  );
334
- server.registerTool(
514
+ register(
335
515
  "validate_overfitting",
336
516
  { title: "Validate overfitting (CSCV)", annotations: { title: "Validate overfitting (CSCV)", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_overfitting, inputSchema: overfittingInput },
337
517
  (args) => toolValidateOverfitting(session, args),
338
518
  );
339
- server.registerTool(
519
+ register(
340
520
  "validate_paper_evidence",
341
521
  { title: "Validate paper evidence", annotations: { title: "Validate paper evidence", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_paper_evidence, inputSchema: paperEvidenceInput },
342
522
  (args) => toolValidatePaperEvidence(session, args),
343
523
  );
344
- server.registerTool(
524
+ register(
345
525
  "validate_breadth",
346
526
  { title: "Validate breadth ceiling", annotations: { title: "Validate breadth ceiling", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput },
347
527
  (args) => toolValidateBreadth(session, args),
348
528
  );
349
- server.registerTool(
529
+ register(
350
530
  "validate_track_record",
351
531
  { title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
352
532
  (args) => toolValidateTrackRecord(session, args),
353
533
  );
354
- server.registerTool(
534
+ register(
535
+ "validate_backtest_length",
536
+ { title: "Minimum backtest length", annotations: { title: "Minimum backtest length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_backtest_length, inputSchema: backtestLengthInput },
537
+ (args) => toolValidateBacktestLength(session, args),
538
+ );
539
+ register(
540
+ "validate_haircut_sharpe",
541
+ { title: "Haircut Sharpe ratio", annotations: { title: "Haircut Sharpe ratio", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_haircut_sharpe, inputSchema: haircutSharpeInput },
542
+ (args) => toolValidateHaircutSharpe(session, args),
543
+ );
544
+ register(
545
+ "validate_luck_trials",
546
+ { title: "Luck-equivalent trials", annotations: { title: "Luck-equivalent trials", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_luck_trials, inputSchema: luckTrialsInput },
547
+ (args) => toolValidateLuckTrials(session, args),
548
+ );
549
+ register(
550
+ "audit_backtest",
551
+ { title: "Audit a backtest", annotations: { title: "Audit a backtest", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.audit_backtest, inputSchema: auditBacktestToolShape },
552
+ (args) => toolAuditBacktest(session, args),
553
+ );
554
+ register(
355
555
  "get_receipt",
356
556
  { title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
357
557
  (args) => toolGetReceipt(session, args),
358
558
  );
359
- server.registerTool(
559
+ register(
560
+ "verify_receipt",
561
+ { title: "Verify a receipt", annotations: { title: "Verify a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.verify_receipt, inputSchema: verifyReceiptToolShape },
562
+ (args) => toolVerifyReceipt(session, args),
563
+ );
564
+ register(
360
565
  "service_status",
361
566
  { title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
362
567
  () => toolServiceStatus(session),
363
568
  );
364
- server.registerTool(
569
+ register(
365
570
  "company_financial_history",
366
571
  { title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryToolShape },
367
572
  (args) => toolCompanyFinancialHistory(session, args),
@@ -440,6 +645,9 @@ export const RESOURCE_TEXT = Object.freeze({
440
645
  "",
441
646
  "- 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
647
  "- 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.",
648
+ "- 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.",
649
+ "- 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.",
650
+ "- 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
651
  "- 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
652
  "- Source code: https://github.com/arhancanli/canli-validation-mcp and https://github.com/arhancanli/canlicapital",
445
653
  ].join("\n"),