sharpebench 0.0.13__tar.gz
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.
- sharpebench-0.0.13/PKG-INFO +96 -0
- sharpebench-0.0.13/README.md +81 -0
- sharpebench-0.0.13/pyproject.toml +35 -0
- sharpebench-0.0.13/python/sharpebench/__init__.py +72 -0
- sharpebench-0.0.13/sharpebench-core/Cargo.toml +26 -0
- sharpebench-0.0.13/sharpebench-core/src/allocation.rs +234 -0
- sharpebench-0.0.13/sharpebench-core/src/attribution.rs +60 -0
- sharpebench-0.0.13/sharpebench-core/src/briefing.rs +342 -0
- sharpebench-0.0.13/sharpebench-core/src/calibration.rs +41 -0
- sharpebench-0.0.13/sharpebench-core/src/comparison_sets.rs +250 -0
- sharpebench-0.0.13/sharpebench-core/src/composite.rs +1010 -0
- sharpebench-0.0.13/sharpebench-core/src/correlation.rs +156 -0
- sharpebench-0.0.13/sharpebench-core/src/decay.rs +66 -0
- sharpebench-0.0.13/sharpebench-core/src/disqualification.rs +293 -0
- sharpebench-0.0.13/sharpebench-core/src/econrationality.rs +141 -0
- sharpebench-0.0.13/sharpebench-core/src/greeks.rs +326 -0
- sharpebench-0.0.13/sharpebench-core/src/lib.rs +81 -0
- sharpebench-0.0.13/sharpebench-core/src/oos.rs +101 -0
- sharpebench-0.0.13/sharpebench-core/src/pass_k.rs +44 -0
- sharpebench-0.0.13/sharpebench-core/src/percentile.rs +180 -0
- sharpebench-0.0.13/sharpebench-core/src/process.rs +196 -0
- sharpebench-0.0.13/sharpebench-core/src/rediscovery.rs +211 -0
- sharpebench-0.0.13/sharpebench-core/src/roles.rs +64 -0
- sharpebench-0.0.13/sharpebench-core/src/rolling.rs +84 -0
- sharpebench-0.0.13/sharpebench-core/src/selfaudit.rs +310 -0
- sharpebench-0.0.13/sharpebench-edge/Cargo.toml +23 -0
- sharpebench-0.0.13/sharpebench-edge/src/hlz.rs +124 -0
- sharpebench-0.0.13/sharpebench-edge/src/lib.rs +40 -0
- sharpebench-0.0.13/sharpebench-edge/src/mintrl.rs +101 -0
- sharpebench-0.0.13/sharpebench-edge/src/pbo.rs +224 -0
- sharpebench-0.0.13/sharpebench-edge/src/verdict.rs +412 -0
- sharpebench-0.0.13/sharpebench-py/Cargo.lock +196 -0
- sharpebench-0.0.13/sharpebench-py/Cargo.toml +26 -0
- sharpebench-0.0.13/sharpebench-py/README.md +81 -0
- sharpebench-0.0.13/sharpebench-py/src/lib.rs +504 -0
- sharpebench-0.0.13/sharpebench-py/tests/test_stats.py +315 -0
- sharpebench-0.0.13/sharpebench-stats/Cargo.toml +19 -0
- sharpebench-0.0.13/sharpebench-stats/src/deflated_sharpe.rs +93 -0
- sharpebench-0.0.13/sharpebench-stats/src/fdr.rs +207 -0
- sharpebench-0.0.13/sharpebench-stats/src/lib.rs +51 -0
- sharpebench-0.0.13/sharpebench-stats/src/selection.rs +113 -0
- sharpebench-0.0.13/sharpebench-stats/src/significance.rs +665 -0
- sharpebench-0.0.13/sharpebench-stats/src/stats.rs +236 -0
- sharpebench-0.0.13/sharpebench-stats/src/stylized_facts.rs +417 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sharpebench
|
|
3
|
+
Version: 0.0.13
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
7
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
8
|
+
Summary: SharpeBench honest-backtest statistics: deflated Sharpe, PSR, PBO/CSCV, Reality Check, SPA, BH-FDR and pass^k for your own return series.
|
|
9
|
+
Keywords: backtest,deflated-sharpe,overfitting,quant,sharpe-ratio,statistics,trading
|
|
10
|
+
Author: General Liquidity, Inc.
|
|
11
|
+
License: MIT OR Apache-2.0
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
14
|
+
|
|
15
|
+
# sharpebench (Python)
|
|
16
|
+
|
|
17
|
+
**Is my Sharpe real, or an artifact of luck and multiple testing?**
|
|
18
|
+
|
|
19
|
+
Python distribution of **SharpeBench**'s honest-backtest statistics, a pyo3
|
|
20
|
+
binding over the same deterministic Rust kernel the SharpeBench CLI and the
|
|
21
|
+
`@general-liquidity/sharpebench` npm package use. Bring your own return series;
|
|
22
|
+
everything takes plain numeric sequences (lists, tuples, numpy arrays,
|
|
23
|
+
`df["ret"].to_numpy()`) and returns plain floats, lists and dicts.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import numpy as np
|
|
27
|
+
from sharpebench import is_my_sharpe_real, bootstrap_dsr_ci
|
|
28
|
+
|
|
29
|
+
returns = df["strategy_ret"].to_numpy() # per-period, NOT annualized
|
|
30
|
+
|
|
31
|
+
# n_trials is the honest one: how many variants did you try before keeping this?
|
|
32
|
+
v = is_my_sharpe_real(returns, n_trials=200)
|
|
33
|
+
print(v["sharpe"], v["deflated_sharpe"], v["verdict"], v["explanation"])
|
|
34
|
+
|
|
35
|
+
ci = bootstrap_dsr_ci(returns, n_trials=200)
|
|
36
|
+
print(ci["lower"], ci["point"], ci["upper"])
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Surface
|
|
40
|
+
|
|
41
|
+
| Function | Answers |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `sharpe_ratio(returns)` | observed per-period Sharpe |
|
|
44
|
+
| `moments(returns, target=0.0)` | mean / std / skew / kurtosis / downside deviation / Sortino |
|
|
45
|
+
| `probabilistic_sharpe_ratio(returns, sr_benchmark=0.0)` | `P(true Sharpe > benchmark)` (PSR) |
|
|
46
|
+
| `deflated_sharpe_ratio(returns, n_trials, trials_sr_std=0.5)` | PSR deflated for the size of the search (DSR) |
|
|
47
|
+
| `expected_max_sharpe(trials_sr_std, n_trials)` | the Sharpe the best of `n_trials` shows with **zero** skill |
|
|
48
|
+
| `min_track_record_length(returns, ...)` | periods needed before the Sharpe is believable |
|
|
49
|
+
| `bootstrap_dsr_ci(returns, n_trials, ...)` | `{point, se, lower, upper}` on the DSR itself |
|
|
50
|
+
| `bootstrap_pvalue(excess, ...)` | stationary-bootstrap p-value for one series |
|
|
51
|
+
| `is_my_sharpe_real(returns, n_trials=1, ...)` | LITE verdict dict: `pass \| borderline \| fail` + explanation |
|
|
52
|
+
| `is_my_sharpe_real_full(field, ...)` | FULL verdict over a whole candidate field (LITE + snooping family + PBO + HLZ) |
|
|
53
|
+
| `reality_check_pvalue(field, ...)` | White's Reality Check over the field |
|
|
54
|
+
| `spa_pvalue` / `spa_consistent_pvalue(field, ...)` | Hansen's SPA (liberal / consistent) |
|
|
55
|
+
| `step_down_significant(field, ..., alpha=0.05)` | Romano-Wolf step-down, per candidate, FWER-controlled |
|
|
56
|
+
| `probability_of_backtest_overfitting(perf_matrix, s=16)` | CSCV PBO |
|
|
57
|
+
| `benjamini_hochberg(p_values, q=0.05)` / `fdr_verdict(...)` | BH-FDR rejections and the operator summary |
|
|
58
|
+
| `hlz_gate(t_stat, t_threshold=None)` | the Harvey-Liu-Zhu `\|t\| >= 3.0` factor bar |
|
|
59
|
+
| `selection_robustness(candidates, n_trials, ...)` | best vs median DSR: is the headline a lucky pick? |
|
|
60
|
+
| `runs_for_power(effect, alpha, power)` | how many runs to detect an effect |
|
|
61
|
+
| `pass_k(passed_per_run, mode="all", n=None)` | pass^k reliability: won on **every** run, not on average |
|
|
62
|
+
|
|
63
|
+
### Matrix orientation
|
|
64
|
+
|
|
65
|
+
Two conventions, deliberately unchanged from the papers they come from:
|
|
66
|
+
|
|
67
|
+
- the data-snooping family (`reality_check_pvalue`, `spa_*`, `step_down_significant`,
|
|
68
|
+
`is_my_sharpe_real_full`) takes a **field: N rows (strategies) x T cols (time)**;
|
|
69
|
+
- `probability_of_backtest_overfitting` takes the **transpose: T rows (time) x N cols
|
|
70
|
+
(strategies)**.
|
|
71
|
+
|
|
72
|
+
### Determinism
|
|
73
|
+
|
|
74
|
+
No I/O, no clock, no ambient randomness. The bootstraps take an explicit `seed`
|
|
75
|
+
(defaulted to a fixed constant, so a result is reproducible unless you ask for
|
|
76
|
+
otherwise). The same input yields byte-identical output on any platform.
|
|
77
|
+
|
|
78
|
+
## Relationship to `sharpearena`
|
|
79
|
+
|
|
80
|
+
`sharpearena` is the **environment**: a leak-free, point-in-time arena where a
|
|
81
|
+
trading agent produces a track, scored end-to-end by `score_run`. `sharpebench`
|
|
82
|
+
is the **judge for a track you already have**: your own backtest, live P&L, or a
|
|
83
|
+
field of candidate strategies. They share one Rust statistics kernel, so the
|
|
84
|
+
verdict is identical either way; this package simply does not, and will not,
|
|
85
|
+
duplicate arena run-scoring.
|
|
86
|
+
|
|
87
|
+
## Building from source
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
python -m pip install maturin
|
|
91
|
+
python -m maturin develop --manifest-path crates/sharpebench-py/Cargo.toml
|
|
92
|
+
python -m pytest crates/sharpebench-py/tests
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
MIT OR Apache-2.0.
|
|
96
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# sharpebench (Python)
|
|
2
|
+
|
|
3
|
+
**Is my Sharpe real, or an artifact of luck and multiple testing?**
|
|
4
|
+
|
|
5
|
+
Python distribution of **SharpeBench**'s honest-backtest statistics, a pyo3
|
|
6
|
+
binding over the same deterministic Rust kernel the SharpeBench CLI and the
|
|
7
|
+
`@general-liquidity/sharpebench` npm package use. Bring your own return series;
|
|
8
|
+
everything takes plain numeric sequences (lists, tuples, numpy arrays,
|
|
9
|
+
`df["ret"].to_numpy()`) and returns plain floats, lists and dicts.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import numpy as np
|
|
13
|
+
from sharpebench import is_my_sharpe_real, bootstrap_dsr_ci
|
|
14
|
+
|
|
15
|
+
returns = df["strategy_ret"].to_numpy() # per-period, NOT annualized
|
|
16
|
+
|
|
17
|
+
# n_trials is the honest one: how many variants did you try before keeping this?
|
|
18
|
+
v = is_my_sharpe_real(returns, n_trials=200)
|
|
19
|
+
print(v["sharpe"], v["deflated_sharpe"], v["verdict"], v["explanation"])
|
|
20
|
+
|
|
21
|
+
ci = bootstrap_dsr_ci(returns, n_trials=200)
|
|
22
|
+
print(ci["lower"], ci["point"], ci["upper"])
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Surface
|
|
26
|
+
|
|
27
|
+
| Function | Answers |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `sharpe_ratio(returns)` | observed per-period Sharpe |
|
|
30
|
+
| `moments(returns, target=0.0)` | mean / std / skew / kurtosis / downside deviation / Sortino |
|
|
31
|
+
| `probabilistic_sharpe_ratio(returns, sr_benchmark=0.0)` | `P(true Sharpe > benchmark)` (PSR) |
|
|
32
|
+
| `deflated_sharpe_ratio(returns, n_trials, trials_sr_std=0.5)` | PSR deflated for the size of the search (DSR) |
|
|
33
|
+
| `expected_max_sharpe(trials_sr_std, n_trials)` | the Sharpe the best of `n_trials` shows with **zero** skill |
|
|
34
|
+
| `min_track_record_length(returns, ...)` | periods needed before the Sharpe is believable |
|
|
35
|
+
| `bootstrap_dsr_ci(returns, n_trials, ...)` | `{point, se, lower, upper}` on the DSR itself |
|
|
36
|
+
| `bootstrap_pvalue(excess, ...)` | stationary-bootstrap p-value for one series |
|
|
37
|
+
| `is_my_sharpe_real(returns, n_trials=1, ...)` | LITE verdict dict: `pass \| borderline \| fail` + explanation |
|
|
38
|
+
| `is_my_sharpe_real_full(field, ...)` | FULL verdict over a whole candidate field (LITE + snooping family + PBO + HLZ) |
|
|
39
|
+
| `reality_check_pvalue(field, ...)` | White's Reality Check over the field |
|
|
40
|
+
| `spa_pvalue` / `spa_consistent_pvalue(field, ...)` | Hansen's SPA (liberal / consistent) |
|
|
41
|
+
| `step_down_significant(field, ..., alpha=0.05)` | Romano-Wolf step-down, per candidate, FWER-controlled |
|
|
42
|
+
| `probability_of_backtest_overfitting(perf_matrix, s=16)` | CSCV PBO |
|
|
43
|
+
| `benjamini_hochberg(p_values, q=0.05)` / `fdr_verdict(...)` | BH-FDR rejections and the operator summary |
|
|
44
|
+
| `hlz_gate(t_stat, t_threshold=None)` | the Harvey-Liu-Zhu `\|t\| >= 3.0` factor bar |
|
|
45
|
+
| `selection_robustness(candidates, n_trials, ...)` | best vs median DSR: is the headline a lucky pick? |
|
|
46
|
+
| `runs_for_power(effect, alpha, power)` | how many runs to detect an effect |
|
|
47
|
+
| `pass_k(passed_per_run, mode="all", n=None)` | pass^k reliability: won on **every** run, not on average |
|
|
48
|
+
|
|
49
|
+
### Matrix orientation
|
|
50
|
+
|
|
51
|
+
Two conventions, deliberately unchanged from the papers they come from:
|
|
52
|
+
|
|
53
|
+
- the data-snooping family (`reality_check_pvalue`, `spa_*`, `step_down_significant`,
|
|
54
|
+
`is_my_sharpe_real_full`) takes a **field: N rows (strategies) x T cols (time)**;
|
|
55
|
+
- `probability_of_backtest_overfitting` takes the **transpose: T rows (time) x N cols
|
|
56
|
+
(strategies)**.
|
|
57
|
+
|
|
58
|
+
### Determinism
|
|
59
|
+
|
|
60
|
+
No I/O, no clock, no ambient randomness. The bootstraps take an explicit `seed`
|
|
61
|
+
(defaulted to a fixed constant, so a result is reproducible unless you ask for
|
|
62
|
+
otherwise). The same input yields byte-identical output on any platform.
|
|
63
|
+
|
|
64
|
+
## Relationship to `sharpearena`
|
|
65
|
+
|
|
66
|
+
`sharpearena` is the **environment**: a leak-free, point-in-time arena where a
|
|
67
|
+
trading agent produces a track, scored end-to-end by `score_run`. `sharpebench`
|
|
68
|
+
is the **judge for a track you already have**: your own backtest, live P&L, or a
|
|
69
|
+
field of candidate strategies. They share one Rust statistics kernel, so the
|
|
70
|
+
verdict is identical either way; this package simply does not, and will not,
|
|
71
|
+
duplicate arena run-scoring.
|
|
72
|
+
|
|
73
|
+
## Building from source
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
python -m pip install maturin
|
|
77
|
+
python -m maturin develop --manifest-path crates/sharpebench-py/Cargo.toml
|
|
78
|
+
python -m pytest crates/sharpebench-py/tests
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
MIT OR Apache-2.0.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.7,<2.0"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sharpebench"
|
|
7
|
+
version = "0.0.13"
|
|
8
|
+
description = "SharpeBench honest-backtest statistics: deflated Sharpe, PSR, PBO/CSCV, Reality Check, SPA, BH-FDR and pass^k for your own return series."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT OR Apache-2.0" }
|
|
12
|
+
authors = [{ name = "General Liquidity, Inc." }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"backtest",
|
|
15
|
+
"deflated-sharpe",
|
|
16
|
+
"overfitting",
|
|
17
|
+
"quant",
|
|
18
|
+
"sharpe-ratio",
|
|
19
|
+
"statistics",
|
|
20
|
+
"trading",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Programming Language :: Rust",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.maturin]
|
|
30
|
+
# Mixed Rust/Python layout: the pure-Python package lives under `python/sharpebench`,
|
|
31
|
+
# and the compiled pyo3 extension is placed inside it as `sharpebench.sharpebench_py`.
|
|
32
|
+
module-name = "sharpebench.sharpebench_py"
|
|
33
|
+
features = ["pyo3/extension-module"]
|
|
34
|
+
manifest-path = "sharpebench-py/Cargo.toml"
|
|
35
|
+
python-source = "python"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""SharpeBench: honest-backtest statistics for your own return series.
|
|
2
|
+
|
|
3
|
+
One question, answered deterministically: *is this Sharpe real, or an artifact of
|
|
4
|
+
luck and multiple testing?* Everything here takes plain numeric sequences (lists,
|
|
5
|
+
tuples, 1-D/2-D numpy arrays, ``df["ret"].to_numpy()``) and returns plain floats,
|
|
6
|
+
lists and dicts.
|
|
7
|
+
|
|
8
|
+
>>> from sharpebench import is_my_sharpe_real
|
|
9
|
+
>>> returns = [0.001 + 0.0001 * ((i % 5) - 2) for i in range(500)]
|
|
10
|
+
>>> v = is_my_sharpe_real(returns, n_trials=200)
|
|
11
|
+
>>> v["verdict"] in {"pass", "borderline", "fail"}
|
|
12
|
+
True
|
|
13
|
+
|
|
14
|
+
The whole surface is a pyo3 binding over the same Rust kernel the SharpeBench CLI
|
|
15
|
+
and the ``@general-liquidity/sharpebench`` npm package use, so a number computed
|
|
16
|
+
here is byte-identical to the one the benchmark reports.
|
|
17
|
+
|
|
18
|
+
Scope: this package scores *arbitrary* return series. It does not run agents. The
|
|
19
|
+
sibling ``sharpearena`` package hosts the leak-free RL environment and scores runs
|
|
20
|
+
of it via ``score_run``; the two are complementary, since arena produces the track,
|
|
21
|
+
sharpebench judges whether the track means anything.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .sharpebench_py import (
|
|
25
|
+
METHODOLOGY_VERSION,
|
|
26
|
+
benjamini_hochberg,
|
|
27
|
+
bootstrap_dsr_ci,
|
|
28
|
+
bootstrap_pvalue,
|
|
29
|
+
deflated_sharpe_ratio,
|
|
30
|
+
expected_max_sharpe,
|
|
31
|
+
fdr_verdict,
|
|
32
|
+
hlz_gate,
|
|
33
|
+
is_my_sharpe_real,
|
|
34
|
+
is_my_sharpe_real_full,
|
|
35
|
+
min_track_record_length,
|
|
36
|
+
moments,
|
|
37
|
+
pass_k,
|
|
38
|
+
probability_of_backtest_overfitting,
|
|
39
|
+
probabilistic_sharpe_ratio,
|
|
40
|
+
reality_check_pvalue,
|
|
41
|
+
runs_for_power,
|
|
42
|
+
selection_robustness,
|
|
43
|
+
sharpe_ratio,
|
|
44
|
+
spa_consistent_pvalue,
|
|
45
|
+
spa_pvalue,
|
|
46
|
+
step_down_significant,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"METHODOLOGY_VERSION",
|
|
51
|
+
"benjamini_hochberg",
|
|
52
|
+
"bootstrap_dsr_ci",
|
|
53
|
+
"bootstrap_pvalue",
|
|
54
|
+
"deflated_sharpe_ratio",
|
|
55
|
+
"expected_max_sharpe",
|
|
56
|
+
"fdr_verdict",
|
|
57
|
+
"hlz_gate",
|
|
58
|
+
"is_my_sharpe_real",
|
|
59
|
+
"is_my_sharpe_real_full",
|
|
60
|
+
"min_track_record_length",
|
|
61
|
+
"moments",
|
|
62
|
+
"pass_k",
|
|
63
|
+
"probabilistic_sharpe_ratio",
|
|
64
|
+
"probability_of_backtest_overfitting",
|
|
65
|
+
"reality_check_pvalue",
|
|
66
|
+
"runs_for_power",
|
|
67
|
+
"selection_robustness",
|
|
68
|
+
"sharpe_ratio",
|
|
69
|
+
"spa_consistent_pvalue",
|
|
70
|
+
"spa_pvalue",
|
|
71
|
+
"step_down_significant",
|
|
72
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "sharpebench-core"
|
|
3
|
+
version = "0.0.13"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
license = "MIT OR Apache-2.0"
|
|
6
|
+
description = "Deterministic, luck-robust scoring kernel for SharpeBench (deflated Sharpe / PSR / pass^k / process / decay)."
|
|
7
|
+
repository = "https://github.com/general-liquidity/sharpebench"
|
|
8
|
+
keywords = ["benchmark", "trading", "sharpe", "quant", "evaluation"]
|
|
9
|
+
categories = ["science", "finance"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
serde = { version = "1", features = ["derive"] }
|
|
13
|
+
sharpebench-stats = { path = "../sharpebench-stats", version = "0.0.13" }
|
|
14
|
+
|
|
15
|
+
[dev-dependencies]
|
|
16
|
+
serde_json = "1"
|
|
17
|
+
|
|
18
|
+
# Lints enforced by the manifest (not just the CI `-D warnings` flag), so a plain
|
|
19
|
+
# `cargo clippy` locally fails the same way CI does. Crates opt in via `[lints]
|
|
20
|
+
# workspace = true`. We do not ban unwrap/expect — internal code is trusted and
|
|
21
|
+
# only validates at boundaries — but we deny stray todo!()/dbg!() and all of
|
|
22
|
+
# clippy's default lints.
|
|
23
|
+
[lints.clippy]
|
|
24
|
+
all = { level = "deny", priority = -1 }
|
|
25
|
+
todo = "deny"
|
|
26
|
+
dbg_macro = "deny"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
//! Allocation-vector scoring contract + turnover penalty.
|
|
2
|
+
//!
|
|
3
|
+
//! SharpeBench's primary contract is order-level (per-order rationale, a risk gate
|
|
4
|
+
//! per order, partial fills). This adds a second, additive contract for agents that
|
|
5
|
+
//! express intent as a continuous **target-allocation vector** rebalanced each
|
|
6
|
+
//! cycle, the way a portfolio-allocation agent does. Two things are scored that the
|
|
7
|
+
//! order-level path can't see:
|
|
8
|
+
//!
|
|
9
|
+
//! - **Weight validity** — a vector that over-leverages (gross > cap) or goes short
|
|
10
|
+
//! when shorts are disallowed is the allocation-analogue of a deny-list breach,
|
|
11
|
+
//! i.e. a discipline-zeroing violation.
|
|
12
|
+
//! - **Turnover** — the L1 churn `Σ|wₜ − wₜ₋₁|` across rebalances, a first-class
|
|
13
|
+
//! cost an agent that "wins" by frantic reallocation should be charged for.
|
|
14
|
+
//!
|
|
15
|
+
//! Pure and deterministic: the caller supplies the realized allocation trajectory.
|
|
16
|
+
|
|
17
|
+
use serde::{Deserialize, Serialize};
|
|
18
|
+
|
|
19
|
+
/// One cycle's target-allocation vector (weights per instrument, in a fixed order).
|
|
20
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
21
|
+
pub struct AllocationStep {
|
|
22
|
+
pub weights: Vec<f64>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// The realized sequence of target allocations the account rebalanced to.
|
|
26
|
+
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
27
|
+
pub struct AllocationTrajectory {
|
|
28
|
+
pub steps: Vec<AllocationStep>,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Validity limits a target-allocation vector must respect.
|
|
32
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
33
|
+
pub struct AllocationPolicy {
|
|
34
|
+
/// Whether negative (short) weights are permitted.
|
|
35
|
+
pub allow_shorts: bool,
|
|
36
|
+
/// Cap on gross exposure `Σ|wᵢ|` (1.0 = fully invested, no leverage).
|
|
37
|
+
pub max_gross: f64,
|
|
38
|
+
/// Tolerance for the gross-exposure comparison (floating-point slack).
|
|
39
|
+
pub epsilon: f64,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
impl Default for AllocationPolicy {
|
|
43
|
+
fn default() -> Self {
|
|
44
|
+
AllocationPolicy {
|
|
45
|
+
allow_shorts: false,
|
|
46
|
+
max_gross: 1.0,
|
|
47
|
+
epsilon: 1e-9,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// A specific way a weight vector violates the policy.
|
|
53
|
+
#[derive(Clone, Debug, Serialize, PartialEq)]
|
|
54
|
+
#[serde(tag = "violation", rename_all = "snake_case")]
|
|
55
|
+
pub enum WeightViolation {
|
|
56
|
+
/// A weight is NaN or infinite — an abusive/garbage vector.
|
|
57
|
+
NonFiniteWeight { index: usize },
|
|
58
|
+
/// A negative weight while shorts are disallowed.
|
|
59
|
+
NegativeWeight { index: usize, weight: f64 },
|
|
60
|
+
/// Gross exposure exceeded the leverage cap.
|
|
61
|
+
GrossExposureExceeded { gross: f64, cap: f64 },
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The validity verdict for one weight vector.
|
|
65
|
+
#[derive(Clone, Debug, Serialize, PartialEq)]
|
|
66
|
+
pub struct WeightValidity {
|
|
67
|
+
pub valid: bool,
|
|
68
|
+
pub violations: Vec<WeightViolation>,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Validate a single weight vector against the policy.
|
|
72
|
+
pub fn check_weights(weights: &[f64], policy: &AllocationPolicy) -> WeightValidity {
|
|
73
|
+
let mut violations = Vec::new();
|
|
74
|
+
let mut gross = 0.0;
|
|
75
|
+
for (index, &w) in weights.iter().enumerate() {
|
|
76
|
+
if !w.is_finite() {
|
|
77
|
+
violations.push(WeightViolation::NonFiniteWeight { index });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if w < 0.0 && !policy.allow_shorts {
|
|
81
|
+
violations.push(WeightViolation::NegativeWeight { index, weight: w });
|
|
82
|
+
}
|
|
83
|
+
gross += w.abs();
|
|
84
|
+
}
|
|
85
|
+
if gross > policy.max_gross + policy.epsilon {
|
|
86
|
+
violations.push(WeightViolation::GrossExposureExceeded {
|
|
87
|
+
gross,
|
|
88
|
+
cap: policy.max_gross,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
WeightValidity {
|
|
92
|
+
valid: violations.is_empty(),
|
|
93
|
+
violations,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Total L1 turnover `Σₜ Σᵢ |wₜ,ᵢ − wₜ₋₁,ᵢ|`. The first step is measured against an
|
|
98
|
+
/// all-cash (all-zero) prior, so initial deployment counts as turnover. Vectors of
|
|
99
|
+
/// differing lengths are compared element-wise with the shorter side zero-padded.
|
|
100
|
+
pub fn turnover(trajectory: &AllocationTrajectory) -> f64 {
|
|
101
|
+
let mut total = 0.0;
|
|
102
|
+
let mut prev: Vec<f64> = Vec::new();
|
|
103
|
+
for step in &trajectory.steps {
|
|
104
|
+
let n = step.weights.len().max(prev.len());
|
|
105
|
+
for i in 0..n {
|
|
106
|
+
let cur = step.weights.get(i).copied().unwrap_or(0.0);
|
|
107
|
+
let old = prev.get(i).copied().unwrap_or(0.0);
|
|
108
|
+
total += (cur - old).abs();
|
|
109
|
+
}
|
|
110
|
+
prev = step.weights.clone();
|
|
111
|
+
}
|
|
112
|
+
total
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// The full allocation score: aggregate weight validity across every step plus the
|
|
116
|
+
/// trajectory's turnover.
|
|
117
|
+
#[derive(Clone, Debug, Serialize)]
|
|
118
|
+
pub struct AllocationReport {
|
|
119
|
+
pub total_turnover: f64,
|
|
120
|
+
/// `total_turnover / steps`, or 0 for an empty trajectory.
|
|
121
|
+
pub mean_turnover: f64,
|
|
122
|
+
/// Every weight violation found, across all steps (a non-empty list = ineligible).
|
|
123
|
+
pub weight_violations: Vec<WeightViolation>,
|
|
124
|
+
pub valid: bool,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Score an allocation trajectory: validity (any breach across any step zeroes
|
|
128
|
+
/// `valid`, mirroring the order-level deny-list semantics) and turnover churn.
|
|
129
|
+
pub fn score_allocation(
|
|
130
|
+
trajectory: &AllocationTrajectory,
|
|
131
|
+
policy: &AllocationPolicy,
|
|
132
|
+
) -> AllocationReport {
|
|
133
|
+
let mut weight_violations = Vec::new();
|
|
134
|
+
for step in &trajectory.steps {
|
|
135
|
+
weight_violations.extend(check_weights(&step.weights, policy).violations);
|
|
136
|
+
}
|
|
137
|
+
let total_turnover = turnover(trajectory);
|
|
138
|
+
let mean_turnover = if trajectory.steps.is_empty() {
|
|
139
|
+
0.0
|
|
140
|
+
} else {
|
|
141
|
+
total_turnover / trajectory.steps.len() as f64
|
|
142
|
+
};
|
|
143
|
+
AllocationReport {
|
|
144
|
+
total_turnover,
|
|
145
|
+
mean_turnover,
|
|
146
|
+
valid: weight_violations.is_empty(),
|
|
147
|
+
weight_violations,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[cfg(test)]
|
|
152
|
+
mod tests {
|
|
153
|
+
use super::*;
|
|
154
|
+
|
|
155
|
+
fn traj(steps: &[&[f64]]) -> AllocationTrajectory {
|
|
156
|
+
AllocationTrajectory {
|
|
157
|
+
steps: steps
|
|
158
|
+
.iter()
|
|
159
|
+
.map(|w| AllocationStep {
|
|
160
|
+
weights: w.to_vec(),
|
|
161
|
+
})
|
|
162
|
+
.collect(),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[test]
|
|
167
|
+
fn valid_low_turnover_trajectory_scores_with_hand_computed_turnover() {
|
|
168
|
+
// prior [0,0]:
|
|
169
|
+
// step1 |0.5-0|+|0.5-0| = 1.0
|
|
170
|
+
// step2 |0.5-0.5|+|0.5-0.5| = 0.0
|
|
171
|
+
// step3 |0.0-0.5|+|1.0-0.5| = 1.0 -> total 2.0, mean 2/3
|
|
172
|
+
let t = traj(&[&[0.5, 0.5], &[0.5, 0.5], &[0.0, 1.0]]);
|
|
173
|
+
let r = score_allocation(&t, &AllocationPolicy::default());
|
|
174
|
+
assert!(r.valid, "{:?}", r.weight_violations);
|
|
175
|
+
assert!((r.total_turnover - 2.0).abs() < 1e-12);
|
|
176
|
+
assert!((r.mean_turnover - 2.0 / 3.0).abs() < 1e-12);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[test]
|
|
180
|
+
fn over_leveraged_vector_flags_gross_exposure() {
|
|
181
|
+
let t = traj(&[&[0.7, 0.7]]); // gross 1.4 > 1.0 cap
|
|
182
|
+
let r = score_allocation(&t, &AllocationPolicy::default());
|
|
183
|
+
assert!(!r.valid);
|
|
184
|
+
assert!(r.weight_violations.iter().any(|v| matches!(
|
|
185
|
+
v,
|
|
186
|
+
WeightViolation::GrossExposureExceeded { cap, .. } if (*cap - 1.0).abs() < 1e-12
|
|
187
|
+
)));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#[test]
|
|
191
|
+
fn negative_weight_flags_when_shorts_disallowed() {
|
|
192
|
+
let t = traj(&[&[-0.3, 0.5]]);
|
|
193
|
+
let r = score_allocation(&t, &AllocationPolicy::default());
|
|
194
|
+
assert!(!r.valid);
|
|
195
|
+
assert!(r
|
|
196
|
+
.weight_violations
|
|
197
|
+
.iter()
|
|
198
|
+
.any(|v| matches!(v, WeightViolation::NegativeWeight { index: 0, .. })));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#[test]
|
|
202
|
+
fn shorts_allowed_permits_negative_within_gross_cap() {
|
|
203
|
+
let policy = AllocationPolicy {
|
|
204
|
+
allow_shorts: true,
|
|
205
|
+
max_gross: 2.0,
|
|
206
|
+
..Default::default()
|
|
207
|
+
};
|
|
208
|
+
let t = traj(&[&[-0.5, 0.5]]); // gross 1.0 <= 2.0
|
|
209
|
+
let r = score_allocation(&t, &policy);
|
|
210
|
+
assert!(r.valid, "{:?}", r.weight_violations);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#[test]
|
|
214
|
+
fn non_finite_weight_flags() {
|
|
215
|
+
let t = traj(&[&[f64::NAN, 0.5]]);
|
|
216
|
+
let r = score_allocation(&t, &AllocationPolicy::default());
|
|
217
|
+
assert!(!r.valid);
|
|
218
|
+
assert!(r
|
|
219
|
+
.weight_violations
|
|
220
|
+
.iter()
|
|
221
|
+
.any(|v| matches!(v, WeightViolation::NonFiniteWeight { index: 0 })));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn empty_trajectory_is_valid_with_zero_turnover() {
|
|
226
|
+
let r = score_allocation(
|
|
227
|
+
&AllocationTrajectory::default(),
|
|
228
|
+
&AllocationPolicy::default(),
|
|
229
|
+
);
|
|
230
|
+
assert!(r.valid);
|
|
231
|
+
assert_eq!(r.total_turnover, 0.0);
|
|
232
|
+
assert_eq!(r.mean_turnover, 0.0);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//! Performance attribution — separate skill (alpha) from market beta.
|
|
2
|
+
//!
|
|
3
|
+
//! "Return rank = luck" taken one step further: how much of an agent's return is
|
|
4
|
+
//! its own decisions versus simply riding the field/market? We regress an agent's
|
|
5
|
+
//! returns on a market proxy (CAPM-style) and report the intercept (alpha — the
|
|
6
|
+
//! skill component) and slope (beta — market exposure). Computed field-relative in
|
|
7
|
+
//! [`crate::rank`], with the market proxy = the equal-weight average of all
|
|
8
|
+
//! submitted agents' returns. (After KTD-Fin's Barra-style attribution.)
|
|
9
|
+
|
|
10
|
+
use crate::stats::{mean, variance};
|
|
11
|
+
|
|
12
|
+
/// CAPM decomposition of `agent` returns against an aligned `market` series.
|
|
13
|
+
/// Returns `(alpha_per_period, beta)`: alpha is the agent's mean return net of its
|
|
14
|
+
/// beta-weighted market exposure — the part that isn't just market drift.
|
|
15
|
+
pub fn alpha_beta(agent: &[f64], market: &[f64]) -> (f64, f64) {
|
|
16
|
+
let n = agent.len().min(market.len());
|
|
17
|
+
if n < 2 {
|
|
18
|
+
return (mean(agent), 0.0);
|
|
19
|
+
}
|
|
20
|
+
let a = &agent[..n];
|
|
21
|
+
let m = &market[..n];
|
|
22
|
+
let ma = mean(a);
|
|
23
|
+
let mm = mean(m);
|
|
24
|
+
let var_m = variance(m);
|
|
25
|
+
if var_m == 0.0 {
|
|
26
|
+
return (ma, 0.0);
|
|
27
|
+
}
|
|
28
|
+
let cov = a
|
|
29
|
+
.iter()
|
|
30
|
+
.zip(m.iter())
|
|
31
|
+
.map(|(x, y)| (x - ma) * (y - mm))
|
|
32
|
+
.sum::<f64>()
|
|
33
|
+
/ (n as f64 - 1.0);
|
|
34
|
+
let beta = cov / var_m;
|
|
35
|
+
let alpha = ma - beta * mm;
|
|
36
|
+
(alpha, beta)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[cfg(test)]
|
|
40
|
+
mod tests {
|
|
41
|
+
use super::*;
|
|
42
|
+
|
|
43
|
+
#[test]
|
|
44
|
+
fn pure_market_follower_has_zero_alpha() {
|
|
45
|
+
let market: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64 * 0.3).sin()).collect();
|
|
46
|
+
let agent: Vec<f64> = market.iter().map(|m| 1.5 * m).collect();
|
|
47
|
+
let (alpha, beta) = alpha_beta(&agent, &market);
|
|
48
|
+
assert!((beta - 1.5).abs() < 1e-9, "beta={beta}");
|
|
49
|
+
assert!(alpha.abs() < 1e-9, "alpha={alpha}");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[test]
|
|
53
|
+
fn constant_excess_is_pure_alpha() {
|
|
54
|
+
let market: Vec<f64> = (0..50).map(|i| 0.001 * (i as f64 * 0.3).sin()).collect();
|
|
55
|
+
let agent: Vec<f64> = market.iter().map(|m| m + 0.002).collect();
|
|
56
|
+
let (alpha, beta) = alpha_beta(&agent, &market);
|
|
57
|
+
assert!((beta - 1.0).abs() < 1e-9, "beta={beta}");
|
|
58
|
+
assert!((alpha - 0.002).abs() < 1e-9, "alpha={alpha}");
|
|
59
|
+
}
|
|
60
|
+
}
|