canli-quant-mcp 0.1.0 → 0.1.2
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/CHANGELOG.md +40 -1
- package/README.md +9 -5
- package/package.json +1 -1
- package/src/math.mjs +22 -7
- package/src/registry.mjs +13 -1
- package/src/server.mjs +2 -2
- package/src/tools/econometrics.mjs +29 -9
- package/src/tools/options.mjs +2 -1
- package/src/tools/portfolio.mjs +9 -1
- package/src/tools/sleeves.mjs +46 -49
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.2
|
|
6
|
+
|
|
7
|
+
This cumulative npm candidate includes the statistical and input corrections recorded under
|
|
8
|
+
0.1.1 below. The 0.1.1 source archive stays available; it will not be published separately to npm.
|
|
9
|
+
|
|
10
|
+
- Non-finite computed results now return a tool error naming their fields and values before JSON
|
|
11
|
+
serialization or success receipt creation. Previously NaN and Infinity appeared as null with no
|
|
12
|
+
diagnostic. Both discovery and directly listed tools use the same result check. Ordinary finite
|
|
13
|
+
answers and legitimate null warm-up positions retain their existing representation.
|
|
14
|
+
|
|
15
|
+
## 0.1.1 (signed source release; not published to npm)
|
|
16
|
+
|
|
17
|
+
- Fix: an asset whose returns never change made `covariance_shrinkage`, `correlation_matrix` and
|
|
18
|
+
the portfolio optimizers (`max_sharpe_portfolio`, `min_variance_portfolio` and the others built
|
|
19
|
+
on the same estimates) answer with NaN. They now refuse and name the asset ("CASH has no
|
|
20
|
+
variance").
|
|
21
|
+
- Fix: `put_call_parity` gave NaN for `implied_carry` when the quotes put the implied forward at or
|
|
22
|
+
below zero. It now gives null with a note to check the quotes for staleness. Both were found by
|
|
23
|
+
canli-mcp's `bench/robustness.mjs`.
|
|
24
|
+
- Fix: `digits` also rounded whole numbers, so `digits: 4` turned 227,917,808 into 227,900,000.
|
|
25
|
+
It now trims fractions only; whole numbers and the integer part of large values stay exact.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
- `deflated_sharpe_ratio` and `probabilistic_sharpe_ratio` also take summary numbers (`sharpe_annual`,
|
|
29
|
+
`observations`, `skew`, `kurtosis`) and refuse fewer than 20 returns or zero variance, instead of
|
|
30
|
+
returning a probability from too little data. Found by watching a model call them in an agent
|
|
31
|
+
evaluation.
|
|
32
|
+
- `sleeve_tournament`: Hansen's SPA and Romano-Wolf StepM are now re-studentized in every resample,
|
|
33
|
+
the default mean block length is round(n^(1/3)) instead of floor(sqrt(n)), and White's Reality
|
|
34
|
+
Check is reported alongside. The 0.1.0 version held one variance estimate fixed across resamples,
|
|
35
|
+
which the Null Zoo benchmark measured rejecting 13.5% of skill-less AR(1) searches at a nominal
|
|
36
|
+
5% with long blocks; on our planted-trend test data it kept 24 sleeves where the corrected test
|
|
37
|
+
keeps 4.
|
|
38
|
+
|
|
39
|
+
- `sleeve_tournament` verdict: no longer calls the effective-trials deflation "the fairer one"; on
|
|
40
|
+
data with no edge it nearly passes a long-only winner. The verdict now says the raw count is the
|
|
41
|
+
conservative bound and that deflation (Sharpe above zero) and SPA/StepM (beats the benchmark)
|
|
42
|
+
answer different questions.
|
|
43
|
+
|
|
5
44
|
## 0.1.0
|
|
6
45
|
|
|
7
46
|
First release: 235 tools in 18 toolsets (performance, options, exotics, rates, tvm, valuation,
|
|
@@ -23,7 +62,7 @@ sleeves, data_checks, labeling), reached through three discovery tools (`find_to
|
|
|
23
62
|
meta, fixed-horizon and trend-scanning labels, sample weights, and purged, embargoed CV splits.
|
|
24
63
|
- Privacy enforced by tests: no network, disk writes, logging, processes or workers; runs under
|
|
25
64
|
Node's permission model.
|
|
26
|
-
-
|
|
65
|
+
- 465 reference cases against QuantLib, statsmodels, arch, TA-Lib, numpy-financial, scikit-learn,
|
|
27
66
|
empyrical, scipy, pandas and dcor; planted-defect tests for the data checks; no-lookahead and warm-up
|
|
28
67
|
checks on every sleeve.
|
|
29
68
|
- Eleven workflow prompts, six resources, and opt-in calculation receipts.
|
package/README.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# canli-quant-mcp
|
|
2
2
|
|
|
3
|
+
Computed NaN and Infinity values are refused with a tool error naming the affected fields.
|
|
4
|
+
They do not become unexplained nulls or receive a success receipt. Check the input scale
|
|
5
|
+
and the calculation's domain. Ordinary finite answers and null warm-up positions are preserved.
|
|
6
|
+
|
|
3
7
|
235 quant finance tools in one MCP server: performance and risk, options and exotics, fixed income,
|
|
4
8
|
portfolio construction, econometrics, technical indicators, execution, sizing, crypto and FX,
|
|
5
9
|
valuation, strategy backtests, a library of 399 strategy sleeves, data checks, and data annotation
|
|
6
10
|
and ML labeling. Every tool
|
|
7
11
|
computes on the data you send, and every tool's results are checked in the test suite against an
|
|
8
12
|
independent implementation (QuantLib, statsmodels, arch, TA-Lib, numpy-financial, scikit-learn,
|
|
9
|
-
empyrical, scipy, pandas, dcor):
|
|
13
|
+
empyrical, scipy, pandas, dcor): 465 reference cases, plus planted-defect tests for the data checks.
|
|
10
14
|
|
|
11
15
|
Local stdio, no key, no account, no network calls: your data never leaves your machine (see
|
|
12
16
|
[Privacy](#privacy), enforced by tests).
|
|
@@ -80,7 +84,7 @@ Then ask, for example:
|
|
|
80
84
|
| `sizing` | 7 | scipy and closed forms |
|
|
81
85
|
| `crypto_fx` | 9 | closed forms and simulated pool reserves |
|
|
82
86
|
| `strategies` | 20 | an independent pandas implementation of every recipe and of the costed engine |
|
|
83
|
-
| `sleeves` | 8 | an independent numpy and pandas port of every recipe and the engine; arch's SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve |
|
|
87
|
+
| `sleeves` | 8 | an independent numpy and pandas port of every recipe and the engine; arch's Reality Check and an independent re-studentized SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve |
|
|
84
88
|
| `data_checks` | 10 | planted-defect tests: each check must find what was planted and stay quiet on clean data |
|
|
85
89
|
| `labeling` | 7 | pandas ports of the snippets in Advances in Financial Machine Learning (ewm volatility, CUSUM filter, triple barrier, uniqueness, purged k-fold) and statsmodels OLS t-values |
|
|
86
90
|
| `tvm` | 15 | numpy-financial and closed forms |
|
|
@@ -111,14 +115,14 @@ sleeves at once correct for it:
|
|
|
111
115
|
|
|
112
116
|
| tool | what it answers |
|
|
113
117
|
|---|---|
|
|
114
|
-
| `sleeve_tournament` | Which sleeves lead on your prices, and does the best survive the deflated Sharpe ratio (counting all sleeves and the effective number of independent ones), Hansen's SPA test
|
|
118
|
+
| `sleeve_tournament` | Which sleeves lead on your prices, and does the best survive the deflated Sharpe ratio (counting all sleeves and the effective number of independent ones), White's Reality Check, Hansen's SPA test and Romano-Wolf StepM (re-studentized in every resample) and the probability of backtest overfitting (CSCV)? |
|
|
115
119
|
| `sleeve_walk_forward` | If you had kept picking the top sleeves by trailing Sharpe, what would you have earned out of sample, and how much of their in-sample Sharpe was luck? |
|
|
116
120
|
| `sleeve_clusters` | How many genuinely different bets do the sleeves make on this data? |
|
|
117
121
|
| `sleeve_regime_map` | Which sleeves held up in calm, turbulent, rising and falling markets? |
|
|
118
122
|
| `combine_sleeves` | What does a book of chosen sleeves look like, and what asset weights does it want as of the latest close? |
|
|
119
123
|
|
|
120
124
|
The tests check that no sleeve looks ahead (rewriting future prices never changes an earlier
|
|
121
|
-
target), that none trades before its warm-up, and that the tournament's statistics match arch's SPA
|
|
125
|
+
target), that none trades before its warm-up, and that the tournament's statistics match arch's Reality Check and an independent re-studentized SPA
|
|
122
126
|
and StepM on the same bootstrap draws, scipy's clustering and an independent numpy CSCV.
|
|
123
127
|
`run_sleeve` and `combine_sleeves` return `target_weights` by symbol, which a paper broker such as
|
|
124
128
|
canli-paper-trading-mcp can rebalance to.
|
|
@@ -457,7 +461,7 @@ randomness is the sleeve bootstrap, drawn from a stated seed.
|
|
|
457
461
|
| `list_sleeves` | Browse the library of 399 pre-defined strategy sleeves (18 families: trend, momentum, reversion, volatility, allocation, pairs). Each is a fixed rule with a spec hash; filter by family, data shape or words. |
|
|
458
462
|
| `describe_sleeve` | One sleeve's exact rule, parameters, warm-up, rationale, published references, known risks and spec SHA-256. |
|
|
459
463
|
| `run_sleeve` | Backtest one library sleeve on your prices with costs and no lookahead: CAGR, Sharpe, drawdown, turnover, cost drag, and the target weights as of the latest close (ready for a paper rebalance). |
|
|
460
|
-
| `sleeve_tournament` | Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV). |
|
|
464
|
+
| `sleeve_tournament` | Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), White's Reality Check, re-studentized Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV). |
|
|
461
465
|
| `sleeve_walk_forward` | Test the selection process itself: at each refit pick the top sleeves by trailing Sharpe, hold them for the next window, repeat. Compares the out-of-sample result with what the picks showed in sample, with holding every sleeve, and with the benchmark. |
|
|
462
466
|
| `combine_sleeves` | Blend chosen sleeves (equal or trailing inverse-volatility weights, no lookahead) into one book: its statistics, each sleeve's, their correlations, the diversification ratio, and the book's combined asset target weights as of the latest close. |
|
|
463
467
|
| `sleeve_clusters` | Group the sleeves that ran on your prices by return correlation (average linkage on sqrt((1 - rho) / 2)), to see how many genuinely different bets the library makes on this data and the best sleeve of each group. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-quant-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "235 quant finance tools and a library of 399 strategy sleeves as one MCP server: performance and risk, options and exotics, fixed income, portfolio construction, econometrics (regime switching, Johansen, CUSUM, HAR), indicators, execution, crypto and FX, valuation, backtests with multiple-testing corrections (deflated Sharpe, SPA, StepM, PBO), data annotation and ML labels (triple barrier, purged CV) and data checks, each checked against QuantLib, statsmodels, arch, TA-Lib or numpy. Keyless and local with no network, disk writes or logging; three tools in context by default.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
package/src/math.mjs
CHANGED
|
@@ -221,19 +221,34 @@ export function olsSimple(x, y) {
|
|
|
221
221
|
export function sig(x, digits = 10) {
|
|
222
222
|
if (typeof x !== "number" || !Number.isFinite(x)) return x === undefined ? null : Number.isNaN(x) ? null : x;
|
|
223
223
|
if (x === 0) return 0;
|
|
224
|
+
// Whole numbers (counts, observations, share amounts) are never changed, nor the integer part of
|
|
225
|
+
// a large value: digits trims fractions only.
|
|
226
|
+
if (Number.isInteger(x)) return x;
|
|
227
|
+
if (Math.abs(x) >= 10 ** digits) return Math.round(x);
|
|
224
228
|
return Number(x.toPrecision(digits));
|
|
225
229
|
}
|
|
226
230
|
|
|
227
231
|
// Rounds every number in a plain result tree.
|
|
228
|
-
export function compact(v, digits = 10) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const out =
|
|
233
|
-
|
|
232
|
+
export function compact(v, digits = 10, onNonFinite) {
|
|
233
|
+
const path = [];
|
|
234
|
+
const child = (key, value) => {
|
|
235
|
+
if (onNonFinite) path.push(key);
|
|
236
|
+
const out = visit(value);
|
|
237
|
+
if (onNonFinite) path.pop();
|
|
234
238
|
return out;
|
|
239
|
+
};
|
|
240
|
+
const visit = (value) => {
|
|
241
|
+
if (typeof value === "number") {
|
|
242
|
+
if (onNonFinite && !Number.isFinite(value)) onNonFinite(value, path);
|
|
243
|
+
return sig(value, digits);
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(value)) return value.map((x, i) => child(i, x));
|
|
246
|
+
if (value && typeof value === "object" && !(value instanceof Date)) {
|
|
247
|
+
return Object.fromEntries(Object.entries(value).filter(([, x]) => x !== undefined).map(([k, x]) => [k, child(k, x)]));
|
|
248
|
+
}
|
|
249
|
+
return value;
|
|
235
250
|
}
|
|
236
|
-
return v;
|
|
251
|
+
return visit(v);
|
|
237
252
|
}
|
|
238
253
|
|
|
239
254
|
// ---------------------------------------------------------------------------------------------
|
package/src/registry.mjs
CHANGED
|
@@ -60,7 +60,19 @@ export function runTool(name, args, { digits = 10 } = {}) {
|
|
|
60
60
|
const issues = parsed.error.issues.slice(0, 5).map((i) => `${i.path.join(".") || "arguments"}: ${i.message}`).join("; ");
|
|
61
61
|
throw new Error(`${name}: ${issues}. describe_tool ${name} gives the exact input schema.`);
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
let nonFiniteCount = 0;
|
|
64
|
+
const examples = [];
|
|
65
|
+
const result = compact(tool.run(parsed.data), digits, (value, path) => {
|
|
66
|
+
nonFiniteCount++;
|
|
67
|
+
if (examples.length < 5) {
|
|
68
|
+
const pointer = path.map((key) => String(key).replace(/~/g, "~0").replace(/\//g, "~1")).join("/");
|
|
69
|
+
examples.push(`${pointer ? "/" + pointer.slice(0, 160) : "root"} (${String(value)})`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
if (nonFiniteCount) {
|
|
73
|
+
throw new Error(`${name}: calculation returned ${nonFiniteCount} non-finite value(s): ${examples.join(", ")}${nonFiniteCount > examples.length ? ", ..." : ""}. JSON cannot represent this numerical result. Check input scale and the tool's mathematical domain.`);
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
// Search: BM25 over each tool's name, title, keywords and description (field-weighted), with light
|
package/src/server.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export const METHODS = Object.freeze({
|
|
|
50
50
|
sizing: "scipy and closed forms",
|
|
51
51
|
crypto_fx: "closed forms and simulated pool reserves",
|
|
52
52
|
strategies: "an independent pandas implementation of every recipe and of the costed engine",
|
|
53
|
-
sleeves: "an independent numpy and pandas port of every recipe and the engine; arch's SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve",
|
|
53
|
+
sleeves: "an independent numpy and pandas port of every recipe and the engine; arch's Reality Check and an independent re-studentized SPA and StepM on the same bootstrap draws; scipy average linkage; numpy CSCV; plus no-lookahead and warm-up checks on every sleeve",
|
|
54
54
|
data_checks: "planted-defect tests: each check must find what was planted and stay quiet on clean data",
|
|
55
55
|
labeling: "pandas ports of the snippets in Advances in Financial Machine Learning (ewm volatility, CUSUM filter, triple barrier, uniqueness, purged k-fold) and statsmodels OLS t-values",
|
|
56
56
|
});
|
|
@@ -130,7 +130,7 @@ export function registerAll(server, { toolsets = selectedToolsets() } = {}) {
|
|
|
130
130
|
name: z.string().max(100).describe("Tool name from find_tool."),
|
|
131
131
|
arguments: z.record(z.string(), z.unknown()).optional().describe("The tool's arguments, as describe_tool gives them."),
|
|
132
132
|
receipt: z.boolean().optional().describe("Add a calculation receipt (input and output SHA-256) so the result can be independently recomputed and compared; default false."),
|
|
133
|
-
digits: z.number().int().min(3).max(10).optional().describe("Significant figures in the result; default 10. 4-6 cuts output tokens when full precision is not needed."),
|
|
133
|
+
digits: z.number().int().min(3).max(10).optional().describe("Significant figures for fractional numbers in the result; default 10. Whole numbers are never changed. 4-6 cuts output tokens when full precision is not needed."),
|
|
134
134
|
}).strict(),
|
|
135
135
|
outputSchema: open,
|
|
136
136
|
}, guard(({ name, arguments: args, receipt, digits }) => {
|
|
@@ -222,9 +222,29 @@ function garchFit(r) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
function trialSharpeStats(r) {
|
|
225
|
-
|
|
225
|
+
if (r.length < 20) throw new Error(`Only ${r.length} returns: a Sharpe ratio's probability needs at least 20. With summary numbers instead, send sharpe_annual and observations (plus skew and kurtosis if known).`);
|
|
226
|
+
const sd = std(r);
|
|
227
|
+
if (!(sd > 0)) throw new Error("The returns have zero variance, so the Sharpe ratio is undefined.");
|
|
228
|
+
const n = r.length, sr = mean(r) / sd, m = moments(r);
|
|
226
229
|
return { n, sr, skew: m.skew, kurt: m.excess_kurtosis + 3 };
|
|
227
230
|
}
|
|
231
|
+
|
|
232
|
+
// The same statistics from a series, or from summary numbers when no series is at hand.
|
|
233
|
+
const summaryFields = {
|
|
234
|
+
sharpe_annual: z.number().min(-20).max(20).optional().describe("Observed annualized Sharpe ratio, instead of returns or prices."),
|
|
235
|
+
observations: z.number().int().min(20).max(1e8).optional().describe("Number of return observations behind sharpe_annual."),
|
|
236
|
+
skew: z.number().min(-50).max(50).optional().describe("Skewness of the returns with sharpe_annual; default 0."),
|
|
237
|
+
kurtosis: z.number().min(1).max(1000).optional().describe("Kurtosis (not excess; 3 if normal) with sharpe_annual; default 3."),
|
|
238
|
+
};
|
|
239
|
+
function sharpeStats(a, ppy) {
|
|
240
|
+
if (a.sharpe_annual === undefined) {
|
|
241
|
+
if (a.observations !== undefined || a.skew !== undefined || a.kurtosis !== undefined) throw new Error("observations, skew and kurtosis go with sharpe_annual.");
|
|
242
|
+
return trialSharpeStats(seriesFrom(a));
|
|
243
|
+
}
|
|
244
|
+
if (a.returns !== undefined || a.prices !== undefined) throw new Error("Send either sharpe_annual with observations, or returns/prices, not both.");
|
|
245
|
+
if (a.observations === undefined) throw new Error("sharpe_annual needs observations (the number of returns behind it).");
|
|
246
|
+
return { n: a.observations, sr: a.sharpe_annual / Math.sqrt(ppy), skew: a.skew ?? 0, kurt: a.kurtosis ?? 3 };
|
|
247
|
+
}
|
|
228
248
|
const psr = ({ n, sr, skew, kurt }, srStar) => normCdf((sr - srStar) * Math.sqrt(n - 1) / Math.sqrt(1 - skew * sr + (kurt - 1) / 4 * sr * sr));
|
|
229
249
|
const EULER = 0.5772156649015329;
|
|
230
250
|
|
|
@@ -405,11 +425,11 @@ export const TOOLS = [
|
|
|
405
425
|
{
|
|
406
426
|
name: "probabilistic_sharpe_ratio",
|
|
407
427
|
title: "Probabilistic Sharpe ratio",
|
|
408
|
-
description: "Compute the probability that the true Sharpe ratio exceeds a benchmark given the track record's length, skewness and kurtosis (Bailey and López de Prado), and the minimum track record length needed.",
|
|
409
|
-
keywords: "probabilistic sharpe ratio psr minimum track record length mintrl skewness kurtosis confidence",
|
|
410
|
-
input: z.object({ ...seriesFields, benchmark_sharpe: z.number().min(-10).max(10).optional().describe("Annual Sharpe to beat; default 0."), confidence: z.number().gt(0.5).lt(1).optional().describe("For the minimum track record; default 0.95.") }).strict(),
|
|
428
|
+
description: "Compute the probability that the true Sharpe ratio exceeds a benchmark given the track record's length, skewness and kurtosis (Bailey and López de Prado), and the minimum track record length needed. Takes returns or prices, or just the observed Sharpe and the number of observations.",
|
|
429
|
+
keywords: "probabilistic sharpe ratio psr probability sharpe real genuine skill significant minimum track record length mintrl skewness kurtosis confidence",
|
|
430
|
+
input: z.object({ ...seriesFields, ...summaryFields, benchmark_sharpe: z.number().min(-10).max(10).optional().describe("Annual Sharpe to beat; default 0."), confidence: z.number().gt(0.5).lt(1).optional().describe("For the minimum track record; default 0.95.") }).strict(),
|
|
411
431
|
run(a) {
|
|
412
|
-
const
|
|
432
|
+
const ppy = a.periods_per_year ?? 252, st = sharpeStats(a, ppy), bench = (a.benchmark_sharpe ?? 0) / Math.sqrt(ppy);
|
|
413
433
|
const zc = normInv(a.confidence ?? 0.95), varTerm = 1 - st.skew * st.sr + (st.kurt - 1) / 4 * st.sr * st.sr;
|
|
414
434
|
const mtrl = st.sr > bench ? 1 + varTerm * (zc / (st.sr - bench)) ** 2 : null;
|
|
415
435
|
return { psr: psr(st, bench), sharpe_annual: st.sr * Math.sqrt(ppy), periods: st.n, min_track_record_periods: mtrl, min_track_record_years: mtrl === null ? null : mtrl / ppy, skew: st.skew, kurtosis: st.kurt };
|
|
@@ -418,16 +438,16 @@ export const TOOLS = [
|
|
|
418
438
|
{
|
|
419
439
|
name: "deflated_sharpe_ratio",
|
|
420
440
|
title: "Deflated Sharpe ratio",
|
|
421
|
-
description: "Deflate a backtest's Sharpe ratio for the number of strategies tried (Bailey and López de Prado 2014): the expected maximum Sharpe under the null and the probability the result beats it.",
|
|
441
|
+
description: "Deflate a backtest's Sharpe ratio for the number of strategies tried (Bailey and López de Prado 2014): the expected maximum Sharpe under the null and the probability the result beats it. Takes returns or prices, or just the observed Sharpe and the number of observations, with the trials' Sharpe variance or Sharpes.",
|
|
422
442
|
keywords: "overfit overfitting variants trials tried multiple testing luck deflated sharpe ratio dsr multiple testing selection bias backtest overfitting trials data snooping",
|
|
423
443
|
input: z.object({
|
|
424
|
-
...seriesFields,
|
|
444
|
+
...seriesFields, ...summaryFields,
|
|
425
445
|
trials: z.number().int().min(1).max(1e9).describe("How many strategy variants were tried, including this one."),
|
|
426
446
|
trial_sharpes: z.array(z.number()).min(2).max(100000).optional().describe("Annual Sharpe ratios of all trials, to estimate their variance."),
|
|
427
|
-
trial_sharpe_variance: z.number().positive().optional().describe("Or the variance of the trials' annual Sharpe ratios directly."),
|
|
447
|
+
trial_sharpe_variance: z.number().positive().optional().describe("Or the variance of the trials' annual Sharpe ratios directly (the square of their standard deviation)."),
|
|
428
448
|
}).strict(),
|
|
429
449
|
run(a) {
|
|
430
|
-
const
|
|
450
|
+
const ppy = a.periods_per_year ?? 252, st = sharpeStats(a, ppy);
|
|
431
451
|
let v = a.trial_sharpe_variance;
|
|
432
452
|
if (v === undefined) { if (!a.trial_sharpes) throw new Error("Send trial_sharpes or trial_sharpe_variance."); v = variance(a.trial_sharpes); }
|
|
433
453
|
const vPer = v / ppy, N = a.trials;
|
package/src/tools/options.mjs
CHANGED
|
@@ -187,7 +187,8 @@ export const TOOLS = [
|
|
|
187
187
|
const dr = Math.exp(-(a.rate ?? 0) * a.years), dq = Math.exp(-(a.dividend_yield ?? 0) * a.years);
|
|
188
188
|
const gap = a.call - a.put - (a.spot * dq - a.strike * dr);
|
|
189
189
|
const fwd = (a.call - a.put) / dr + a.strike;
|
|
190
|
-
|
|
190
|
+
// A forward at or below zero means the quotes break parity's bounds; no carry implies it.
|
|
191
|
+
return { parity_gap: gap, implied_forward: fwd, implied_carry: fwd > 0 ? Math.log(fwd / a.spot) / a.years : null, ...(fwd > 0 ? {} : { implied_carry_note: "The implied forward is not positive, so the quotes cannot be consistent with any carry; check them for staleness." }), meaning: "parity_gap = C - P - (S e^-qT - K e^-rT); positive means the call is rich relative to the put." };
|
|
191
192
|
},
|
|
192
193
|
},
|
|
193
194
|
{
|
package/src/tools/portfolio.mjs
CHANGED
|
@@ -51,7 +51,11 @@ export function estimates(a, { needMu = false } = {}) {
|
|
|
51
51
|
if (a.expected_returns) { if (a.expected_returns.length !== C.length) throw new Error(`expected_returns has ${a.expected_returns.length} entries for ${C.length} assets.`); mu = a.expected_returns; }
|
|
52
52
|
if (needMu && !mu) throw new Error("Send expected_returns with a covariance matrix.");
|
|
53
53
|
if (a.assets && a.assets.length !== C.length) throw new Error(`assets has ${a.assets.length} names for ${C.length} assets.`);
|
|
54
|
-
|
|
54
|
+
const names = a.assets ?? C.map((_, i) => `asset_${i + 1}`);
|
|
55
|
+
// An asset that never moves has no risk to weigh: every optimizer here divides by it.
|
|
56
|
+
const flat = C.findIndex((row, i) => !(row[i] > 0));
|
|
57
|
+
if (flat >= 0) throw new Error(`${names[flat]} has no variance (its returns never change), so risk-based weights are undefined. Drop it or check the data.`);
|
|
58
|
+
return { C, mu, cols, n: C.length, names };
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
const label = (names, w) => Object.fromEntries(names.map((n, i) => [n, w[i]]));
|
|
@@ -382,6 +386,8 @@ export const TOOLS = [
|
|
|
382
386
|
input: z.object({ returns: sources.returns.unwrap(), periods_per_year: ppyArg }).strict(),
|
|
383
387
|
run(a) {
|
|
384
388
|
const R = a.returns, p = R[0].length, cols = Array.from({ length: p }, (_, j) => R.map((r) => r[j])), ppy = a.periods_per_year ?? 252;
|
|
389
|
+
const flat = cols.findIndex((c) => !(cov2(c, c, 0) > 0));
|
|
390
|
+
if (flat >= 0) throw new Error(`asset_${flat + 1} has no variance (its returns never change), so there is no covariance to shrink. Drop it or check the data.`);
|
|
385
391
|
const lw = ledoitWolf(cols), cond = (M) => { const e = symmetricEigen(M).values; return e[0] / e.at(-1); };
|
|
386
392
|
const sample = cols.map((x) => cols.map((y) => cov2(x, y, 0)));
|
|
387
393
|
return { shrinkage: lw.shrinkage, covariance_annual: lw.matrix.map((r) => r.map((v) => v * ppy)), covariance_per_period: lw.matrix, condition_number_sample: cond(sample), condition_number_shrunk: cond(lw.matrix) };
|
|
@@ -398,6 +404,8 @@ export const TOOLS = [
|
|
|
398
404
|
let cols = Array.from({ length: p }, (_, j) => R.map((r) => r[j]));
|
|
399
405
|
if (a.method === "spearman") cols = cols.map(rank);
|
|
400
406
|
const sd = cols.map((c) => std(c));
|
|
407
|
+
const flat = sd.findIndex((v) => !(v > 0));
|
|
408
|
+
if (flat >= 0) throw new Error(`${(a.assets ?? [])[flat] ?? `asset_${flat + 1}`} has no variance (its returns never change), so its correlations are undefined. Drop it or check the data.`);
|
|
401
409
|
const M = cols.map((x, i) => cols.map((y, j) => (i === j ? 1 : cov2(x, y) / (sd[i] * sd[j]))));
|
|
402
410
|
let s = 0; for (let i = 0; i < p; i++) for (let j = i + 1; j < p; j++) s += M[i][j];
|
|
403
411
|
const ev = symmetricEigen(M).values;
|
package/src/tools/sleeves.mjs
CHANGED
|
@@ -8,14 +8,17 @@
|
|
|
8
8
|
// Multiple-testing machinery (all deterministic for a given seed):
|
|
9
9
|
// - Deflated Sharpe ratio for the best sleeve, with the raw count of sleeves and with the effective
|
|
10
10
|
// number of independent sleeves (K^2 / sum of squared pairwise correlations).
|
|
11
|
-
// -
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// - White's Reality Check against a benchmark (unstudentized, as arch computes it), and Hansen's SPA
|
|
12
|
+
// test and Romano-Wolf StepM re-studentized in every resample: each bootstrap mean is divided by
|
|
13
|
+
// that resample's own standard deviation (Romano and Wolf 2005). The Null Zoo benchmark measured
|
|
14
|
+
// the fixed-variance studentization, which reuses one variance estimate, rejecting 9.4% of
|
|
15
|
+
// skill-less searches at block length 8 and 13.5% at 22 under AR(1) at a nominal 5%;
|
|
16
|
+
// re-studentizing brought it to 6.6%. The default mean block length is round(n^(1/3)).
|
|
14
17
|
// - Probability of backtest overfitting by combinatorially symmetric cross-validation (CSCV).
|
|
15
18
|
// The bootstrap draws come from mulberry32(seed), so anyone can regenerate them.
|
|
16
19
|
import { z } from "zod";
|
|
17
20
|
|
|
18
|
-
import { correlation, mean, moments, mulberry32, normCdf, normInv,
|
|
21
|
+
import { correlation, mean, moments, mulberry32, normCdf, normInv, quantile, std, variance } from "../math.mjs";
|
|
19
22
|
import { MAX_SERIES, ppyArg } from "../inputs.mjs";
|
|
20
23
|
import { FAMILY_INFO, SLEEVES, SLEEVE_BY_ID } from "../sleeves.mjs";
|
|
21
24
|
import { RECIPES, runEngine, statsOf } from "./strategies.mjs";
|
|
@@ -142,17 +145,6 @@ function effectiveTrials(R, C) {
|
|
|
142
145
|
return (K * K) / s2;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
|
-
// Stationary-bootstrap long-run variance of each column the way arch's SPA computes it.
|
|
146
|
-
function spaVariance(col, block) {
|
|
147
|
-
const t = col.length, m = mean(col), d = col.map((v) => v - m), p = 1 / block, s = lagProducts(d);
|
|
148
|
-
let v = s[0] / t;
|
|
149
|
-
for (let i = 1; i < t; i++) {
|
|
150
|
-
const kappa = (1 - i / t) * (1 - p) ** i + (i / t) * (1 - p) ** (t - i);
|
|
151
|
-
v += 2 * kappa * s[i] / t;
|
|
152
|
-
}
|
|
153
|
-
return v;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
148
|
// Stationary bootstrap (Politis and Romano 1994) as a list of [start, length] runs over n positions,
|
|
157
149
|
// drawn from mulberry32(seed): at each step one uniform decides a new block (probability 1/block),
|
|
158
150
|
// and a second picks its start.
|
|
@@ -177,43 +169,46 @@ const runSum = (S, n, start, len) => {
|
|
|
177
169
|
return total;
|
|
178
170
|
};
|
|
179
171
|
|
|
180
|
-
//
|
|
181
|
-
function
|
|
182
|
-
const n = B.length;
|
|
183
|
-
const
|
|
184
|
-
R.forEach((x, k) => { const d = x.map((v, t) => v - B[t]); const om = Math.sqrt(spaVariance(d, block)); if (om > 0 && std(d) > 0) { cols.push(d.map((v) => v / om)); keep.push(k); } });
|
|
172
|
+
// Joint tests on d[k] = sleeve - benchmark, with one stationary bootstrap shared by all sleeves.
|
|
173
|
+
function jointTests(R, B, { reps, block, seed, size }) {
|
|
174
|
+
const n = B.length, cols = [], keep = [];
|
|
175
|
+
R.forEach((x, k) => { const d = x.map((v, t) => v - B[t]); if (std(d) > 0) { cols.push(d); keep.push(k); } });
|
|
185
176
|
const K = cols.length;
|
|
186
177
|
if (K === 0) return null;
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
const
|
|
178
|
+
const m = cols.map(mean), sd = cols.map((c) => std(c)), rn = Math.sqrt(n), tObs = m.map((v, k) => rn * v / sd[k]);
|
|
179
|
+
const thr = sd.map((v) => -v * Math.sqrt(2 * Math.log(Math.log(n)) / n));
|
|
180
|
+
const centers = [m.map((v) => Math.max(v, 0)), m.map((v, k) => (v >= thr[k] ? v : 0)), m];
|
|
181
|
+
const pref = (c, sq) => { const s = new Float64Array(n + 1); for (let t = 0; t < n; t++) s[t + 1] = s[t] + (sq ? c[t] * c[t] : c[t]); return s; };
|
|
182
|
+
const S1 = cols.map((c) => pref(c, false)), S2 = cols.map((c) => pref(c, true));
|
|
183
|
+
const rand = mulberry32(seed), rc = new Float64Array(reps).fill(-Infinity), spa = [0, 1, 2].map(() => new Float64Array(reps).fill(0));
|
|
184
|
+
const tStar = Array.from({ length: K }, () => new Float64Array(reps));
|
|
192
185
|
for (let r = 0; r < reps; r++) {
|
|
193
186
|
const runs = bootstrapRuns(n, block, rand);
|
|
194
187
|
for (let k = 0; k < K; k++) {
|
|
195
|
-
let
|
|
196
|
-
for (const [st, len] of runs)
|
|
197
|
-
const mb =
|
|
198
|
-
|
|
188
|
+
let s1 = 0, s2 = 0;
|
|
189
|
+
for (const [st, len] of runs) { s1 += runSum(S1[k], n, st, len); s2 += runSum(S2[k], n, st, len); }
|
|
190
|
+
const mb = s1 / n, sdb = Math.sqrt(Math.max(0, (s2 - s1 * s1 / n) / (n - 1)));
|
|
191
|
+
if (mb - m[k] > rc[r]) rc[r] = mb - m[k];
|
|
192
|
+
for (let j = 0; j < 3; j++) { const v = sdb > 0 ? rn * (mb - centers[j][k]) / sdb : 0; if (v > spa[j][r]) spa[j][r] = v; }
|
|
193
|
+
tStar[k][r] = sdb > 0 ? rn * (mb - m[k]) / sdb : -Infinity;
|
|
199
194
|
}
|
|
200
195
|
}
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const pv =
|
|
204
|
-
// StepM: reject every sleeve whose
|
|
205
|
-
//
|
|
196
|
+
const obsRC = Math.max(...m), obsSPA = Math.max(0, ...tObs);
|
|
197
|
+
let rcCount = 0; for (let r = 0; r < reps; r++) if (rc[r] > obsRC) rcCount++;
|
|
198
|
+
const pv = spa.map((sims) => { let c = 0; for (let r = 0; r < reps; r++) if (sims[r] >= obsSPA) c++; return c / reps; });
|
|
199
|
+
// StepM: reject every sleeve whose t-statistic beats the (1 - size) quantile of the largest
|
|
200
|
+
// re-studentized resample among those not yet rejected; repeat until nothing new is rejected.
|
|
206
201
|
const superior = [];
|
|
207
|
-
let active =
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
202
|
+
let active = [...Array(K).keys()];
|
|
203
|
+
while (active.length) {
|
|
204
|
+
const mx = new Float64Array(reps).fill(-Infinity);
|
|
205
|
+
for (const k of active) for (let r = 0; r < reps; r++) if (tStar[k][r] > mx[r]) mx[r] = tStar[k][r];
|
|
206
|
+
const crit = quantile(mx.sort(), 1 - size), better = active.filter((k) => tObs[k] > crit);
|
|
207
|
+
if (!better.length) break;
|
|
212
208
|
superior.push(...better);
|
|
213
209
|
active = active.filter((k) => !better.includes(k));
|
|
214
|
-
if (active.length === 0) break;
|
|
215
210
|
}
|
|
216
|
-
return { tested: K, statistic:
|
|
211
|
+
return { tested: K, statistic: obsSPA, p_values: { lower: pv[0], consistent: pv[1], upper: pv[2] }, reality_check: rcCount / reps, superior: superior.map((k) => keep[k]).sort((x, y) => x - y) };
|
|
217
212
|
}
|
|
218
213
|
|
|
219
214
|
// Combinatorially symmetric cross-validation (Bailey, Borwein, Lopez de Prado and Zhu 2017).
|
|
@@ -315,13 +310,13 @@ export const TOOLS = [
|
|
|
315
310
|
{
|
|
316
311
|
name: "sleeve_tournament",
|
|
317
312
|
title: "Run every sleeve, corrected for multiple testing",
|
|
318
|
-
description: "Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV).",
|
|
313
|
+
description: "Run all library sleeves that fit your prices (up to 399) on a common window and rank them, then correct for the search: deflated Sharpe (raw and effective number of trials), White's Reality Check, re-studentized Hansen SPA and Romano-Wolf StepM against a benchmark, and the probability of backtest overfitting (CSCV).",
|
|
319
314
|
keywords: "which work survive best all sleeve tournament run all strategies leaderboard multiple testing spa reality check stepm romano wolf pbo cscv deflated sharpe data snooping",
|
|
320
315
|
input: z.object({
|
|
321
316
|
prices: pricesIn, ...engineArgs, ...selectArgs,
|
|
322
317
|
benchmark: z.enum(["buy_and_hold", "cash"]).optional().describe("What a sleeve must beat: equal-weight buy and hold of the columns (default) or cash at risk_free."),
|
|
323
|
-
reps: z.number().int().min(100).max(5000).optional().describe("Bootstrap repetitions for SPA and StepM; default 1000."),
|
|
324
|
-
block: z.number().int().min(1).max(1000).optional().describe("Mean bootstrap block length; default
|
|
318
|
+
reps: z.number().int().min(100).max(5000).optional().describe("Bootstrap repetitions for the Reality Check, SPA and StepM; default 1000."),
|
|
319
|
+
block: z.number().int().min(1).max(1000).optional().describe("Mean bootstrap block length; leave unset for the default round(periods^(1/3)). Long blocks make every block-bootstrap test liberal."),
|
|
325
320
|
seed: z.number().int().min(0).max(4294967295).optional().describe("Bootstrap seed; default 7."),
|
|
326
321
|
splits: z.number().int().min(4).max(16).multipleOf(2).optional().describe("CSCV splits (even); default 16, or fewer for short histories."),
|
|
327
322
|
top: z.number().int().min(1).max(400).optional().describe("Leaderboard rows to return; default 15."),
|
|
@@ -336,14 +331,15 @@ export const TOOLS = [
|
|
|
336
331
|
const order = [...rows].sort((x, y) => y.sharpe - x.sharpe || x.k - y.k);
|
|
337
332
|
const best = order[0], K = chosen.length, Keff = effectiveTrials(R);
|
|
338
333
|
const sv = variance(R.map(perSharpe)), dRaw = deflated(R[best.k], K, sv), dEff = deflated(R[best.k], Keff, sv);
|
|
339
|
-
const S = a.splits ?? (n >= 16 * 40 ? 16 : Math.max(4, 2 * Math.floor(n / 80))), block = a.block ?? Math.
|
|
340
|
-
const spa =
|
|
334
|
+
const S = a.splits ?? (n >= 16 * 40 ? 16 : Math.max(4, 2 * Math.floor(n / 80))), block = a.block ?? Math.max(1, Math.round(n ** (1 / 3)));
|
|
335
|
+
const spa = jointTests(R, B, { reps: a.reps ?? 1000, block, seed: a.seed ?? 7, size: 0.05 });
|
|
341
336
|
const cv = pbo(R, S), bs = statsOf(B, ppy);
|
|
342
337
|
const superior = spa ? spa.superior.map((k) => chosen[k].id) : [];
|
|
343
338
|
const verdict = [
|
|
344
339
|
`${K} sleeves ran on ${n} common periods (effective independent sleeves ${Keff.toFixed(1)}).`,
|
|
345
|
-
`Best sleeve ${best.id}: deflated Sharpe probability ${dRaw.dsr.toFixed(3)} counting all ${K} sleeves, ${dEff.dsr.toFixed(3)} counting ${Keff.toFixed(1)} independent bets${dEff.dsr > 0.95 ? (dRaw.dsr > 0.95 ? "; it survives both." : "; it survives only the effective count, which
|
|
346
|
-
|
|
340
|
+
`Best sleeve ${best.id}: deflated Sharpe probability ${dRaw.dsr.toFixed(3)} counting all ${K} sleeves, ${dEff.dsr.toFixed(3)} counting ${Keff.toFixed(1)} independent bets${dEff.dsr > 0.95 ? (dRaw.dsr > 0.95 ? "; it survives both." : "; it survives only the effective count, which can be too lenient (the raw count is the conservative bound).") : "; it does not survive deflation, so its Sharpe is what the best of this many tries shows by luck."}`,
|
|
341
|
+
"Deflation asks whether the Sharpe ratio is above zero; SPA and StepM ask whether a sleeve beats the benchmark, so a long-only sleeve in a rising market can pass the first and fail the second.",
|
|
342
|
+
spa ? (spa.p_values.consistent < 0.05 ? `SPA rejects "no sleeve beats the benchmark" (p ${spa.p_values.consistent}; Reality Check p ${spa.reality_check}); StepM keeps ${superior.length} sleeve(s).` : `SPA cannot reject that no sleeve beats the benchmark (p ${spa.p_values.consistent}; Reality Check p ${spa.reality_check}).`) : "No sleeve differs from the benchmark.",
|
|
347
343
|
`Probability of backtest overfitting ${cv.probability.toFixed(2)}${cv.probability > 0.5 ? ": picking the in-sample winner did worse than a random pick more often than not" : ""}.`,
|
|
348
344
|
].join(" ");
|
|
349
345
|
return {
|
|
@@ -357,7 +353,8 @@ export const TOOLS = [
|
|
|
357
353
|
multiple_testing: {
|
|
358
354
|
trials: K, effective_trials: Keff,
|
|
359
355
|
best: { id: best.id, sharpe: best.sharpe, deflated_sharpe_probability: dRaw.dsr, deflated_sharpe_probability_effective: dEff.dsr, expected_max_sharpe_under_null: dRaw.star * Math.sqrt(ppy), expected_max_sharpe_under_null_effective: dEff.star * Math.sqrt(ppy) },
|
|
360
|
-
|
|
356
|
+
reality_check: spa ? { p_value: spa.reality_check } : null,
|
|
357
|
+
spa: spa ? { sleeves_tested: spa.tested, statistic: spa.statistic, p_values: spa.p_values, studentization: "re-estimated in every resample", block, reps: a.reps ?? 1000, seed: a.seed ?? 7, bootstrap: "stationary, mulberry32(seed)" } : null,
|
|
361
358
|
stepm: { family_wise_error: 0.05, superior },
|
|
362
359
|
pbo: cv,
|
|
363
360
|
},
|