canli-validation-mcp 0.4.0 → 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.
package/README.md CHANGED
@@ -37,7 +37,7 @@ repository for the full design.
37
37
  | `validate_track_record` | `POST /api/v1/validate/track-record` | yes |
38
38
  | `get_receipt` | `GET /api/v1/receipts/{id}` | no |
39
39
  | `service_status` | `GET /api/v1/validate/status` | no |
40
- | `company_financial_history` | `GET /company-data/{cik}.json` | no |
40
+ | `company_financial_history` | `GET /company-data/{cik}.json` (a ticker resolves through `GET /api/v1/company-tickers.json`) | no |
41
41
 
42
42
  `validate_deflated_sharpe` accepts exactly one of two input shapes, never a mix of both:
43
43
 
@@ -59,6 +59,14 @@ original SEC response and the record's own boundary sentence: these are accounti
59
59
  reported to the SEC, not market prices, returns or a recommendation. Companies and concepts
60
60
  outside the current release return an error with the available concepts listed.
61
61
 
62
+ ## Prompts, resources and structured results
63
+
64
+ Clients that show MCP prompts offer two guided workflows: `validate_backtest` (deflated Sharpe, then
65
+ overfitting, then the track record needed, reported with what each number does not establish) and
66
+ `track_record_needed`. Two resources can be read: `canli://limits`, the boundary sentences every
67
+ result carries, and `canli://sources`, the papers behind each validator and how each is checked
68
+ against them. Every tool result carries its envelope both as text and as `structuredContent`.
69
+
62
70
  ## Compact context (0.3.0)
63
71
 
64
72
  An agent pays for every token a tool returns, including whitespace it never reads. Since 0.3.0
@@ -88,8 +96,9 @@ an array of objects.
88
96
  |---|---|---|
89
97
  | `CANLI_API_BASE` | `https://canlicapital.com` | Where the API lives. Point it at a preview deployment for testing. |
90
98
  | `CANLI_KEY` | unset | A key already issued from `POST /api/v1/keys`. When set, `get_key` sends no request and reports the key is already configured; every other tool sends it as `Authorization: Bearer <key>`. |
99
+ | `CANLI_LOCAL` | unset | `1` or `true` runs the five validators on this machine (private local mode, below): no key, no network, no receipt. |
91
100
 
92
- If `CANLI_KEY` is not set, call `get_key` once per session before the four validators. The key it
101
+ If `CANLI_KEY` is not set and local mode is off, call `get_key` once per session before the validators. The key it
93
102
  returns lives only in this process's memory for the life of the session; it is not written to
94
103
  disk.
95
104
 
@@ -154,6 +163,20 @@ claude mcp add canli -- npx -y canli-validation-mcp
154
163
 
155
164
  Run `claude mcp list` to confirm it is registered, and `claude mcp remove canli` to remove it.
156
165
 
166
+ ## Private local mode
167
+
168
+ Set `CANLI_LOCAL=1` and the five validators run on your machine: nothing about the series you
169
+ submit is sent to canlicapital.com, no key is needed, and no receipt is stored. The computation is
170
+ the API's own, shipped byte for byte in `src/local` (a test fails if it drifts), so a local result
171
+ equals the hosted one; it names no receipt id because none was made.
172
+
173
+ ```bash
174
+ claude mcp add canli-local --env CANLI_LOCAL=1 -- npx -y canli-validation-mcp
175
+ ```
176
+
177
+ `get_receipt`, `service_status` and `company_financial_history` still read from canlicapital.com;
178
+ they send no series. In the Claude Desktop extension this is the "Private local mode" setting.
179
+
157
180
  ## Generic stdio client
158
181
 
159
182
  Any MCP client that can spawn a process and speak stdio will work. Using the official SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-validation-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for canlicapital.com's free validation API: deflated Sharpe, CSCV overfitting, paper-evidence conformance, and breadth ceiling, each returned as the full API envelope so the boundary language cannot be dropped.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  "canlicapital-validation-mcp": "src/server.mjs"
10
10
  },
11
11
  "engines": {
12
- "node": ">=20"
12
+ "node": ">=20.10"
13
13
  },
