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.
Files changed (44) hide show
  1. sharpebench-0.0.13/PKG-INFO +96 -0
  2. sharpebench-0.0.13/README.md +81 -0
  3. sharpebench-0.0.13/pyproject.toml +35 -0
  4. sharpebench-0.0.13/python/sharpebench/__init__.py +72 -0
  5. sharpebench-0.0.13/sharpebench-core/Cargo.toml +26 -0
  6. sharpebench-0.0.13/sharpebench-core/src/allocation.rs +234 -0
  7. sharpebench-0.0.13/sharpebench-core/src/attribution.rs +60 -0
  8. sharpebench-0.0.13/sharpebench-core/src/briefing.rs +342 -0
  9. sharpebench-0.0.13/sharpebench-core/src/calibration.rs +41 -0
  10. sharpebench-0.0.13/sharpebench-core/src/comparison_sets.rs +250 -0
  11. sharpebench-0.0.13/sharpebench-core/src/composite.rs +1010 -0
  12. sharpebench-0.0.13/sharpebench-core/src/correlation.rs +156 -0
  13. sharpebench-0.0.13/sharpebench-core/src/decay.rs +66 -0
  14. sharpebench-0.0.13/sharpebench-core/src/disqualification.rs +293 -0
  15. sharpebench-0.0.13/sharpebench-core/src/econrationality.rs +141 -0
  16. sharpebench-0.0.13/sharpebench-core/src/greeks.rs +326 -0
  17. sharpebench-0.0.13/sharpebench-core/src/lib.rs +81 -0
  18. sharpebench-0.0.13/sharpebench-core/src/oos.rs +101 -0
  19. sharpebench-0.0.13/sharpebench-core/src/pass_k.rs +44 -0
  20. sharpebench-0.0.13/sharpebench-core/src/percentile.rs +180 -0
  21. sharpebench-0.0.13/sharpebench-core/src/process.rs +196 -0
  22. sharpebench-0.0.13/sharpebench-core/src/rediscovery.rs +211 -0
  23. sharpebench-0.0.13/sharpebench-core/src/roles.rs +64 -0
  24. sharpebench-0.0.13/sharpebench-core/src/rolling.rs +84 -0
  25. sharpebench-0.0.13/sharpebench-core/src/selfaudit.rs +310 -0
  26. sharpebench-0.0.13/sharpebench-edge/Cargo.toml +23 -0
  27. sharpebench-0.0.13/sharpebench-edge/src/hlz.rs +124 -0
  28. sharpebench-0.0.13/sharpebench-edge/src/lib.rs +40 -0
  29. sharpebench-0.0.13/sharpebench-edge/src/mintrl.rs +101 -0
  30. sharpebench-0.0.13/sharpebench-edge/src/pbo.rs +224 -0
  31. sharpebench-0.0.13/sharpebench-edge/src/verdict.rs +412 -0
  32. sharpebench-0.0.13/sharpebench-py/Cargo.lock +196 -0
  33. sharpebench-0.0.13/sharpebench-py/Cargo.toml +26 -0
  34. sharpebench-0.0.13/sharpebench-py/README.md +81 -0
  35. sharpebench-0.0.13/sharpebench-py/src/lib.rs +504 -0
  36. sharpebench-0.0.13/sharpebench-py/tests/test_stats.py +315 -0
  37. sharpebench-0.0.13/sharpebench-stats/Cargo.toml +19 -0
  38. sharpebench-0.0.13/sharpebench-stats/src/deflated_sharpe.rs +93 -0
  39. sharpebench-0.0.13/sharpebench-stats/src/fdr.rs +207 -0
  40. sharpebench-0.0.13/sharpebench-stats/src/lib.rs +51 -0
  41. sharpebench-0.0.13/sharpebench-stats/src/selection.rs +113 -0
  42. sharpebench-0.0.13/sharpebench-stats/src/significance.rs +665 -0
  43. sharpebench-0.0.13/sharpebench-stats/src/stats.rs +236 -0
  44. 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
+ }