canli-validation-mcp 0.14.1 → 0.15.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 CHANGED
@@ -116,18 +116,35 @@ and one column per variant, as `matrix` or as `matrix_file` (a CSV or JSON on yo
116
116
  optional `benchmark` series; with none, variants are tested against zero. It runs three tests on one
117
117
  seeded stationary bootstrap:
118
118
 
119
- - Hansen's SPA: the chance that the best variant's studentized excess return is this good if no
120
- variant has an edge, with lower and upper bounds (`spa.p_value`, the consistent p-value, is the
121
- headline, and `monte_carlo_se` its sampling error);
119
+ - Hansen's SPA: the chance that the best variant's studentized excess return is at least this good
120
+ if no variant has an edge, with lower and upper bounds (`spa.p_value`, the consistent p-value, is
121
+ the headline, and `monte_carlo_se` its sampling error);
122
122
  - White's Reality Check: the same question without studentizing, so one volatile variant can
123
123
  dominate it;
124
124
  - Romano and Wolf's StepM: which variants beat the benchmark, with the familywise error held at
125
125
  `alpha`.
126
126
 
127
- A result reproduces exactly from its `seed`. On three fixed-seed cases the p-values agree with
128
- Python's `arch` 8.0 and with a numpy transcription of Hansen's formulas within Monte Carlo error, and
129
- the StepM sets match `arch`'s (`js/snooping-core.test.js`). Only the variants sent are counted: a
130
- search that tried more than it sends makes luck look smaller than it was.
127
+ Studentizing divides a variant's mean excess return by its standard deviation. Each bootstrap
128
+ resample is divided by its own standard deviation, worked out again from that resample, as Romano
129
+ and Wolf (2005) recommend, and SPA and StepM both use these figures. Earlier versions divided every
130
+ resample by one figure estimated once from your data. That ignores how noisy the estimate is, and
131
+ the test rejected too often: on Null Zoo v1's searches with no edge (20 variants, 504 days, 1,000
132
+ resamples) it rejected 7.5% at a nominal 5% with normal returns and 12.5% with negatively skewed
133
+ returns. On 4,000 new searches of each kind the new version rejected 5.1% and 5.4% (6.9% with
134
+ autocorrelated returns, where the Reality Check also rejected 6.6%). Studentizing helps find a
135
+ skilled variant that is less volatile than the rest and costs power when the skilled variant is the
136
+ most volatile; the Reality Check, which does not studentize, is reported beside it for that case.
137
+
138
+ A p-value is the share of resamples at least as extreme as your result, ties included. When no
139
+ variant beats the benchmark, the SPA statistic is 0, so its p-values are 1 and StepM names no
140
+ variant. Earlier versions counted only resamples strictly above 0 and could report a p-value near 0,
141
+ a rejection, in exactly that case.
142
+
143
+ A result reproduces exactly from its `seed`. On four fixed-seed cases the p-values agree within
144
+ Monte Carlo error with Python's `arch` 8.0 (run without studentizing) and with a separate numpy
145
+ implementation of the per-resample studentization, and the StepM sets match both
146
+ (`js/snooping-core.test.js`). Only the variants sent are counted: a search that tried more than it
147
+ sends makes luck look smaller than it was.
131
148
 
132
149
  `company_financial_history` is different from the other tools: it reads the public company
133
150
  reference at canlicapital.com, not the validation API. Give it a CIK (1 to 10 digits) to list a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-validation-mcp",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Check whether a backtest is real: deflated Sharpe, CSCV probability of backtest overfitting, minimum track record and backtest length, haircut Sharpe and luck-equivalent trials, as an MCP server. Local mode, hosted endpoint, signed receipts.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -7,18 +7,29 @@
7
7
  // White's Reality Check (2000) the best mean excess return against the bootstrap distribution
8
8
  // of the best recentred mean. Not studentized, so one volatile
9
9
  // variant can dominate it.
