sharpebench_core/
allocation.rs1use serde::{Deserialize, Serialize};
18
19#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct AllocationStep {
22 pub weights: Vec<f64>,
23}
24
25#[derive(Clone, Debug, Default, Serialize, Deserialize)]
27pub struct AllocationTrajectory {
28 pub steps: Vec<AllocationStep>,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
33pub struct AllocationPolicy {
34 pub allow_shorts: bool,
36 pub max_gross: f64,
38 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#[derive(Clone, Debug, Serialize, PartialEq)]
54#[serde(tag = "violation", rename_all = "snake_case")]
55pub enum WeightViolation {
56 NonFiniteWeight { index: usize },
58 NegativeWeight { index: usize, weight: f64 },
60 GrossExposureExceeded { gross: f64, cap: f64 },
62}
63
64#[derive(Clone, Debug, Serialize, PartialEq)]
66pub struct WeightValidity {
67 pub valid: bool,
68 pub violations: Vec<WeightViolation>,
69}
70
71pub 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
97pub 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#[derive(Clone, Debug, Serialize)]
118pub struct AllocationReport {
119 pub total_turnover: f64,
120 pub mean_turnover: f64,
122 pub weight_violations: Vec<WeightViolation>,
124 pub valid: bool,
125}
126
127pub 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 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]]); 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]]); 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}