use scirs2_core::ndarray::{Array1, Array2};
use sklears_core::error::{Result as SklResult, SklearsError};
type Result<T> = SklResult<T>;
type Float = f64;
fn cholesky(a: &[Vec<Float>], n: usize) -> Option<Vec<Vec<Float>>> {
let mut l = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in 0..=i {
let mut s: Float = a[i][j];
for k in 0..j {
s -= l[i][k] * l[j][k];
}
if i == j {
if s < 0.0 {
return None;
}
l[i][j] = s.sqrt();
} else {
if l[j][j].abs() < 1e-15 {
return None;
}
l[i][j] = s / l[j][j];
}
}
}
Some(l)
}
fn forward_sub(l: &[Vec<Float>], b: &[Float], n: usize) -> Vec<Float> {
let mut x = vec![0.0_f64; n];
for i in 0..n {
let mut s = b[i];
for j in 0..i {
s -= l[i][j] * x[j];
}
x[i] = if l[i][i].abs() > 1e-15 {
s / l[i][i]
} else {
0.0
};
}
x
}
fn backward_sub(l: &[Vec<Float>], b: &[Float], n: usize) -> Vec<Float> {
let mut x = vec![0.0_f64; n];
for i in (0..n).rev() {
let mut s = b[i];
for j in (i + 1)..n {
s -= l[j][i] * x[j];
}
x[i] = if l[i][i].abs() > 1e-15 {
s / l[i][i]
} else {
0.0
};
}
x
}
fn cholesky_solve(a: &[Vec<Float>], b: &[Float], n: usize) -> Option<Vec<Float>> {
let l = cholesky(a, n)?;
let y = forward_sub(&l, b, n);
Some(backward_sub(&l, &y, n))
}
fn regularised_cov_vecs(cov: &Array2<Float>, n: usize) -> Vec<Vec<Float>> {
let trace: Float = (0..n).map(|i| cov[[i, i]]).sum();
let eps = 1e-8 * (trace / n as Float).max(1e-10);
let mut a = vec![vec![0.0_f64; n]; n];
for i in 0..n {
for j in 0..n {
a[i][j] = cov[[i, j]];
}
a[i][i] += eps;
}
a
}
pub fn compute_expected_returns(x: &Array2<Float>) -> Result<Array1<Float>> {
let n_features = x.ncols();
let mut returns = Array1::zeros(n_features);
for i in 0..n_features {
returns[i] = x.column(i).mean().unwrap_or(0.0);
}
Ok(returns)
}
pub fn mean_variance_optimization(
expected_returns: &Array1<Float>,
cov_matrix: &Array2<Float>,
) -> Result<Array1<Float>> {
let n_assets = expected_returns.len();
if n_assets == 0 {
return Ok(Array1::zeros(0));
}
if n_assets == 1 {
return Ok(Array1::from_elem(1, 1.0));
}
if cov_matrix.nrows() != n_assets || cov_matrix.ncols() != n_assets {
return Err(SklearsError::InvalidInput(format!(
"Covariance matrix shape ({}, {}) does not match expected_returns length {}",
cov_matrix.nrows(),
cov_matrix.ncols(),
n_assets,
)));
}
let a_reg = regularised_cov_vecs(cov_matrix, n_assets);
let mu_vec: Vec<Float> = expected_returns.to_vec();
let w_phase1: Array1<Float> = match cholesky_solve(&a_reg, &mu_vec, n_assets) {
Some(z) => {
let z_pos: Array1<Float> = z.iter().map(|&zi| zi.max(0.0)).collect();
let s = z_pos.sum();
if s > 1e-12 {
z_pos / s
} else {
Array1::from_elem(n_assets, 1.0 / n_assets as Float)
}
}
None => {
Array1::from_elem(n_assets, 1.0 / n_assets as Float)
}
};
let n_active = w_phase1.iter().filter(|&&wi| wi > 1e-9).count();
let w_final = if n_active == n_assets || n_active == 0 {
w_phase1.clone()
} else {
let active_idx: Vec<usize> = (0..n_assets).filter(|&i| w_phase1[i] > 1e-9).collect();
let n_a = active_idx.len();
let mut cov_a = vec![vec![0.0_f64; n_a]; n_a];
let mut mu_a = vec![0.0_f64; n_a];
for (ii, &i) in active_idx.iter().enumerate() {
mu_a[ii] = expected_returns[i];
for (jj, &j) in active_idx.iter().enumerate() {
cov_a[ii][jj] = cov_matrix[[i, j]];
}
cov_a[ii][ii] += 1e-8 * cov_matrix[[i, i]].max(1e-10);
}
match cholesky_solve(&cov_a, &mu_a, n_a) {
Some(z_a) => {
let z_pos: Vec<Float> = z_a.iter().map(|&zi| zi.max(0.0)).collect();
let s: Float = z_pos.iter().sum();
if s > 1e-12 {
let mut w = Array1::zeros(n_assets);
for (ii, &i) in active_idx.iter().enumerate() {
w[i] = z_pos[ii] / s;
}
w
} else {
w_phase1.clone()
}
}
None => w_phase1.clone(),
}
};
let s = w_final.sum();
if !(0.999..=1.001).contains(&s) || w_final.iter().any(|wi| !wi.is_finite()) {
return Ok(Array1::from_elem(n_assets, 1.0 / n_assets as Float));
}
Ok(w_final)
}
pub fn minimum_variance_portfolio(cov_matrix: &Array2<Float>) -> Result<Array1<Float>> {
let n_assets = cov_matrix.nrows();
if n_assets == 0 {
return Ok(Array1::zeros(0));
}
let mut weights = Array1::zeros(n_assets);
for i in 0..n_assets {
let variance = cov_matrix[[i, i]];
weights[i] = if variance > 1e-10 {
1.0 / variance.sqrt()
} else {
0.0
};
}
let sum = weights.sum();
if sum > 1e-10 {
weights /= sum;
}
Ok(weights)
}
pub fn risk_parity_portfolio(cov_matrix: &Array2<Float>) -> Result<Array1<Float>> {
let n_assets = cov_matrix.nrows();
if n_assets == 0 {
return Ok(Array1::zeros(0));
}
let mut weights = Array1::zeros(n_assets);
for i in 0..n_assets {
let volatility = cov_matrix[[i, i]].sqrt();
weights[i] = if volatility > 1e-10 {
1.0 / volatility
} else {
0.0
};
}
let sum = weights.sum();
if sum > 1e-10 {
weights /= sum;
}
Ok(weights)
}
pub fn maximum_sharpe_ratio_portfolio(
expected_returns: &Array1<Float>,
cov_matrix: &Array2<Float>,
risk_free_rate: Float,
) -> Result<Array1<Float>> {
let n_assets = expected_returns.len();
if n_assets == 0 {
return Ok(Array1::zeros(0));
}
let excess_returns = expected_returns.mapv(|r| r - risk_free_rate);
mean_variance_optimization(&excess_returns, cov_matrix)
}
pub fn compute_portfolio_return(
weights: &Array1<Float>,
expected_returns: &Array1<Float>,
) -> Float {
if weights.len() != expected_returns.len() {
return 0.0;
}
weights
.iter()
.zip(expected_returns.iter())
.map(|(w, r)| w * r)
.sum()
}
pub fn compute_portfolio_variance(weights: &Array1<Float>, cov_matrix: &Array2<Float>) -> Float {
if weights.len() != cov_matrix.nrows() || weights.len() != cov_matrix.ncols() {
return 0.0;
}
let mut variance = 0.0;
for i in 0..weights.len() {
for j in 0..weights.len() {
variance += weights[i] * weights[j] * cov_matrix[[i, j]];
}
}
variance
}
pub fn compute_diversification_ratio(weights: &Array1<Float>, cov_matrix: &Array2<Float>) -> Float {
let n = weights.len();
if n == 0 {
return 0.0;
}
let mut weighted_volatility = 0.0;
for i in 0..n {
weighted_volatility += weights[i] * cov_matrix[[i, i]].sqrt();
}
let portfolio_volatility = compute_portfolio_variance(weights, cov_matrix).sqrt();
if portfolio_volatility < 1e-10 {
return 0.0;
}
weighted_volatility / portfolio_volatility
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
#[test]
fn test_two_asset_tangency() {
let mu = array![0.10, 0.20];
let cov = array![[1.0, 0.0], [0.0, 1.0]];
let w = mean_variance_optimization(&mu, &cov).expect("optimisation failed");
assert_eq!(w.len(), 2);
assert!(w[0] >= 0.0);
assert!(w[1] >= 0.0);
let sum: f64 = w.sum();
assert!((sum - 1.0).abs() < 1e-6, "weights do not sum to 1: {sum}");
assert!(
w[1] > w[0],
"expected w[1] > w[0] for higher μ[1]; got w={w:?}"
);
assert!(
(w[0] - 1.0 / 3.0).abs() < 0.05,
"w[0]={} expected ~1/3",
w[0]
);
assert!(
(w[1] - 2.0 / 3.0).abs() < 0.05,
"w[1]={} expected ~2/3",
w[1]
);
}
#[test]
fn test_equal_weights_when_uniform_returns() {
let n = 4usize;
let mu = Array1::from_elem(n, 0.10);
let cov = Array2::eye(n);
let w = mean_variance_optimization(&mu, &cov).expect("optimisation failed");
assert_eq!(w.len(), n);
let sum: f64 = w.sum();
assert!((sum - 1.0).abs() < 1e-6);
for &wi in w.iter() {
assert!(wi >= 0.0, "negative weight: {wi}");
assert!(
(wi - 0.25).abs() < 0.05,
"expected ~0.25 per asset, got {wi}"
);
}
}
#[test]
fn test_simplex_feasibility() {
let n = 5usize;
let mut cov = Array2::zeros((n, n));
for i in 0..n {
cov[[i, i]] = 0.04 * (i + 1) as f64; if i + 1 < n {
cov[[i, i + 1]] = 0.005;
cov[[i + 1, i]] = 0.005;
}
}
let mu = Array1::from_shape_fn(n, |i| 0.05 + 0.03 * i as f64);
let w = mean_variance_optimization(&mu, &cov).expect("optimisation failed");
assert_eq!(w.len(), n);
let sum: f64 = w.sum();
assert!((sum - 1.0).abs() < 1e-5, "weights do not sum to 1: {sum}");
for &wi in w.iter() {
assert!(wi >= -1e-9, "weight is negative: {wi}");
}
}
#[test]
fn test_dimension_mismatch_errors() {
let mu = array![0.10, 0.20];
let cov = Array2::eye(3usize);
assert!(mean_variance_optimization(&mu, &cov).is_err());
}
#[test]
fn test_empty_input() {
let mu = Array1::zeros(0usize);
let cov = Array2::zeros((0, 0));
let w = mean_variance_optimization(&mu, &cov).expect("should not error on empty input");
assert_eq!(w.len(), 0);
}
#[test]
fn test_active_set_clips_negative_return_asset() {
let mu = array![0.10, -0.05, 0.15];
let cov = Array2::eye(3usize);
let w = mean_variance_optimization(&mu, &cov).expect("optimisation failed");
assert_eq!(w.len(), 3);
for (i, &wi) in w.iter().enumerate() {
assert!(wi >= -1e-9, "weight[{i}] is negative: {wi}");
}
let sum: f64 = w.sum();
assert!((sum - 1.0).abs() < 1e-5, "weights do not sum to 1: {sum}");
assert!(
w[1] < 0.01,
"dominated asset should receive ~0 weight; got w[1]={}",
w[1]
);
assert!(
w[2] > w[0],
"expected w[2] > w[0] since μ[2] > μ[0]; got w={w:?}"
);
}
}