1use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
11pub struct Asset {
12 pub symbol: String,
14 pub expected_return: f64,
16 pub variance: f64,
18}
19
20#[derive(Debug, Clone)]
22pub struct CovarianceMatrix {
23 pub data: Vec<Vec<f64>>,
25 pub symbols: Vec<String>,
27}
28
29impl CovarianceMatrix {
30 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 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 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 pub fn n(&self) -> usize {
61 self.symbols.len()
62 }
63
64 pub fn ledoit_wolf_shrinkage(&mut self) {
78 let n = self.n();
79 if n == 0 {
80 return;
81 }
82
83 let trace: f64 = (0..n).map(|i| self.get(i, i)).sum();
85 let mu = trace / n as f64;
86
87 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 return;
101 }
102
103 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#[derive(Debug, Clone)]
124pub enum OptimizationObjective {
125 MinVariance,
127 MaxSharpe {
129 risk_free_rate: f64,
131 },
132 RiskParity,
134 EqualWeight,
136}
137
138#[derive(Debug, Clone)]
140pub enum Constraint {
141 MaxWeight(f64),
143 MinWeight(f64),
145 LongOnly,
147 SectorConstraint {
149 sector: String,
151 max_weight: f64,
153 },
154}
155
156#[derive(Debug, Clone)]
158pub struct OptimizedPortfolio {
159 pub weights: HashMap<String, f64>,
161 pub expected_return: f64,
163 pub expected_variance: f64,
165 pub sharpe_ratio: f64,
167 pub effective_n: f64,
169}
170
171pub struct PortfolioOptimizer;
177
178impl PortfolioOptimizer {
179 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 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 let mut w: Vec<f64> = vec![1.0 / n as f64; n];
217
218 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 for i in 0..n {
249 w[i] -= STEP * grad[i];
250 }
251
252 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 w = project_simplex(&w);
262
263 for (sector, sec_max) in §or_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 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 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 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 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 let d_sharpe = (d_mu * sigma_p - excess * d_sigma) / (sigma_p * sigma_p);
339 g[i] = -d_sharpe;
341 }
342 g
343 }
344 OptimizationObjective::RiskParity => {
345 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 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 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 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
410fn 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
422fn 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 #[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 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 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(); 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(); }
534
535 #[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 #[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 #[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(); 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 #[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 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 #[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 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 #[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 #[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 #[test]
750 fn effective_n_concentrated_portfolio() {
751 let assets = two_assets();
752 let mut cov = two_asset_cov();
753 let result = PortfolioOptimizer::optimize(
755 &assets, &cov, &OptimizationObjective::EqualWeight, &[],
756 );
757 assert!((result.effective_n - 2.0).abs() < 1e-6);
759 let _ = cov.get(0, 0); }
761
762 #[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}