canli-quant-mcp 0.1.0 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,7 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3
4
+
5
+ - zod 4.6.7 (a high advisory in earlier zod: memory exhaustion on oversized constrained collections). No code change.
6
+
3
7
  ## Unreleased
4
8
 
9
+ ## 0.1.2
10
+
11
+ This cumulative npm candidate includes the statistical and input corrections recorded under
12
+ 0.1.1 below. The 0.1.1 source archive stays available; it will not be published separately to npm.
13
+
14
+ - Non-finite computed results now return a tool error naming their fields and values before JSON
15
+ serialization or success receipt creation. Previously NaN and Infinity appeared as null with no
16
+ diagnostic. Both discovery and directly listed tools use the same result check. Ordinary finite
17
+ answers and legitimate null warm-up positions retain their existing representation.
18
+
19
+ ## 0.1.1 (signed source release; not published to npm)
20
+
21
+ - Fix: an asset whose returns never change made `covariance_shrinkage`, `correlation_matrix` and
22
+ the portfolio optimizers (`max_sharpe_portfolio`, `min_variance_portfolio` and the others built
23
+ on the same estimates) answer with NaN. They now refuse and name the asset ("CASH has no
24
+ variance").
25
+ - Fix: `put_call_parity` gave NaN for `implied_carry` when the quotes put the implied forward at or
26
+ below zero. It now gives null with a note to check the quotes for staleness. Both were found by
27
+ canli-mcp's `bench/robustness.mjs`.
28
+ - Fix: `digits` also rounded whole numbers, so `digits: 4` turned 227,917,808 into 227,900,000.
29
+ It now trims fractions only; whole numbers and the integer part of large values stay exact.
30
+
31
+
32
+ - `deflated_sharpe_ratio` and `probabilistic_sharpe_ratio` also take summary numbers (`sharpe_annual`,
33
+ `observations`, `skew`, `kurtosis`) and refuse fewer than 20 returns or zero variance, instead of
34
+ returning a probability from too little data. Found by watching a model call them in an agent
35
+ evaluation.
36
+ - `sleeve_tournament`: Hansen's SPA and Romano-Wolf StepM are now re-studentized in every resample,
37
+ the default mean block length is round(n^(1/3)) instead of floor(sqrt(n)), and White's Reality
38
+ Check is reported alongside. The 0.1.0 version held one variance estimate fixed across resamples,
39
+ which the Null Zoo benchmark measured rejecting 13.5% of skill-less AR(1) searches at a nominal
40
+ 5% with long blocks; on our planted-trend test data it kept 24 sleeves where the corrected test
41
+ keeps 4.
42
+
43
+ - `sleeve_tournament` verdict: no longer calls the effective-trials deflation "the fairer one"; on
44
+ data with no edge it nearly passes a long-only winner. The verdict now says the raw count is the
45
+ conservative bound and that deflation (Sharpe above zero) and SPA/StepM (beats the benchmark)
46
+ answer different questions.
47
+
5
48
  ## 0.1.0
6
49
 
7
50
  First release: 235 tools in 18 toolsets (performance, options, exotics, rates, tvm, valuation,
@@ -23,7 +66,7 @@ sleeves, data_checks, labeling), reached through three discovery tools (`find_to
23
66
  meta, fixed-horizon and trend-scanning labels, sample weights, and purged, embargoed CV splits.
24
67
  - Privacy enforced by tests: no network, disk writes, logging, processes or workers; runs under
25
68
  Node's permission model.
26
- - 461 reference cases against QuantLib, statsmodels, arch, TA-Lib, numpy-financial, scikit-learn,
69
+ - 465 reference cases against QuantLib, statsmodels, arch, TA-Lib, numpy-financial, scikit-learn,
27
70
  empyrical, scipy, pandas and dcor; planted-defect tests for the data checks; no-lookahead and warm-up
28
71
  checks on every sleeve.
29
72
  - Eleven workflow prompts, six resources, and opt-in calculation receipts.
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # canli-quant-mcp
2
2
 
3
+ Computed NaN and Infinity values are refused with a tool error naming the affected fields.
4
+ They do not become unexplained nulls or receive a success receipt. Check the input scale
5
+ and the calculation's domain. Ordinary finite answers and null warm-up positions are preserved.
6
+
3
7
  235 quant finance tools in one MCP server: performance and risk, options and exotics, fixed income,
4
8
  portfolio construction, econometrics, technical indicators, execution, sizing, crypto and FX,
5
9
  valuation, strategy backtests, a library of 399 strategy sleeves, data checks, and data annotation
6
10
  and ML labeling. Every tool
7
11
  computes on the data you send, and every tool's results are checked in the test suite against an
8
12
  independent implementation (QuantLib, statsmodels, arch, TA-Lib, numpy-financial, scikit-learn,
9
- empyrical, scipy, pandas, dcor): 461 reference cases, plus planted-defect tests for the data checks.
13
+ empyrical, scipy, pandas, dcor): 465 reference cases, plus planted-defect tests for the data checks.
10
14
 
11
15
  Local stdio, no key, no account, no network calls: your data never leaves your machine (see
12
16
  [Privacy](#privacy), enforced by tests).
@@ -80,7 +84,7 @@ Then ask, for example:
80
84
  | `sizing` | 7 | scipy and closed forms |
81
85
  | `crypto_fx` | 9 | closed forms and simulated pool reserves |
82
86
  | `strategies` | 20 | an independent pandas implementation of every recipe and of the costed engine |
83
- | `sleeves` | 8 | an independent numpy and pandas port of every recipe and the engine; arch's SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve |
87
+ | `sleeves` | 8 | an independent numpy and pandas port of every recipe and the engine; arch's Reality Check and an independent re-studentized SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve |
84
88
  | `data_checks` | 10 | planted-defect tests: each check must find what was planted and stay quiet on clean data |
85
89
  | `labeling` | 7 | pandas ports of the snippets in Advances in Financial Machine Learning (ewm volatility, CUSUM filter, triple barrier, uniqueness, purged k-fold) and statsmodels OLS t-values |
86
90
  | `tvm` | 15 | numpy-financial and closed forms |
@@ -111,14 +115,14 @@ sleeves at once correct for it:
111
115
 
112
116
  | tool | what it answers |
113
117
  |---|---|
114
- | `sleeve_tournament` | Which sleeves lead on your prices, and does the best survive the deflated Sharpe ratio (counting all sleeves and the effective number of independent ones), Hansen's SPA test, Romano-Wolf StepM and the probability of backtest overfitting (CSCV)? |
118
+ | `sleeve_tournament` | Which sleeves lead on your prices, and does the best survive the deflated Sharpe ratio (counting all sleeves and the effective number of independent ones), White's Reality Check, Hansen's SPA test and Romano-Wolf StepM (re-studentized in every resample) and the probability of backtest overfitting (CSCV)? |
115
119
  | `sleeve_walk_forward` | If you had kept picking the top sleeves by trailing Sharpe, what would you have earned out of sample, and how much of their in-sample Sharpe was luck? |
116
120
  | `sleeve_clusters` | How many genuinely different bets do the sleeves make on this data? |
117
121
  | `sleeve_regime_map` | Which sleeves held up in calm, turbulent, rising and falling markets? |
118
122
  | `combine_sleeves` | What does a book of chosen sleeves look like, and what asset weights does it want as of the latest close? |
119
123
 
120
124
  The tests check that no sleeve looks ahead (rewriting future prices never changes an earlier
121
- target), that none trades before its warm-up, and that the tournament's statistics match arch's SPA
125
+ target), that none trades before its warm-up, and that the tournament's statistics match arch's Reality Check and an independent re-studentized SPA
122
126
  and StepM on the same bootstrap draws, scipy's clustering and an independent numpy CSCV.
123
127
  `run_sleeve` and `combine_sleeves` return `target_weights` by symbol, which a paper broker such as
124
128
  canli-paper-trading-mcp can rebalance to.
@@ -457,7 +461,7 @@ randomness is the sleeve bootstrap, drawn from a stated seed.
457
461
  | `list_sleeves` | Browse the library of 399 pre-defined strategy sleeves (18 families: trend, momentum, reversion, volatility, allocation, pairs). Each is a fixed rule with a spec hash; filter by family, data shape or words. |
458
462
  | `describe_sleeve` | One sleeve's exact rule, parameters, warm-up, rationale, published references, known risks and spec SHA-256. |
459
463
  | `run_sleeve` | Backtest one library sleeve on your prices with costs and no lookahead: CAGR, Sharpe, drawdown, turnover, cost drag, and the target weights as of the latest close (ready for a paper rebalance). |
460
- | `sleeve_tournament` | Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV). |
464
+ | `sleeve_tournament` | Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), White's Reality Check, re-studentized Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV). |
461
465
  | `sleeve_walk_forward` | Test the selection process itself: at each refit pick the top sleeves by trailing Sharpe, hold them for the next window, repeat. Compares the out-of-sample result with what the picks showed in sample, with holding every sleeve, and with the benchmark. |
462
466
  | `combine_sleeves` | Blend chosen sleeves (equal or trailing inverse-volatility weights, no lookahead) into one book: its statistics, each sleeve's, their correlations, the diversification ratio, and the book's combined asset target weights as of the latest close. |
463
467
  | `sleeve_clusters` | Group the sleeves that ran on your prices by return correlation (average linkage on sqrt((1 - rho) / 2)), to see how many genuinely different bets the library makes on this data and the best sleeve of each group. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-quant-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "235 quant finance tools and a library of 399 strategy sleeves as one MCP server: performance and risk, options and exotics, fixed income, portfolio construction, econometrics (regime switching, Johansen, CUSUM, HAR), indicators, execution, crypto and FX, valuation, backtests with multiple-testing corrections (deflated Sharpe, SPA, StepM, PBO), data annotation and ML labels (triple barrier, purged CV) and data checks, each checked against QuantLib, statsmodels, arch, TA-Lib or numpy. Keyless and local with no network, disk writes or logging; three tools in context by default.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@modelcontextprotocol/server": "2.3.1",
25
- "zod": "4.6.5"
25
+ "zod": "4.6.7"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@modelcontextprotocol/client": "2.3.1"
package/src/math.mjs CHANGED
@@ -221,19 +221,34 @@ export function olsSimple(x, y) {
221
221
  export function sig(x, digits = 10) {
222
222
  if (typeof x !== "number" || !Number.isFinite(x)) return x === undefined ? null : Number.isNaN(x) ? null : x;
223
223
  if (x === 0) return 0;
224
+ // Whole numbers (counts, observations, share amounts) are never changed, nor the integer part of
225
+ // a large value: digits trims fractions only.
226
+ if (Number.isInteger(x)) return x;
227
+ if (Math.abs(x) >= 10 ** digits) return Math.round(x);
224
228
  return Number(x.toPrecision(digits));
225
229
  }
226
230
 
227
231
  // Rounds every number in a plain result tree.
228
- export function compact(v, digits = 10) {
229
- if (typeof v === "number") return sig(v, digits);
230
- if (Array.isArray(v)) return v.map((x) => compact(x, digits));
231
- if (v && typeof v === "object" && !(v instanceof Date)) {
232
- const out = {};
233
- for (const [k, x] of Object.entries(v)) if (x !== undefined) out[k] = compact(x, digits);
232
+ export function compact(v, digits = 10, onNonFinite) {
233
+ const path = [];
234
+ const child = (key, value) => {
235
+ if (onNonFinite) path.push(key);
236
+ const out = visit(value);
237
+ if (onNonFinite) path.pop();
234
238
  return out;
239
+ };
240
+ const visit = (value) => {
241
+ if (typeof value === "number") {
242
+ if (onNonFinite && !Number.isFinite(value)) onNonFinite(value, path);
243
+ return sig(value, digits);
244
+ }
245
+ if (Array.isArray(value)) return value.map((x, i) => child(i, x));
246
+ if (value && typeof value === "object" && !(value instanceof Date)) {
247
+ return Object.fromEntries(Object.entries(value).filter(([, x]) => x !== undefined).map(([k, x]) => [k, child(k, x)]));
248
+ }
249
+ return value;
235
250
  }
236
- return v;
251
+ return visit(v);
237
252
  }
238
253
 
239
254
  // ---------------------------------------------------------------------------------------------
package/src/registry.mjs CHANGED
@@ -60,7 +60,19 @@ export function runTool(name, args, { digits = 10 } = {}) {
60
60
  const issues = parsed.error.issues.slice(0, 5).map((i) => `${i.path.join(".") || "arguments"}: ${i.message}`).join("; ");
61
61
  throw new Error(`${name}: ${issues}. describe_tool ${name} gives the exact input schema.`);
62
62
  }
63
- return compact(tool.run(parsed.data), digits);
63
+ let nonFiniteCount = 0;
64
+ const examples = [];
65
+ const result = compact(tool.run(parsed.data), digits, (value, path) => {
66
+ nonFiniteCount++;
67
+ if (examples.length < 5) {
68
+ const pointer = path.map((key) => String(key).replace(/~/g, "~0").replace(/\//g, "~1")).join("/");
69
+ examples.push(`${pointer ? "/" + pointer.slice(0, 160) : "root"} (${String(value)})`);
70
+ }
71
+ });
72
+ if (nonFiniteCount) {
73
+ throw new Error(`${name}: calculation returned ${nonFiniteCount} non-finite value(s): ${examples.join(", ")}${nonFiniteCount > examples.length ? ", ..." : ""}. JSON cannot represent this numerical result. Check input scale and the tool's mathematical domain.`);
74
+ }
75
+ return result;
64
76
  }
