canli-validation-mcp 0.11.0 → 0.13.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 +82 -36
- package/package.json +1 -1
- package/src/lab-schemas.mjs +50 -5
- package/src/lab.mjs +149 -7
- package/src/local/js/audit-core.js +329 -0
- package/src/local/js/leakage-core.js +182 -0
- package/src/local/js/null-zoo-v1-sizes.js +40 -0
- package/src/local/js/placebo-core.js +164 -0
- package/src/local/js/validate/overfitting.js +3 -0
- package/src/schemas.mjs +46 -7
- package/src/series-file.mjs +86 -5
- package/src/server.mjs +179 -29
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// audit-core.js
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// The parts of audit_backtest computed from the caller's own series rather than by a stored
|
|
5
|
+
// validator:
|
|
6
|
+
//
|
|
7
|
+
// returnShape lag-1 autocorrelation, volatility clustering, skew and tails of the
|
|
8
|
+
// returns, and Lo's (2002) autocorrelation-adjusted Sharpe ratio.
|
|
9
|
+
// sharpeInterval a stationary-bootstrap percentile interval for the annualized Sharpe.
|
|
10
|
+
// searchFromVariants what every variant's returns say about the search: how many were tried,
|
|
11
|
+
// how many independent ones that is (Nyholt 2004; Li and Ji 2005), how far
|
|
12
|
+
// their Sharpe ratios spread, and how correlated they are.
|
|
13
|
+
// nullZooFamily which Null Zoo family the shape resembles, by the rules fixed in
|
|
14
|
+
// config/research/null-zoo-v1-prereg.json before the calibration run.
|
|
15
|
+
// neweyWestT,
|
|
16
|
+
// blockBootstrapT the single-series tests the calibration scores (the same arithmetic as
|
|
17
|
+
// scripts/research/null-zoo/v1/validators.mjs; a test keeps them equal).
|
|
18
|
+
// oosDecay what the in-sample best did out of sample across CSCV splits.
|
|
19
|
+
// fixNext at most five concrete next steps, each naming the check that raised it.
|
|
20
|
+
//
|
|
21
|
+
// Nothing here decides whether a strategy is good. Each number is a description of the series and
|
|
22
|
+
// search exactly as sent.
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
import { autocorrelationFactor } from "./haircut-core.js";
|
|
26
|
+
import { perPeriodMoments } from "./moments-core.js";
|
|
27
|
+
import { makeRandom } from "./selection-risk-core.js";
|
|
28
|
+
import { defaultBlock, stationaryIndices } from "./snooping-core.js";
|
|
29
|
+
|
|
30
|
+
const mean = (xs) => { let s = 0; for (let i = 0; i < xs.length; i++) s += xs[i]; return s / xs.length; };
|
|
31
|
+
|
|
32
|
+
function lagOneCorrelation(xs) {
|
|
33
|
+
const m = mean(xs);
|
|
34
|
+
let c = 0, v = 0;
|
|
35
|
+
for (let i = 0; i < xs.length; i++) { const d = xs[i] - m; v += d * d; if (i > 0) c += d * (xs[i - 1] - m); }
|
|
36
|
+
return v > 0 ? c / v : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The shape of one return series: what the Null Zoo families vary, plus Lo's adjusted Sharpe. */
|
|
40
|
+
export function returnShape(returns, periodsPerYear) {
|
|
41
|
+
const m = perPeriodMoments(returns);
|
|
42
|
+
const ppy = Number(periodsPerYear);
|
|
43
|
+
const xs = returns.map(Number);
|
|
44
|
+
const lag1 = lagOneCorrelation(xs);
|
|
45
|
+
const lag1Squares = lagOneCorrelation(xs.map((x) => x * x));
|
|
46
|
+
// Lo's factor needs rho strictly inside (-1, 1); a sample can sit on the edge only when it is tiny.
|
|
47
|
+
const rho = Math.max(-0.95, Math.min(0.95, lag1));
|
|
48
|
+
const sharpe = m.sharpe_per_period * Math.sqrt(ppy);
|
|
49
|
+
const factor = autocorrelationFactor(rho, ppy);
|
|
50
|
+
return {
|
|
51
|
+
observations: m.observations,
|
|
52
|
+
sharpe_annualized: sharpe,
|
|
53
|
+
skew: m.skew,
|
|
54
|
+
excess_kurtosis: m.non_excess_kurtosis - 3,
|
|
55
|
+
lag1_autocorrelation: lag1,
|
|
56
|
+
lag1_autocorrelation_of_squares: lag1Squares,
|
|
57
|
+
lo_factor: factor,
|
|
58
|
+
lo_adjusted_sharpe_annualized: sharpe * factor,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Percentile intervals for the annualized Sharpe from a stationary bootstrap (mean block n^(1/3)),
|
|
64
|
+
* and the share of resamples whose Sharpe is at or below zero.
|
|
65
|
+
*/
|
|
66
|
+
export function sharpeInterval(returns, { periodsPerYear, reps = 2000, block, seed = 42, levels = [0.9, 0.95] } = {}) {
|
|
67
|
+
const xs = Float64Array.from(returns, Number);
|
|
68
|
+
const n = xs.length;
|
|
69
|
+
const b = block ?? defaultBlock(n);
|
|
70
|
+
const next = makeRandom(seed);
|
|
71
|
+
const idx = new Int32Array(n);
|
|
72
|
+
const root = Math.sqrt(Number(periodsPerYear));
|
|
73
|
+
const draws = new Float64Array(reps);
|
|
74
|
+
let atOrBelowZero = 0;
|
|
75
|
+
for (let r = 0; r < reps; r++) {
|
|
76
|
+
stationaryIndices(n, b, next, idx);
|
|
77
|
+
let s = 0, q = 0;
|
|
78
|
+
for (let i = 0; i < n; i++) { const v = xs[idx[i]]; s += v; q += v * v; }
|
|
79
|
+
const m = s / n;
|
|
80
|
+
const variance = (q - n * m * m) / (n - 1);
|
|
81
|
+
// A resample with no spread has no Sharpe ratio; it counts on the side its mean falls.
|
|
82
|
+
const sharpe = variance > 0 ? (m / Math.sqrt(variance)) * root : m > 0 ? Infinity : m < 0 ? -Infinity : 0;
|
|
83
|
+
draws[r] = sharpe;
|
|
84
|
+
if (sharpe <= 0) atOrBelowZero++;
|
|
85
|
+
}
|
|
86
|
+
draws.sort();
|
|
87
|
+
const at = (q) => {
|
|
88
|
+
const h = (reps - 1) * q;
|
|
89
|
+
const lo = Math.floor(h);
|
|
90
|
+
const hi = Math.min(lo + 1, reps - 1);
|
|
91
|
+
return draws[lo] + (h - lo) * (draws[hi] - draws[lo]);
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
method: "stationary bootstrap, percentile",
|
|
95
|
+
reps, block: b, seed,
|
|
96
|
+
intervals: Object.fromEntries(levels.map((l) => [String(l), [at((1 - l) / 2), at(1 - (1 - l) / 2)]])),
|
|
97
|
+
share_at_or_below_zero: atOrBelowZero / reps,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Eigenvalues of a symmetric matrix (cyclic Jacobi), for the effective number of trials.
|
|
102
|
+
function symmetricEigenvalues(a, k) {
|
|
103
|
+
const m = Float64Array.from(a);
|
|
104
|
+
for (let sweep = 0; sweep < 100; sweep++) {
|
|
105
|
+
let off = 0;
|
|
106
|
+
for (let i = 0; i < k; i++) for (let j = i + 1; j < k; j++) off += m[i * k + j] ** 2;
|
|
107
|
+
if (off < 1e-24) break;
|
|
108
|
+
for (let p = 0; p < k; p++) {
|
|
109
|
+
for (let q = p + 1; q < k; q++) {
|
|
110
|
+
const apq = m[p * k + q];
|
|
111
|
+
if (Math.abs(apq) < 1e-300) continue;
|
|
112
|
+
const theta = (m[q * k + q] - m[p * k + p]) / (2 * apq);
|
|
113
|
+
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
|
|
114
|
+
const c = 1 / Math.sqrt(t * t + 1);
|
|
115
|
+
const s = t * c;
|
|
116
|
+
for (let r = 0; r < k; r++) {
|
|
117
|
+
const arp = m[r * k + p], arq = m[r * k + q];
|
|
118
|
+
m[r * k + p] = c * arp - s * arq;
|
|
119
|
+
m[r * k + q] = s * arp + c * arq;
|
|
120
|
+
}
|
|
121
|
+
for (let r = 0; r < k; r++) {
|
|
122
|
+
const apr = m[p * k + r], aqr = m[q * k + r];
|
|
123
|
+
m[p * k + r] = c * apr - s * aqr;
|
|
124
|
+
m[q * k + r] = s * apr + c * aqr;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return Array.from({ length: k }, (_, i) => m[i * k + i]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What a search's variants say about it. `matrix[t][j]` is variant j's return in period t (the shape
|
|
134
|
+
* validate_overfitting takes). Constant variants are counted but left out of the correlations.
|
|
135
|
+
*/
|
|
136
|
+
export function searchFromVariants(matrix, periodsPerYear) {
|
|
137
|
+
const periods = matrix.length;
|
|
138
|
+
const k = matrix[0].length;
|
|
139
|
+
const root = Math.sqrt(Number(periodsPerYear));
|
|
140
|
+
const means = new Float64Array(k);
|
|
141
|
+
for (let t = 0; t < periods; t++) for (let j = 0; j < k; j++) means[j] += Number(matrix[t][j]);
|
|
142
|
+
for (let j = 0; j < k; j++) means[j] /= periods;
|
|
143
|
+
const sds = new Float64Array(k);
|
|
144
|
+
for (let t = 0; t < periods; t++) for (let j = 0; j < k; j++) sds[j] += (Number(matrix[t][j]) - means[j]) ** 2;
|
|
145
|
+
for (let j = 0; j < k; j++) sds[j] = Math.sqrt(sds[j] / (periods - 1));
|
|
146
|
+
const live = [];
|
|
147
|
+
for (let j = 0; j < k; j++) if (sds[j] > 0) live.push(j);
|
|
148
|
+
const sharpes = live.map((j) => (means[j] / sds[j]) * root);
|
|
149
|
+
const sharpeMean = sharpes.reduce((a, b) => a + b, 0) / sharpes.length;
|
|
150
|
+
const sharpeSd = sharpes.length > 1 ? Math.sqrt(sharpes.reduce((a, b) => a + (b - sharpeMean) ** 2, 0) / (sharpes.length - 1)) : 0;
|
|
151
|
+
const L = live.length;
|
|
152
|
+
const corr = new Float64Array(L * L);
|
|
153
|
+
const pair = [];
|
|
154
|
+
for (let a = 0; a < L; a++) {
|
|
155
|
+
corr[a * L + a] = 1;
|
|
156
|
+
for (let b = a + 1; b < L; b++) {
|
|
157
|
+
const ja = live[a], jb = live[b];
|
|
158
|
+
let c = 0;
|
|
159
|
+
for (let t = 0; t < periods; t++) c += (Number(matrix[t][ja]) - means[ja]) * (Number(matrix[t][jb]) - means[jb]);
|
|
160
|
+
const r = c / ((periods - 1) * sds[ja] * sds[jb]);
|
|
161
|
+
corr[a * L + b] = corr[b * L + a] = r;
|
|
162
|
+
pair.push(r);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const pairMean = pair.length ? pair.reduce((a, b) => a + b, 0) / pair.length : 0;
|
|
166
|
+
const pairSd = pair.length > 1 ? Math.sqrt(pair.reduce((a, b) => a + (b - pairMean) ** 2, 0) / (pair.length - 1)) : 0;
|
|
167
|
+
let nyholt = L, liJi = L;
|
|
168
|
+
if (L > 1) {
|
|
169
|
+
const lambdas = symmetricEigenvalues(corr, L);
|
|
170
|
+
const lm = lambdas.reduce((a, b) => a + b, 0) / L;
|
|
171
|
+
const lv = lambdas.reduce((a, b) => a + (b - lm) ** 2, 0) / (L - 1);
|
|
172
|
+
nyholt = 1 + (L - 1) * (1 - lv / L);
|
|
173
|
+
liJi = lambdas.reduce((a, l) => { const x = Math.abs(l); return a + (x >= 1 ? 1 : 0) + (x - Math.floor(x)); }, 0);
|
|
174
|
+
}
|
|
175
|
+
let best = 0;
|
|
176
|
+
for (let i = 1; i < sharpes.length; i++) if (sharpes[i] > sharpes[best]) best = i;
|
|
177
|
+
return {
|
|
178
|
+
variants: k,
|
|
179
|
+
periods,
|
|
180
|
+
constant_variants: k - L,
|
|
181
|
+
best_variant: live.length ? live[best] : null,
|
|
182
|
+
best_sharpe_annualized: live.length ? sharpes[best] : null,
|
|
183
|
+
cross_trial_sharpe_sd_annualized: sharpeSd,
|
|
184
|
+
mean_pairwise_correlation: pairMean,
|
|
185
|
+
sd_pairwise_correlation: pairSd,
|
|
186
|
+
// Rounded up with a tolerance (a sum of eigenvalues equal to 12 can arrive as 12.000000000000014),
|
|
187
|
+
// never more than the variants that vary, and at least 2, the smallest count the DSR accepts.
|
|
188
|
+
effective_trials: { nyholt, li_ji: liJi, used: Math.max(2, Math.min(L, Math.ceil(liJi - 1e-9))), method: "Li and Ji (2005), rounded up, at most the variants sent, at least 2" },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The Null Zoo family whose measured size an audit quotes for this shape. The rules and their order
|
|
194
|
+
* are the ones pre-registered in config/research/null-zoo-v1-prereg.json; a test keeps them equal.
|
|
195
|
+
*/
|
|
196
|
+
export function nullZooFamily(shape, search = null) {
|
|
197
|
+
if (search && search.mean_pairwise_correlation >= 0.3) return search.sd_pairwise_correlation >= 0.15 ? "block_cluster" : "correlated_trials";
|
|
198
|
+
if (Math.abs(shape.lag1_autocorrelation) >= 0.1) return "ar1";
|
|
199
|
+
if (shape.skew <= -0.5) return "skew_negative";
|
|
200
|
+
if (shape.skew >= 0.5) return "skew_positive";
|
|
201
|
+
if (shape.excess_kurtosis >= 2 && shape.lag1_autocorrelation_of_squares >= 0.1) return "garch";
|
|
202
|
+
if (shape.excess_kurtosis >= 2) return "student_t4";
|
|
203
|
+
if (shape.lag1_autocorrelation_of_squares >= 0.1) return "regimes";
|
|
204
|
+
return "iid_normal";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The t-statistic of a series' mean with a Newey-West (Bartlett, lag floor(4 (T/100)^(2/9))) variance. */
|
|
208
|
+
export function neweyWestT(xs) {
|
|
209
|
+
const n = xs.length;
|
|
210
|
+
const m = mean(xs);
|
|
211
|
+
const lag = Math.floor(4 * Math.pow(n / 100, 2 / 9));
|
|
212
|
+
let lrv = 0;
|
|
213
|
+
for (let i = 0; i < n; i++) { const d = xs[i] - m; lrv += d * d; }
|
|
214
|
+
for (let l = 1; l <= lag; l++) {
|
|
215
|
+
let g = 0;
|
|
216
|
+
for (let i = l; i < n; i++) g += (xs[i] - m) * (xs[i - l] - m);
|
|
217
|
+
lrv += 2 * (1 - l / (lag + 1)) * g;
|
|
218
|
+
}
|
|
219
|
+
lrv /= n;
|
|
220
|
+
return lrv > 0 ? m / Math.sqrt(lrv / n) : 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One-sided p-value of "the mean is above zero" from a stationary-bootstrap t (see the header). */
|
|
224
|
+
export function blockBootstrapT(xs, { reps, block = defaultBlock(xs.length), seed }) {
|
|
225
|
+
const n = xs.length;
|
|
226
|
+
let sum = 0, sq = 0;
|
|
227
|
+
for (let i = 0; i < n; i++) { sum += xs[i]; sq += xs[i] * xs[i]; }
|
|
228
|
+
const m0 = sum / n;
|
|
229
|
+
const sd = Math.sqrt(Math.max((sq - n * m0 * m0) / (n - 1), 0));
|
|
230
|
+
if (!(sd > 0)) return 1;
|
|
231
|
+
const observed = m0 / (sd / Math.sqrt(n));
|
|
232
|
+
const next = makeRandom(seed);
|
|
233
|
+
const idx = new Int32Array(n);
|
|
234
|
+
let hits = 0;
|
|
235
|
+
for (let b = 0; b < reps; b++) {
|
|
236
|
+
stationaryIndices(n, block, next, idx);
|
|
237
|
+
let s = 0, q = 0;
|
|
238
|
+
for (let i = 0; i < n; i++) { const v = xs[idx[i]]; s += v; q += v * v; }
|
|
239
|
+
const m = s / n;
|
|
240
|
+
const v = (q - n * m * m) / (n - 1);
|
|
241
|
+
if (!(v > 0)) { hits++; continue; }
|
|
242
|
+
if ((m - m0) / Math.sqrt(v / n) >= observed) hits++;
|
|
243
|
+
}
|
|
244
|
+
return (hits + 1) / (reps + 1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const quantileSorted = (sorted, q) => {
|
|
248
|
+
const h = (sorted.length - 1) * q;
|
|
249
|
+
const lo = Math.floor(h);
|
|
250
|
+
const hi = Math.min(lo + 1, sorted.length - 1);
|
|
251
|
+
return sorted[lo] + (h - lo) * (sorted[hi] - sorted[lo]);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Out-of-sample decay from CSCV's in-sample-best pairs ([in-sample Sharpe, out-of-sample Sharpe],
|
|
256
|
+
* per period, as js/pbo-core.js returns them): the distribution of the selected variant's
|
|
257
|
+
* out-of-sample Sharpe, and the least-squares line of out-of-sample on in-sample. With
|
|
258
|
+
* periodsPerYear 1 the Sharpe ratios stay per period, as validate_overfitting reports them.
|
|
259
|
+
*/
|
|
260
|
+
export function oosDecay(isOosPairs, periodsPerYear = 1) {
|
|
261
|
+
const root = Math.sqrt(Number(periodsPerYear));
|
|
262
|
+
const pairs = isOosPairs.map(([a, b]) => [a * root, b * root]);
|
|
263
|
+
const oos = pairs.map((p) => p[1]).sort((a, b) => a - b);
|
|
264
|
+
const n = pairs.length;
|
|
265
|
+
const mx = pairs.reduce((a, p) => a + p[0], 0) / n;
|
|
266
|
+
const my = pairs.reduce((a, p) => a + p[1], 0) / n;
|
|
267
|
+
let sxy = 0, sxx = 0, syy = 0;
|
|
268
|
+
for (const [x, y] of pairs) { sxy += (x - mx) * (y - my); sxx += (x - mx) ** 2; syy += (y - my) ** 2; }
|
|
269
|
+
const slope = sxx > 0 ? sxy / sxx : 0;
|
|
270
|
+
return {
|
|
271
|
+
splits: n,
|
|
272
|
+
unit: Number(periodsPerYear) === 1 ? "per period" : "annualized",
|
|
273
|
+
oos_sharpe: {
|
|
274
|
+
median: quantileSorted(oos, 0.5), p10: quantileSorted(oos, 0.1), p90: quantileSorted(oos, 0.9),
|
|
275
|
+
prob_below_zero: oos.filter((v) => v < 0).length / n,
|
|
276
|
+
},
|
|
277
|
+
degradation: { slope, intercept: my - slope * mx, r2: sxx > 0 && syy > 0 ? (sxy * sxy) / (sxx * syy) : 0 },
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A per-period oosDecay result in annualized Sharpe units; the slope, r2 and shares do not change. */
|
|
282
|
+
export function annualizeDecay(decay, periodsPerYear) {
|
|
283
|
+
const root = Math.sqrt(Number(periodsPerYear));
|
|
284
|
+
const o = decay.oos_sharpe;
|
|
285
|
+
return {
|
|
286
|
+
...decay,
|
|
287
|
+
unit: "annualized",
|
|
288
|
+
oos_sharpe: { median: o.median * root, p10: o.p10 * root, p90: o.p90 * root, prob_below_zero: o.prob_below_zero },
|
|
289
|
+
degradation: { ...decay.degradation, intercept: decay.degradation.intercept * root },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// The ids are stable: an agent can act on them, and a changelog can name them.
|
|
294
|
+
export const FIX_IDS = Object.freeze([
|
|
295
|
+
"count_every_variant", "declared_trials_below_counted", "not_distinguishable_from_luck", "interval_includes_zero",
|
|
296
|
+
"autocorrelated_returns", "track_record_too_short", "overfit_selection", "expected_live_sharpe_below_zero",
|
|
297
|
+
"stress_and_capacity_next",
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* At most five next steps, most decisive first. `context` carries what the audit found; each step
|
|
302
|
+
* is {id, why, next}: the finding with its numbers, and the change or call to make next.
|
|
303
|
+
*/
|
|
304
|
+
export function fixNext(context) {
|
|
305
|
+
const steps = [];
|
|
306
|
+
const add = (id, why, next) => steps.push({ id, why, next });
|
|
307
|
+
const { headline, shape, interval, search, declaredTrials, trackRecord, overfitting, decay } = context;
|
|
308
|
+
const f2 = (x) => x.toFixed(2);
|
|
309
|
+
if (headline && headline.p > headline.level) {
|
|
310
|
+
add("not_distinguishable_from_luck", `p ${headline.p.toFixed(3)} > ${headline.level}.`, search ? "Pre-declare a smaller grid (backtest_strategy), then audit again." : "Try fewer variants or add out-of-sample data.");
|
|
311
|
+
}
|
|
312
|
+
if (search && declaredTrials !== undefined && declaredTrials < search.effective_trials.used) {
|
|
313
|
+
add("declared_trials_below_counted", `Declared ${declaredTrials} trials; the variants count as ${search.effective_trials.used}.`, "Already applied: the counted number was used.");
|
|
314
|
+
}
|
|
315
|
+
if (!search) add("count_every_variant", "Only declared trials counted.", "Send every variant's returns (variants_file).");
|
|
316
|
+
if (Math.abs(shape.lag1_autocorrelation) >= 0.1) {
|
|
317
|
+
add("autocorrelated_returns", `Lag-1 autocorrelation ${f2(shape.lag1_autocorrelation)}; Lo-adjusted Sharpe ${f2(shape.lo_adjusted_sharpe_annualized)}.`,
|
|
318
|
+
"Check for stale or smoothed prices (summarize_series); quote the adjusted Sharpe.");
|
|
319
|
+
}
|
|
320
|
+
if (interval && interval.intervals["0.95"][0] <= 0) {
|
|
321
|
+
add("interval_includes_zero", `95% Sharpe interval ${f2(interval.intervals["0.95"][0])} to ${f2(interval.intervals["0.95"][1])}.`, "Needs a longer history (validate_backtest_length).");
|
|
322
|
+
}
|
|
323
|
+
if (overfitting && overfitting.pbo >= 0.5) add("overfit_selection", `Overfitting probability ${f2(overfitting.pbo)}.`, "Shrink the grid or fix the selection rule in advance.");
|
|
324
|
+
if (decay && decay.oos_sharpe.median <= 0) add("expected_live_sharpe_below_zero", `Median out-of-sample Sharpe of the pick ${f2(decay.oos_sharpe.median)}.`, "Expect no live edge from this selection.");
|
|
325
|
+
if (trackRecord && trackRecord.short) add("track_record_too_short", trackRecord.finding, "Keep it running before scaling (validate_track_record).");
|
|
326
|
+
// Nothing to fix and luck rejected: the next hurdles are bad markets and a real broker.
|
|
327
|
+
if (!steps.length && headline && headline.p <= headline.level) add("stress_and_capacity_next", `p ${headline.p.toFixed(3)} <= ${headline.level}.`, "Run stress_test and check_feasibility before trading it.");
|
|
328
|
+
return steps.slice(0, 5);
|
|
329
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// leakage-core.js
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// A lookahead check that needs no code. A causal signal's value at row t depends only on rows 0
|
|
5
|
+
// to t, so computing it on the first n rows gives exactly the values the full run gives for rows
|
|
6
|
+
// 0 to n - 1. The server picks the cut points; the caller reruns its own code on each prefix and
|
|
7
|
+
// sends every column back; a row whose value changed when the rows after the cut were removed
|
|
8
|
+
// used those rows.
|
|
9
|
+
//
|
|
10
|
+
// planPrefixes the cut points (prefix lengths) for a series, seeded so a plan reproduces
|
|
11
|
+
// compareColumn for each prefix: rows compared, rows that changed, the first and last changed
|
|
12
|
+
// row, the largest change, and how far before the cut the changes start
|
|
13
|
+
// diagnose what the pattern of changes says: future rows (a fixed horizon before every
|
|
14
|
+
// cut), the full sample (most of every prefix changed: a full-sample
|
|
15
|
+
// normalization, rank or fit), or sparse rows (typical of filling gaps backwards)
|
|
16
|
+
//
|
|
17
|
+
// A pass shows only that these prefixes found no lookahead: the caller supplies the columns, and
|
|
18
|
+
// survivorship, vendor revisions or a leak that only shows in rows never cut leave no trace here.
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
import { makeRandom } from "./selection-risk-core.js";
|
|
22
|
+
|
|
23
|
+
export const LEAKAGE_LIMITS = Object.freeze({
|
|
24
|
+
min_observations: 30,
|
|
25
|
+
max_observations: 100000,
|
|
26
|
+
min_prefixes: 2,
|
|
27
|
+
max_prefixes: 20,
|
|
28
|
+
default_prefixes: 5,
|
|
29
|
+
max_columns: 20,
|
|
30
|
+
default_tolerance: 1e-9,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const LEAKAGE_LIMITS_TEXT = Object.freeze([
|
|
34
|
+
"A pass covers only these cuts: the columns are the caller's, and survivorship, vendor revisions or a leak no cut reaches leave no trace.",
|
|
35
|
+
"Cut points fall between 40% and 95% of the series, so a leak confined to its first rows is not tested.",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prefix lengths for a series of `observations` rows: `prefixes` distinct cuts between 40% and 95%
|
|
40
|
+
* of the series, one in each equal slot of that range at a seeded position, sorted.
|
|
41
|
+
*/
|
|
42
|
+
export function planPrefixes(observations, { prefixes = LEAKAGE_LIMITS.default_prefixes, seed = 1 } = {}) {
|
|
43
|
+
const n = Number(observations);
|
|
44
|
+
const k = Number(prefixes);
|
|
45
|
+
if (!Number.isInteger(n) || n < LEAKAGE_LIMITS.min_observations || n > LEAKAGE_LIMITS.max_observations) {
|
|
46
|
+
throw new RangeError(`observations must be an integer from ${LEAKAGE_LIMITS.min_observations} to ${LEAKAGE_LIMITS.max_observations}`);
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isInteger(k) || k < LEAKAGE_LIMITS.min_prefixes || k > LEAKAGE_LIMITS.max_prefixes) {
|
|
49
|
+
throw new RangeError(`prefixes must be an integer from ${LEAKAGE_LIMITS.min_prefixes} to ${LEAKAGE_LIMITS.max_prefixes}`);
|
|
50
|
+
}
|
|
51
|
+
const lo = Math.ceil(0.4 * n);
|
|
52
|
+
const hi = Math.floor(0.95 * n);
|
|
53
|
+
if (hi - lo + 1 < k) throw new RangeError(`a series of ${n} rows has room for at most ${hi - lo + 1} cuts`);
|
|
54
|
+
const next = makeRandom(Number(seed) >>> 0 || 1);
|
|
55
|
+
const width = (hi - lo + 1) / k;
|
|
56
|
+
const cuts = new Set();
|
|
57
|
+
for (let i = 0; i < k; i++) {
|
|
58
|
+
const start = lo + Math.floor(i * width);
|
|
59
|
+
const end = Math.max(start, lo + Math.floor((i + 1) * width) - 1);
|
|
60
|
+
let cut = start + Math.floor(next() * (end - start + 1));
|
|
61
|
+
while (cuts.has(cut)) cut += 1;
|
|
62
|
+
cuts.add(cut);
|
|
63
|
+
}
|
|
64
|
+
return [...cuts].sort((a, b) => a - b);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const missing = (v) => v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
|
|
68
|
+
|
|
69
|
+
function readValue(v, where) {
|
|
70
|
+
if (missing(v)) return null;
|
|
71
|
+
const x = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
|
|
72
|
+
if (!Number.isFinite(x)) throw new RangeError(`${where} is ${JSON.stringify(v)}, not a number or null`);
|
|
73
|
+
return x;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compares one column's full run with its prefix runs. `prefixes[i]` must hold exactly `cuts[i]`
|
|
78
|
+
* values: the caller's code run on rows 0 to cuts[i] - 1. Missing values (null) match only missing
|
|
79
|
+
* values; numbers match within `tolerance` relative to max(1, |a|, |b|).
|
|
80
|
+
*/
|
|
81
|
+
export function compareColumn(full, prefixes, cuts, { tolerance = LEAKAGE_LIMITS.default_tolerance, name = "column" } = {}) {
|
|
82
|
+
if (!Array.isArray(full)) throw new RangeError(`${name}.full must be an array`);
|
|
83
|
+
if (!Array.isArray(prefixes) || prefixes.length !== cuts.length) throw new RangeError(`${name}.prefixes must hold ${cuts.length} runs, one per cut`);
|
|
84
|
+
const f = full.map((v, i) => readValue(v, `${name}.full[${i}]`));
|
|
85
|
+
return cuts.map((cut, p) => {
|
|
86
|
+
const run = prefixes[p];
|
|
87
|
+
if (!Array.isArray(run) || run.length !== cut) {
|
|
88
|
+
throw new RangeError(`${name}.prefixes[${p}] has ${Array.isArray(run) ? run.length : "no"} values; its cut is ${cut}: send your code's output on exactly the first ${cut} rows`);
|
|
89
|
+
}
|
|
90
|
+
if (cut > f.length) throw new RangeError(`${name}: cut ${cut} is beyond the ${f.length} rows of the full run`);
|
|
91
|
+
let changed = 0, first = -1, last = -1, maxDiff = 0;
|
|
92
|
+
for (let t = 0; t < cut; t++) {
|
|
93
|
+
const a = f[t];
|
|
94
|
+
const b = readValue(run[t], `${name}.prefixes[${p}][${t}]`);
|
|
95
|
+
let same;
|
|
96
|
+
if (a === null || b === null) same = a === b;
|
|
97
|
+
else {
|
|
98
|
+
const d = Math.abs(a - b);
|
|
99
|
+
same = d <= tolerance * Math.max(1, Math.abs(a), Math.abs(b));
|
|
100
|
+
if (!same && d > maxDiff) maxDiff = d;
|
|
101
|
+
}
|
|
102
|
+
if (!same) {
|
|
103
|
+
changed += 1;
|
|
104
|
+
if (first < 0) first = t;
|
|
105
|
+
last = t;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
cut,
|
|
110
|
+
compared: cut,
|
|
111
|
+
changed,
|
|
112
|
+
share_changed: changed / cut,
|
|
113
|
+
first_changed: first < 0 ? null : first,
|
|
114
|
+
last_changed: last < 0 ? null : last,
|
|
115
|
+
rows_before_cut: first < 0 ? null : cut - first,
|
|
116
|
+
max_abs_difference: changed ? maxDiff : 0,
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* What a column's changes say. future_rows: in every prefix the changed rows sit in a block that
|
|
123
|
+
* ends at the cut and is no wider than a tenth of the shortest prefix, so the value at row t uses
|
|
124
|
+
* rows up to t + horizon. full_sample: most rows of some prefix changed. sparse: anything else.
|
|
125
|
+
*/
|
|
126
|
+
export function diagnose(results) {
|
|
127
|
+
const touched = results.filter((r) => r.changed > 0);
|
|
128
|
+
if (!touched.length) {
|
|
129
|
+
return { verdict: "no_lookahead_found", pattern: "none", sentence: `No value changed in ${results.length} prefixes: these cuts found no lookahead.` };
|
|
130
|
+
}
|
|
131
|
+
const shortest = Math.min(...results.map((r) => r.cut));
|
|
132
|
+
const horizon = Math.max(...touched.map((r) => r.rows_before_cut));
|
|
133
|
+
const endsAtCut = touched.every((r) => r.last_changed === r.cut - 1 && r.changed === r.rows_before_cut);
|
|
134
|
+
if (endsAtCut && horizon <= Math.max(1, Math.floor(shortest / 10))) {
|
|
135
|
+
return {
|
|
136
|
+
verdict: "lookahead_found",
|
|
137
|
+
pattern: "future_rows",
|
|
138
|
+
horizon_rows: horizon,
|
|
139
|
+
sentence: `Up to ${horizon} row${horizon === 1 ? "" : "s"} before each cut changed when the later rows were removed: the value at row t uses at least the next ${horizon} row${horizon === 1 ? "" : "s"}, as shift(-${horizon}), a centered window or a forward return would.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const widest = Math.max(...touched.map((r) => r.share_changed));
|
|
143
|
+
if (widest > 0.5) {
|
|
144
|
+
return {
|
|
145
|
+
verdict: "lookahead_found",
|
|
146
|
+
pattern: "full_sample",
|
|
147
|
+
share_changed: widest,
|
|
148
|
+
sentence: `Up to ${(widest * 100).toFixed(0)}% of a prefix's rows changed when later rows were removed: values depend on the whole sample, as a full-sample normalization, rank, scaling or model fit would.`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const first = Math.min(...touched.map((r) => r.first_changed));
|
|
152
|
+
return {
|
|
153
|
+
verdict: "lookahead_found",
|
|
154
|
+
pattern: "sparse",
|
|
155
|
+
first_changed: first,
|
|
156
|
+
sentence: `Scattered rows changed when later rows were removed (the earliest at row ${first}): typical of filling gaps backwards (bfill) or a lookup that reaches forward.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The whole check: every column compared at every cut, and one verdict for the set. */
|
|
161
|
+
export function checkLeakage({ cuts, columns, tolerance = LEAKAGE_LIMITS.default_tolerance, timestamps }) {
|
|
162
|
+
if (!Array.isArray(cuts) || cuts.length < LEAKAGE_LIMITS.min_prefixes || cuts.length > LEAKAGE_LIMITS.max_prefixes) {
|
|
163
|
+
throw new RangeError(`cuts must list ${LEAKAGE_LIMITS.min_prefixes} to ${LEAKAGE_LIMITS.max_prefixes} prefix lengths, as check_leakage's plan returned them`);
|
|
164
|
+
}
|
|
165
|
+
const names = Object.keys(columns ?? {});
|
|
166
|
+
if (!names.length || names.length > LEAKAGE_LIMITS.max_columns) throw new RangeError(`send 1 to ${LEAKAGE_LIMITS.max_columns} columns`);
|
|
167
|
+
const out = {};
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
const results = compareColumn(columns[name].full, columns[name].prefixes, cuts, { tolerance, name });
|
|
170
|
+
const d = diagnose(results);
|
|
171
|
+
const label = (i) => (timestamps && i !== null && i !== undefined && timestamps[i] !== undefined ? timestamps[i] : undefined);
|
|
172
|
+
out[name] = { ...d, ...(d.pattern === "sparse" && label(d.first_changed) ? { first_changed_at: label(d.first_changed) } : {}), prefixes: results };
|
|
173
|
+
}
|
|
174
|
+
const flagged = names.filter((n) => out[n].verdict === "lookahead_found");
|
|
175
|
+
return {
|
|
176
|
+
verdict: flagged.length ? "lookahead_found" : "no_lookahead_found",
|
|
177
|
+
flagged_columns: flagged,
|
|
178
|
+
cuts,
|
|
179
|
+
tolerance,
|
|
180
|
+
columns: out,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Generated by scripts/research/null-zoo/v1/evaluate.mjs from the Null Zoo v1 run. Do not edit.
|
|
2
|
+
// The measured false-positive rate (size) of audit_backtest's headline tests on each return family.
|
|
3
|
+
export const NULL_ZOO_V1 = Object.freeze({
|
|
4
|
+
"source": "https://github.com/arhancanli/canlicapital/blob/main/config/research/null-zoo-v1b-evaluation.json (10,000 searches per cell; bar pre-registered in config/research/null-zoo-v1b-prereg.json)",
|
|
5
|
+
"level": 0.05,
|
|
6
|
+
"with_variants": {
|
|
7
|
+
"test": "reality_check",
|
|
8
|
+
"size_by_family": {
|
|
9
|
+
"iid_normal": 0.0538,
|
|
10
|
+
"student_t4": 0.0494,
|
|
11
|
+
"skew_negative": 0.0535,
|
|
12
|
+
"skew_positive": 0.0521,
|
|
13
|
+
"garch": 0.0446,
|
|
14
|
+
"ar1": 0.0637,
|
|
15
|
+
"regimes": 0.0507,
|
|
16
|
+
"correlated_trials": 0.0553,
|
|
17
|
+
"block_cluster": 0.0565
|
|
18
|
+
},
|
|
19
|
+
"worst": "ar1",
|
|
20
|
+
"meets_every_bar": false,
|
|
21
|
+
"power_iid_skill_2": 0.5346
|
|
22
|
+
},
|
|
23
|
+
"single_series": {
|
|
24
|
+
"test": "luck_trials_lo",
|
|
25
|
+
"size_by_family": {
|
|
26
|
+
"iid_normal": 0.0551,
|
|
27
|
+
"student_t4": 0.0561,
|
|
28
|
+
"skew_negative": 0.0877,
|
|
29
|
+
"skew_positive": 0.0317,
|
|
30
|
+
"garch": 0.0487,
|
|
31
|
+
"ar1": 0.0549,
|
|
32
|
+
"regimes": 0.0551,
|
|
33
|
+
"correlated_trials": 0.0358,
|
|
34
|
+
"block_cluster": 0.0356
|
|
35
|
+
},
|
|
36
|
+
"worst": "skew_negative",
|
|
37
|
+
"meets_every_bar": false,
|
|
38
|
+
"power_iid_skill_2": 0.5276
|
|
39
|
+
}
|
|
40
|
+
});
|