canli-validation-mcp 0.14.1 → 0.16.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 +24 -7
- package/package.json +1 -1
- package/src/local/js/backtest-core.js +3 -0
- package/src/local/js/snooping-core.js +67 -39
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
|
|
120
|
-
variant has an edge, with lower and upper bounds (`spa.p_value`, the consistent p-value, is
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -456,6 +456,9 @@ export function runBacktest({ prices, family, grid = {}, periods_per_year: ppy =
|
|
|
456
456
|
top: shown.map((r) => [...spec.params.map((k) => r.params[k]), r.metrics.sharpe, r.metrics.cagr, r.metrics.max_drawdown, r.metrics.turnover_per_year]),
|
|
457
457
|
},
|
|
458
458
|
selected: { params: best.params, metrics: best.metrics },
|
|
459
|
+
// Every variant's annualized Sharpe, best first as in top, so a server that counts trials across a session
|
|
460
|
+
// (canli-mcp's luck) counts each variant run and not one, as with canli-quant-mcp's strategy_sweep.
|
|
461
|
+
every_trial: { sharpes: ranked.filter((r) => r.metrics.sharpe !== null).map((r) => r.metrics.sharpe), periods: evaluated, periods_per_year: Number(ppy) },
|
|
459
462
|
buy_and_hold: performance(buyHold.returns, ppy),
|
|
460
463
|
validation,
|
|
461
464
|
plain_reading: reading(family, runs.length, best, validation, performance(buyHold.returns, ppy)),
|
|
@@ -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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
81
|
-
* StepM rejections
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
103
|
-
Float64Array.from(means, (m) => Math.
|
|
104
|
-
Float64Array.from(means, (m, j) => (m >= threshold(j) ?
|
|
105
|
-
Float64Array
|
|
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 =
|
|
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
|
|
120
|
-
const
|
|
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
|
-
|
|
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)
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
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,
|
|
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
|
-
|
|
163
|
-
|
|
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 },
|