14
14
  "files": [
15
15
  "src",
@@ -32,5 +32,8 @@
32
32
  "homepage": "https://canlicapital.com/developers",
33
33
  "bugs": {
34
34
  "url": "https://github.com/arhancanli/canlicapital/issues"
35
+ },
36
+ "devDependencies": {
37
+ "fast-check": "4.10.2"
35
38
  }
36
39
  }
@@ -0,0 +1,30 @@
1
+ // api/_lib/limits.js
2
+ // THE quota constants. Enforcement (handler.js), the /developers page, the OpenAPI document and
3
+ // public/glassbox/validation_api_limits.json all import or derive from this object, so the number a
4
+ // reader sees is the number the service enforces. Change a value here and nowhere else.
5
+ export const LIMITS = Object.freeze({
6
+ validations_per_key_per_day: 1000,
7
+ keys_per_client_per_day: 5,
8
+ max_key_revoke_body_bytes: 1024,
9
+ max_body_bytes: 1024 * 1024,
10
+ max_observations: 20000,
11
+ max_variants: 200,
12
+ max_cscv_combinations: 2000,
13
+ wall_time_seconds: 10,
14
+ });
15
+
16
+ export const LIMITS_TEXT = Object.freeze([
17
+ "This verdict is about the series exactly as submitted. The service never saw the data source, its costs, survivorship, or any lookahead in how the series was built.",
18
+ "A deflated Sharpe or overfitting probability above or below any threshold is not admission to anything and is not a forecast.",
19
+ "The receipt is content-hashed and reproducible from the open-source core it names. It is not signed.",
20
+ `Quotas: ${LIMITS.validations_per_key_per_day} validations per key per UTC day, ${LIMITS.keys_per_client_per_day} keys per client per UTC day, ${LIMITS.max_body_bytes} bytes per validation request, ${LIMITS.max_key_revoke_body_bytes} bytes per key revocation request, ${LIMITS.max_observations} observations per series, ${LIMITS.max_variants} variants per matrix.`,
21
+ ]);
22
+
23
+ // What the page has never said. None of this is enforcement text: it is what happens to a key
24
+ // after it is issued, which a reader otherwise has to find out by trying it. Any numeral here is
25
+ // a LIMITS value, never a hand-typed one, so it cannot drift from what the service actually does.
26
+ export const KEY_LIFECYCLE_TEXT = Object.freeze([
27
+ "Keys do not expire once issued.",
28
+ "Revoke the bearer key with POST /api/v1/keys/revoke. Revocation does not use validation quota and is irreversible; repeating it leaves the original revocation time intact. Requests already admitted may finish. Confirm a successful response before assuming the key is disabled. Issue a replacement separately; there is no atomic rotate operation.",
29
+ `"Client", for the daily key-issuance quota, means the request's IP address hashed together with a salt that rotates every UTC day, not a stored account. A shared office or NAT IP address draws from the same pool of ${LIMITS.keys_per_client_per_day} keys a day as every other request behind it.`,
30
+ ]);
@@ -0,0 +1,96 @@
1
+ // =============================================================================
2
+ // breadth-core.js
3
+ // -----------------------------------------------------------------------------
4
+ // The arithmetic behind /tools/breadth: what a book of N sleeves is worth, and
5
+ // why adding sleeves stops helping.
6
+ //
7
+ // For N equally weighted sleeves, each with per-period Sharpe s and identical
8
+ // pairwise correlation rho:
9
+ //
10
+ // portfolio mean = s * sigma
11
+ // portfolio variance = sigma^2 * (1 + (N-1) * rho) / N
12
+ // BOOK SHARPE = s * sqrt( N / (1 + (N-1) * rho) )
13
+ //
14
+ // The limit as N grows without bound is the part worth staring at:
15
+ //
16
+ // ceiling = s / sqrt(rho) for rho > 0
17
+ //
18
+ // It does not depend on N at all. Past a certain point, breadth is not the lever;
19
+ // correlation is. A project that answers a disappointing Sharpe by adding sleeves
20
+ // is working on the wrong number, and this file exists so that is visible rather
21
+ // than argued about.
22
+ //
23
+ // The assumptions are strong and stated everywhere they are used: equal weights,
24
+ // equal Sharpe, one shared pairwise correlation. Real books have none of those.
25
+ // The lab is for the SHAPE of the constraint, not for forecasting a book.
26
+ // =============================================================================
27
+
28
+ /** Book Sharpe for N equally weighted sleeves at shared correlation rho. */
29
+ export function bookSharpe({ sleeveSharpe, sleeves, correlation }) {
30
+ if (!Number.isInteger(sleeves) || sleeves < 1) throw new Error("sleeves must be a positive integer");
31
+ if (!Number.isFinite(sleeveSharpe)) throw new Error("sleeveSharpe must be finite");
32
+ if (!Number.isFinite(correlation)) throw new Error("correlation must be finite");
33
+ // The single condition, stated once. `1 + (N-1)*rho` IS the portfolio variance in
34
+ // units of a sleeve's variance, so it must be strictly positive:
35
+ //
36
+ // rho < -1/(N-1) the covariance matrix is not positive semidefinite and no
37
+ // set of real return series can produce it;
38
+ // rho == -1/(N-1) the matrix is PSD but singular, and the equally weighted
39
+ // portfolio has exactly zero variance, so its Sharpe is
40
+ // infinite. Mathematically real, financially a fantasy, and
41
+ // returning a spectacular number for it is precisely the
42
+ // failure mode this tool argues against.
43
+ //
44
+ // Both are refused, and the boundary case is named separately because a caller
45
+ // who lands on it exactly has done something interesting rather than careless.
46
+ const denominator = 1 + (sleeves - 1) * correlation;
47
+ if (denominator <= 0) {
48
+ const floor = sleeves > 1 ? -1 / (sleeves - 1) : -1;
49
+ throw new Error(
50
+ denominator === 0
51
+ ? `a shared correlation of exactly ${correlation} across ${sleeves} sleeves gives the ` +
52
+ "equally weighted book zero variance and an infinite Sharpe. That is a degenerate " +
53
+ "case, not an opportunity."
54
+ : `a shared correlation of ${correlation} is impossible for ${sleeves} sleeves: below ` +
55
+ `${floor.toFixed(4)} the covariance matrix is not positive semidefinite`,
56
+ );
57
+ }
58
+ return sleeveSharpe * Math.sqrt(sleeves / denominator);
59
+ }
60
+
61
+ /** The value no amount of breadth can exceed. Infinite only when rho <= 0. */
62
+ export function breadthCeiling({ sleeveSharpe, correlation }) {
63
+ if (correlation <= 0) return Number.POSITIVE_INFINITY;
64
+ return sleeveSharpe / Math.sqrt(correlation);
65
+ }
66
+
67
+ /** The smallest N reaching `target`, or null when the ceiling forbids it. */
68
+ export function sleevesRequired({ sleeveSharpe, correlation, target, maxSleeves = 500 }) {
69
+ const ceiling = breadthCeiling({ sleeveSharpe, correlation });
70
+ if (target > ceiling) return { sleeves: null, ceiling, reachable: false };
71
+ for (let n = 1; n <= maxSleeves; n += 1) {
72
+ if (bookSharpe({ sleeveSharpe, sleeves: n, correlation }) >= target) {
73
+ return { sleeves: n, ceiling, reachable: true };
74
+ }
75
+ }
76
+ return { sleeves: null, ceiling, reachable: false };
77
+ }
78
+
79
+ /** The curve of book Sharpe against N, for plotting. */
80
+ export function breadthCurve({ sleeveSharpe, correlation, maxSleeves }) {
81
+ const points = [];
82
+ for (let n = 1; n <= maxSleeves; n += 1) {
83
+ // Stop at the last N the correlation can actually support, rather than at the
84
+ // last one that does not throw: those differ by exactly the degenerate case.
85
+ if (1 + (n - 1) * correlation <= 0) break;
86
+ points.push({ sleeves: n, sharpe: bookSharpe({ sleeveSharpe, sleeves: n, correlation }) });
87
+ }
88
+ return points;
89
+ }
90
+
91
+ /** How much of the distance to the ceiling N sleeves have actually captured. */
92
+ export function ceilingCaptured({ sleeveSharpe, sleeves, correlation }) {
93
+ const ceiling = breadthCeiling({ sleeveSharpe, correlation });
94
+ if (!Number.isFinite(ceiling)) return null;
95
+ return bookSharpe({ sleeveSharpe, sleeves, correlation }) / ceiling;
96
+ }
@@ -0,0 +1,254 @@
1
+ // Pure deflated-Sharpe arithmetic. No DOM: shared by /tools/deflated-sharpe and the
2
+ // validation API so the two can never disagree. Bound to
3
+ // public/glassbox/deflated_sharpe_calculator_contract.json by checkGoldenVectors.
4
+ const EULER_MASCHERONI = 0.5772156649;
5
+
6
+ // Complementary error function to full double precision (relative error at most 1.5e-14 for
7
+ // x >= -20 and 6e-14 to -38, measured against the C library erfc). For |x| < 1.5 it uses the series
8
+ // erf(x) = 2/sqrt(pi) exp(-x^2) sum 2^n x^(2n+1) / (1*3*...*(2n+1)), which has no cancellation;
9
+ // beyond that, the continued fraction erfc(x) = exp(-x^2)/sqrt(pi) / (x + 1/2 / (x + 1 / (x + ...)))
10
+ // evaluated by the modified Lentz method. It replaced the Abramowitz and Stegun 7.1.26
11
+ // approximation (absolute error up to 1.5e-7, no relative accuracy in the tails) on 2026-09-25.
12
+ function erfc(value) {
13
+ if (Number.isNaN(value)) return Number.NaN;
14
+ if (value < 0) return 2 - erfc(-value);
15
+ const x = value;
16
+ if (x < 1.5) {
17
+ let term = x;
18
+ let sum = x;
19
+ for (let n = 1; n < 200; n++) {
20
+ term *= (2 * x * x) / (2 * n + 1);
21
+ sum += term;
22
+ if (term < sum * 1e-17) break;
23
+ }
24
+ return 1 - (2 / Math.sqrt(Math.PI)) * Math.exp(-x * x) * sum;
25
+ }
26
+ if (x > 27.3) return 0;
27
+ // Lentz: f = b0 + a1/(b1 + a2/(b2 + ...)) with b_k = x, a_k = k/2.
28
+ const tiny = 1e-300;
29
+ let f = x;
30
+ let c = x;
31
+ let d = 0;
32
+ for (let k = 1; k < 500; k++) {
33
+ const a = k / 2;
34
+ d = x + a * d;
35
+ d = d === 0 ? tiny : d;
36
+ c = x + a / c;
37
+ c = c === 0 ? tiny : c;
38
+ d = 1 / d;
39
+ const delta = c * d;
40
+ f *= delta;
41
+ if (Math.abs(delta - 1) < 1e-16) break;
42
+ }
43
+ return Math.exp(-x * x) / (Math.sqrt(Math.PI) * f);
44
+ }
45
+
46
+ export function normalCdf(value) {
47
+ if (value === Infinity) return 1;
48
+ if (value === -Infinity) return 0;
49
+ return 0.5 * erfc(-value / Math.SQRT2);
50
+ }
51
+
52
+ function acklamPpf(probability) {
53
+ if (!(probability > 0 && probability < 1)) {
54
+ throw new RangeError("Normal quantile probability must be between zero and one");
55
+ }
56
+
57
+ const a = [
58
+ -3.969683028665376e1,
59
+ 2.209460984245205e2,
60
+ -2.759285104469687e2,
61
+ 1.38357751867269e2,
62
+ -3.066479806614716e1,
63
+ 2.506628277459239,
64
+ ];
65
+ const b = [
66
+ -5.447609879822406e1,
67
+ 1.615858368580409e2,
68
+ -1.556989798598866e2,
69
+ 6.680131188771972e1,
70
+ -1.328068155288572e1,
71
+ ];
72
+ const c = [
73
+ -7.784894002430293e-3,
74
+ -3.223964580411365e-1,
75
+ -2.400758277161838,
76
+ -2.549732539343734,
77
+ 4.374664141464968,
78
+ 2.938163982698783,
79
+ ];
80
+ const d = [
81
+ 7.784695709041462e-3,
82
+ 3.224671290700398e-1,
83
+ 2.445134137142996,
84
+ 3.754408661907416,
85
+ ];
86
+ const low = 0.02425;
87
+ const high = 1 - low;
88
+
89
+ if (probability < low) {
90
+ const q = Math.sqrt(-2 * Math.log(probability));
91
+ return (
92
+ (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
93
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
94
+ );
95
+ }
96
+ if (probability <= high) {
97
+ const q = probability - 0.5;
98
+ const r = q * q;
99
+ return (
100
+ (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
101
+ (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
102
+ );
103
+ }
104
+ const q = Math.sqrt(-2 * Math.log(1 - probability));
105
+ return -(
106
+ (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
107
+ ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
108
+ );
109
+ }
110
+
111
+ // Inverse normal CDF: Acklam's rational approximation (relative error near 1.15e-9) refined by two
112
+ // Halley steps against the full-precision normalCdf, which brings it to the precision of the CDF.
113
+ export function normalPpf(probability) {
114
+ let x = acklamPpf(probability);
115
+ for (let step = 0; step < 2; step++) {
116
+ const error = normalCdf(x) - probability;
117
+ const u = error * Math.sqrt(2 * Math.PI) * Math.exp((x * x) / 2);
118
+ if (!Number.isFinite(u)) break;
119
+ x -= u / (1 + (x * u) / 2);
120
+ }
121
+ return x;
122
+ }
123
+
124
+ function requireFinite(name, value) {
125
+ if (!Number.isFinite(value)) throw new RangeError(`${name} must be a finite number`);
126
+ }
127
+
128
+ export function calculateDsr(input) {
129
+ const values = Object.fromEntries(
130
+ Object.entries(input).map(([key, value]) => [key, Number(value)]),
131
+ );
132
+ for (const [key, value] of Object.entries(values)) requireFinite(key, value);
133
+ if (!Number.isInteger(values.observations) || values.observations < 2) {
134
+ throw new RangeError("Return observations must be an integer of at least 2");
135
+ }
136
+ if (!(values.periods_per_year > 0)) {
137
+ throw new RangeError("Periods per year must be greater than zero");
138
+ }
139
+ if (
140
+ !Number.isInteger(values.effective_independent_trials) ||
141
+ values.effective_independent_trials < 2
142
+ ) {
143
+ throw new RangeError("Effective independent trials must be an integer of at least 2");
144
+ }
145
+ if (values.cross_trial_sharpe_sd_annualized < 0) {
146
+ throw new RangeError("Cross-trial Sharpe dispersion cannot be negative");
147
+ }
148
+
149
+ const annualizationScale = Math.sqrt(values.periods_per_year);
150
+ const observedSharpePerPeriod = values.observed_sharpe_annualized / annualizationScale;
151
+ const trialSdPerPeriod = values.cross_trial_sharpe_sd_annualized / annualizationScale;
152
+ const trialVariancePerPeriod = trialSdPerPeriod ** 2;
153
+ const nTrials = values.effective_independent_trials;
154
+ const quantile =
155
+ (1 - EULER_MASCHERONI) * normalPpf(1 - 1 / nTrials) +
156
+ EULER_MASCHERONI * normalPpf(1 - 1 / (nTrials * Math.E));
157
+ const expectedMaxSharpePerPeriod = trialSdPerPeriod * quantile;
158
+ const nonNormalityVarianceTerm =
159
+ 1 -
160
+ values.skew * observedSharpePerPeriod +
161
+ ((values.non_excess_kurtosis - 1) / 4) * observedSharpePerPeriod ** 2;
162
+ if (!(nonNormalityVarianceTerm > 0)) {
163
+ throw new RangeError(
164
+ "These skew, kurtosis and Sharpe inputs produce a non-positive estimator variance term",
165
+ );
166
+ }
167
+ const denominator = Math.sqrt(nonNormalityVarianceTerm);
168
+ const sampleScale = Math.sqrt(values.observations - 1);
169
+ const psrZ = observedSharpePerPeriod * sampleScale / denominator;
170
+ const dsrZ =
171
+ (observedSharpePerPeriod - expectedMaxSharpePerPeriod) * sampleScale / denominator;
172
+
173
+ return {
174
+ observed_sharpe_per_period: observedSharpePerPeriod,
175
+ cross_trial_sharpe_variance_per_period: trialVariancePerPeriod,
176
+ expected_max_sharpe_per_period: expectedMaxSharpePerPeriod,
177
+ expected_max_sharpe_annualized: expectedMaxSharpePerPeriod * annualizationScale,
178
+ psr_against_zero: normalCdf(psrZ),
179
+ deflated_sharpe_ratio: normalCdf(dsrZ),
180
+ non_normality_variance_term: nonNormalityVarianceTerm,
181
+ search_haircut_annualized:
182
+ values.observed_sharpe_annualized - expectedMaxSharpePerPeriod * annualizationScale,
183
+ psr_z_score: psrZ,
184
+ dsr_z_score: dsrZ,
185
+ };
186
+ }
187
+
188
+ export function checkGoldenVectors(vectors, tolerance = 8e-7) {
189
+ const failures = [];
190
+ for (const vector of vectors) {
191
+ const observed = calculateDsr(vector.inputs);
192
+ for (const [key, expected] of Object.entries(vector.outputs)) {
193
+ const error = Math.abs(observed[key] - expected);
194
+ if (error > tolerance) failures.push({ vector: vector.id, key, expected, observed: observed[key], error });
195
+ }
196
+ }
197
+ return failures;
198
+ }
199
+
200
+
201
+ // Probabilistic Sharpe ratio against a benchmark Sharpe, and the minimum track record length for
202
+ // it to clear that benchmark at a confidence level: Bailey and López de Prado, "The Sharpe Ratio
203
+ // Efficient Frontier", Journal of Risk 15(2), 2012, Eqs. (11) and (13). Inputs are annualized; the
204
+ // estimator works per period, like calculateDsr above.
205
+ function perPeriodInputs(values) {
206
+ for (const [key, value] of Object.entries(values)) requireFinite(key, value);
207
+ if (!(values.periods_per_year > 0)) throw new RangeError("Periods per year must be greater than zero");
208
+ const scale = Math.sqrt(values.periods_per_year);
209
+ const sr = values.observed_sharpe_annualized / scale;
210
+ const benchmark = values.benchmark_sharpe_annualized / scale;
211
+ const term = 1 - values.skew * sr + ((values.non_excess_kurtosis - 1) / 4) * sr ** 2;
212
+ if (!(term > 0)) {
213
+ throw new RangeError("These skew, kurtosis and Sharpe inputs produce a non-positive estimator variance term");
214
+ }
215
+ return { sr, benchmark, term };
216
+ }
217
+
218
+ export function probabilisticSharpe(input) {
219
+ const values = {
220
+ observed_sharpe_annualized: Number(input.observed_sharpe_annualized),
221
+ benchmark_sharpe_annualized: Number(input.benchmark_sharpe_annualized ?? 0),
222
+ observations: Number(input.observations),
223
+ periods_per_year: Number(input.periods_per_year),
224
+ skew: Number(input.skew),
225
+ non_excess_kurtosis: Number(input.non_excess_kurtosis),
226
+ };
227
+ if (!Number.isInteger(values.observations) || values.observations < 2) {
228
+ throw new RangeError("Return observations must be an integer of at least 2");
229
+ }
230
+ const { sr, benchmark, term } = perPeriodInputs(values);
231
+ const z = ((sr - benchmark) * Math.sqrt(values.observations - 1)) / Math.sqrt(term);
232
+ return { probabilistic_sharpe_ratio: normalCdf(z), z_score: z };
233
+ }
234
+
235
+ export function minimumTrackRecordLength(input) {
236
+ const confidence = Number(input.confidence ?? 0.95);
237
+ if (!(confidence > 0 && confidence < 1)) throw new RangeError("Confidence must be strictly between 0 and 1");
238
+ const values = {
239
+ observed_sharpe_annualized: Number(input.observed_sharpe_annualized),
240
+ benchmark_sharpe_annualized: Number(input.benchmark_sharpe_annualized ?? 0),
241
+ periods_per_year: Number(input.periods_per_year),
242
+ skew: Number(input.skew),
243
+ non_excess_kurtosis: Number(input.non_excess_kurtosis),
244
+ };
245
+ const { sr, benchmark, term } = perPeriodInputs(values);
246
+ if (!(sr > benchmark)) {
247
+ throw new RangeError("The observed Sharpe must exceed the benchmark; no track record length is enough otherwise");
248
+ }
249
+ const observations = 1 + term * (normalPpf(confidence) / (sr - benchmark)) ** 2;
250
+ if (!Number.isFinite(observations)) {
251
+ throw new RangeError("The observed Sharpe is too close to the benchmark; no finite track record length reaches this confidence");
252
+ }
253
+ return { observations, years: observations / values.periods_per_year, confidence };
254
+ }
@@ -0,0 +1,54 @@
1
+ // js/moments-core.js
2
+ // Per-period moments of a return series, in the exact conventions of
3
+ // alphaforge.validation.dsr._per_period_moments: Sharpe with the SAMPLE standard
4
+ // deviation (ddof=1); skewness and kurtosis as the BIASED population moment
5
+ // estimators (scipy bias=True), kurtosis NON-excess (fisher=False). These are the
6
+ // Bailey-Lopez de Prado conventions the PSR/DSR formulas assume. Pinned to
7
+ // standards/validation-api/vectors.json.
8
+ import { calculateDsr } from "./dsr-core.js";
9
+
10
+ export function perPeriodMoments(returns) {
11
+ if (!Array.isArray(returns)) throw new RangeError("returns must be an array of numbers");
12
+ const x = returns.map(Number).filter((v) => Number.isFinite(v));
13
+ const n = x.length;
14
+ if (n < 2) throw new RangeError("need at least 2 finite return observations");
15
+ let min = Infinity;
16
+ let max = -Infinity;
17
+ let sum = 0;
18
+ for (const v of x) { sum += v; if (v < min) min = v; if (v > max) max = v; }
19
+ if (max - min === 0) throw new RangeError("return series has zero variance; Sharpe is undefined");
20
+ const mean = sum / n;
21
+ let m2 = 0;
22
+ let m3 = 0;
23
+ let m4 = 0;
24
+ for (const v of x) {
25
+ const d = v - mean;
26
+ const d2 = d * d;
27
+ m2 += d2; m3 += d2 * d; m4 += d2 * d2;
28
+ }
29
+ const sampleStd = Math.sqrt(m2 / (n - 1));
30
+ if (sampleStd === 0) throw new RangeError("return series has zero variance; Sharpe is undefined");
31
+ m2 /= n; m3 /= n; m4 /= n;
32
+ return {
33
+ sharpe_per_period: mean / sampleStd,
34
+ skew: m3 / Math.pow(m2, 1.5),
35
+ non_excess_kurtosis: m4 / (m2 * m2),
36
+ observations: n,
37
+ };
38
+ }
39
+
40
+ export function dsrFromReturns({ returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized }) {
41
+ const ppy = Number(periods_per_year);
42
+ if (!(ppy > 0)) throw new RangeError("periods_per_year must be greater than zero");
43
+ const m = perPeriodMoments(returns);
44
+ const derived_inputs = {
45
+ observed_sharpe_annualized: m.sharpe_per_period * Math.sqrt(ppy),
46
+ observations: m.observations,
47
+ periods_per_year: ppy,
48
+ skew: m.skew,
49
+ non_excess_kurtosis: m.non_excess_kurtosis,
50
+ effective_independent_trials: Number(effective_independent_trials),
51
+ cross_trial_sharpe_sd_annualized: Number(cross_trial_sharpe_sd_annualized),
52
+ };
53
+ return { derived_inputs, result: calculateDsr(derived_inputs) };
54
+ }