65
77
 
66
78
  // Search: BM25 over each tool's name, title, keywords and description (field-weighted), with light
package/src/server.mjs CHANGED
@@ -50,7 +50,7 @@ export const METHODS = Object.freeze({
50
50
  sizing: "scipy and closed forms",
51
51
  crypto_fx: "closed forms and simulated pool reserves",
52
52
  strategies: "an independent pandas implementation of every recipe and of the costed engine",
53
- sleeves: "an independent numpy and pandas port of every recipe and the engine; arch's SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve",
53
+ sleeves: "an independent numpy and pandas port of every recipe and the engine; arch's Reality Check and an independent re-studentized SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve",
54
54
  data_checks: "planted-defect tests: each check must find what was planted and stay quiet on clean data",
55
55
  labeling: "pandas ports of the snippets in Advances in Financial Machine Learning (ewm volatility, CUSUM filter, triple barrier, uniqueness, purged k-fold) and statsmodels OLS t-values",
56
56
  });
@@ -130,7 +130,7 @@ export function registerAll(server, { toolsets = selectedToolsets() } = {}) {
130
130
  name: z.string().max(100).describe("Tool name from find_tool."),
131
131
  arguments: z.record(z.string(), z.unknown()).optional().describe("The tool's arguments, as describe_tool gives them."),
132
132
  receipt: z.boolean().optional().describe("Add a calculation receipt (input and output SHA-256) so the result can be independently recomputed and compared; default false."),
133
- digits: z.number().int().min(3).max(10).optional().describe("Significant figures in the result; default 10. 4-6 cuts output tokens when full precision is not needed."),
133
+ digits: z.number().int().min(3).max(10).optional().describe("Significant figures for fractional numbers in the result; default 10. Whole numbers are never changed. 4-6 cuts output tokens when full precision is not needed."),
134
134
  }).strict(),
