Skip to main content

quantwave_core/portfolio/
mod.rs

1//! Portfolio optimization engine (Rust/nalgebra port, quantwave-a465).
2//!
3//! Rust port of the Python-first v1 shipped at
4//! `quantwave-py/python/quantwave/portfolio.py` (p2k0.5, PR #21). That module
5//! is the numerical SPEC: sample/EWMA/Ledoit-Wolf covariance estimators;
6//! min-variance / max-Sharpe (closed-form) / risk-parity (Spinu fixed-point)
7//! / HRP (hand-rolled single-linkage) optimizers; capped-simplex projection
8//! for box+budget constraints. This module mirrors that algorithmically,
9//! operating on `nalgebra` `DMatrix<f64>` / `DVector<f64>` instead of numpy
10//! arrays, so it can be embedded in the core engine and driven from Rust
11//! callers (backtests, live sizing) without a Python round-trip. PyO3
12//! bindings in `quantwave-py` expose it to Python as `quantwave._portfolio`
13//! (see that crate for the numpy <-> nalgebra bridge).
14//!
15//! Conventions
16//! -----------
17//! * Returns matrices are `(rows = time, cols = assets)`.
18//! * Every optimizer returns a weights vector that sums to 1.0.
19//! * Degenerate inputs (singular covariance, a single asset, NaN returns)
20//!   return [`PortfolioError`] rather than panicking or returning NaN
21//!   weights (workspace lint: `unwrap_used`/`expect_used` = warn,
22//!   `panic` = deny).
23
24mod 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
36/// Per-asset `(low, high)` weight bounds.
37pub type Bounds = (f64, f64);
38
39/// Errors from the portfolio-optimization engine.
40///
41/// Mirrors `quantwave.portfolio.PortfolioError` in the Python v1: raised for
42/// degenerate or invalid inputs (singular/non-invertible covariance, NaN/Inf
43/// returns, empty asset universe, or bounds that cannot satisfy the
44/// full-investment budget constraint) rather than letting a raw linear
45/// algebra failure or NaN weights escape.
46#[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/// Numerical parity tests against the numpy v1
119/// (`quantwave-py/python/quantwave/portfolio.py`). Expected values below
120/// were generated by running that module directly (bypassing the PyO3
121/// package `__init__`, which needs a built native extension) via
122/// `.venv/bin/python` against the fixed inputs reproduced here; see the
123/// task report (bead quantwave-a465) for the exact script used.
124#[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}