Skip to main content

sharpebench_core/
allocation.rs

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
17use serde::{Deserialize, Serialize};
18
19/// One cycle's target-allocation vector (weights per instrument, in a fixed order).
20#[derive(Clone, Debug, Serialize, Deserialize)]
21pub 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)]
27pub struct AllocationTrajectory {
28    pub steps: Vec<AllocationStep>,
29}
30
31/// Validity limits a target-allocation vector must respect.
32#[derive(Clone, Debug, Serialize, Deserialize)]
33pub 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
42impl 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")]
55pub 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)]
66pub struct WeightValidity {
67    pub valid: bool,
68    pub violations: Vec<WeightViolation>,
69}
70
71/// Validate a single weight vector against the policy.
72pub 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.
100pub 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)]
118pub 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.
129pub 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)]
152mod 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}