10
- // Hansen's SPA (2005) the best t-statistic, each variant studentized by the
11
- // Politis-Romano variance of its mean. Three recentrings give a
12
- // lower bound, a consistent p-value and an upper bound: poor
13
- // variants are not allowed to inflate the p-value.
10
+ // Hansen's SPA (2005) the best t-statistic. Three recentrings give a lower bound, a
11
+ // consistent p-value and an upper bound: poor variants are not
12
+ // allowed to inflate the p-value.
14
13
  // Romano and Wolf's StepM (2005) step-down: which variants beat the benchmark, controlling the
15
14
  // familywise error at alpha.
16
15
  //
16
+ // Studentizing is a bootstrap-t. The observed t-statistic divides each variant's mean by its sample
17
+ // standard deviation, and every resample divides its recentred means by that resample's own
18
+ // standard deviations, as in Romano and Wolf's (2005) studentized StepM. Dividing every resample by
19
+ // one variance estimated once from the sample ignores that estimate's noise and its downward bias,
20
+ // and the test rejected too often (Null Zoo v1: 7.5% of skill-less searches at a nominal 5% with
21
+ // i.i.d. normal returns, 12.5% with negative skew). The Politis-Romano variance still sets Hansen's
22
+ // consistent-recentring threshold.
23
+ //
24
+ // p-values count ties, #(T* >= T) / reps. The SPA statistic is floored at zero, so when no variant
25
+ // beats the benchmark T = 0 and p = 1; a strict inequality counted only the few positive resamples
26
+ // there and rejected. StepM's critical value is floored at zero for the same reason.
27
+ //
17
28
  // d[t][k] is variant k's return minus the benchmark's in period t; higher is better. Every draw
18
- // comes from makeRandom(seed), so a result reproduces exactly from its inputs. The reference
19
- // implementation it is checked against (Python's arch 8.0, and a separate numpy transcription of
20
- // Hansen's formulas for the studentized SPA) draws with numpy, so agreement is within Monte Carlo
21
- // error, not to the digit; scripts/research/reality-check/ holds the comparison.
29
+ // comes from makeRandom(seed), so a result reproduces exactly from its inputs. The implementations
30
+ // it is checked against (Python's arch 8.0 without studentizing, and a separate numpy transcription
31
+ // of the bootstrap-t) draw with numpy, so agreement is within Monte Carlo error, not to the digit;
32
+ // scripts/research/reality-check/ holds the comparison.
22
33
  // =============================================================================
23
34
 
24
35
  import { makeRandom } from "./selection-risk-core.js";
