canli-validation-mcp 0.3.1 → 0.5.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.
@@ -0,0 +1,167 @@
1
+ // =============================================================================
2
+ // paper-evidence-core.js
3
+ // -----------------------------------------------------------------------------
4
+ // A validator for canli.paper-evidence.v0, with no dependencies.
5
+ //
6
+ // It implements the subset of JSON Schema the standard actually uses: type,
7
+ // const, enum, required, additionalProperties, properties, items, minItems,
8
+ // minLength, minimum, maximum, pattern, and the date and date-time formats.
9
+ //
10
+ // WHY NOT A LIBRARY. A standard is only adoptable if checking it is cheap. A
11
+ // validator that pulls a dependency tree makes conformance a project; one that
12
+ // runs in any browser and any Node without installing anything makes it a
13
+ // paste. The subset is small enough to read in one sitting, which is the other
14
+ // half of adoptability.
15
+ //
16
+ // Every error carries a JSON Pointer to the offending location, because "invalid"
17
+ // without a path is a validator that makes you do its work.
18
+ // =============================================================================
19
+
20
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
21
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
22
+
23
+ const typeOf = (value) => {
24
+ if (value === null) return "null";
25
+ if (Array.isArray(value)) return "array";
26
+ if (Number.isInteger(value)) return "integer";
27
+ return typeof value;
28
+ };
29
+
30
+ const matchesType = (value, expected) => {
31
+ const actual = typeOf(value);
32
+ if (expected === "number") return actual === "number" || actual === "integer";
33
+ return actual === expected;
34
+ };
35
+
36
+ function isValidDate(text) {
37
+ if (!ISO_DATE.test(text)) return false;
38
+ const [y, m, d] = text.split("-").map(Number);
39
+ const date = new Date(Date.UTC(y, m - 1, d));
40
+ // Round-trip: rejects 2026-02-30, which a regex alone accepts.
41
+ return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
42
+ }
43
+
44
+ /**
45
+ * Validate `value` against `schema`. Returns every error found, not just the
46
+ * first: a conformance report that stops at the first problem turns adoption
47
+ * into a guessing game.
48
+ */
49
+ export function validate(value, schema, pointer = "") {
50
+ const errors = [];
51
+ const fail = (message, at = pointer) => errors.push({ pointer: at || "/", message });
52
+
53
+ if ("const" in schema && value !== schema.const) {
54
+ fail(`must equal ${JSON.stringify(schema.const)}`);
55
+ return errors;
56
+ }
57
+
58
+ if (schema.enum && !schema.enum.includes(value)) {
59
+ fail(`must be one of ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}`);
60
+ return errors;
61
+ }
62
+
63
+ if (schema.type) {
64
+ const allowed = Array.isArray(schema.type) ? schema.type : [schema.type];
65
+ if (!allowed.some((t) => matchesType(value, t))) {
66
+ fail(`expected ${allowed.join(" or ")}, got ${typeOf(value)}`);
67
+ return errors;
68
+ }
69
+ }
70
+
71
+ if (typeof value === "string") {
72
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
73
+ fail(`must be at least ${schema.minLength} character(s)`);
74
+ }
75
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
76
+ fail(`must match ${schema.pattern}`);
77
+ }
78
+ if (schema.format === "date" && !isValidDate(value)) fail("must be a valid ISO date");
79
+ if (schema.format === "date-time" && !ISO_DATE_TIME.test(value)) {
80
+ fail("must be an ISO date-time");
81
+ }
82
+ }
83
+
84
+ if (typeof value === "number") {
85
+ if (schema.minimum !== undefined && value < schema.minimum) fail(`must be >= ${schema.minimum}`);
86
+ if (schema.maximum !== undefined && value > schema.maximum) fail(`must be <= ${schema.maximum}`);
87
+ }
88
+
89
+ if (Array.isArray(value)) {
90
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
91
+ fail(`must contain at least ${schema.minItems} item(s)`);
92
+ }
93
+ if (schema.items) {
94
+ value.forEach((item, i) => errors.push(...validate(item, schema.items, `${pointer}/${i}`)));
95
+ }
96
+ }
97
+
98
+ if (value && typeof value === "object" && !Array.isArray(value)) {
99
+ for (const key of schema.required ?? []) {
100
+ if (!(key in value)) fail(`missing required property "${key}"`);
101
+ }
102
+ if (schema.additionalProperties === false) {
103
+ for (const key of Object.keys(value)) {
104
+ if (!(schema.properties && key in schema.properties)) {
105
+ fail(`unexpected property "${key}"`, `${pointer}/${key}`);
106
+ }
107
+ }
108
+ }
109
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
110
+ if (key in value) errors.push(...validate(value[key], sub, `${pointer}/${key}`));
111
+ }
112
+ }
113
+
114
+ return errors;
115
+ }
116
+
117
+ /**
118
+ * Checks the standard cannot express as shape, only as a relationship between
119
+ * fields. A record can satisfy every type in the schema and still make a claim
120
+ * it has disqualified itself from making, and these are exactly those cases.
121
+ */
122
+ export function semanticChecks(record) {
123
+ const errors = [];
124
+ const fail = (pointer, message) => errors.push({ pointer, message });
125
+
126
+ if (record?.returns?.sharpe_reportable === false && record?.returns?.sharpe_annualised !== null) {
127
+ fail(
128
+ "/returns/sharpe_annualised",
129
+ "sharpe_reportable is false, so sharpe_annualised must be null. Declaring a sample " +
130
+ "cannot support a figure and then publishing the figure is the defect this field exists to prevent.",
131
+ );
132
+ }
133
+ if (record?.selection?.trials_counted === true && record?.selection?.trial_count === null) {
134
+ fail("/selection/trial_count", "trials_counted is true, so trial_count may not be null");
135
+ }
136
+ if (record?.selection?.deflation_applied === true && record?.selection?.deflated_sharpe_ratio === null) {
137
+ fail("/selection/deflated_sharpe_ratio", "deflation_applied is true, so the deflated ratio must be present");
138
+ }
139
+ if (record?.capital?.kind === "PAPER" && record?.returns?.basis === "NET_OF_REALISED_COSTS") {
140
+ fail(
141
+ "/returns/basis",
142
+ "a PAPER record cannot be net of REALISED costs: realised costs require funded execution. " +
143
+ "Use NET_OF_MODELLED_COSTS.",
144
+ );
145
+ }
146
+ if (record?.risk?.drawdown_basis === "NOT_ESTABLISHED" && record?.risk?.max_drawdown_realised !== null) {
147
+ fail(
148
+ "/risk/max_drawdown_realised",
149
+ "drawdown_basis is NOT_ESTABLISHED, so no realised drawdown may be stated",
150
+ );
151
+ }
152
+ const first = record?.period?.first_observation;
153
+ const last = record?.period?.last_observation;
154
+ if (first && last && first > last) {
155
+ fail("/period", "first_observation is after last_observation");
156
+ }
157
+ return errors;
158
+ }
159
+
160
+ /** Full conformance: shape, then the relationships shape cannot express. */
161
+ export function conformance(record, schema) {
162
+ const structural = validate(record, schema);
163
+ // Semantic checks read fields; running them on a structurally broken record
164
+ // produces noise, so they are gated on the shape being right first.
165
+ const semantic = structural.length === 0 ? semanticChecks(record) : [];
166
+ return { valid: structural.length === 0 && semantic.length === 0, structural, semantic };
167
+ }
@@ -0,0 +1,112 @@
1
+ // js/pbo-core.js
2
+ // Probability of Backtest Overfitting by Combinatorially Symmetric Cross-Validation.
3
+ // A line-for-line port of alphaforge.validation.pbo.pbo_cscv. Where the Python samples
4
+ // combinations with numpy's generator this port uses the repo's mulberry32 and SAYS SO
5
+ // in the result (`exhaustive: false`): the exhaustive case is bit-comparable, the sampled
6
+ // case is comparable within sampling noise. Pinned to standards/validation-api/vectors.json.
7
+ import { makeRandom } from "./selection-risk-core.js";
8
+
9
+ function blockSharpe(rows, nConfigs) {
10
+ // Population std (ddof=0); zero variance -> -Infinity so it is never IS-best and ranks worst OOS.
11
+ const out = new Array(nConfigs);
12
+ for (let j = 0; j < nConfigs; j++) {
13
+ let sum = 0;
14
+ for (const row of rows) sum += row[j];
15
+ const mean = sum / rows.length;
16
+ let ss = 0;
17
+ for (const row of rows) { const d = row[j] - mean; ss += d * d; }
18
+ const std = Math.sqrt(ss / rows.length);
19
+ out[j] = std > 0 ? mean / std : Number.NEGATIVE_INFINITY;
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function averageRanks(values) {
25
+ // scipy.stats.rankdata(method="average"): 1-based, ties share the mean rank.
26
+ const order = values.map((v, i) => [v, i]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
27
+ const ranks = new Array(values.length);
28
+ let i = 0;
29
+ while (i < order.length) {
30
+ let j = i;
31
+ while (j + 1 < order.length && order[j + 1][0] === order[i][0]) j++;
32
+ const rank = (i + 1 + j + 1) / 2;
33
+ for (let k = i; k <= j; k++) ranks[order[k][1]] = rank;
34
+ i = j + 1;
35
+ }
36
+ return ranks;
37
+ }
38
+
39
+ function binomial(n, k) {
40
+ let r = 1;
41
+ for (let i = 1; i <= k; i++) r = (r * (n - k + i)) / i;
42
+ return Math.round(r);
43
+ }
44
+
45
+ function* combinations(n, k) {
46
+ const idx = Array.from({ length: k }, (_, i) => i);
47
+ while (true) {
48
+ yield idx.slice();
49
+ let i = k - 1;
50
+ while (i >= 0 && idx[i] === n - k + i) i--;
51
+ if (i < 0) return;
52
+ idx[i]++;
53
+ for (let j = i + 1; j < k; j++) idx[j] = idx[j - 1] + 1;
54
+ }
55
+ }
56
+
57
+ function sampleCombinations(n, k, count, seed) {
58
+ const next = makeRandom(seed);
59
+ const seen = new Set();
60
+ const out = [];
61
+ while (out.length < count) {
62
+ const pool = Array.from({ length: n }, (_, i) => i);
63
+ const pick = [];
64
+ for (let d = 0; d < k; d++) {
65
+ const at = Math.floor(next() * pool.length);
66
+ pick.push(pool[at]);
67
+ pool.splice(at, 1);
68
+ }
69
+ pick.sort((a, b) => a - b);
70
+ const key = pick.join(",");
71
+ if (!seen.has(key)) { seen.add(key); out.push(pick); }
72
+ }
73
+ return out.sort((a, b) => a.join(",").localeCompare(b.join(","), undefined, { numeric: true }));
74
+ }
75
+
76
+ export function pboCscv(matrix, { nSplits = 16, maxCombinations = 2000, seed = 42 } = {}) {
77
+ if (!Number.isInteger(nSplits) || nSplits < 2 || nSplits % 2 !== 0) throw new RangeError(`n_splits must be an even integer >= 2; got ${nSplits}`);
78
+ if (!Number.isInteger(maxCombinations) || maxCombinations < 1) throw new RangeError(`max_combinations must be >= 1; got ${maxCombinations}`);
79
+ if (!Array.isArray(matrix) || matrix.length === 0 || !Array.isArray(matrix[0])) throw new RangeError("matrix must be a non-empty array of rows");
80
+ const nConfigs = matrix[0].length;
81
+ if (nConfigs < 2) throw new RangeError(`matrix needs >= 2 config columns to rank cross-sectionally; got ${nConfigs}`);
82
+ for (const row of matrix) {
83
+ if (row.length !== nConfigs) throw new RangeError("every row must have the same number of columns");
84
+ for (const v of row) if (!Number.isFinite(Number(v))) throw new RangeError("matrix values must be finite numbers");
85
+ }
86
+ const nRows = matrix.length;
87
+ if (nRows < nSplits) throw new RangeError(`matrix has ${nRows} rows < n_splits=${nSplits}; cannot form that many non-empty contiguous blocks`);
88
+ const blockLen = Math.floor(nRows / nSplits);
89
+ const blocks = Array.from({ length: nSplits }, (_, b) => matrix.slice(b * blockLen, (b + 1) * blockLen).map((r) => r.map(Number)));
90
+ const half = nSplits / 2;
91
+ const total = binomial(nSplits, half);
92
+ const exhaustive = total <= maxCombinations;
93
+ const combos = exhaustive ? [...combinations(nSplits, half)] : sampleCombinations(nSplits, half, maxCombinations, seed);
94
+ const lambdas = [];
95
+ const isOos = [];
96
+ for (const isBlocks of combos) {
97
+ const isSet = new Set(isBlocks);
98
+ const isRows = isBlocks.flatMap((b) => blocks[b]);
99
+ const oosRows = [];
100
+ for (let b = 0; b < nSplits; b++) if (!isSet.has(b)) oosRows.push(...blocks[b]);
101
+ const srIs = blockSharpe(isRows, nConfigs);
102
+ let best = 0;
103
+ for (let j = 1; j < nConfigs; j++) if (srIs[j] > srIs[best]) best = j;
104
+ const srOos = blockSharpe(oosRows, nConfigs);
105
+ const ranks = averageRanks(srOos);
106
+ const omega = ranks[best] / (nConfigs + 1);
107
+ lambdas.push(Math.log(omega / (1 - omega)));
108
+ isOos.push([srIs[best], srOos[best]]);
109
+ }
110
+ const pbo = lambdas.filter((l) => l <= 0).length / lambdas.length;
111
+ return { pbo, n_combinations: combos.length, lambdas, is_oos_pairs: isOos, exhaustive, block_length: blockLen };
112
+ }
@@ -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,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,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
+ }
@@ -0,0 +1,48 @@
1
+ // js/validate/track-record.js
2
+ // The pure computation behind POST /api/v1/validate/track-record, 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 { minimumTrackRecordLength, probabilisticSharpe } from "../dsr-core.js";
5
+
6
+ // Minimum track record length for an observed Sharpe to clear a benchmark Sharpe at a confidence
7
+ // level, and, when the record's length is sent, the probabilistic Sharpe against that benchmark:
8
+ // Bailey and López de Prado, "The Sharpe Ratio Efficient Frontier" (2012), checked against the
9
+ // paper's worked examples in js/dsr-paper-vectors.test.js.
10
+
11
+ const REQUIRED = ["observed_sharpe_annualized", "periods_per_year", "skew", "non_excess_kurtosis"];
12
+
13
+ export function compute(body) {
14
+ const missing = REQUIRED.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
+ benchmark_sharpe_annualized: body.benchmark_sharpe_annualized === undefined ? 0 : Number(body.benchmark_sharpe_annualized),
19
+ periods_per_year: Number(body.periods_per_year),
20
+ skew: Number(body.skew),
21
+ non_excess_kurtosis: Number(body.non_excess_kurtosis),
22
+ confidence: body.confidence === undefined ? 0.95 : Number(body.confidence),
23
+ };
24
+ if (!(inputs.periods_per_year > 0 && inputs.periods_per_year <= 10000)) throw new RangeError("periods_per_year must be between 0 and 10000");
25
+ if (!(inputs.non_excess_kurtosis >= 1 && inputs.non_excess_kurtosis <= 100)) throw new RangeError("non_excess_kurtosis must be between 1 and 100 (a Normal distribution is 3)");
26
+ const minimum = minimumTrackRecordLength(inputs);
27
+ const result = { minimum_observations: Math.ceil(minimum.observations), minimum_years: minimum.years, confidence: minimum.confidence };
28
+ if (body.observations !== undefined) {
29
+ const observations = Number(body.observations);
30
+ if (!Number.isInteger(observations) || observations < 2 || observations > 1000000) throw new RangeError("observations must be an integer from 2 to 1000000");
31
+ const psr = probabilisticSharpe({ ...inputs, observations });
32
+ result.record = {
33
+ observations,
34
+ years: observations / inputs.periods_per_year,
35
+ psr_against_benchmark: psr.probabilistic_sharpe_ratio,
36
+ long_enough: observations >= result.minimum_observations,
37
+ };
38
+ }
39
+ return { derived_inputs: inputs, result, plain_reading: reading(inputs, result) };
40
+ }
41
+
42
+ function reading(inputs, r) {
43
+ const pct = (x) => `${(x * 100).toFixed(1)} percent`;
44
+ const need = `To be ${pct(r.confidence)} confident that a Sharpe of ${inputs.observed_sharpe_annualized} is above ${inputs.benchmark_sharpe_annualized}, the track record needs at least ${r.minimum_observations} observations, about ${r.minimum_years.toFixed(2)} years.`;
45
+ if (!r.record) return `${need} This counts sample uncertainty and the shape of the returns only; it is not a forecast.`;
46
+ const verdict = r.record.long_enough ? "is long enough" : "is not long enough yet";
47
+ return `${need} The record sent, ${r.record.observations} observations, ${verdict}: the probability its Sharpe is above the benchmark is ${pct(r.record.psr_against_benchmark)}. Neither number is a forecast.`;
48
+ }