Skip to main content

fin_primitives/portfolio/
optimizer.rs

1//! Markowitz mean-variance portfolio optimization.
2//!
3//! Implements projected gradient descent over the simplex, supporting
4//! MinVariance, MaxSharpe, RiskParity, and EqualWeight objectives with
5//! per-asset and sector-level weight constraints.
6
7use std::collections::HashMap;
8
9/// A single investable asset.
10#[derive(Debug, Clone)]
11pub struct Asset {
12    /// Ticker or identifier.
13    pub symbol: String,
14    /// Annualized expected return (e.g. 0.08 = 8 %).
15    pub expected_return: f64,
16    /// Annualized variance of returns.
17    pub variance: f64,
18}
19
20/// Symmetric covariance matrix with named rows/columns.
21#[derive(Debug, Clone)]
22pub struct CovarianceMatrix {
23    /// Row-major covariance data; element `[i * n + j]` is `cov(i, j)`.
24    pub data: Vec<Vec<f64>>,
25    /// Symbol labels matching the rows/columns.
26    pub symbols: Vec<String>,
27}
28
29impl CovarianceMatrix {
30    /// Create a new all-zero covariance matrix of dimension `n`.
31    pub fn new(symbols: Vec<String>) -> Self {
32        let n = symbols.len();
33        Self {
34            data: vec![vec![0.0; n]; n],
35            symbols,
36        }
37    }
38
39    /// Get element `(i, j)`.
40    ///
41    /// Returns `0.0` if the indices are out of bounds.
42    pub fn get(&self, i: usize, j: usize) -> f64 {
43        self.data
44            .get(i)
45            .and_then(|row| row.get(j))
46            .copied()
47            .unwrap_or(0.0)
48    }
49
50    /// Set element `(i, j)` (and the symmetric counterpart `(j, i)`).
51    pub fn set(&mut self, i: usize, j: usize, v: f64) {
52        let n = self.symbols.len();
53        if i < n && j < n {
54            self.data[i][j] = v;
55            self.data[j][i] = v;
56        }
57    }
58
59    /// Number of assets (dimension of the matrix).
60    pub fn n(&self) -> usize {
61        self.symbols.len()
62    }
63
64    /// Apply Ledoit-Wolf analytical shrinkage toward the identity scaled by
65    /// the average diagonal (scaled identity target).
66    ///
67    /// Formula used:
68    /// ```text
69    /// mu   = trace(S) / n
70    /// delta^2 = sum_{i!=j} S[i,j]^2          (off-diagonal squared sum)
71    /// alpha = delta^2 / ((n + 2) * delta^2)   (simplifies to 1/(n+2) when non-zero,
72    ///                                           but we use the full two-sample formula)
73    /// S_shrunk = (1 - alpha) * S + alpha * mu * I
74    /// ```
75    ///
76    /// When `delta^2 == 0` (already diagonal) the matrix is left unchanged.
77    pub fn ledoit_wolf_shrinkage(&mut self) {
78        let n = self.n();
79        if n == 0 {
80            return;
81        }
82
83        // mu = average diagonal
84        let trace: f64 = (0..n).map(|i| self.get(i, i)).sum();
85        let mu = trace / n as f64;
86
87        // Sum of squared off-diagonal elements
88        let mut off_diag_sq_sum = 0.0_f64;
89        for i in 0..n {
90            for j in 0..n {
91                if i != j {
92                    let v = self.get(i, j);
93                    off_diag_sq_sum += v * v;
94                }
95            }
96        }
97
98        if off_diag_sq_sum == 0.0 {
99            // Already diagonal — nothing to shrink.
100            return;
101        }
102
103        // Analytical shrinkage intensity
104        let alpha = off_diag_sq_sum / ((n as f64 + 2.0) * off_diag_sq_sum);
105
106        let new_data: Vec<Vec<f64>> = (0..n)
107            .map(|i| {
108                (0..n)
109                    .map(|j| {
110                        let s_ij = self.get(i, j);
111                        let target = if i == j { mu } else { 0.0 };
112                        (1.0 - alpha) * s_ij + alpha * target
113                    })
114                    .collect()
115            })
116            .collect();
117
118        self.data = new_data;
119    }
120}
121
122/// Objective function for portfolio optimization.
123#[derive(Debug, Clone)]
124pub enum OptimizationObjective {
125    /// Minimize portfolio variance.
126    MinVariance,
127    /// Maximize Sharpe ratio given a risk-free rate.
128    MaxSharpe {
129        /// Annualized risk-free rate (e.g. 0.05 = 5 %).
130        risk_free_rate: f64,
131    },
132    /// Risk-parity: equalize each asset's marginal risk contribution.
133    RiskParity,
134    /// Equal-weight: `1/N` across all assets.
135    EqualWeight,
136}
137
138/// A weight constraint applied during optimization.
139#[derive(Debug, Clone)]
140pub enum Constraint {
141    /// No asset weight may exceed this value (e.g. `0.3` = 30 %).
142    MaxWeight(f64),
143    /// No asset weight may fall below this value (floor).
144    MinWeight(f64),
145    /// All weights must be non-negative (no short selling).
146    LongOnly,
147    /// Total weight of assets in the named sector must not exceed `max_weight`.
148    SectorConstraint {
149        /// Sector label, matched against [`Asset::symbol`] prefix convention.
150        sector: String,
151        /// Maximum aggregate weight for the sector.
152        max_weight: f64,
153    },
154}
155
156/// The result of a portfolio optimization run.
157#[derive(Debug, Clone)]
158pub struct OptimizedPortfolio {
159    /// Optimal asset weights, keyed by symbol.
160    pub weights: HashMap<String, f64>,
161    /// Expected portfolio return (`sum_i w_i * mu_i`).
162    pub expected_return: f64,
163    /// Expected portfolio variance (`w' Σ w`).
164    pub expected_variance: f64,
165    /// Sharpe ratio (uses risk_free_rate = 0 unless MaxSharpe objective given).
166    pub sharpe_ratio: f64,
167    /// Effective N (inverse HHI): `1 / sum(w_i^2)`. Higher = more diversified.
168    pub effective_n: f64,
169}
170
171/// Portfolio optimizer using projected gradient descent.
172///
173/// Runs 200 gradient steps with step size 0.01 and projects weights onto the
174/// probability simplex after each step to satisfy the budget constraint.
175/// Additional constraints are enforced via clipping before simplex projection.
176pub struct PortfolioOptimizer;
177
178impl PortfolioOptimizer {
179    /// Optimize a portfolio given assets, a covariance matrix, an objective,
180    /// and a list of constraints.
181    ///
182    /// Returns an [`OptimizedPortfolio`] with the solved weights and metrics.
183    ///
184    /// # Panics
185    ///
186    /// Does not panic; invalid configurations (empty asset list, mismatched
187    /// dimensions) return a zero-weight portfolio.
188    pub fn optimize(
189        assets: &[Asset],
190        cov_matrix: &CovarianceMatrix,
191        objective: &OptimizationObjective,
192        constraints: &[Constraint],
193    ) -> OptimizedPortfolio {
194        let n = assets.len();
195        if n == 0 {
196            return OptimizedPortfolio {
197                weights: HashMap::new(),
198                expected_return: 0.0,
199                expected_variance: 0.0,
200                sharpe_ratio: 0.0,
201                effective_n: 0.0,
202            };
203        }
204
205        // Handle EqualWeight immediately — no gradient needed.
206        if matches!(objective, OptimizationObjective::EqualWeight) {
207            let w = 1.0 / n as f64;
208            let weights: HashMap<String, f64> = assets
209                .iter()
210                .map(|a| (a.symbol.clone(), w))
211                .collect();
212            return Self::build_result(assets, cov_matrix, weights, 0.0);
213        }
214
215        // Initial weights: equal weight.
216        let mut w: Vec<f64> = vec![1.0 / n as f64; n];
217
218        // Determine min/max bounds from constraints.
219        let long_only = constraints.iter().any(|c| matches!(c, Constraint::LongOnly));
220        let min_w: f64 = constraints
221            .iter()
222            .filter_map(|c| if let Constraint::MinWeight(v) = c { Some(*v) } else { None })
223            .fold(if long_only { 0.0 } else { f64::NEG_INFINITY }, f64::max);
224        let max_w: f64 = constraints
225            .iter()
226            .filter_map(|c| if let Constraint::MaxWeight(v) = c { Some(*v) } else { None })
227            .fold(1.0, f64::min);
228
229        let sector_constraints: Vec<(&str, f64)> = constraints
230            .iter()
231            .filter_map(|c| {
232                if let Constraint::SectorConstraint { sector, max_weight } = c {
233                    Some((sector.as_str(), *max_weight))
234                } else {
235                    None
236                }
237            })
238            .collect();
239
240        const ITERS: usize = 200;
241        const STEP: f64 = 0.01;
242
243        for _ in 0..ITERS {
244            let grad = Self::compute_gradient(assets, cov_matrix, objective, &w);
245
246            // Gradient step (gradient descent: subtract for minimization,
247            // negate gradient for maximization objectives).
248            for i in 0..n {
249                w[i] -= STEP * grad[i];
250            }
251
252            // Clip to per-asset bounds before projection.
253            let eff_min = if long_only { 0.0_f64.max(min_w) } else { min_w };
254            let eff_min = eff_min.max(f64::NEG_INFINITY);
255            let eff_max = max_w.min(1.0);
256            for wi in w.iter_mut() {
257                *wi = wi.clamp(eff_min, eff_max);
258            }
259
260            // Project onto simplex.
261            w = project_simplex(&w);
262
263            // Enforce sector constraints (greedy clamp, then re-project).
264            for (sector, sec_max) in &sector_constraints {
265                let sector_total: f64 = assets
266                    .iter()
267                    .enumerate()
268                    .filter(|(_, a)| a.symbol.starts_with(sector))
269                    .map(|(i, _)| w[i])
270                    .sum();
271                if sector_total > *sec_max && sector_total > 0.0 {
272                    let scale = sec_max / sector_total;
273                    for (i, a) in assets.iter().enumerate() {
274                        if a.symbol.starts_with(sector) {
275                            w[i] *= scale;
276                        }
277                    }
278                    // Re-project after sector clamp.
279                    w = project_simplex(&w);
280                }
281            }
282        }
283
284        let weights: HashMap<String, f64> = assets
285            .iter()
286            .enumerate()
287            .map(|(i, a)| (a.symbol.clone(), w[i]))
288            .collect();
289
290        let rf = match objective {
291            OptimizationObjective::MaxSharpe { risk_free_rate } => *risk_free_rate,
292            _ => 0.0,
293        };
294
295        Self::build_result(assets, cov_matrix, weights, rf)
296    }
297
298    /// Compute the gradient of the objective w.r.t. weights.
299    ///
300    /// For minimization objectives the gradient points in the ascent direction
301    /// so callers subtract it. For maximization (MaxSharpe) the negative
302    /// gradient is returned so subtraction still descends.
303    fn compute_gradient(
304        assets: &[Asset],
305        cov: &CovarianceMatrix,
306        objective: &OptimizationObjective,
307        w: &[f64],
308    ) -> Vec<f64> {
309        let n = assets.len();
310        match objective {
311            OptimizationObjective::MinVariance => {
312                // grad_i = 2 * (Σ w)_i
313                let mut g = vec![0.0; n];
314                for i in 0..n {
315                    for j in 0..n {
316                        g[i] += 2.0 * cov.get(i, j) * w[j];
317                    }
318                }
319                g
320            }
321            OptimizationObjective::MaxSharpe { risk_free_rate } => {
322                // Maximize (mu_p - rf) / sigma_p.
323                // Use negative gradient so that gradient descent increases Sharpe.
324                let mu_p: f64 = assets.iter().enumerate().map(|(i, a)| w[i] * a.expected_return).sum();
325                let sigma2_p: f64 = portfolio_variance(cov, w);
326                let sigma_p = sigma2_p.sqrt().max(1e-10);
327                let excess = mu_p - risk_free_rate;
328
329                let mut g = vec![0.0; n];
330                for i in 0..n {
331                    let d_mu = assets[i].expected_return;
332                    let mut d_sigma2 = 0.0_f64;
333                    for j in 0..n {
334                        d_sigma2 += 2.0 * cov.get(i, j) * w[j];
335                    }
336                    let d_sigma = d_sigma2 / (2.0 * sigma_p);
337                    // d(Sharpe)/dw_i = (d_mu * sigma_p - excess * d_sigma) / sigma_p^2
338                    let d_sharpe = (d_mu * sigma_p - excess * d_sigma) / (sigma_p * sigma_p);
339                    // Negate because we subtract the gradient in the optimizer loop.
340                    g[i] = -d_sharpe;
341                }
342                g
343            }
344            OptimizationObjective::RiskParity => {
345                // Minimize sum_i (RC_i - RC_avg)^2 where RC_i = w_i * (Σw)_i / w'Σw.
346                let sigma2_p = portfolio_variance(cov, w).max(1e-12);
347                let mut sigma_w = vec![0.0_f64; n];
348                for i in 0..n {
349                    for j in 0..n {
350                        sigma_w[i] += cov.get(i, j) * w[j];
351                    }
352                }
353                // RC_i = w_i * sigma_w[i] / sigma2_p
354                let rc: Vec<f64> = (0..n).map(|i| w[i] * sigma_w[i] / sigma2_p).collect();
355                let rc_avg = rc.iter().sum::<f64>() / n as f64;
356
357                // Gradient of sum_i (RC_i - rc_avg)^2 w.r.t. w_k (approximate).
358                let mut g = vec![0.0_f64; n];
359                for k in 0..n {
360                    for i in 0..n {
361                        let d_rc_i_d_wk = if i == k {
362                            sigma_w[i] / sigma2_p + w[i] * cov.get(i, k) / sigma2_p
363                                - w[i] * sigma_w[i] * 2.0 * sigma_w[k] * w[k] / (sigma2_p * sigma2_p)
364                        } else {
365                            w[i] * cov.get(i, k) / sigma2_p
366                                - w[i] * sigma_w[i] * 2.0 * sigma_w[k] * w[k] / (sigma2_p * sigma2_p)
367                        };
368                        g[k] += 2.0 * (rc[i] - rc_avg) * d_rc_i_d_wk;
369                    }
370                }
371                g
372            }
373            OptimizationObjective::EqualWeight => {
374                // Handled before loop; shouldn't reach here.
375                vec![0.0; n]
376            }
377        }
378    }
379
380    fn build_result(
381        assets: &[Asset],
382        cov: &CovarianceMatrix,
383        weights: HashMap<String, f64>,
384        rf: f64,
385    ) -> OptimizedPortfolio {
386        let n = assets.len();
387        let w: Vec<f64> = assets
388            .iter()
389            .map(|a| weights.get(&a.symbol).copied().unwrap_or(0.0))
390            .collect();
391
392        let expected_return: f64 = assets.iter().enumerate().map(|(i, a)| w[i] * a.expected_return).sum();
393        let expected_variance = portfolio_variance(cov, &w);
394        let sigma = expected_variance.sqrt().max(1e-10);
395        let sharpe_ratio = (expected_return - rf) / sigma;
396
397        let hhi: f64 = w.iter().map(|wi| wi * wi).sum();
398        let effective_n = if hhi > 0.0 { 1.0 / hhi } else { n as f64 };
399
400        OptimizedPortfolio {
401            weights,
402            expected_return,
403            expected_variance,
404            sharpe_ratio,
405            effective_n,
406        }
407    }
408}
409
410/// Compute portfolio variance `w' Σ w`.
411fn portfolio_variance(cov: &CovarianceMatrix, w: &[f64]) -> f64 {
412    let n = w.len();
413    let mut var = 0.0_f64;
414    for i in 0..n {
415        for j in 0..n {
416            var += w[i] * cov.get(i, j) * w[j];
417        }
418    }
419    var.max(0.0)
420}
421
422/// Project a weight vector onto the probability simplex (`sum = 1`, `w_i >= 0`)
423/// using the O(n log n) sorting algorithm (Duchi et al. 2008).
424fn project_simplex(v: &[f64]) -> Vec<f64> {
425    let mut u: Vec<f64> = v.to_vec();
426    u.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
427
428    let mut cssv = 0.0_f64;
429    let mut rho = 0_usize;
430    for (j, &uj) in u.iter().enumerate() {
431        cssv += uj;
432        if uj - (cssv - 1.0) / (j as f64 + 1.0) > 0.0 {
433            rho = j;
434        }
435    }
436
437    let mut cssv2 = 0.0_f64;
438    for k in 0..=rho {
439        cssv2 += u[k];
440    }
441    let theta = (cssv2 - 1.0) / (rho as f64 + 1.0);
442
443    v.iter().map(|&vi| (vi - theta).max(0.0)).collect()
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn two_asset_cov() -> CovarianceMatrix {
451        let mut c = CovarianceMatrix::new(vec!["A".into(), "B".into()]);
452        c.set(0, 0, 0.04);
453        c.set(0, 1, 0.01);
454        c.set(1, 1, 0.09);
455        c
456    }
457
458    fn three_asset_cov() -> CovarianceMatrix {
459        let mut c = CovarianceMatrix::new(vec!["A".into(), "B".into(), "C".into()]);
460        c.set(0, 0, 0.04);
461        c.set(1, 1, 0.09);
462        c.set(2, 2, 0.01);
463        c.set(0, 1, 0.01);
464        c.set(0, 2, 0.005);
465        c.set(1, 2, 0.015);
466        c
467    }
468
469    fn two_assets() -> Vec<Asset> {
470        vec![
471            Asset { symbol: "A".into(), expected_return: 0.10, variance: 0.04 },
472            Asset { symbol: "B".into(), expected_return: 0.15, variance: 0.09 },
473        ]
474    }
475
476    fn three_assets() -> Vec<Asset> {
477        vec![
478            Asset { symbol: "A".into(), expected_return: 0.08, variance: 0.04 },
479            Asset { symbol: "B".into(), expected_return: 0.12, variance: 0.09 },
480            Asset { symbol: "C".into(), expected_return: 0.06, variance: 0.01 },
481        ]
482    }
483
484    // --- CovarianceMatrix tests ---
485
486    #[test]
487    fn cov_get_set_symmetry() {
488        let mut c = CovarianceMatrix::new(vec!["X".into(), "Y".into()]);
489        c.set(0, 1, 0.05);
490        assert!((c.get(0, 1) - 0.05).abs() < 1e-10);
491        assert!((c.get(1, 0) - 0.05).abs() < 1e-10);
492    }
493
494    #[test]
495    fn cov_get_out_of_bounds_returns_zero() {
496        let c = CovarianceMatrix::new(vec!["X".into()]);
497        assert_eq!(c.get(5, 5), 0.0);
498    }
499
500    #[test]
501    fn cov_ledoit_wolf_shrinks_off_diagonal() {
502        let mut c = two_asset_cov();
503        let before_off = c.get(0, 1);
504        c.ledoit_wolf_shrinkage();
505        let after_off = c.get(0, 1);
506        // Off-diagonal should be strictly smaller in magnitude.
507        assert!(after_off.abs() < before_off.abs());
508    }
509
510    #[test]
511    fn cov_ledoit_wolf_diagonal_unchanged_order_of_magnitude() {
512        let mut c = two_asset_cov();
513        c.ledoit_wolf_shrinkage();
514        // Diagonal should still be positive.
515        assert!(c.get(0, 0) > 0.0);
516        assert!(c.get(1, 1) > 0.0);
517    }
518
519    #[test]
520    fn cov_ledoit_wolf_already_diagonal_unchanged() {
521        let mut c = CovarianceMatrix::new(vec!["X".into(), "Y".into()]);
522        c.set(0, 0, 0.04);
523        c.set(1, 1, 0.09);
524        c.ledoit_wolf_shrinkage(); // off_diag_sq_sum == 0 → no-op
525        assert!((c.get(0, 0) - 0.04).abs() < 1e-10);
526        assert!((c.get(1, 1) - 0.09).abs() < 1e-10);
527    }
528
529    #[test]
530    fn cov_ledoit_wolf_empty_matrix_no_panic() {
531        let mut c = CovarianceMatrix::new(vec![]);
532        c.ledoit_wolf_shrinkage(); // should not panic
533    }
534
535    // --- project_simplex tests ---
536
537    #[test]
538    fn simplex_projection_sums_to_one() {
539        let v = vec![0.5, 0.5, 0.5];
540        let p = project_simplex(&v);
541        let sum: f64 = p.iter().sum();
542        assert!((sum - 1.0).abs() < 1e-10);
543    }
544
545    #[test]
546    fn simplex_projection_nonnegative() {
547        let v = vec![-1.0, 2.0, 0.5];
548        let p = project_simplex(&v);
549        for wi in &p {
550            assert!(*wi >= 0.0);
551        }
552    }
553
554    // --- EqualWeight ---
555
556    #[test]
557    fn equal_weight_two_assets() {
558        let assets = two_assets();
559        let cov = two_asset_cov();
560        let result = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::EqualWeight, &[]);
561        assert!((result.weights["A"] - 0.5).abs() < 1e-10);
562        assert!((result.weights["B"] - 0.5).abs() < 1e-10);
563    }
564
565    #[test]
566    fn equal_weight_effective_n_equals_n() {
567        let assets = three_assets();
568        let cov = three_asset_cov();
569        let result = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::EqualWeight, &[]);
570        assert!((result.effective_n - 3.0).abs() < 1e-6);
571    }
572
573    // --- MinVariance ---
574
575    #[test]
576    fn min_variance_weights_sum_to_one() {
577        let assets = two_assets();
578        let cov = two_asset_cov();
579        let result = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::MinVariance, &[]);
580        let sum: f64 = result.weights.values().sum();
581        assert!((sum - 1.0).abs() < 1e-6, "weights sum = {sum}");
582    }
583
584    #[test]
585    fn min_variance_lower_than_equal_weight() {
586        let assets = two_assets();
587        let cov = two_asset_cov();
588        let mv = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::MinVariance, &[]);
589        let ew = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::EqualWeight, &[]);
590        assert!(mv.expected_variance <= ew.expected_variance + 1e-6);
591    }
592
593    #[test]
594    fn min_variance_favors_lower_variance_asset() {
595        let assets = two_assets(); // A has lower variance (0.04 vs 0.09)
596        let cov = two_asset_cov();
597        let result = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::MinVariance, &[]);
598        assert!(result.weights["A"] > result.weights["B"]);
599    }
600
601    #[test]
602    fn min_variance_long_only_constraint() {
603        let assets = two_assets();
604        let cov = two_asset_cov();
605        let result = PortfolioOptimizer::optimize(
606            &assets, &cov, &OptimizationObjective::MinVariance, &[Constraint::LongOnly],
607        );
608        for w in result.weights.values() {
609            assert!(*w >= -1e-9, "negative weight {w}");
610        }
611    }
612
613    #[test]
614    fn min_variance_max_weight_constraint() {
615        let assets = two_assets();
616        let cov = two_asset_cov();
617        let result = PortfolioOptimizer::optimize(
618            &assets, &cov, &OptimizationObjective::MinVariance,
619            &[Constraint::MaxWeight(0.6), Constraint::LongOnly],
620        );
621        for w in result.weights.values() {
622            assert!(*w <= 0.6 + 1e-6, "weight {w} exceeds max");
623        }
624    }
625
626    #[test]
627    fn min_variance_min_weight_constraint() {
628        let assets = three_assets();
629        let cov = three_asset_cov();
630        let result = PortfolioOptimizer::optimize(
631            &assets, &cov, &OptimizationObjective::MinVariance,
632            &[Constraint::MinWeight(0.1), Constraint::LongOnly],
633        );
634        for w in result.weights.values() {
635            assert!(*w >= 0.1 - 1e-6, "weight {w} below min");
636        }
637    }
638
639    // --- MaxSharpe ---
640
641    #[test]
642    fn max_sharpe_weights_sum_to_one() {
643        let assets = two_assets();
644        let cov = two_asset_cov();
645        let obj = OptimizationObjective::MaxSharpe { risk_free_rate: 0.02 };
646        let result = PortfolioOptimizer::optimize(&assets, &cov, &obj, &[Constraint::LongOnly]);
647        let sum: f64 = result.weights.values().sum();
648        assert!((sum - 1.0).abs() < 1e-6, "weights sum = {sum}");
649    }
650
651    #[test]
652    fn max_sharpe_higher_sharpe_than_equal_weight() {
653        let assets = two_assets();
654        let cov = two_asset_cov();
655        let obj = OptimizationObjective::MaxSharpe { risk_free_rate: 0.02 };
656        let ms = PortfolioOptimizer::optimize(&assets, &cov, &obj, &[Constraint::LongOnly]);
657        let ew = PortfolioOptimizer::optimize(&assets, &cov, &OptimizationObjective::EqualWeight, &[]);
658        // `sharpe_ratio` uses rf = 0 for EqualWeight (see OptimizedPortfolio docs)
659        // but rf = 0.02 for MaxSharpe, so compare both at the same rf.
660        let ew_sharpe_rf = (ew.expected_return - 0.02) / ew.expected_variance.sqrt();
661        assert!(ms.sharpe_ratio >= ew_sharpe_rf - 1e-4, "ms={} ew={}", ms.sharpe_ratio, ew_sharpe_rf);
662    }
663
664    #[test]
665    fn max_sharpe_positive_sharpe() {
666        let assets = two_assets();
667        let cov = two_asset_cov();
668        let obj = OptimizationObjective::MaxSharpe { risk_free_rate: 0.02 };
669        let result = PortfolioOptimizer::optimize(&assets, &cov, &obj, &[]);
670        assert!(result.sharpe_ratio > 0.0);
671    }
672
673    // --- RiskParity ---
674
675    #[test]
676    fn risk_parity_weights_sum_to_one() {
677        let assets = three_assets();
678        let cov = three_asset_cov();
679        let result = PortfolioOptimizer::optimize(
680            &assets, &cov, &OptimizationObjective::RiskParity, &[Constraint::LongOnly],
681        );
682        let sum: f64 = result.weights.values().sum();
683        assert!((sum - 1.0).abs() < 1e-5, "weights sum = {sum}");
684    }
685
686    #[test]
687    fn risk_parity_nonnegative_weights() {
688        let assets = three_assets();
689        let cov = three_asset_cov();
690        let result = PortfolioOptimizer::optimize(
691            &assets, &cov, &OptimizationObjective::RiskParity, &[Constraint::LongOnly],
692        );
693        for w in result.weights.values() {
694            assert!(*w >= -1e-9);
695        }
696    }
697
698    #[test]
699    fn risk_parity_high_vol_asset_gets_lower_weight() {
700        // B has much higher variance (0.09) vs A (0.04) and C (0.01).
701        // Risk parity should give C the highest weight, B the lowest.
702        let assets = three_assets();
703        let cov = three_asset_cov();
704        let result = PortfolioOptimizer::optimize(
705            &assets, &cov, &OptimizationObjective::RiskParity, &[Constraint::LongOnly],
706        );
707        let wb = result.weights["B"];
708        let wc = result.weights["C"];
709        assert!(wc > wb, "C ({wc}) should outweigh B ({wb}) in risk parity");
710    }
711
712    // --- SectorConstraint ---
713
714    #[test]
715    fn sector_constraint_respected() {
716        let assets = vec![
717            Asset { symbol: "TECH_A".into(), expected_return: 0.15, variance: 0.10 },
718            Asset { symbol: "TECH_B".into(), expected_return: 0.18, variance: 0.12 },
719            Asset { symbol: "BOND_A".into(), expected_return: 0.04, variance: 0.01 },
720        ];
721        let mut cov = CovarianceMatrix::new(vec!["TECH_A".into(), "TECH_B".into(), "BOND_A".into()]);
722        cov.set(0, 0, 0.10);
723        cov.set(1, 1, 0.12);
724        cov.set(2, 2, 0.01);
725        let constraints = vec![
726            Constraint::LongOnly,
727            Constraint::SectorConstraint { sector: "TECH".into(), max_weight: 0.5 },
728        ];
729        let result = PortfolioOptimizer::optimize(
730            &assets, &cov, &OptimizationObjective::MinVariance, &constraints,
731        );
732        let tech_total: f64 = result.weights["TECH_A"] + result.weights["TECH_B"];
733        assert!(tech_total <= 0.5 + 1e-6, "tech total {tech_total} exceeds limit");
734    }
735
736    // --- Empty asset list ---
737
738    #[test]
739    fn empty_assets_returns_zero_portfolio() {
740        let cov = CovarianceMatrix::new(vec![]);
741        let result = PortfolioOptimizer::optimize(&[], &cov, &OptimizationObjective::MinVariance, &[]);
742        assert!(result.weights.is_empty());
743        assert_eq!(result.expected_return, 0.0);
744        assert_eq!(result.expected_variance, 0.0);
745    }
746
747    // --- effective_n ---
748
749    #[test]
750    fn effective_n_concentrated_portfolio() {
751        let assets = two_assets();
752        let mut cov = two_asset_cov();
753        // Force a concentrated portfolio by extreme constraint.
754        let result = PortfolioOptimizer::optimize(
755            &assets, &cov, &OptimizationObjective::EqualWeight, &[],
756        );
757        // Equal weight → effective_n = 2.
758        assert!((result.effective_n - 2.0).abs() < 1e-6);
759        let _ = cov.get(0, 0); // suppress warning
760    }
761
762    // --- expected_return and expected_variance consistency ---
763
764    #[test]
765    fn expected_return_consistent_with_weights() {
766        let assets = three_assets();
767        let cov = three_asset_cov();
768        let result = PortfolioOptimizer::optimize(
769            &assets, &cov, &OptimizationObjective::MinVariance, &[Constraint::LongOnly],
770        );
771        let manual_ret: f64 = assets
772            .iter()
773            .map(|a| result.weights[&a.symbol] * a.expected_return)
774            .sum();
775        assert!((result.expected_return - manual_ret).abs() < 1e-10);
776    }
777
778    #[test]
779    fn expected_variance_nonnegative() {
780        let assets = three_assets();
781        let cov = three_asset_cov();
782        let result = PortfolioOptimizer::optimize(
783            &assets, &cov, &OptimizationObjective::RiskParity, &[Constraint::LongOnly],
784        );
785        assert!(result.expected_variance >= 0.0);
786    }
787}