@@ -77,8 +88,9 @@ export function bootstrapVariance(d, n, k, block, means) {
77
88
 
78
89
  /**
79
90
  * All three tests on one bootstrap. `d` is a row-major Float64Array of n periods by k variants.
80
- * Returns means, the variance estimates, the bootstrap maxima under each recentring and the
81
- * StepM rejections; p-values are shares of `reps` draws strictly above the observed statistic.
91
+ * Returns means, the Politis-Romano variances, the sample standard deviations, the p-values and the
92
+ * StepM rejections. A p-value is the share of `reps` draws at least as large as the observed
93
+ * statistic (the Reality Check's, which is not floored, strictly above it, as in arch).
82
94
  */
83
95
  export function snoopingTests(d, n, k, { block = defaultBlock(n), reps = 1000, seed = 42, alpha = 0.05, studentize = true } = {}) {
84
96
  if (!(n >= 2 && k >= 1)) throw new RangeError("need at least 2 periods and 1 variant");
@@ -89,58 +101,72 @@ export function snoopingTests(d, n, k, { block = defaultBlock(n), reps = 1000, s
89
101
  for (let t = 0; t < n; t += 1) for (let j = 0; j < k; j += 1) means[j] += d[t * k + j];
90
102
  for (let j = 0; j < k; j += 1) means[j] /= n;
91
103
  const variance = bootstrapVariance(d, n, k, block, means);
92
- const sd = new Float64Array(k);
93
- for (let j = 0; j < k; j += 1) sd[j] = Math.sqrt(Math.max(variance[j], 0));
104
+ // Sample variances (divisor n - 1), the observed statistic's studentizer.
105
+ const sampleVar = new Float64Array(k);
106
+ for (let t = 0; t < n; t += 1) for (let j = 0; j < k; j += 1) sampleVar[j] += (d[t * k + j] - means[j]) ** 2;
107
+ for (let j = 0; j < k; j += 1) sampleVar[j] /= n - 1;
108
+ const sd = Float64Array.from(sampleVar, Math.sqrt);
94
109
  // A variant whose excess return never varies has no sampling distribution to studentize by.
95
- const usable = Array.from(sd, (s) => s > 0);
110
+ const usable = Array.from(sd, (s, j) => s > 0 && variance[j] > 0);
96
111
  if (!usable.some(Boolean)) throw new RangeError("every variant's excess return is constant; there is nothing to test");
97
112
  const root = Math.sqrt(n);
98
- const scale = (j) => (studentize ? root / sd[j] : 1);
99
113
  // Hansen's recentrings: lower keeps poor variants at zero, consistent drops only those far below
100
- // zero, upper recentres everything (White's null, the least favourable configuration).
114
+ // zero, upper recentres everything (White's null, the least favourable configuration). A resample
115
+ // mean recentred is (resample mean - sample mean) + offset, with offset = mean - centre.
101
116
  const threshold = (j) => -Math.sqrt((variance[j] / n) * 2 * Math.log(Math.log(n)));
102
- const centre = [
103
- Float64Array.from(means, (m) => Math.max(m, 0)),
104
- Float64Array.from(means, (m, j) => (m >= threshold(j) ? m : 0)),
105
- Float64Array.from(means),
117
+ const offset = [
118
+ Float64Array.from(means, (m) => Math.min(m, 0)),
119
+ Float64Array.from(means, (m, j) => (m >= threshold(j) ? 0 : m)),
120
+ new Float64Array(k),
106
121
  ];
122
+ const statistic = (j) => (studentize ? (root * means[j]) / sd[j] : means[j]);
107
123
  let observed = Number.NEGATIVE_INFINITY;
108
124
  let best = -1;
109
125
  for (let j = 0; j < k; j += 1) {
110
126
  if (!usable[j]) continue;
111
- const s = means[j] * scale(j);
127
+ const s = statistic(j);
112
128
  if (s > observed) { observed = s; best = j; }
113
129
  }
114
130
  let rcObserved = Number.NEGATIVE_INFINITY;
115
131
  for (let j = 0; j < k; j += 1) if (usable[j]) rcObserved = Math.max(rcObserved, means[j]);
116
132
 
133
+ const spaStat = Math.max(observed, 0);
134
+ const exceed = [0, 0, 0];
135
+ let rcExceed = 0;
117
136
  const next = makeRandom(seed);
118
137
  const idx = new Int32Array(n);
119
- const star = new Float64Array(k);
120
- const boot = new Float64Array(reps * k); // bootstrap mean of each variant, per draw
138
+ const sum = new Float64Array(k); // a resample's sum of deviations from the sample mean
139
+ const sumSq = new Float64Array(k); // and of their squares
140
+ const stepT = new Float64Array(reps * k); // each variant's consistent-recentred statistic, per draw
121
141
  for (let r = 0; r < reps; r += 1) {
122
142
  stationaryIndices(n, block, next, idx);
123
- star.fill(0);
143
+ sum.fill(0);
144
+ sumSq.fill(0);
124
145
  for (let t = 0; t < n; t += 1) {
125
146
  const row = idx[t] * k;
126
- for (let j = 0; j < k; j += 1) star[j] += d[row + j];
147
+ for (let j = 0; j < k; j += 1) {
148
+ const x = d[row + j] - means[j];
149
+ sum[j] += x;
150
+ sumSq[j] += x * x;
151
+ }
127
152
  }
128
- for (let j = 0; j < k; j += 1) boot[r * k + j] = star[j] / n;
129
- }
130
-
131
- const spaStat = Math.max(observed, 0);
132
- const exceed = [0, 0, 0];
133
- let rcExceed = 0;
134
- for (let r = 0; r < reps; r += 1) {
135
153
  const maxima = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
136
154
  let rcMax = Number.NEGATIVE_INFINITY;
137
155
  for (let j = 0; j < k; j += 1) {
138
156
  if (!usable[j]) continue;
139
- const m = boot[r * k + j];
140
- for (let c = 0; c < 3; c += 1) maxima[c] = Math.max(maxima[c], (m - centre[c][j]) * scale(j));
141
- rcMax = Math.max(rcMax, m - means[j]);
157
+ const shift = sum[j] / n;
158
+ let scale = 1;
159
+ if (studentize) {
160
+ // The resample's own standard deviation. A variant constant in this resample (it differs
161
+ // from the benchmark in few periods and the resample missed them) keeps the sample's.
162
+ const v = (sumSq[j] - sum[j] * shift) / (n - 1);
163
+ scale = root / (v > Number.EPSILON * sampleVar[j] ? Math.sqrt(v) : sd[j]);
164
+ }
165
+ for (let c = 0; c < 3; c += 1) maxima[c] = Math.max(maxima[c], (shift + offset[c][j]) * scale);
166
+ stepT[r * k + j] = (shift + offset[1][j]) * scale;
167
+ rcMax = Math.max(rcMax, shift);
142
168
  }
143
- for (let c = 0; c < 3; c += 1) if (Math.max(maxima[c], 0) > spaStat) exceed[c] += 1;
169
+ for (let c = 0; c < 3; c += 1) if (Math.max(maxima[c], 0) >= spaStat) exceed[c] += 1;
144
170
  if (rcMax > rcObserved) rcExceed += 1;
145
171
  }
146
172
 
@@ -155,12 +181,14 @@ export function snoopingTests(d, n, k, { block = defaultBlock(n), reps = 1000, s
155
181
  const maxima = new Float64Array(reps);
156
182
  for (let r = 0; r < reps; r += 1) {
157
183
  let m = Number.NEGATIVE_INFINITY;
158
- for (const j of live) m = Math.max(m, (boot[r * k + j] - centre[1][j]) * scale(j));
184
+ for (const j of live) m = Math.max(m, stepT[r * k + j]);
159
185
  maxima[r] = m;
160
186
  }
161
187
  maxima.sort();
162
- const critical = quantile(maxima, 1 - alpha);
163
- const now = live.filter((j) => means[j] * scale(j) > critical);
188
+ // At least zero, as the SPA statistic is: a variant that does not beat the benchmark in sample
189
+ // is never declared superior. It binds only when every live variant is far below zero.
190
+ const critical = Math.max(quantile(maxima, 1 - alpha), 0);
191
+ const now = live.filter((j) => statistic(j) > critical);
164
192
  if (!now.length) break;
165
193
  for (const j of now) rejected.add(j);
166
194
  rounds.push({ critical, rejected: now });
@@ -168,7 +196,7 @@ export function snoopingTests(d, n, k, { block = defaultBlock(n), reps = 1000, s
168
196
 
169
197
  return {
170
198
  n, k, block, reps, seed, alpha, studentize,
171
- means, variance, usable,
199
+ means, variance, sd, usable,
172
200
  best, observed,
173
201
  spa: { statistic: spaStat, p_lower: exceed[0] / reps, p_consistent: exceed[1] / reps, p_upper: exceed[2] / reps },
174
202
  reality_check: { statistic: rcObserved, p_value: rcExceed / reps },