quantwave_core/portfolio/
projection.rs1use super::{Bounds, PortfolioError, PortfolioResult};
4use nalgebra::DVector;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9pub enum PortfolioConstraint {
10 #[default]
12 LongOnly,
13 LongShort,
15}
16
17impl PortfolioConstraint {
18 pub const fn is_long_only(self) -> bool {
19 matches!(self, Self::LongOnly)
20 }
21
22 pub const fn is_long_short(self) -> bool {
23 matches!(self, Self::LongShort)
24 }
25}
26
27impl From<bool> for PortfolioConstraint {
28 fn from(long_only: bool) -> Self {
29 if long_only {
30 Self::LongOnly
31 } else {
32 Self::LongShort
33 }
34 }
35}
36
37impl From<PortfolioConstraint> for bool {
38 fn from(constraint: PortfolioConstraint) -> Self {
39 constraint.is_long_only()
40 }
41}
42
43pub fn apply_bounds_and_budget(
58 w: &DVector<f64>,
59 bounds: Option<&[Bounds]>,
60 constraint: PortfolioConstraint,
61) -> PortfolioResult<DVector<f64>> {
62 let n = w.len();
63
64 let (lo, hi): (DVector<f64>, DVector<f64>) = match bounds {
65 None => {
66 let lo = if constraint.is_long_only() {
67 DVector::zeros(n)
68 } else {
69 DVector::from_element(n, f64::NEG_INFINITY)
70 };
71 let hi = DVector::from_element(n, 1.0);
72 (lo, hi)
73 }
74 Some(b) => {
75 if b.len() != n {
76 return Err(PortfolioError::BoundsLengthMismatch(b.len(), n));
77 }
78 let lo = DVector::from_iterator(n, b.iter().map(|x| x.0));
79 let hi = DVector::from_iterator(n, b.iter().map(|x| x.1));
80 if lo.iter().zip(hi.iter()).any(|(l, h)| l > h) {
81 return Err(PortfolioError::BoundsLowGreaterThanHigh);
82 }
83 (lo, hi)
84 }
85 };
86
87 if hi.sum() < 1.0 - 1e-9 {
88 return Err(PortfolioError::BoundsUpperSumTooLow);
89 }
90 let finite_lo_sum: f64 = lo.iter().filter(|v| v.is_finite()).sum();
91 if finite_lo_sum > 1.0 + 1e-9 {
92 return Err(PortfolioError::BoundsLowerSumTooHigh);
93 }
94
95 let lo_b: DVector<f64> =
96 DVector::from_iterator(n, lo.iter().map(|v| if v.is_finite() { *v } else { -1e9 }));
97 let hi_b: DVector<f64> =
98 DVector::from_iterator(n, hi.iter().map(|v| if v.is_finite() { *v } else { 1e9 }));
99
100 let excess = |lam: f64| -> f64 {
101 let sum: f64 = (0..n).map(|i| (w[i] - lam).clamp(lo_b[i], hi_b[i])).sum();
102 sum - 1.0
103 };
104
105 let mut lam_lo = -1e9_f64;
106 let mut lam_hi = 1e9_f64;
107 for _ in 0..200 {
108 let mid = (lam_lo + lam_hi) / 2.0;
109 if excess(mid) > 0.0 {
110 lam_lo = mid;
111 } else {
112 lam_hi = mid;
113 }
114 if lam_hi - lam_lo < 1e-14 {
115 break;
116 }
117 }
118 let lam = (lam_lo + lam_hi) / 2.0;
119
120 let mut w_final =
121 DVector::from_iterator(n, (0..n).map(|i| (w[i] - lam).clamp(lo_b[i], hi_b[i])));
122 let total = w_final.sum();
123 if !w_final.iter().all(|v| v.is_finite()) || (total - 1.0).abs() > 1e-6 {
124 return Err(PortfolioError::ProjectionFailed);
125 }
126 w_final /= total;
128 Ok(w_final)
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn unconstrained_long_only_sums_to_one() {
137 let w = DVector::from_vec(vec![0.5, 0.3, 0.2]);
138 let out = apply_bounds_and_budget(&w, None, PortfolioConstraint::LongOnly).unwrap();
139 assert!((out.sum() - 1.0).abs() < 1e-9);
140 assert!(out.iter().all(|v| *v >= 0.0));
141 }
142
143 #[test]
144 fn negative_weight_clipped_to_zero_and_redistributed() {
145 let w = DVector::from_vec(vec![-0.1, 0.6, 0.5]);
146 let out = apply_bounds_and_budget(&w, None, PortfolioConstraint::LongOnly).unwrap();
147 assert!((out.sum() - 1.0).abs() < 1e-9);
148 assert!(out.iter().all(|v| *v >= -1e-9));
149 }
150
151 #[test]
152 fn infeasible_upper_bounds_error() {
153 let w = DVector::from_vec(vec![0.5, 0.5]);
154 let bounds = vec![(0.0, 0.3), (0.0, 0.3)];
155 let err =
156 apply_bounds_and_budget(&w, Some(&bounds), PortfolioConstraint::LongOnly).unwrap_err();
157 assert_eq!(err, PortfolioError::BoundsUpperSumTooLow);
158 }
159}