use super::covariance::check_cov;
use super::projection::{PortfolioConstraint, apply_bounds_and_budget};
use super::{Bounds, PortfolioError, PortfolioResult};
use nalgebra::{DMatrix, DVector};
fn safe_solve_spd(cov: &DMatrix<f64>, rhs: &DVector<f64>) -> PortfolioResult<DVector<f64>> {
let n = cov.nrows();
if n == 1 {
let val = cov[(0, 0)];
if val <= 0.0 || !val.is_finite() {
return Err(PortfolioError::InvalidSingleAssetCov);
}
return Ok(DVector::from_vec(vec![rhs[0] / val]));
}
let svd = cov.clone().svd(false, false);
let sv = svd.singular_values;
let max_sv = sv.max();
let min_sv = sv.min();
let cond = if min_sv > 0.0 {
max_sv / min_sv
} else {
f64::INFINITY
};
if !cond.is_finite() || cond > 1e12 {
return Err(PortfolioError::IllConditionedCov(cond));
}
let lu = cov.clone().lu();
let x = lu
.solve(rhs)
.ok_or_else(|| PortfolioError::DegenerateCov("LU solve failed".to_string()))?;
if !x.iter().all(|v| v.is_finite()) {
return Err(PortfolioError::NonFiniteSolve);
}
Ok(x)
}
pub fn min_variance(
cov: &DMatrix<f64>,
bounds: Option<&[Bounds]>,
) -> PortfolioResult<DVector<f64>> {
check_cov(cov)?;
let n = cov.nrows();
let ones = DVector::from_element(n, 1.0);
let raw = safe_solve_spd(cov, &ones)?;
let total = raw.sum();
if total.abs() < 1e-12 {
return Err(PortfolioError::MinVarianceDegenerate);
}
let w = raw / total;
apply_bounds_and_budget(&w, bounds, PortfolioConstraint::LongOnly)
}
pub fn max_sharpe(
mean: &DVector<f64>,
cov: &DMatrix<f64>,
bounds: Option<&[Bounds]>,
) -> PortfolioResult<DVector<f64>> {
check_cov(cov)?;
if mean.len() != cov.nrows() {
return Err(PortfolioError::MeanLengthMismatch(mean.len(), cov.nrows()));
}
if !mean.iter().all(|v| v.is_finite()) {
return Err(PortfolioError::NonFinite("mean".to_string()));
}
let raw = safe_solve_spd(cov, mean)?;
let total = raw.sum();
if total.abs() < 1e-12 {
return Err(PortfolioError::MaxSharpeDegenerate);
}
let w = raw / total;
apply_bounds_and_budget(&w, bounds, PortfolioConstraint::LongOnly)
}
pub fn risk_parity(
cov: &DMatrix<f64>,
bounds: Option<&[Bounds]>,
max_iter: usize,
tol: f64,
) -> PortfolioResult<DVector<f64>> {
check_cov(cov)?;
let n = cov.nrows();
let b = 1.0 / n as f64;
let mut y = DVector::from_element(n, 1.0 / n as f64);
for _ in 0..max_iter {
let sigma_y = cov * &y;
if sigma_y.iter().any(|v| *v <= 0.0) || !sigma_y.iter().all(|v| v.is_finite()) {
return Err(PortfolioError::RiskParityNonPositiveMarginalRisk);
}
let y_sigma_y: f64 = (0..n).map(|i| y[i] * sigma_y[i]).sum();
let mut y_new = DVector::from_iterator(
n,
(0..n).map(|i| y[i] * (b / (y[i] * sigma_y[i]) * y_sigma_y).sqrt()),
);
let sum_new = y_new.sum();
y_new /= sum_new;
let max_diff = (0..n).map(|i| (y_new[i] - y[i]).abs()).fold(0.0, f64::max);
y = y_new;
if max_diff < tol {
break;
}
}
if !y.iter().all(|v| v.is_finite()) {
return Err(PortfolioError::RiskParityDidNotConverge);
}
let w = &y / y.sum();
apply_bounds_and_budget(&w, bounds, PortfolioConstraint::LongOnly)
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn diag_cov(vals: &[f64]) -> DMatrix<f64> {
DMatrix::from_diagonal(&DVector::from_row_slice(vals))
}
#[test]
fn min_variance_diagonal_cov_inverse_variance_weighted() {
let cov = diag_cov(&[0.04, 0.01, 0.09]); let w = min_variance(&cov, None).unwrap();
let inv = [1.0 / 0.04, 1.0 / 0.01, 1.0 / 0.09];
let total: f64 = inv.iter().sum();
for i in 0..3 {
assert_relative_eq!(w[i], inv[i] / total, epsilon = 1e-9);
}
assert_relative_eq!(w.sum(), 1.0, epsilon = 1e-9);
}
#[test]
fn min_variance_equal_variance_equal_weight() {
let cov = DMatrix::<f64>::identity(4, 4) * 0.02;
let w = min_variance(&cov, None).unwrap();
for i in 0..4 {
assert_relative_eq!(w[i], 0.25, epsilon = 1e-9);
}
}
#[test]
fn max_sharpe_diagonal_cov_matches_mu_over_var() {
let cov = diag_cov(&[0.04, 0.01]);
let mean = DVector::from_row_slice(&[0.08, 0.02]);
let w = max_sharpe(&mean, &cov, None).unwrap();
assert_relative_eq!(w[0], 0.5, epsilon = 1e-9);
assert_relative_eq!(w[1], 0.5, epsilon = 1e-9);
}
#[test]
fn risk_parity_equal_variance_uncorrelated_is_equal_weight() {
let cov = DMatrix::<f64>::identity(3, 3) * 0.05;
let w = risk_parity(&cov, None, 500, 1e-12).unwrap();
for i in 0..3 {
assert_relative_eq!(w[i], 1.0 / 3.0, epsilon = 1e-6);
}
}
#[test]
fn risk_parity_equalizes_risk_contributions() {
let cov = diag_cov(&[0.04, 0.09, 0.01]);
let w = risk_parity(&cov, None, 1000, 1e-13).unwrap();
let sigma_w = &cov * &w;
let rc: Vec<f64> = (0..3).map(|i| w[i] * sigma_w[i]).collect();
for i in 1..3 {
assert_relative_eq!(rc[i], rc[0], epsilon = 1e-6);
}
}
#[test]
fn min_variance_rejects_ill_conditioned_cov() {
let cov = DMatrix::from_row_slice(2, 2, &[1.0, 1.0, 1.0, 1.0 + 1e-15]);
let err = min_variance(&cov, None);
assert!(err.is_err());
}
#[test]
fn min_variance_with_upper_bound_clips_dominant_asset() {
let cov = diag_cov(&[0.01, 1.0, 1.0]); let bounds = vec![(0.0, 0.5), (0.0, 1.0), (0.0, 1.0)];
let w = min_variance(&cov, Some(&bounds)).unwrap();
assert!(w[0] <= 0.5 + 1e-9);
assert_relative_eq!(w.sum(), 1.0, epsilon = 1e-9);
}
}