use nalgebra::{DMatrix, DVector};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct MvoConfig {
pub regularization: Option<f64>,
pub shrinkage: Option<f64>,
}
impl Default for MvoConfig {
fn default() -> Self {
Self {
regularization: None,
shrinkage: None,
}
}
}
pub struct PortfolioAllocator {
price_data: DMatrix<f64>,
cov_matrix: DMatrix<f64>,
}
impl PortfolioAllocator {
pub fn new(price_data: DMatrix<f64>) -> Self {
let cov_matrix = PortfolioAllocator::compute_covariance_matrix(&price_data);
PortfolioAllocator {
price_data,
cov_matrix,
}
}
fn compute_covariance_matrix(returns: &DMatrix<f64>) -> DMatrix<f64> {
let mean = returns.row_mean();
let mean_matrix = DMatrix::from_rows(&vec![mean.clone(); returns.nrows()]);
let centered = returns - mean_matrix;
(centered.transpose() * centered) / (returns.nrows() as f64 - 1.0)
}
pub fn mvo_allocation(&self) -> HashMap<usize, f64> {
self.mvo_allocation_with_config(&MvoConfig::default())
}
pub fn mvo_allocation_with_config(&self, config: &MvoConfig) -> HashMap<usize, f64> {
let n = self.cov_matrix.ncols();
let ones = DVector::from_element(n, 1.0);
let identity = DMatrix::identity(n, n);
let shrunk_cov = if let Some(lambda) = config.shrinkage {
lambda * &identity + (1.0 - lambda) * &self.cov_matrix
} else {
self.cov_matrix.clone()
};
let regularized_cov = if let Some(eps) = config.regularization {
&shrunk_cov + eps * &identity
} else {
shrunk_cov
};
let inv_cov = regularized_cov.clone().try_inverse().unwrap_or_else(|| {
let eps = config.regularization.unwrap_or(1e-8);
regularized_cov
.pseudo_inverse(eps)
.expect("Pseudo-inverse failed")
});
let denom = (ones.transpose() * &inv_cov * &ones)[(0, 0)];
let weights = &inv_cov * &ones / denom;
(0..n).map(|i| (i, weights[i])).collect()
}
pub fn ew_allocation(&self) -> HashMap<usize, f64> {
let n = self.price_data.ncols();
(0..n).map(|i| (i, 1.0 / n as f64)).collect()
}
pub fn hrp_allocation(&self) -> HashMap<usize, f64> {
let n = self.cov_matrix.ncols();
let mut weights = vec![1.0; n];
let mut clusters: Vec<Vec<usize>> = vec![(0..n).collect()];
while let Some(cluster) = clusters.pop() {
if cluster.len() != 1 {
let mid = cluster.len() / 2;
let left = &cluster[..mid];
let right = &cluster[mid..];
let vol_left: f64 = left.iter().map(|&i| self.cov_matrix[(i, i)]).sum();
let vol_right: f64 = right.iter().map(|&i| self.cov_matrix[(i, i)]).sum();
let total_vol = vol_left + vol_right;
for &idx in left {
weights[idx] *= vol_right / total_vol;
}
for &idx in right {
weights[idx] *= vol_left / total_vol;
}
clusters.push(left.to_vec());
clusters.push(right.to_vec());
}
}
(0..n).map(|i| (i, weights[i])).collect()
}
}
pub fn run_portfolio_allocation(prices: DMatrix<f64>) -> HashMap<usize, f64> {
let allocator = PortfolioAllocator::new(prices);
allocator.mvo_allocation()
}
#[cfg(test)]
mod tests {
use super::*;
use nalgebra::dmatrix;
#[test]
fn test_ew_allocation() {
let prices = dmatrix![
100.0, 200.0, 300.0;
110.0, 210.0, 310.0;
120.0, 220.0, 320.0
];
let allocator = PortfolioAllocator::new(prices);
let ew_weights = allocator.ew_allocation();
assert_eq!(ew_weights.len(), 3);
assert!((ew_weights.values().sum::<f64>() - 1.0).abs() < 1e-6);
}
#[test]
fn test_mvo_allocation() {
let prices = dmatrix![
100.0, 200.0, 300.0;
110.0, 210.0, 310.0;
120.0, 220.0, 320.0
];
let allocator = PortfolioAllocator::new(prices);
let mvo_weights = allocator.mvo_allocation();
assert_eq!(mvo_weights.len(), 3);
assert!((mvo_weights.values().sum::<f64>() - 1.0).abs() < 1e-6);
}
#[test]
fn test_hrp_allocation() {
let prices = dmatrix![
100.0, 200.0, 300.0;
110.0, 210.0, 310.0;
120.0, 220.0, 320.0
];
let allocator = PortfolioAllocator::new(prices);
let hrp_weights = allocator.hrp_allocation();
assert_eq!(hrp_weights.len(), 3);
assert!((hrp_weights.values().sum::<f64>() - 1.0).abs() < 1e-6);
}
#[test]
fn test_hrp_allocation_triggers_continue_path() {
let prices = dmatrix![
100.0, 200.0, 300.0, 400.0;
101.0, 201.0, 301.0, 401.0;
102.0, 202.0, 302.0, 402.0;
103.0, 203.0, 303.0, 403.0
];
let allocator = PortfolioAllocator::new(prices);
let weights = allocator.hrp_allocation();
assert_eq!(weights.len(), 4);
for &w in weights.values() {
assert!(w.is_finite());
}
let sum: f64 = weights.values().sum();
assert!((sum - 1.0).abs() < 1e-6);
}
#[test]
fn test_single_asset() {
let prices = dmatrix![
100.0;
110.0;
120.0
];
let allocator = PortfolioAllocator::new(prices);
let ew_weights = allocator.ew_allocation();
assert_eq!(ew_weights.len(), 1);
assert_eq!(ew_weights.get(&0), Some(&1.0));
}
#[test]
fn test_run_portfolio_allocation() {
let prices = DMatrix::from_row_slice(
4,
4,
&[
125.0, 1500.0, 210.0, 600.0, 123.0, 1520.0, 215.0, 620.0, 130.0, 1510.0, 220.0,
610.0, 128.0, 1530.0, 225.0, 630.0,
],
);
let weights = run_portfolio_allocation(prices);
let total_weight: f64 = weights.values().sum();
assert!(
(total_weight - 1.0).abs() < 1e-6,
"Weights should sum to 1.0"
);
assert_eq!(weights.len(), 4, "Should return weights for 4 assets");
}
#[test]
fn test_mvo_allocation_with_config_variants() {
let prices = dmatrix![
100.0, 200.0, 300.0;
110.0, 210.0, 310.0;
120.0, 220.0, 320.0
];
let allocator = PortfolioAllocator::new(prices);
let config_none = MvoConfig::default();
let weights_none = allocator.mvo_allocation_with_config(&config_none);
assert_eq!(weights_none.len(), 3);
assert!((weights_none.values().sum::<f64>() - 1.0).abs() < 1e-6);
let config_reg = MvoConfig {
regularization: Some(1e-6),
shrinkage: None,
};
let weights_reg = allocator.mvo_allocation_with_config(&config_reg);
assert_eq!(weights_reg.len(), 3);
assert!((weights_reg.values().sum::<f64>() - 1.0).abs() < 1e-6);
let config_shrink = MvoConfig {
regularization: None,
shrinkage: Some(0.1),
};
let weights_shrink = allocator.mvo_allocation_with_config(&config_shrink);
assert_eq!(weights_shrink.len(), 3);
assert!((weights_shrink.values().sum::<f64>() - 1.0).abs() < 1e-6);
let config_both = MvoConfig {
regularization: Some(1e-6),
shrinkage: Some(0.2),
};
let weights_both = allocator.mvo_allocation_with_config(&config_both);
assert_eq!(weights_both.len(), 3);
assert!((weights_both.values().sum::<f64>() - 1.0).abs() < 1e-6);
}
#[test]
fn test_mvo_allocation_pseudo_inverse_succeeds() {
let prices = dmatrix![
100.0, 200.0, 300.0;
101.0, 201.0, 301.0;
102.0, 202.0, 302.0
];
let mut allocator = PortfolioAllocator::new(prices);
allocator.cov_matrix = dmatrix![
f64::NAN, 0.0, 0.0;
0.0, 1.0, 0.0;
0.0, 0.0, 1.0
];
let config = MvoConfig {
regularization: None,
shrinkage: None,
};
let _ = allocator.mvo_allocation_with_config(&config);
}
#[test]
fn test_mvo_allocation_pseudo_inverse_on_nan_matrix() {
let bad_matrix = DMatrix::<f64>::from_element(3, 3, f64::NAN);
let mut allocator = PortfolioAllocator::new(dmatrix![
100.0, 200.0, 300.0;
101.0, 201.0, 301.0;
102.0, 202.0, 302.0
]);
allocator.cov_matrix = bad_matrix;
let config = MvoConfig {
regularization: None,
shrinkage: None,
};
let _ = allocator.mvo_allocation_with_config(&config);
}
}