135
135
  outputSchema: open,
136
136
  }, guard(({ name, arguments: args, receipt, digits }) => {
@@ -222,9 +222,29 @@ function garchFit(r) {
222
222
  }
223
223
 
224
224
  function trialSharpeStats(r) {
225
- const n = r.length, sr = mean(r) / std(r), m = moments(r);
225
+ if (r.length < 20) throw new Error(`Only ${r.length} returns: a Sharpe ratio's probability needs at least 20. With summary numbers instead, send sharpe_annual and observations (plus skew and kurtosis if known).`);
226
+ const sd = std(r);
227
+ if (!(sd > 0)) throw new Error("The returns have zero variance, so the Sharpe ratio is undefined.");
228
+ const n = r.length, sr = mean(r) / sd, m = moments(r);
226
229
  return { n, sr, skew: m.skew, kurt: m.excess_kurtosis + 3 };
227
230
  }
231
+
232
+ // The same statistics from a series, or from summary numbers when no series is at hand.
233
+ const summaryFields = {
234
+ sharpe_annual: z.number().min(-20).max(20).optional().describe("Observed annualized Sharpe ratio, instead of returns or prices."),
235
+ observations: z.number().int().min(20).max(1e8).optional().describe("Number of return observations behind sharpe_annual."),
236
+ skew: z.number().min(-50).max(50).optional().describe("Skewness of the returns with sharpe_annual; default 0."),
237
+ kurtosis: z.number().min(1).max(1000).optional().describe("Kurtosis (not excess; 3 if normal) with sharpe_annual; default 3."),
238
+ };
239
+ function sharpeStats(a, ppy) {
240
+ if (a.sharpe_annual === undefined) {
241
+ if (a.observations !== undefined || a.skew !== undefined || a.kurtosis !== undefined) throw new Error("observations, skew and kurtosis go with sharpe_annual.");
242
+ return trialSharpeStats(seriesFrom(a));
243
+ }
244
+ if (a.returns !== undefined || a.prices !== undefined) throw new Error("Send either sharpe_annual with observations, or returns/prices, not both.");
245
+ if (a.observations === undefined) throw new Error("sharpe_annual needs observations (the number of returns behind it).");
246
+ return { n: a.observations, sr: a.sharpe_annual / Math.sqrt(ppy), skew: a.skew ?? 0, kurt: a.kurtosis ?? 3 };
247
+ }
228
248
  const psr = ({ n, sr, skew, kurt }, srStar) => normCdf((sr - srStar) * Math.sqrt(n - 1) / Math.sqrt(1 - skew * sr + (kurt - 1) / 4 * sr * sr));
229
249
  const EULER = 0.5772156649015329;
230
250
 
@@ -405,11 +425,11 @@ export const TOOLS = [
405
425
  {
406
426
  name: "probabilistic_sharpe_ratio",
407
427
  title: "Probabilistic Sharpe ratio",
408
- description: "Compute the probability that the true Sharpe ratio exceeds a benchmark given the track record's length, skewness and kurtosis (Bailey and López de Prado), and the minimum track record length needed.",
409
- keywords: "probabilistic sharpe ratio psr minimum track record length mintrl skewness kurtosis confidence",
410
- input: z.object({ ...seriesFields, benchmark_sharpe: z.number().min(-10).max(10).optional().describe("Annual Sharpe to beat; default 0."), confidence: z.number().gt(0.5).lt(1).optional().describe("For the minimum track record; default 0.95.") }).strict(),
428
+ description: "Compute the probability that the true Sharpe ratio exceeds a benchmark given the track record's length, skewness and kurtosis (Bailey and López de Prado), and the minimum track record length needed. Takes returns or prices, or just the observed Sharpe and the number of observations.",
429
+ keywords: "probabilistic sharpe ratio psr probability sharpe real genuine skill significant minimum track record length mintrl skewness kurtosis confidence",
430
+ input: z.object({ ...seriesFields, ...summaryFields, benchmark_sharpe: z.number().min(-10).max(10).optional().describe("Annual Sharpe to beat; default 0."), confidence: z.number().gt(0.5).lt(1).optional().describe("For the minimum track record; default 0.95.") }).strict(),
411
431
  run(a) {
412
- const r = seriesFrom(a), ppy = a.periods_per_year ?? 252, st = trialSharpeStats(r), bench = (a.benchmark_sharpe ?? 0) / Math.sqrt(ppy);
432
+ const ppy = a.periods_per_year ?? 252, st = sharpeStats(a, ppy), bench = (a.benchmark_sharpe ?? 0) / Math.sqrt(ppy);
413
433
  const zc = normInv(a.confidence ?? 0.95), varTerm = 1 - st.skew * st.sr + (st.kurt - 1) / 4 * st.sr * st.sr;
414
434
  const mtrl = st.sr > bench ? 1 + varTerm * (zc / (st.sr - bench)) ** 2 : null;
415
435
  return { psr: psr(st, bench), sharpe_annual: st.sr * Math.sqrt(ppy), periods: st.n, min_track_record_periods: mtrl, min_track_record_years: mtrl === null ? null : mtrl / ppy, skew: st.skew, kurtosis: st.kurt };
@@ -418,16 +438,16 @@ export const TOOLS = [
418
438
  {
419
439
  name: "deflated_sharpe_ratio",
420
440
  title: "Deflated Sharpe ratio",
421
- description: "Deflate a backtest's Sharpe ratio for the number of strategies tried (Bailey and López de Prado 2014): the expected maximum Sharpe under the null and the probability the result beats it.",
441
+ description: "Deflate a backtest's Sharpe ratio for the number of strategies tried (Bailey and López de Prado 2014): the expected maximum Sharpe under the null and the probability the result beats it. Takes returns or prices, or just the observed Sharpe and the number of observations, with the trials' Sharpe variance or Sharpes.",
422
442
  keywords: "overfit overfitting variants trials tried multiple testing luck deflated sharpe ratio dsr multiple testing selection bias backtest overfitting trials data snooping",
423
443
  input: z.object({
424
- ...seriesFields,
444
+ ...seriesFields, ...summaryFields,
425
445
  trials: z.number().int().min(1).max(1e9).describe("How many strategy variants were tried, including this one."),
426
446
  trial_sharpes: z.array(z.number()).min(2).max(100000).optional().describe("Annual Sharpe ratios of all trials, to estimate their variance."),
427
- trial_sharpe_variance: z.number().positive().optional().describe("Or the variance of the trials' annual Sharpe ratios directly."),
447
+ trial_sharpe_variance: z.number().positive().optional().describe("Or the variance of the trials' annual Sharpe ratios directly (the square of their standard deviation)."),
428
448
  }).strict(),
429
449
  run(a) {
430
- const r = seriesFrom(a), ppy = a.periods_per_year ?? 252, st = trialSharpeStats(r);
450
+ const ppy = a.periods_per_year ?? 252, st = sharpeStats(a, ppy);
431
451
  let v = a.trial_sharpe_variance;
432
452
  if (v === undefined) { if (!a.trial_sharpes) throw new Error("Send trial_sharpes or trial_sharpe_variance."); v = variance(a.trial_sharpes); }
433
453
  const vPer = v / ppy, N = a.trials;
@@ -187,7 +187,8 @@ export const TOOLS = [
187
187
  const dr = Math.exp(-(a.rate ?? 0) * a.years), dq = Math.exp(-(a.dividend_yield ?? 0) * a.years);
188
188
  const gap = a.call - a.put - (a.spot * dq - a.strike * dr);
189
189
  const fwd = (a.call - a.put) / dr + a.strike;
190
- return { parity_gap: gap, implied_forward: fwd, implied_carry: Math.log(fwd / a.spot) / a.years, meaning: "parity_gap = C - P - (S e^-qT - K e^-rT); positive means the call is rich relative to the put." };
190
+ // A forward at or below zero means the quotes break parity's bounds; no carry implies it.
191
+ return { parity_gap: gap, implied_forward: fwd, implied_carry: fwd > 0 ? Math.log(fwd / a.spot) / a.years : null, ...(fwd > 0 ? {} : { implied_carry_note: "The implied forward is not positive, so the quotes cannot be consistent with any carry; check them for staleness." }), meaning: "parity_gap = C - P - (S e^-qT - K e^-rT); positive means the call is rich relative to the put." };
191
192
  },
192
193
  },
193
194
  {
@@ -51,7 +51,11 @@ export function estimates(a, { needMu = false } = {}) {
51
51
  if (a.expected_returns) { if (a.expected_returns.length !== C.length) throw new Error(`expected_returns has ${a.expected_returns.length} entries for ${C.length} assets.`); mu = a.expected_returns; }
52
52
  if (needMu && !mu) throw new Error("Send expected_returns with a covariance matrix.");
53
53
  if (a.assets && a.assets.length !== C.length) throw new Error(`assets has ${a.assets.length} names for ${C.length} assets.`);
54
- return { C, mu, cols, n: C.length, names: a.assets ?? C.map((_, i) => `asset_${i + 1}`) };
54
+ const names = a.assets ?? C.map((_, i) => `asset_${i + 1}`);
55
+ // An asset that never moves has no risk to weigh: every optimizer here divides by it.
56
+ const flat = C.findIndex((row, i) => !(row[i] > 0));
57
+ if (flat >= 0) throw new Error(`${names[flat]} has no variance (its returns never change), so risk-based weights are undefined. Drop it or check the data.`);
58
+ return { C, mu, cols, n: C.length, names };
55
59
  }
56
60
 
57
61
  const label = (names, w) => Object.fromEntries(names.map((n, i) => [n, w[i]]));
@@ -382,6 +386,8 @@ export const TOOLS = [
382
386
  input: z.object({ returns: sources.returns.unwrap(), periods_per_year: ppyArg }).strict(),
383
387
  run(a) {
384
388
  const R = a.returns, p = R[0].length, cols = Array.from({ length: p }, (_, j) => R.map((r) => r[j])), ppy = a.periods_per_year ?? 252;
389
+ const flat = cols.findIndex((c) => !(cov2(c, c, 0) > 0));
390
+ if (flat >= 0) throw new Error(`asset_${flat + 1} has no variance (its returns never change), so there is no covariance to shrink. Drop it or check the data.`);
385
391
  const lw = ledoitWolf(cols), cond = (M) => { const e = symmetricEigen(M).values; return e[0] / e.at(-1); };
386
392
  const sample = cols.map((x) => cols.map((y) => cov2(x, y, 0)));
387
393
  return { shrinkage: lw.shrinkage, covariance_annual: lw.matrix.map((r) => r.map((v) => v * ppy)), covariance_per_period: lw.matrix, condition_number_sample: cond(sample), condition_number_shrunk: cond(lw.matrix) };
@@ -398,6 +404,8 @@ export const TOOLS = [
398
404
  let cols = Array.from({ length: p }, (_, j) => R.map((r) => r[j]));
399
405
  if (a.method === "spearman") cols = cols.map(rank);
400
406
  const sd = cols.map((c) => std(c));
407
+ const flat = sd.findIndex((v) => !(v > 0));
408
+ if (flat >= 0) throw new Error(`${(a.assets ?? [])[flat] ?? `asset_${flat + 1}`} has no variance (its returns never change), so its correlations are undefined. Drop it or check the data.`);
401
409
  const M = cols.map((x, i) => cols.map((y, j) => (i === j ? 1 : cov2(x, y) / (sd[i] * sd[j]))));
402
410
  let s = 0; for (let i = 0; i < p; i++) for (let j = i + 1; j < p; j++) s += M[i][j];
403
411
  const ev = symmetricEigen(M).values;
@@ -8,14 +8,17 @@
8
8
  // Multiple-testing machinery (all deterministic for a given seed):
9
9
  // - Deflated Sharpe ratio for the best sleeve, with the raw count of sleeves and with the effective
10
10
  // number of independent sleeves (K^2 / sum of squared pairwise correlations).
11
- // - Hansen's SPA test against a benchmark, studentized with the stationary-bootstrap variance and
12
- // computed the way the arch package does (lower, consistent and upper p-values), plus Romano-Wolf
13
- // StepM for the set of sleeves that beat the benchmark with family-wise error control.
11
+ // - White's Reality Check against a benchmark (unstudentized, as arch computes it), and Hansen's SPA
12
+ // test and Romano-Wolf StepM re-studentized in every resample: each bootstrap mean is divided by
13
+ // that resample's own standard deviation (Romano and Wolf 2005). The Null Zoo benchmark measured
14
+ // the fixed-variance studentization, which reuses one variance estimate, rejecting 9.4% of
15
+ // skill-less searches at block length 8 and 13.5% at 22 under AR(1) at a nominal 5%;
16
+ // re-studentizing brought it to 6.6%. The default mean block length is round(n^(1/3)).
14
17
  // - Probability of backtest overfitting by combinatorially symmetric cross-validation (CSCV).
15
18
  // The bootstrap draws come from mulberry32(seed), so anyone can regenerate them.
16
19
  import { z } from "zod";
17
20
 
18
- import { correlation, mean, moments, mulberry32, normCdf, normInv, lagProducts, quantile, std, variance } from "../math.mjs";
21
+ import { correlation, mean, moments, mulberry32, normCdf, normInv, quantile, std, variance } from "../math.mjs";
19
22
  import { MAX_SERIES, ppyArg } from "../inputs.mjs";
20
23
  import { FAMILY_INFO, SLEEVES, SLEEVE_BY_ID } from "../sleeves.mjs";
21
24
  import { RECIPES, runEngine, statsOf } from "./strategies.mjs";
@@ -142,17 +145,6 @@ function effectiveTrials(R, C) {
142
145
  return (K * K) / s2;
143
146
  }
144
147
 
145
- // Stationary-bootstrap long-run variance of each column the way arch's SPA computes it.
146
- function spaVariance(col, block) {
147
- const t = col.length, m = mean(col), d = col.map((v) => v - m), p = 1 / block, s = lagProducts(d);
148
- let v = s[0] / t;
149
- for (let i = 1; i < t; i++) {
150
- const kappa = (1 - i / t) * (1 - p) ** i + (i / t) * (1 - p) ** (t - i);
151
- v += 2 * kappa * s[i] / t;
152
- }
153
- return v;
154
- }
155
-
156
148
  // Stationary bootstrap (Politis and Romano 1994) as a list of [start, length] runs over n positions,
157
149
  // drawn from mulberry32(seed): at each step one uniform decides a new block (probability 1/block),
158
150
  // and a second picks its start.
@@ -177,43 +169,46 @@ const runSum = (S, n, start, len) => {
177
169
  return total;
178
170
  };
179
171
 
180
- // Hansen SPA (studentized) and Romano-Wolf StepM on loss differentials d[k] = sleeve - benchmark.
181
- function spaStepM(R, B, { reps, block, seed, size }) {
182
- const n = B.length;
183
- const cols = [], keep = [];
184
- R.forEach((x, k) => { const d = x.map((v, t) => v - B[t]); const om = Math.sqrt(spaVariance(d, block)); if (om > 0 && std(d) > 0) { cols.push(d.map((v) => v / om)); keep.push(k); } });
172
+ // Joint tests on d[k] = sleeve - benchmark, with one stationary bootstrap shared by all sleeves.
173
+ function jointTests(R, B, { reps, block, seed, size }) {
174
+ const n = B.length, cols = [], keep = [];
175
+ R.forEach((x, k) => { const d = x.map((v, t) => v - B[t]); if (std(d) > 0) { cols.push(d); keep.push(k); } });
185
176
  const K = cols.length;
186
177
  if (K === 0) return null;
187
- const means = cols.map(mean), thresh = -Math.sqrt((1 / n) * 2 * Math.log(Math.log(n)));
188
- const centers = [means.map((m) => Math.max(m, 0)), means.map((m) => (m >= thresh ? m : 0)), means];
189
- const S = cols.map((c) => { const s = new Float64Array(n + 1); for (let t = 0; t < n; t++) s[t + 1] = s[t] + c[t]; return s; });
190
- const rand = mulberry32(seed);
191
- const sims = [0, 1, 2].map(() => Array.from({ length: K }, () => new Float64Array(reps)));
178
+ const m = cols.map(mean), sd = cols.map((c) => std(c)), rn = Math.sqrt(n), tObs = m.map((v, k) => rn * v / sd[k]);
179
+ const thr = sd.map((v) => -v * Math.sqrt(2 * Math.log(Math.log(n)) / n));
180
+ const centers = [m.map((v) => Math.max(v, 0)), m.map((v, k) => (v >= thr[k] ? v : 0)), m];
181
+ const pref = (c, sq) => { const s = new Float64Array(n + 1); for (let t = 0; t < n; t++) s[t + 1] = s[t] + (sq ? c[t] * c[t] : c[t]); return s; };
182
+ const S1 = cols.map((c) => pref(c, false)), S2 = cols.map((c) => pref(c, true));
183
+ const rand = mulberry32(seed), rc = new Float64Array(reps).fill(-Infinity), spa = [0, 1, 2].map(() => new Float64Array(reps).fill(0));
184
+ const tStar = Array.from({ length: K }, () => new Float64Array(reps));
192
185
  for (let r = 0; r < reps; r++) {
193
186
  const runs = bootstrapRuns(n, block, rand);
194
187
  for (let k = 0; k < K; k++) {
195
- let tot = 0;
196
- for (const [st, len] of runs) tot += runSum(S[k], n, st, len);
197
- const mb = tot / n;
198
- for (let j = 0; j < 3; j++) sims[j][k][r] = mb - centers[j][k];
188
+ let s1 = 0, s2 = 0;
189
+ for (const [st, len] of runs) { s1 += runSum(S1[k], n, st, len); s2 += runSum(S2[k], n, st, len); }
190
+ const mb = s1 / n, sdb = Math.sqrt(Math.max(0, (s2 - s1 * s1 / n) / (n - 1)));
191
+ if (mb - m[k] > rc[r]) rc[r] = mb - m[k];
192
+ for (let j = 0; j < 3; j++) { const v = sdb > 0 ? rn * (mb - centers[j][k]) / sdb : 0; if (v > spa[j][r]) spa[j][r] = v; }
193
+ tStar[k][r] = sdb > 0 ? rn * (mb - m[k]) / sdb : -Infinity;
199
194
  }
200
195
  }
201
- const maxOver = (j, active) => { const out = new Float64Array(reps).fill(-Infinity); for (const k of active) { const s = sims[j][k]; for (let r = 0; r < reps; r++) if (s[r] > out[r]) out[r] = s[r]; } return out; };
202
- const all = [...Array(K).keys()], observed = Math.max(...means);
203
- const pv = [0, 1, 2].map((j) => { const mx = maxOver(j, all); let c = 0; for (let r = 0; r < reps; r++) if (mx[r] > observed) c++; return c / reps; });
204
- // StepM: reject every sleeve whose studentized mean beats the consistent critical value, drop
205
- // them, and repeat on the rest until nothing new is rejected.
196
+ const obsRC = Math.max(...m), obsSPA = Math.max(0, ...tObs);
197
+ let rcCount = 0; for (let r = 0; r < reps; r++) if (rc[r] > obsRC) rcCount++;
198
+ const pv = spa.map((sims) => { let c = 0; for (let r = 0; r < reps; r++) if (sims[r] >= obsSPA) c++; return c / reps; });
199
+ // StepM: reject every sleeve whose t-statistic beats the (1 - size) quantile of the largest
200
+ // re-studentized resample among those not yet rejected; repeat until nothing new is rejected.
206
201
  const superior = [];
207
- let active = all;
208
- for (;;) {
209
- const crit = quantile(Float64Array.from(maxOver(1, active)).sort(), 1 - size);
210
- const better = active.filter((k) => means[k] > crit);
211
- if (better.length === 0) break;
202
+ let active = [...Array(K).keys()];
203
+ while (active.length) {
204
+ const mx = new Float64Array(reps).fill(-Infinity);
205
+ for (const k of active) for (let r = 0; r < reps; r++) if (tStar[k][r] > mx[r]) mx[r] = tStar[k][r];
206
+ const crit = quantile(mx.sort(), 1 - size), better = active.filter((k) => tObs[k] > crit);
207
+ if (!better.length) break;
212
208
  superior.push(...better);
213
209
  active = active.filter((k) => !better.includes(k));
214
- if (active.length === 0) break;
215
210
  }
216
- return { tested: K, statistic: observed * Math.sqrt(n), p_values: { lower: pv[0], consistent: pv[1], upper: pv[2] }, superior: superior.map((k) => keep[k]).sort((x, y) => x - y) };
211
+ return { tested: K, statistic: obsSPA, p_values: { lower: pv[0], consistent: pv[1], upper: pv[2] }, reality_check: rcCount / reps, superior: superior.map((k) => keep[k]).sort((x, y) => x - y) };
217
212
  }
218
213
 
219
214
  // Combinatorially symmetric cross-validation (Bailey, Borwein, Lopez de Prado and Zhu 2017).
@@ -315,13 +310,13 @@ export const TOOLS = [
315
310
  {
316
311
  name: "sleeve_tournament",
317
312
  title: "Run every sleeve, corrected for multiple testing",
318
- description: "Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV).",
313
+ description: "Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), White's Reality Check, re-studentized Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV).",
319
314
  keywords: "which work survive best all sleeve tournament run all strategies leaderboard multiple testing spa reality check stepm romano wolf pbo cscv deflated sharpe data snooping",
320
315
  input: z.object({
321
316
  prices: pricesIn, ...engineArgs, ...selectArgs,
322
317
  benchmark: z.enum(["buy_and_hold", "cash"]).optional().describe("What a sleeve must beat: equal-weight buy and hold of the columns (default) or cash at risk_free."),
323
- reps: z.number().int().min(100).max(5000).optional().describe("Bootstrap repetitions for SPA and StepM; default 1000."),
324
- block: z.number().int().min(1).max(1000).optional().describe("Mean bootstrap block length; default floor(sqrt(periods))."),
318
+ reps: z.number().int().min(100).max(5000).optional().describe("Bootstrap repetitions for the Reality Check, SPA and StepM; default 1000."),
319
+ block: z.number().int().min(1).max(1000).optional().describe("Mean bootstrap block length; leave unset for the default round(periods^(1/3)). Long blocks make every block-bootstrap test liberal."),
325
320
  seed: z.number().int().min(0).max(4294967295).optional().describe("Bootstrap seed; default 7."),
326
321
  splits: z.number().int().min(4).max(16).multipleOf(2).optional().describe("CSCV splits (even); default 16, or fewer for short histories."),
327
322
  top: z.number().int().min(1).max(400).optional().describe("Leaderboard rows to return; default 15."),
@@ -336,14 +331,15 @@ export const TOOLS = [
336
331
  const order = [...rows].sort((x, y) => y.sharpe - x.sharpe || x.k - y.k);
337
332
  const best = order[0], K = chosen.length, Keff = effectiveTrials(R);
338
333
  const sv = variance(R.map(perSharpe)), dRaw = deflated(R[best.k], K, sv), dEff = deflated(R[best.k], Keff, sv);
339
- const S = a.splits ?? (n >= 16 * 40 ? 16 : Math.max(4, 2 * Math.floor(n / 80))), block = a.block ?? Math.floor(Math.sqrt(n));
340
- const spa = spaStepM(R, B, { reps: a.reps ?? 1000, block, seed: a.seed ?? 7, size: 0.05 });
334
+ const S = a.splits ?? (n >= 16 * 40 ? 16 : Math.max(4, 2 * Math.floor(n / 80))), block = a.block ?? Math.max(1, Math.round(n ** (1 / 3)));
335
+ const spa = jointTests(R, B, { reps: a.reps ?? 1000, block, seed: a.seed ?? 7, size: 0.05 });
341
336
  const cv = pbo(R, S), bs = statsOf(B, ppy);
342
337
  const superior = spa ? spa.superior.map((k) => chosen[k].id) : [];
343
338
  const verdict = [
344
339
  `${K} sleeves ran on ${n} common periods (effective independent sleeves ${Keff.toFixed(1)}).`,
345
- `Best sleeve ${best.id}: deflated Sharpe probability ${dRaw.dsr.toFixed(3)} counting all ${K} sleeves, ${dEff.dsr.toFixed(3)} counting ${Keff.toFixed(1)} independent bets${dEff.dsr > 0.95 ? (dRaw.dsr > 0.95 ? "; it survives both." : "; it survives only the effective count, which is the fairer one when sleeves are this correlated.") : "; it does not survive deflation, so its Sharpe is what the best of this many tries shows by luck."}`,
346
- spa ? (spa.p_values.consistent < 0.05 ? `SPA rejects "no sleeve beats the benchmark" (p ${spa.p_values.consistent}); StepM keeps ${superior.length} sleeve(s).` : `SPA cannot reject that no sleeve beats the benchmark (p ${spa.p_values.consistent}).`) : "No sleeve differs from the benchmark.",
340
+ `Best sleeve ${best.id}: deflated Sharpe probability ${dRaw.dsr.toFixed(3)} counting all ${K} sleeves, ${dEff.dsr.toFixed(3)} counting ${Keff.toFixed(1)} independent bets${dEff.dsr > 0.95 ? (dRaw.dsr > 0.95 ? "; it survives both." : "; it survives only the effective count, which can be too lenient (the raw count is the conservative bound).") : "; it does not survive deflation, so its Sharpe is what the best of this many tries shows by luck."}`,
341
+ "Deflation asks whether the Sharpe ratio is above zero; SPA and StepM ask whether a sleeve beats the benchmark, so a long-only sleeve in a rising market can pass the first and fail the second.",
342
+ spa ? (spa.p_values.consistent < 0.05 ? `SPA rejects "no sleeve beats the benchmark" (p ${spa.p_values.consistent}; Reality Check p ${spa.reality_check}); StepM keeps ${superior.length} sleeve(s).` : `SPA cannot reject that no sleeve beats the benchmark (p ${spa.p_values.consistent}; Reality Check p ${spa.reality_check}).`) : "No sleeve differs from the benchmark.",
347
343
  `Probability of backtest overfitting ${cv.probability.toFixed(2)}${cv.probability > 0.5 ? ": picking the in-sample winner did worse than a random pick more often than not" : ""}.`,
348
344
  ].join(" ");
349
345
  return {
@@ -357,7 +353,8 @@ export const TOOLS = [
357
353
  multiple_testing: {
358
354
  trials: K, effective_trials: Keff,
359
355
  best: { id: best.id, sharpe: best.sharpe, deflated_sharpe_probability: dRaw.dsr, deflated_sharpe_probability_effective: dEff.dsr, expected_max_sharpe_under_null: dRaw.star * Math.sqrt(ppy), expected_max_sharpe_under_null_effective: dEff.star * Math.sqrt(ppy) },
360
- spa: spa ? { sleeves_tested: spa.tested, statistic: spa.statistic, p_values: spa.p_values, block, reps: a.reps ?? 1000, seed: a.seed ?? 7, bootstrap: "stationary, mulberry32(seed)" } : null,
356
+ reality_check: spa ? { p_value: spa.reality_check } : null,
357
+ spa: spa ? { sleeves_tested: spa.tested, statistic: spa.statistic, p_values: spa.p_values, studentization: "re-estimated in every resample", block, reps: a.reps ?? 1000, seed: a.seed ?? 7, bootstrap: "stationary, mulberry32(seed)" } : null,
361
358
  stepm: { family_wise_error: 0.05, superior },
362
359
  pbo: cv,
363
360
  },