use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AllocationStep {
pub weights: Vec<f64>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct AllocationTrajectory {
pub steps: Vec<AllocationStep>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AllocationPolicy {
pub allow_shorts: bool,
pub max_gross: f64,
pub epsilon: f64,
}
impl Default for AllocationPolicy {
fn default() -> Self {
AllocationPolicy {
allow_shorts: false,
max_gross: 1.0,
epsilon: 1e-9,
}
}
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(tag = "violation", rename_all = "snake_case")]
pub enum WeightViolation {
NonFiniteWeight { index: usize },
NegativeWeight { index: usize, weight: f64 },
GrossExposureExceeded { gross: f64, cap: f64 },
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct WeightValidity {
pub valid: bool,
pub violations: Vec<WeightViolation>,
}
pub fn check_weights(weights: &[f64], policy: &AllocationPolicy) -> WeightValidity {
let mut violations = Vec::new();
let mut gross = 0.0;
for (index, &w) in weights.iter().enumerate() {
if !w.is_finite() {
violations.push(WeightViolation::NonFiniteWeight { index });
continue;
}
if w < 0.0 && !policy.allow_shorts {
violations.push(WeightViolation::NegativeWeight { index, weight: w });
}
gross += w.abs();
}
if gross > policy.max_gross + policy.epsilon {
violations.push(WeightViolation::GrossExposureExceeded {
gross,
cap: policy.max_gross,
});
}
WeightValidity {
valid: violations.is_empty(),
violations,
}
}
pub fn turnover(trajectory: &AllocationTrajectory) -> f64 {
let mut total = 0.0;
let mut prev: Vec<f64> = Vec::new();
for step in &trajectory.steps {
let n = step.weights.len().max(prev.len());
for i in 0..n {
let cur = step.weights.get(i).copied().unwrap_or(0.0);
let old = prev.get(i).copied().unwrap_or(0.0);
total += (cur - old).abs();
}
prev = step.weights.clone();
}
total
}
#[derive(Clone, Debug, Serialize)]
pub struct AllocationReport {
pub total_turnover: f64,
pub mean_turnover: f64,
pub weight_violations: Vec<WeightViolation>,
pub valid: bool,
}
pub fn score_allocation(
trajectory: &AllocationTrajectory,
policy: &AllocationPolicy,
) -> AllocationReport {
let mut weight_violations = Vec::new();
for step in &trajectory.steps {
weight_violations.extend(check_weights(&step.weights, policy).violations);
}
let total_turnover = turnover(trajectory);
let mean_turnover = if trajectory.steps.is_empty() {
0.0
} else {
total_turnover / trajectory.steps.len() as f64
};
AllocationReport {
total_turnover,
mean_turnover,
valid: weight_violations.is_empty(),
weight_violations,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn traj(steps: &[&[f64]]) -> AllocationTrajectory {
AllocationTrajectory {
steps: steps
.iter()
.map(|w| AllocationStep {
weights: w.to_vec(),
})
.collect(),
}
}
#[test]
fn valid_low_turnover_trajectory_scores_with_hand_computed_turnover() {
let t = traj(&[&[0.5, 0.5], &[0.5, 0.5], &[0.0, 1.0]]);
let r = score_allocation(&t, &AllocationPolicy::default());
assert!(r.valid, "{:?}", r.weight_violations);
assert!((r.total_turnover - 2.0).abs() < 1e-12);
assert!((r.mean_turnover - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn over_leveraged_vector_flags_gross_exposure() {
let t = traj(&[&[0.7, 0.7]]); let r = score_allocation(&t, &AllocationPolicy::default());
assert!(!r.valid);
assert!(r.weight_violations.iter().any(|v| matches!(
v,
WeightViolation::GrossExposureExceeded { cap, .. } if (*cap - 1.0).abs() < 1e-12
)));
}
#[test]
fn negative_weight_flags_when_shorts_disallowed() {
let t = traj(&[&[-0.3, 0.5]]);
let r = score_allocation(&t, &AllocationPolicy::default());
assert!(!r.valid);
assert!(r
.weight_violations
.iter()
.any(|v| matches!(v, WeightViolation::NegativeWeight { index: 0, .. })));
}
#[test]
fn shorts_allowed_permits_negative_within_gross_cap() {
let policy = AllocationPolicy {
allow_shorts: true,
max_gross: 2.0,
..Default::default()
};
let t = traj(&[&[-0.5, 0.5]]); let r = score_allocation(&t, &policy);
assert!(r.valid, "{:?}", r.weight_violations);
}
#[test]
fn non_finite_weight_flags() {
let t = traj(&[&[f64::NAN, 0.5]]);
let r = score_allocation(&t, &AllocationPolicy::default());
assert!(!r.valid);
assert!(r
.weight_violations
.iter()
.any(|v| matches!(v, WeightViolation::NonFiniteWeight { index: 0 })));
}
#[test]
fn empty_trajectory_is_valid_with_zero_turnover() {
let r = score_allocation(
&AllocationTrajectory::default(),
&AllocationPolicy::default(),
);
assert!(r.valid);
assert_eq!(r.total_turnover, 0.0);
assert_eq!(r.mean_turnover, 0.0);
}
}