1mod covariance;
25mod hrp;
26mod optimizers;
27mod projection;
28
29pub use covariance::{ewma_cov, ledoit_wolf_cov, sample_cov};
30pub use hrp::hrp;
31pub use optimizers::{max_sharpe, min_variance, risk_parity};
32pub use projection::apply_bounds_and_budget;
33
34use thiserror::Error;
35
36pub type Bounds = (f64, f64);
38
39#[derive(Error, Debug, Clone, PartialEq)]
47pub enum PortfolioError {
48 #[error("{0} is empty")]
49 Empty(String),
50
51 #[error("{0} contains NaN or Inf values")]
52 NonFinite(String),
53
54 #[error("returns must have at least 2 time observations")]
55 InsufficientObservations,
56
57 #[error("returns must have at least 1 asset")]
58 NoAssets,
59
60 #[error("covariance must be square 2-D, got shape=({0}, {1})")]
61 NonSquareCov(usize, usize),
62
63 #[error("halflife must be positive, got {0}")]
64 InvalidHalflife(f64),
65
66 #[error("single-asset covariance is non-positive or invalid")]
67 InvalidSingleAssetCov,
68
69 #[error(
70 "covariance matrix is singular or ill-conditioned (condition number={0:.3e}); cannot invert"
71 )]
72 IllConditionedCov(f64),
73
74 #[error("covariance matrix is degenerate: {0}")]
75 DegenerateCov(String),
76
77 #[error("solve produced non-finite weights")]
78 NonFiniteSolve,
79
80 #[error("mean length {0} does not match covariance size {1}")]
81 MeanLengthMismatch(usize, usize),
82
83 #[error("min_variance: Sigma^-1 . 1 sums to ~0, cannot normalize")]
84 MinVarianceDegenerate,
85
86 #[error("max_sharpe: Sigma^-1 . mu sums to ~0, cannot normalize")]
87 MaxSharpeDegenerate,
88
89 #[error(
90 "risk_parity: covariance produced non-positive marginal risk; matrix may not be positive semi-definite"
91 )]
92 RiskParityNonPositiveMarginalRisk,
93
94 #[error("risk_parity failed to converge to finite weights")]
95 RiskParityDidNotConverge,
96
97 #[error("hrp produced degenerate weights")]
98 HrpDegenerate,
99
100 #[error("bounds length {0} does not match number of assets {1}")]
101 BoundsLengthMismatch(usize, usize),
102
103 #[error("bounds have low > high for at least one asset")]
104 BoundsLowGreaterThanHigh,
105
106 #[error("bounds cannot satisfy full-investment budget: sum(upper bounds) < 1")]
107 BoundsUpperSumTooLow,
108
109 #[error("bounds cannot satisfy full-investment budget: sum(lower bounds) > 1")]
110 BoundsLowerSumTooHigh,
111
112 #[error("unable to project weights onto the feasible bounds set")]
113 ProjectionFailed,
114}
115
116pub(crate) type PortfolioResult<T> = Result<T, PortfolioError>;
117
118#[cfg(test)]
125mod parity_tests {
126 use super::*;
127 use approx::assert_relative_eq;
128 use nalgebra::{DMatrix, DVector};
129
130 fn toy_returns() -> DMatrix<f64> {
131 DMatrix::from_row_slice(
132 5,
133 3,
134 &[
135 0.01, 0.02, -0.01, 0.015, -0.005, 0.02, -0.02, 0.01, 0.005, 0.005, 0.03, -0.01,
136 0.0, 0.01, 0.015,
137 ],
138 )
139 }
140
141 fn cov4() -> DMatrix<f64> {
142 DMatrix::from_row_slice(
143 4,
144 4,
145 &[
146 0.04, 0.03, 0.001, 0.0, 0.03, 0.05, 0.0, 0.001, 0.001, 0.0, 0.02, 0.015, 0.0,
147 0.001, 0.015, 0.03,
148 ],
149 )
150 }
151
152 #[test]
153 fn min_variance_matches_numpy_v1() {
154 let r = toy_returns();
155 let cov = sample_cov(&r).unwrap();
156 let w = min_variance(&cov, None).unwrap();
157 let expected = [0.06852607456263189, 0.4824772231848401, 0.4489967022525281];
158 for i in 0..3 {
159 assert_relative_eq!(w[i], expected[i], epsilon = 1e-8);
160 }
161 }
162
163 #[test]
164 fn max_sharpe_matches_numpy_v1() {
165 let r = toy_returns();
166 let cov = sample_cov(&r).unwrap();
167 let mean = DVector::from_vec(vec![
168 r.column(0).sum() / 5.0,
169 r.column(1).sum() / 5.0,
170 r.column(2).sum() / 5.0,
171 ]);
172 let w = max_sharpe(&mean, &cov, None).unwrap();
173 let expected = [0.04134877384196476, 0.5069482288828321, 0.4517029972752033];
174 for i in 0..3 {
175 assert_relative_eq!(w[i], expected[i], epsilon = 1e-8);
176 }
177 }
178
179 #[test]
180 fn hrp_from_cov_matches_numpy_v1() {
181 let r = toy_returns();
182 let cov = sample_cov(&r).unwrap();
183 let w = hrp(&cov, None, true).unwrap();
184 let expected = [0.32939214692886076, 0.35832698650221917, 0.3122808665689201];
185 for i in 0..3 {
186 assert_relative_eq!(w[i], expected[i], epsilon = 1e-8);
187 }
188 }
189
190 #[test]
191 fn hrp_from_returns_matches_numpy_v1() {
192 let r = toy_returns();
193 let w = hrp(&r, None, false).unwrap();
194 let expected = [0.32939214692886076, 0.35832698650221917, 0.3122808665689201];
195 for i in 0..3 {
196 assert_relative_eq!(w[i], expected[i], epsilon = 1e-8);
197 }
198 }
199
200 #[test]
201 fn hrp4_matches_numpy_v1() {
202 let cov = cov4();
203 let w = hrp(&cov, None, true).unwrap();
204 let expected = [
205 0.18967334035827263,
206 0.15173867228661872,
207 0.39515279241306456,
208 0.26343519494204404,
209 ];
210 for i in 0..4 {
211 assert_relative_eq!(w[i], expected[i], epsilon = 1e-8);
212 }
213 }
214
215 #[test]
216 fn min_variance4_bounded_matches_numpy_v1() {
217 let cov = cov4();
218 let bounds = vec![(0.0, 0.3); 4];
219 let w = min_variance(&cov, Some(&bounds)).unwrap();
220 let expected = [
221 0.27721197871550607,
222 0.18718599183269521,
223 0.29999999999999744,
224 0.2356020294518014,
225 ];
226 for i in 0..4 {
227 assert_relative_eq!(w[i], expected[i], epsilon = 1e-8);
228 }
229 }
230
231 #[test]
232 fn risk_parity4_matches_numpy_v1() {
233 let cov = cov4();
234 let w = risk_parity(&cov, None, 200, 1e-10).unwrap();
235 let expected = [
236 0.22127417224640838,
237 0.19884787598270773,
238 0.3185765076370055,
239 0.2613014441338784,
240 ];
241 for i in 0..4 {
242 assert_relative_eq!(w[i], expected[i], epsilon = 1e-6);
243 }
244 }
245
246 #[test]
247 fn risk_parity_diag_matches_numpy_v1() {
248 let cov = DMatrix::from_diagonal(&DVector::from_row_slice(&[0.04, 0.09, 0.01]));
249 let w = risk_parity(&cov, None, 200, 1e-10).unwrap();
250 let expected = [0.2727272727272733, 0.18181818181818327, 0.5454545454545433];
251 for i in 0..3 {
252 assert_relative_eq!(w[i], expected[i], epsilon = 1e-6);
253 }
254 }
255
256 #[test]
257 fn sample_cov_matches_numpy_v1() {
258 let r = toy_returns();
259 let cov = sample_cov(&r).unwrap();
260 let expected = DMatrix::from_row_slice(
261 3,
262 3,
263 &[
264 0.00018250000000000002,
265 -1.3749999999999999e-05,
266 2.5e-06,
267 -1.3749999999999999e-05,
268 0.00017,
269 -0.000165,
270 2.5e-06,
271 -0.000165,
272 0.0001925,
273 ],
274 );
275 for i in 0..3 {
276 for j in 0..3 {
277 assert_relative_eq!(cov[(i, j)], expected[(i, j)], epsilon = 1e-12);
278 }
279 }
280 }
281
282 #[test]
283 fn ewma_cov_matches_numpy_v1() {
284 let r = toy_returns();
285 let cov = ewma_cov(&r, 3.0).unwrap();
286 let expected = DMatrix::from_row_slice(
287 3,
288 3,
289 &[
290 0.0001629382102880254,
291 -1.5433060639633344e-06,
292 -2.4151258161425324e-06,
293 -1.5433060639633435e-06,
294 0.00016378886197253209,
295 -0.00015799870649033202,
296 -2.415125816142528e-06,
297 -0.00015799870649033202,
298 0.00018564391979524118,
299 ],
300 );
301 for i in 0..3 {
302 for j in 0..3 {
303 assert_relative_eq!(cov[(i, j)], expected[(i, j)], epsilon = 1e-12);
304 }
305 }
306 }
307
308 #[test]
309 fn ledoit_wolf_cov_matches_numpy_v1() {
310 let r = toy_returns();
311 let cov = ledoit_wolf_cov(&r).unwrap();
312 let expected = DMatrix::from_row_slice(
313 3,
314 3,
315 &[
316 0.0001542774782099034,
317 -3.3089325216010282e-06,
318 6.016240948365506e-07,
319 -3.3089325216010282e-06,
320 0.00015126935773572065,
321 -3.970719025921234e-05,
322 6.016240948365506e-07,
323 -3.970719025921234e-05,
324 0.0001566839745892496,
325 ],
326 );
327 for i in 0..3 {
328 for j in 0..3 {
329 assert_relative_eq!(cov[(i, j)], expected[(i, j)], epsilon = 1e-12);
330 }
331 }
332 }
333}