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 +25 -2
- package/package.json +5 -2
- 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 +254 -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/selection-risk-core.js +197 -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/overfitting.js +29 -0
- package/src/local/js/validate/paper-evidence.js +11 -0
- package/src/local/js/validate/track-record.js +48 -0
- package/src/local/standards/paper-evidence/schema.json +429 -0
- package/src/local.mjs +39 -0
- package/src/schemas.mjs +17 -4
- package/src/server.mjs +161 -5
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
|
|
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.
|
|
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
|
+
}
|