canli-validation-mcp 0.4.0 → 0.6.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 +80 -12
- package/package.json +6 -3
- package/src/local/api/_lib/limits.js +30 -0
- package/src/local/js/breadth-core.js +96 -0
- package/src/local/js/dsr-core.js +302 -0
- package/src/local/js/haircut-core.js +96 -0
- package/src/local/js/luck-core.js +97 -0
- package/src/local/js/moments-core.js +54 -0
- package/src/local/js/paper-evidence-core.js +167 -0
- package/src/local/js/pbo-core.js +112 -0
- package/src/local/js/receipt-statement.js +54 -0
- package/src/local/js/selection-risk-core.js +197 -0
- package/src/local/js/student-t.js +139 -0
- package/src/local/js/validate/backtest-length.js +61 -0
- package/src/local/js/validate/breadth.js +24 -0
- package/src/local/js/validate/deflated-sharpe.js +28 -0
- package/src/local/js/validate/haircut-sharpe.js +54 -0
- package/src/local/js/validate/luck-trials.js +63 -0
- package/src/local/js/validate/overfitting.js +29 -0
- package/src/local/js/validate/paper-evidence.js +11 -0
- package/src/local/js/validate/track-record.js +50 -0
- package/src/local/scripts/canonical-json.mjs +176 -0
- package/src/local/standards/paper-evidence/schema.json +429 -0
- package/src/local.mjs +45 -0
- package/src/receipt-keys.json +13 -0
- package/src/schemas.mjs +195 -51
- package/src/series-file.mjs +124 -0
- package/src/server.mjs +349 -10
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// selection-risk-core.js
|
|
3
|
+
// -----------------------------------------------------------------------------
|
|
4
|
+
// The engine behind /tools/selection-risk: a sandbox where you search a strategy
|
|
5
|
+
// space over a series that provably has NO edge, and watch what your best result
|
|
6
|
+
// is worth once the search is counted.
|
|
7
|
+
//
|
|
8
|
+
// WHY SYNTHETIC, AND WHY THAT IS NOT A COMPROMISE.
|
|
9
|
+
// Two reasons, and the second is the better one.
|
|
10
|
+
//
|
|
11
|
+
// 1. Rights. config/data_source_rights_policy.json records every market-data
|
|
12
|
+
// source this project uses as PROHIBITED or WITHHELD for raw redistribution,
|
|
13
|
+
// with a project default of "do not bundle raw third-party rows". Shipping
|
|
14
|
+
// real bars into a browser is not available, and no amount of wanting it
|
|
15
|
+
// changes that.
|
|
16
|
+
//
|
|
17
|
+
// 2. Proof. On real data you can always argue the edge you found was real. Here
|
|
18
|
+
// the generator is a driftless random walk, so the true Sharpe is ZERO by
|
|
19
|
+
// construction. Every good result the visitor finds is therefore luck, with
|
|
20
|
+
// no room for debate, which is exactly the point being demonstrated. A real
|
|
21
|
+
// series would make the lesson weaker, not stronger.
|
|
22
|
+
//
|
|
23
|
+
// NO-LOOKAHEAD, the same rule the engine enforces. A signal is computed from
|
|
24
|
+
// closes up to and including bar t. The position it implies is held from the OPEN
|
|
25
|
+
// of bar t+1 to the OPEN of bar t+2. A decision never touches a price from its own
|
|
26
|
+
// bar or earlier than its own execution. `assertNoLookahead` below is the
|
|
27
|
+
// mechanical statement of that, and the test suite perturbs a bar to prove the
|
|
28
|
+
// property is real rather than asserted.
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/** Deterministic PRNG (mulberry32). Same seed, same series, on every machine. */
|
|
32
|
+
export function makeRandom(seed) {
|
|
33
|
+
let a = seed >>> 0;
|
|
34
|
+
return function next() {
|
|
35
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
36
|
+
let t = a;
|
|
37
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
38
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
39
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Box-Muller, drawing from a supplied uniform generator so it stays deterministic. */
|
|
44
|
+
function gaussian(next) {
|
|
45
|
+
let u = 0;
|
|
46
|
+
let v = 0;
|
|
47
|
+
while (u === 0) u = next();
|
|
48
|
+
while (v === 0) v = next();
|
|
49
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A driftless geometric random walk, as OHLC-style bars with an open and a close.
|
|
54
|
+
*
|
|
55
|
+
* `drift` exists only so a caller can demonstrate the contrast; the sandbox pins it
|
|
56
|
+
* to zero, which is what makes the true Sharpe zero.
|
|
57
|
+
*/
|
|
58
|
+
export function generateSeries({ seed, bars = 750, volatility = 0.011, drift = 0, gapVolatility = 0 }) {
|
|
59
|
+
if (!Number.isInteger(bars) || bars < 32) throw new Error("bars must be an integer >= 32");
|
|
60
|
+
if (!(volatility > 0)) throw new Error("volatility must be positive");
|
|
61
|
+
const next = makeRandom(seed);
|
|
62
|
+
const out = [];
|
|
63
|
+
let level = 100;
|
|
64
|
+
for (let i = 0; i < bars; i += 1) {
|
|
65
|
+
// The overnight gap. With gapVolatility at zero, open[i+1] equals close[i] and
|
|
66
|
+
// the series is continuous, which is what the Selection Risk Lab wants: it
|
|
67
|
+
// studies search, not execution, and a gap would only add noise there.
|
|
68
|
+
//
|
|
69
|
+
// The Execution Reality Lab needs a non-zero gap, because without one "fill at
|
|
70
|
+
// the next open" and "fill at the decision close" are the SAME price and the
|
|
71
|
+
// most important comparison on that page silently collapses to a tie.
|
|
72
|
+
if (gapVolatility > 0) level *= Math.exp(gapVolatility * gaussian(next));
|
|
73
|
+
const open = level;
|
|
74
|
+
level *= Math.exp(drift + volatility * gaussian(next));
|
|
75
|
+
out.push({ i, open, close: level });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Simple moving average of `closes` ending at index `end`, inclusive. */
|
|
81
|
+
function sma(closes, end, window) {
|
|
82
|
+
if (end + 1 < window) return Number.NaN;
|
|
83
|
+
let total = 0;
|
|
84
|
+
for (let k = end - window + 1; k <= end; k += 1) total += closes[k];
|
|
85
|
+
return total / window;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A fast/slow moving-average crossover, run under the engine's fill rule.
|
|
90
|
+
*
|
|
91
|
+
* Returns per-period returns, the equity path, and the summary statistics. The
|
|
92
|
+
* returned `decisions` array records, for each realised return, which bar the
|
|
93
|
+
* decision was taken on and which bars priced it, so a test can assert the
|
|
94
|
+
* causality rather than trust the comment above it.
|
|
95
|
+
*/
|
|
96
|
+
export function runCrossover({ series, fast, slow, costBps = 0 }) {
|
|
97
|
+
if (!Number.isInteger(fast) || !Number.isInteger(slow)) throw new Error("windows must be integers");
|
|
98
|
+
if (fast < 1 || slow < 2) throw new Error("windows must be positive");
|
|
99
|
+
if (fast >= slow) throw new Error("fast window must be shorter than slow");
|
|
100
|
+
const closes = series.map((b) => b.close);
|
|
101
|
+
const cost = costBps / 10000;
|
|
102
|
+
|
|
103
|
+
const returns = [];
|
|
104
|
+
const decisions = [];
|
|
105
|
+
let previousPosition = 0;
|
|
106
|
+
|
|
107
|
+
// t is the DECISION bar. Execution spans open[t+1] -> open[t+2], so the loop
|
|
108
|
+
// stops early enough that both exist.
|
|
109
|
+
for (let t = slow - 1; t + 2 < series.length; t += 1) {
|
|
110
|
+
const position = sma(closes, t, fast) > sma(closes, t, slow) ? 1 : 0;
|
|
111
|
+
const entry = series[t + 1].open;
|
|
112
|
+
const exit = series[t + 2].open;
|
|
113
|
+
const gross = position * (exit / entry - 1);
|
|
114
|
+
const turnover = Math.abs(position - previousPosition);
|
|
115
|
+
const net = gross - turnover * cost;
|
|
116
|
+
returns.push(net);
|
|
117
|
+
decisions.push({ decisionBar: t, entryBar: t + 1, exitBar: t + 2, position });
|
|
118
|
+
previousPosition = position;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let equity = 1;
|
|
122
|
+
const curve = [equity];
|
|
123
|
+
for (const r of returns) {
|
|
124
|
+
equity *= 1 + r;
|
|
125
|
+
curve.push(equity);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
returns,
|
|
130
|
+
curve,
|
|
131
|
+
decisions,
|
|
132
|
+
trades: decisions.reduce(
|
|
133
|
+
(n, d, k) => n + (k === 0 ? d.position : Math.abs(d.position - decisions[k - 1].position)),
|
|
134
|
+
0,
|
|
135
|
+
),
|
|
136
|
+
...summarize(returns, curve),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Per-period Sharpe, its annualised form, and the worst peak-to-trough drop. */
|
|
141
|
+
export function summarize(returns, curve) {
|
|
142
|
+
const n = returns.length;
|
|
143
|
+
if (n < 2) return { sharpePerPeriod: 0, sharpeAnnualised: 0, maxDrawdown: 0, totalReturn: 0 };
|
|
144
|
+
const mean = returns.reduce((a, b) => a + b, 0) / n;
|
|
145
|
+
const variance = returns.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1);
|
|
146
|
+
const sd = Math.sqrt(variance);
|
|
147
|
+
const sharpePerPeriod = sd > 0 ? mean / sd : 0;
|
|
148
|
+
let peak = curve[0];
|
|
149
|
+
let maxDrawdown = 0;
|
|
150
|
+
for (const value of curve) {
|
|
151
|
+
if (value > peak) peak = value;
|
|
152
|
+
const drop = peak > 0 ? 1 - value / peak : 0;
|
|
153
|
+
if (drop > maxDrawdown) maxDrawdown = drop;
|
|
154
|
+
}
|
|
155
|
+
// Sample skewness and NON-EXCESS kurtosis, which is what the deflation wants:
|
|
156
|
+
// a Gaussian scores 3 here, not 0. Handing it excess kurtosis silently shifts
|
|
157
|
+
// the estimator variance and the answer stays plausible, so the convention is
|
|
158
|
+
// named rather than assumed.
|
|
159
|
+
const m3 = returns.reduce((a, b) => a + (b - mean) ** 3, 0) / n;
|
|
160
|
+
const m4 = returns.reduce((a, b) => a + (b - mean) ** 4, 0) / n;
|
|
161
|
+
const populationSd = Math.sqrt(returns.reduce((a, b) => a + (b - mean) ** 2, 0) / n);
|
|
162
|
+
const skew = populationSd > 0 ? m3 / populationSd ** 3 : 0;
|
|
163
|
+
const nonExcessKurtosis = populationSd > 0 ? m4 / populationSd ** 4 : 3;
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
sharpePerPeriod,
|
|
167
|
+
// 252 periods, the convention for a daily-frequency series.
|
|
168
|
+
sharpeAnnualised: sharpePerPeriod * Math.sqrt(252),
|
|
169
|
+
maxDrawdown,
|
|
170
|
+
totalReturn: curve[curve.length - 1] - 1,
|
|
171
|
+
skew,
|
|
172
|
+
nonExcessKurtosis,
|
|
173
|
+
observations: n,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The mechanical no-lookahead statement: for every realised return, the decision
|
|
179
|
+
* bar strictly precedes the bar whose open is paid, which strictly precedes the
|
|
180
|
+
* bar whose open is received.
|
|
181
|
+
*/
|
|
182
|
+
export function assertNoLookahead(result) {
|
|
183
|
+
for (const d of result.decisions) {
|
|
184
|
+
if (!(d.decisionBar < d.entryBar && d.entryBar < d.exitBar)) {
|
|
185
|
+
return { ok: false, offending: d };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, offending: null };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Sample variance of a list of Sharpe ratios, the V[SR] the deflation needs. */
|
|
192
|
+
export function sharpeVariance(sharpes) {
|
|
193
|
+
const n = sharpes.length;
|
|
194
|
+
if (n < 2) return 0;
|
|
195
|
+
const mean = sharpes.reduce((a, b) => a + b, 0) / n;
|
|
196
|
+
return sharpes.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1);
|
|
197
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Student's t distribution to full double precision, for the haircut Sharpe ratio (Harvey and Liu,
|
|
2
|
+
// "Backtesting", 2015), which tests a Sharpe ratio's t-statistic against t with N - 1 degrees of
|
|
3
|
+
// freedom. Checked against R's pt and qt (js/fixtures/student-t-reference.json).
|
|
4
|
+
//
|
|
5
|
+
// The upper tail is computed directly, never as 1 - cdf: the authors' Haircut_SR.m computes
|
|
6
|
+
// 2 * (1 - tcdf(t, N - 1)), which rounds to 0 once t passes about 8 and makes the haircut Sharpe
|
|
7
|
+
// ratio infinite. P(T > t) = I_x(df / 2, 1 / 2) / 2 with x = df / (df + t^2) keeps full relative
|
|
8
|
+
// precision far into the tail.
|
|
9
|
+
|
|
10
|
+
const HALF_LOG_TWO_PI = 0.5 * Math.log(2 * Math.PI);
|
|
11
|
+
|
|
12
|
+
// Stirling's correction ln Gamma(x) - [(x - 1/2) ln x - x + ln(2 pi) / 2], for x >= 10: the series
|
|
13
|
+
// to the 1/x^13 term, whose error there is below 1e-16.
|
|
14
|
+
function stirlingCorrection(x) {
|
|
15
|
+
const inv = 1 / x;
|
|
16
|
+
const inv2 = inv * inv;
|
|
17
|
+
return inv * (1 / 12 + inv2 * (-1 / 360 + inv2 * (1 / 1260 + inv2 * (-1 / 1680 + inv2 * (1 / 1188 + inv2 * (-691 / 360360 + inv2 * (1 / 156)))))));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ln Gamma(x) for x > 0: shifted to x >= 10, where the series applies; ln Gamma(x) = ln Gamma(x + 1) -
|
|
21
|
+
// ln x undoes the shift.
|
|
22
|
+
export function logGamma(value) {
|
|
23
|
+
let x = Number(value);
|
|
24
|
+
if (!(x > 0) || !Number.isFinite(x)) throw new RangeError("logGamma needs a positive finite number");
|
|
25
|
+
let shift = 0;
|
|
26
|
+
while (x < 10) {
|
|
27
|
+
shift -= Math.log(x);
|
|
28
|
+
x += 1;
|
|
29
|
+
}
|
|
30
|
+
return (x - 0.5) * Math.log(x) - x + HALF_LOG_TWO_PI + stirlingCorrection(x) + shift;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ln Gamma(a + b) - ln Gamma(a) without subtracting two large numbers when a is large (degrees of
|
|
34
|
+
// freedom in the thousands): (a - 1/2) ln(1 + b/a) + b ln(a + b) - b + the two corrections.
|
|
35
|
+
function logGammaRatio(a, b) {
|
|
36
|
+
if (a < 10) return logGamma(a + b) - logGamma(a);
|
|
37
|
+
return (a - 0.5) * Math.log1p(b / a) + b * Math.log(a + b) - b + stirlingCorrection(a + b) - stirlingCorrection(a);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Continued fraction for the incomplete beta function (modified Lentz).
|
|
41
|
+
function betaFraction(a, b, x) {
|
|
42
|
+
const TINY = 1e-300;
|
|
43
|
+
const qab = a + b;
|
|
44
|
+
const qap = a + 1;
|
|
45
|
+
const qam = a - 1;
|
|
46
|
+
let c = 1;
|
|
47
|
+
let d = 1 - (qab * x) / qap;
|
|
48
|
+
if (Math.abs(d) < TINY) d = TINY;
|
|
49
|
+
d = 1 / d;
|
|
50
|
+
let h = d;
|
|
51
|
+
for (let m = 1; m <= 100000; m += 1) {
|
|
52
|
+
const m2 = 2 * m;
|
|
53
|
+
let aa = (m * (b - m) * x) / ((qam + m2) * (a + m2));
|
|
54
|
+
d = 1 + aa * d;
|
|
55
|
+
if (Math.abs(d) < TINY) d = TINY;
|
|
56
|
+
c = 1 + aa / c;
|
|
57
|
+
if (Math.abs(c) < TINY) c = TINY;
|
|
58
|
+
d = 1 / d;
|
|
59
|
+
h *= d * c;
|
|
60
|
+
aa = (-(a + m) * (qab + m) * x) / ((a + m2) * (qap + m2));
|
|
61
|
+
d = 1 + aa * d;
|
|
62
|
+
if (Math.abs(d) < TINY) d = TINY;
|
|
63
|
+
c = 1 + aa / c;
|
|
64
|
+
if (Math.abs(c) < TINY) c = TINY;
|
|
65
|
+
d = 1 / d;
|
|
66
|
+
const delta = d * c;
|
|
67
|
+
h *= delta;
|
|
68
|
+
if (Math.abs(delta - 1) < 1e-16) return h;
|
|
69
|
+
}
|
|
70
|
+
throw new Error("The incomplete beta continued fraction did not converge");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Regularized incomplete beta I_x(a, b) for b = 1/2 (all Student t needs), with y = 1 - x and both
|
|
74
|
+
// logarithms passed separately so that nothing is lost to cancellation when x or y is tiny.
|
|
75
|
+
function incompleteBeta(x, y, logX, logY, a, b) {
|
|
76
|
+
if (x <= 0) return 0;
|
|
77
|
+
if (y <= 0) return 1;
|
|
78
|
+
const front = Math.exp(logGammaRatio(a, b) - logGamma(b) + a * logX + b * logY);
|
|
79
|
+
if (x < (a + 1) / (a + b + 2)) return (front * betaFraction(a, b, x)) / a;
|
|
80
|
+
return 1 - (front * betaFraction(b, a, y)) / b;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function requireDf(df) {
|
|
84
|
+
const v = Number(df);
|
|
85
|
+
if (!(v > 0) || !Number.isFinite(v)) throw new RangeError("Degrees of freedom must be a positive finite number");
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// P(T > t) for T ~ t(df).
|
|
90
|
+
export function studentTUpper(t, df) {
|
|
91
|
+
const v = requireDf(df);
|
|
92
|
+
const value = Number(t);
|
|
93
|
+
if (Number.isNaN(value)) return Number.NaN;
|
|
94
|
+
if (value === 0) return 0.5;
|
|
95
|
+
if (value === Infinity) return 0;
|
|
96
|
+
if (value === -Infinity) return 1;
|
|
97
|
+
const t2 = value * value;
|
|
98
|
+
const logX = -Math.log1p(t2 / v);
|
|
99
|
+
const logY = Math.log(t2) - Math.log(v + t2);
|
|
100
|
+
const half = 0.5 * incompleteBeta(Math.exp(logX), t2 / (v + t2), logX, logY, v / 2, 0.5);
|
|
101
|
+
return value > 0 ? half : 1 - half;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function studentTPdf(t, df) {
|
|
105
|
+
const v = requireDf(df);
|
|
106
|
+
const value = Number(t);
|
|
107
|
+
return Math.exp(logGammaRatio(v / 2, 0.5) - 0.5 * Math.log(v * Math.PI) - ((v + 1) / 2) * Math.log1p((value * value) / v));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The t with P(T > t) = upper: bisection on a bracket (the tail can be very heavy for small df),
|
|
111
|
+
// then Newton steps on log P(T > t), which is smooth and keeps relative precision in the tail.
|
|
112
|
+
export function studentTQuantileUpper(upper, df) {
|
|
113
|
+
const v = requireDf(df);
|
|
114
|
+
const p = Number(upper);
|
|
115
|
+
if (!(p > 0 && p < 1)) throw new RangeError("The upper-tail probability must be strictly between 0 and 1");
|
|
116
|
+
if (p === 0.5) return 0;
|
|
117
|
+
if (p > 0.5) return -studentTQuantileUpper(1 - p, v);
|
|
118
|
+
let lo = 0;
|
|
119
|
+
let hi = 1;
|
|
120
|
+
while (studentTUpper(hi, v) > p) {
|
|
121
|
+
lo = hi;
|
|
122
|
+
hi *= 2;
|
|
123
|
+
if (!Number.isFinite(hi)) throw new RangeError("The upper-tail probability is too small for these degrees of freedom");
|
|
124
|
+
}
|
|
125
|
+
for (let i = 0; i < 200 && hi - lo > 1e-12 * hi; i += 1) {
|
|
126
|
+
const mid = 0.5 * (lo + hi);
|
|
127
|
+
if (studentTUpper(mid, v) > p) lo = mid;
|
|
128
|
+
else hi = mid;
|
|
129
|
+
}
|
|
130
|
+
let t = 0.5 * (lo + hi);
|
|
131
|
+
const target = Math.log(p);
|
|
132
|
+
for (let i = 0; i < 4; i += 1) {
|
|
133
|
+
const tail = studentTUpper(t, v);
|
|
134
|
+
const step = (Math.log(tail) - target) / (-studentTPdf(t, v) / tail);
|
|
135
|
+
if (!Number.isFinite(step)) break;
|
|
136
|
+
t -= step;
|
|
137
|
+
}
|
|
138
|
+
return t;
|
|
139
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// js/validate/backtest-length.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/backtest-length, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { expectedMaxStandardNormal, maximumIndependentTrials, minimumBacktestLength } from "../dsr-core.js";
|
|
5
|
+
|
|
6
|
+
// Minimum Backtest Length: Bailey, Borwein, López de Prado and Zhu, "Pseudo-Mathematics and
|
|
7
|
+
// Financial Charlatanism" (Notices of the AMS, 2014), Theorem 3.1, checked against the paper's own
|
|
8
|
+
// statements in js/minbtl-paper-vectors.test.js. With N independent trials and a target in-sample
|
|
9
|
+
// Sharpe, how many years a backtest needs before the best trial is not expected to reach the target
|
|
10
|
+
// by luck; with the backtest's years, how many independent trials those years allow.
|
|
11
|
+
|
|
12
|
+
export function compute(body) {
|
|
13
|
+
const hasTrials = body.effective_independent_trials !== undefined;
|
|
14
|
+
const hasYears = body.backtest_years !== undefined;
|
|
15
|
+
if (!hasTrials && !hasYears) throw new RangeError("Send effective_independent_trials, backtest_years, or both");
|
|
16
|
+
const inputs = { target_sharpe_annualized: body.target_sharpe_annualized === undefined ? 1 : Number(body.target_sharpe_annualized) };
|
|
17
|
+
if (!(inputs.target_sharpe_annualized > 0 && inputs.target_sharpe_annualized <= 10)) throw new RangeError("target_sharpe_annualized must be greater than 0 and at most 10");
|
|
18
|
+
if (hasTrials) {
|
|
19
|
+
inputs.effective_independent_trials = Number(body.effective_independent_trials);
|
|
20
|
+
if (!Number.isInteger(inputs.effective_independent_trials) || inputs.effective_independent_trials < 2 || inputs.effective_independent_trials > 1e9) {
|
|
21
|
+
throw new RangeError("effective_independent_trials must be an integer from 2 to 1000000000");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (hasYears) {
|
|
25
|
+
inputs.backtest_years = Number(body.backtest_years);
|
|
26
|
+
if (!(inputs.backtest_years > 0 && inputs.backtest_years <= 1000)) throw new RangeError("backtest_years must be greater than 0 and at most 1000");
|
|
27
|
+
}
|
|
28
|
+
const result = {};
|
|
29
|
+
if (hasTrials) {
|
|
30
|
+
const minimum = minimumBacktestLength({ trials: inputs.effective_independent_trials, targetSharpe: inputs.target_sharpe_annualized });
|
|
31
|
+
result.minimum_backtest_years = minimum.years;
|
|
32
|
+
result.upper_bound_years = minimum.upper_bound_years;
|
|
33
|
+
}
|
|
34
|
+
if (hasYears) {
|
|
35
|
+
result.maximum_independent_trials = maximumIndependentTrials({ years: inputs.backtest_years, targetSharpe: inputs.target_sharpe_annualized });
|
|
36
|
+
}
|
|
37
|
+
if (hasTrials && hasYears) {
|
|
38
|
+
result.expected_max_sharpe_annualized = expectedMaxStandardNormal(inputs.effective_independent_trials) / Math.sqrt(inputs.backtest_years);
|
|
39
|
+
result.long_enough = inputs.backtest_years >= result.minimum_backtest_years;
|
|
40
|
+
}
|
|
41
|
+
return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reading(inputs, r) {
|
|
45
|
+
const sharpe = (x) => String(Number(Number(x).toFixed(3)));
|
|
46
|
+
const target = sharpe(inputs.target_sharpe_annualized);
|
|
47
|
+
const parts = [];
|
|
48
|
+
if (r.minimum_backtest_years !== undefined) {
|
|
49
|
+
parts.push(`With ${inputs.effective_independent_trials} independent trials, the best one is expected to show an in-sample Sharpe of ${target} by luck alone unless the backtest covers at least ${r.minimum_backtest_years.toFixed(2)} years.`);
|
|
50
|
+
}
|
|
51
|
+
if (r.maximum_independent_trials !== undefined) {
|
|
52
|
+
parts.push(r.maximum_independent_trials === 1
|
|
53
|
+
? `${sharpe(inputs.backtest_years)} years of backtest is too short for even two independent trials: their best is expected to reach a Sharpe of ${target} by luck.`
|
|
54
|
+
: `${sharpe(inputs.backtest_years)} years of backtest allows at most ${r.maximum_independent_trials} independent trials before the best is expected to reach a Sharpe of ${target} by luck.`);
|
|
55
|
+
}
|
|
56
|
+
if (r.expected_max_sharpe_annualized !== undefined) {
|
|
57
|
+
parts.push(`Over ${sharpe(inputs.backtest_years)} years, the best of ${inputs.effective_independent_trials} skill-less trials is expected to show a Sharpe of ${sharpe(r.expected_max_sharpe_annualized)}.`);
|
|
58
|
+
}
|
|
59
|
+
parts.push("Meeting this length is necessary, not sufficient: a backtest can still be overfit.");
|
|
60
|
+
return parts.join(" ");
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// js/validate/breadth.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/breadth, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { bookSharpe, breadthCeiling, ceilingCaptured, sleevesRequired } from "../breadth-core.js";
|
|
5
|
+
|
|
6
|
+
export function compute(body) {
|
|
7
|
+
const sleeveSharpe = Number(body.sleeve_sharpe);
|
|
8
|
+
const correlation = Number(body.average_pairwise_correlation);
|
|
9
|
+
if (!Number.isFinite(sleeveSharpe) || sleeveSharpe <= 0) throw new RangeError("sleeve_sharpe must be a positive number");
|
|
10
|
+
if (!Number.isFinite(correlation) || correlation < -1 || correlation > 1) throw new RangeError("average_pairwise_correlation must be between -1 and 1");
|
|
11
|
+
const sleeves = body.sleeves === undefined ? null : Number(body.sleeves);
|
|
12
|
+
if (sleeves !== null && (!Number.isInteger(sleeves) || sleeves < 1 || sleeves > 500)) throw new RangeError("sleeves must be an integer from 1 to 500");
|
|
13
|
+
const target = body.target === undefined ? null : Number(body.target);
|
|
14
|
+
if (target !== null && !(target > 0)) throw new RangeError("target must be a positive Sharpe");
|
|
15
|
+
const ceiling = breadthCeiling({ sleeveSharpe, correlation });
|
|
16
|
+
const out = { identity: "S_book = s_bar * sqrt(N / (1 + (N - 1) * rho_bar)); ceiling as N grows is s_bar / sqrt(rho_bar)", ceiling: Number.isFinite(ceiling) ? ceiling : null, ceiling_is_unbounded: !Number.isFinite(ceiling) };
|
|
17
|
+
if (sleeves !== null) out.book = { sleeves, book_sharpe: bookSharpe({ sleeveSharpe, sleeves, correlation }), ceiling_captured: Number.isFinite(ceiling) ? ceilingCaptured({ sleeveSharpe, sleeves, correlation }) : null };
|
|
18
|
+
if (target !== null) {
|
|
19
|
+
const req = sleevesRequired({ sleeveSharpe, correlation, target });
|
|
20
|
+
out.target = { target, reachable: req.reachable, sleeves_required: req.sleeves, note: req.reachable ? "At this per-sleeve quality and correlation the target is reachable with the stated sleeve count." : "No number of sleeves of this quality at this correlation reaches the target; raise per-sleeve quality or lower correlation." };
|
|
21
|
+
}
|
|
22
|
+
out.plain_reading = out.ceiling_is_unbounded ? "With average pairwise correlation at or below zero the book Sharpe has no ceiling from breadth alone; correlation this low is rare and should be checked in stress." : `Adding sleeves of this quality can never take the book above a Sharpe of ${ceiling.toFixed(3)}. Quality and correlation set the ceiling; count only approaches it.`;
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// js/validate/deflated-sharpe.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/deflated-sharpe, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { calculateDsr } from "../dsr-core.js";
|
|
5
|
+
import { dsrFromReturns } from "../moments-core.js";
|
|
6
|
+
import { LIMITS } from "../../api/_lib/limits.js";
|
|
7
|
+
|
|
8
|
+
const CONTRACT_KEYS = ["observed_sharpe_annualized", "observations", "periods_per_year", "skew", "non_excess_kurtosis", "effective_independent_trials", "cross_trial_sharpe_sd_annualized"];
|
|
9
|
+
|
|
10
|
+
export function compute(body) {
|
|
11
|
+
const hasSeries = Array.isArray(body.returns);
|
|
12
|
+
const hasContract = CONTRACT_KEYS.every((k) => body[k] !== undefined);
|
|
13
|
+
if (hasSeries && body.observed_sharpe_annualized !== undefined) throw new RangeError("Send either a return series or the contract inputs, not both");
|
|
14
|
+
if (hasSeries) {
|
|
15
|
+
if (body.returns.length > LIMITS.max_observations) throw new RangeError(`A series may hold at most ${LIMITS.max_observations} observations`);
|
|
16
|
+
const { derived_inputs, result } = dsrFromReturns(body);
|
|
17
|
+
return { input_mode: "return_series", derived_inputs, result, plain_reading: reading(result) };
|
|
18
|
+
}
|
|
19
|
+
if (!hasContract) throw new RangeError(`Send a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized) or all of: ${CONTRACT_KEYS.join(", ")}`);
|
|
20
|
+
const inputs = Object.fromEntries(CONTRACT_KEYS.map((k) => [k, body[k]]));
|
|
21
|
+
const result = calculateDsr(inputs);
|
|
22
|
+
return { input_mode: "contract_inputs", derived_inputs: inputs, result, plain_reading: reading(result) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reading(r) {
|
|
26
|
+
const pct = (x) => `${(x * 100).toFixed(1)} percent`;
|
|
27
|
+
return `Counting only sample uncertainty, the probability this Sharpe is above zero is ${pct(r.psr_against_zero)}. Deflated for the best-by-luck Sharpe the declared search would produce (${r.expected_max_sharpe_annualized.toFixed(3)} annualised), it is ${pct(r.deflated_sharpe_ratio)}. Neither number is a forecast.`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// js/validate/haircut-sharpe.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/haircut-sharpe, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { haircutSharpe } from "../haircut-core.js";
|
|
5
|
+
|
|
6
|
+
// The haircut Sharpe ratio of Harvey and Liu, "Backtesting" (2015), checked against the authors'
|
|
7
|
+
// Haircut_SR.m and R in js/haircut-paper-vectors.test.js. Bonferroni and independent-test adjustments
|
|
8
|
+
// need only the number of tests; Holm and BHY need the other tests' Sharpe ratios, which the caller
|
|
9
|
+
// knows (the authors simulate them from a published factor model when they are unknown).
|
|
10
|
+
|
|
11
|
+
const MAX_OTHERS = 10000;
|
|
12
|
+
|
|
13
|
+
export function compute(body) {
|
|
14
|
+
const missing = ["observed_sharpe_annualized", "periods_per_year", "observations"].filter((k) => body[k] === undefined);
|
|
15
|
+
if (missing.length) throw new RangeError(`Missing required fields: ${missing.join(", ")}`);
|
|
16
|
+
const others = body.other_sharpe_ratios_annualized;
|
|
17
|
+
if (others !== undefined && (!Array.isArray(others) || others.length < 1 || others.length > MAX_OTHERS)) {
|
|
18
|
+
throw new RangeError(`other_sharpe_ratios_annualized must be an array of 1 to ${MAX_OTHERS} Sharpe ratios`);
|
|
19
|
+
}
|
|
20
|
+
if (others === undefined && body.tests === undefined) throw new RangeError("Send tests, other_sharpe_ratios_annualized, or both");
|
|
21
|
+
const inputs = {
|
|
22
|
+
observed_sharpe_annualized: Number(body.observed_sharpe_annualized),
|
|
23
|
+
periods_per_year: Number(body.periods_per_year),
|
|
24
|
+
observations: Number(body.observations),
|
|
25
|
+
autocorrelation: body.autocorrelation === undefined ? 0 : Number(body.autocorrelation),
|
|
26
|
+
...(body.tests !== undefined ? { tests: Number(body.tests) } : {}),
|
|
27
|
+
...(others !== undefined ? { other_tests: others.length } : {}),
|
|
28
|
+
};
|
|
29
|
+
if (!(inputs.observations <= 1000000)) throw new RangeError("observations must be at most 1000000");
|
|
30
|
+
const result = haircutSharpe({
|
|
31
|
+
sharpeAnnualized: inputs.observed_sharpe_annualized,
|
|
32
|
+
periodsPerYear: inputs.periods_per_year,
|
|
33
|
+
observations: inputs.observations,
|
|
34
|
+
tests: inputs.tests,
|
|
35
|
+
autocorrelation: inputs.autocorrelation,
|
|
36
|
+
otherSharpesAnnualized: others,
|
|
37
|
+
});
|
|
38
|
+
return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reading(inputs, r) {
|
|
42
|
+
const num = (x, d = 3) => String(Number(Number(x).toFixed(d)));
|
|
43
|
+
const pct = (x) => `${(x * 100).toFixed(1)} percent`;
|
|
44
|
+
const p = (x) => (x < 0.0001 ? Number(x).toExponential(2) : num(x, 4));
|
|
45
|
+
const line = (name, a) => `${name} gives an adjusted p-value of ${p(a.adjusted_p)} and a haircut Sharpe of ${num(a.haircut_sharpe_annualized)}, a ${pct(a.haircut)} haircut`;
|
|
46
|
+
const corrected = inputs.autocorrelation !== 0 ? ` (${num(r.sharpe_annualized_corrected)} after the autocorrelation correction)` : "";
|
|
47
|
+
const parts = [
|
|
48
|
+
`Tested alone, an annualized Sharpe of ${num(inputs.observed_sharpe_annualized)}${corrected} over ${inputs.observations} observations has a p-value of ${p(r.p_value_single)}.`,
|
|
49
|
+
`Counting ${r.tests} tests, ${line("Bonferroni", r.bonferroni)}; treating the tests as independent ${line("", r.independent).trim()}.`,
|
|
50
|
+
];
|
|
51
|
+
if (r.holm) parts.push(`With the other tests' own Sharpe ratios, ${line("Holm", r.holm)}, and ${line("BHY", r.bhy)}.`);
|
|
52
|
+
parts.push("The haircut counts only the tests declared; tests run and not counted are invisible to it.");
|
|
53
|
+
return parts.join(" ");
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// js/validate/luck-trials.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/luck-trials, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { luckEquivalentTrials, TRIAL_CAP } from "../luck-core.js";
|
|
5
|
+
|
|
6
|
+
// Luck-equivalent trials (js/luck-core.js): how many skill-less strategies a search would have had
|
|
7
|
+
// to try for its best to reach the observed Sharpe by luck. Calibrated by Monte Carlo in
|
|
8
|
+
// js/luck-core.test.js; too kind to negatively skewed strategies, which the reading says when the
|
|
9
|
+
// caller sends the skew.
|
|
10
|
+
|
|
11
|
+
const NEGATIVE_SKEW = -0.5;
|
|
12
|
+
|
|
13
|
+
export function compute(body) {
|
|
14
|
+
const missing = ["observed_sharpe_annualized", "periods_per_year", "observations"].filter((k) => body[k] === undefined);
|
|
15
|
+
if (missing.length) throw new RangeError(`Missing required fields: ${missing.join(", ")}`);
|
|
16
|
+
const inputs = {
|
|
17
|
+
observed_sharpe_annualized: Number(body.observed_sharpe_annualized),
|
|
18
|
+
periods_per_year: Number(body.periods_per_year),
|
|
19
|
+
observations: Number(body.observations),
|
|
20
|
+
...(body.effective_independent_trials !== undefined ? { effective_independent_trials: Number(body.effective_independent_trials) } : {}),
|
|
21
|
+
...(body.skew !== undefined ? { skew: Number(body.skew) } : {}),
|
|
22
|
+
...(body.autocorrelation !== undefined ? { autocorrelation: Number(body.autocorrelation) } : {}),
|
|
23
|
+
};
|
|
24
|
+
if (!(Math.abs(inputs.observed_sharpe_annualized) <= 20)) throw new RangeError("observed_sharpe_annualized must be a number from -20 to 20");
|
|
25
|
+
if (!(inputs.periods_per_year > 0 && inputs.periods_per_year <= 100000)) throw new RangeError("periods_per_year must be greater than 0 and at most 100000");
|
|
26
|
+
if (!Number.isInteger(inputs.observations) || inputs.observations < 3 || inputs.observations > 1000000) throw new RangeError("observations must be an integer from 3 to 1000000");
|
|
27
|
+
const trials = inputs.effective_independent_trials;
|
|
28
|
+
if (trials !== undefined && (!Number.isInteger(trials) || trials < 1 || trials > 1e9)) throw new RangeError("effective_independent_trials must be an integer from 1 to 1000000000");
|
|
29
|
+
if (inputs.skew !== undefined && !Number.isFinite(inputs.skew)) throw new RangeError("skew must be a finite number");
|
|
30
|
+
if (inputs.autocorrelation !== undefined && !(inputs.autocorrelation > -1 && inputs.autocorrelation < 1)) throw new RangeError("autocorrelation must be strictly between -1 and 1");
|
|
31
|
+
const result = luckEquivalentTrials({ sharpe: inputs.observed_sharpe_annualized, observations: inputs.observations, periodsPerYear: inputs.periods_per_year, trials, autocorrelation: inputs.autocorrelation });
|
|
32
|
+
return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const whole = (n) => Math.floor(n).toLocaleString("en-US");
|
|
36
|
+
|
|
37
|
+
function reading(inputs, r) {
|
|
38
|
+
const sharpe = String(Number(inputs.observed_sharpe_annualized.toFixed(3)));
|
|
39
|
+
const over = `over these ${inputs.observations} observations`;
|
|
40
|
+
const parts = [];
|
|
41
|
+
if (r.trials_for_five_percent >= TRIAL_CAP) {
|
|
42
|
+
parts.push(`Luck alone would need more than ${TRIAL_CAP.toExponential(0).replace("e+", "e")} skill-less strategies to reach a Sharpe of ${sharpe} ${over}.`);
|
|
43
|
+
} else if (r.trials_for_even_odds < 2) {
|
|
44
|
+
parts.push(`A single skill-less strategy shows a Sharpe of ${sharpe} or more ${over} with probability ${r.single_trial_probability.toFixed(3)}: luck alone readily explains it.`);
|
|
45
|
+
} else if (r.trials_for_five_percent < 1) {
|
|
46
|
+
parts.push(`The best of ${whole(r.trials_for_even_odds)} skill-less strategies reaches a Sharpe of ${sharpe} ${over} about half the time, and even one reaches it more than 5 percent of the time.`);
|
|
47
|
+
} else {
|
|
48
|
+
parts.push(`The best of ${whole(r.trials_for_even_odds)} skill-less strategies reaches a Sharpe of ${sharpe} ${over} about half the time; with at most ${whole(r.trials_for_five_percent)} tried, luck reaches it less than 5 percent of the time.`);
|
|
49
|
+
}
|
|
50
|
+
if (r.best_of_trials_probability !== undefined) {
|
|
51
|
+
parts.push(`With ${inputs.effective_independent_trials} independent trials, the chance that the best reaches it by luck is ${(r.best_of_trials_probability * 100).toPrecision(3)} percent.`);
|
|
52
|
+
}
|
|
53
|
+
if (inputs.skew !== undefined && inputs.skew < NEGATIVE_SKEW) {
|
|
54
|
+
parts.push(`With a return skew of ${inputs.skew}, these counts are too generous: negatively skewed returns reach high Sharpe ratios by luck more often than this assumes.`);
|
|
55
|
+
}
|
|
56
|
+
if (inputs.autocorrelation === undefined) {
|
|
57
|
+
parts.push("Without the returns' autocorrelation this assumes none; positively autocorrelated returns reach high Sharpe ratios by luck more often.");
|
|
58
|
+
} else if (inputs.autocorrelation !== 0) {
|
|
59
|
+
parts.push(`The Sharpe was first corrected for an autocorrelation of ${inputs.autocorrelation} (Lo, 2002), to ${String(Number((inputs.observed_sharpe_annualized * r.autocorrelation_factor).toFixed(3)))}.`);
|
|
60
|
+
}
|
|
61
|
+
parts.push("It counts independent trials; correlated trials count as fewer.");
|
|
62
|
+
return parts.join(" ");
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// js/validate/overfitting.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/overfitting, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import { pboCscv } from "../pbo-core.js";
|
|
5
|
+
import { LIMITS } from "../../api/_lib/limits.js";
|
|
6
|
+
|
|
7
|
+
const mean = (xs) => xs.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0) / xs.length;
|
|
8
|
+
|
|
9
|
+
export function compute(body) {
|
|
10
|
+
const { matrix, n_splits = 16, max_combinations = LIMITS.max_cscv_combinations, seed = 42 } = body;
|
|
11
|
+
if (!Array.isArray(matrix) || !Array.isArray(matrix[0])) throw new RangeError("matrix must be an array of rows, each a row of variant returns for one period");
|
|
12
|
+
if (matrix.length > LIMITS.max_observations) throw new RangeError(`A matrix may hold at most ${LIMITS.max_observations} rows`);
|
|
13
|
+
if (matrix[0].length > LIMITS.max_variants) throw new RangeError(`A matrix may hold at most ${LIMITS.max_variants} variants`);
|
|
14
|
+
if (!Number.isInteger(max_combinations) || max_combinations > LIMITS.max_cscv_combinations) throw new RangeError(`max_combinations may not exceed ${LIMITS.max_cscv_combinations}`);
|
|
15
|
+
const out = pboCscv(matrix, { nSplits: Number(n_splits), maxCombinations: max_combinations, seed: Number(seed) });
|
|
16
|
+
const sorted = [...out.lambdas].sort((a, b) => a - b);
|
|
17
|
+
const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
|
|
18
|
+
const degraded = out.is_oos_pairs.filter(([i, o]) => o < i).length / out.is_oos_pairs.length;
|
|
19
|
+
return {
|
|
20
|
+
pbo: out.pbo,
|
|
21
|
+
n_combinations: out.n_combinations,
|
|
22
|
+
exhaustive: out.exhaustive,
|
|
23
|
+
sampler: out.exhaustive ? "all combinations enumerated in lexicographic order" : `${out.n_combinations} combinations drawn without replacement by mulberry32(seed=${Number(seed)}); the Python reference draws with numpy, so estimates agree within sampling noise, not bit for bit`,
|
|
24
|
+
block_length: out.block_length,
|
|
25
|
+
lambda_quantiles: { p05: q(0.05), p25: q(0.25), p50: q(0.5), p75: q(0.75), p95: q(0.95) },
|
|
26
|
+
is_oos_summary: { mean_is_sharpe_of_selected: mean(out.is_oos_pairs.map((p) => p[0])), mean_oos_sharpe_of_selected: mean(out.is_oos_pairs.map((p) => p[1])), share_oos_below_is: degraded },
|
|
27
|
+
plain_reading: `In ${(out.pbo * 100).toFixed(1)} percent of the ${out.n_combinations} in-sample and out-of-sample splits, the variant that looked best in sample ranked in the worse half out of sample. That share is the probability of backtest overfitting for this set of variants as submitted.`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// js/validate/paper-evidence.js
|
|
2
|
+
// The pure computation behind POST /api/v1/validate/paper-evidence, shared by the API route and the
|
|
3
|
+
// MCP package's local mode (mcp/src/local mirrors it byte for byte), so the two cannot disagree.
|
|
4
|
+
import schema from "../../standards/paper-evidence/schema.json" with { type: "json" };
|
|
5
|
+
import { conformance } from "../paper-evidence-core.js";
|
|
6
|
+
|
|
7
|
+
export function compute(body) {
|
|
8
|
+
if (!body.record || typeof body.record !== "object" || Array.isArray(body.record)) throw new RangeError("Send the record to validate under the key \"record\"");
|
|
9
|
+
const out = conformance(body.record, schema);
|
|
10
|
+
return { standard: "canli.paper-evidence.v0", valid: out.valid, structural: out.structural, semantic: out.semantic, plain_reading: out.valid ? "The record conforms to the standard. Conformance says the record states what a paper record must state; it does not say the strategy works." : `The record does not conform: ${out.structural.length} structural and ${out.semantic.length} semantic failure(s), each with a JSON pointer.` };
|
|
11
|
+
}
|