use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptimizationConstraints {
pub min_weights: Option<Vec<Decimal>>,
pub max_weights: Option<Vec<Decimal>>,
pub long_only: bool,
pub max_total_short: Option<Decimal>,
pub sector_constraints: Option<Vec<SectorConstraint>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectorConstraint {
pub name: String,
pub asset_indices: Vec<usize>,
pub min_weight: Decimal,
pub max_weight: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeanVarianceInput {
pub asset_names: Vec<String>,
pub expected_returns: Vec<Decimal>,
pub covariance_matrix: Vec<Vec<Decimal>>,
pub risk_free_rate: Decimal,
pub constraints: OptimizationConstraints,
pub frontier_points: Option<u32>,
pub target_return: Option<Decimal>,
pub target_risk: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetWeight {
pub name: String,
pub weight: Decimal,
pub contribution_to_risk: Decimal,
pub contribution_to_return: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FrontierPoint {
pub expected_return: Decimal,
pub risk: Decimal,
pub sharpe_ratio: Decimal,
pub weights: Vec<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioPoint {
pub weights: Vec<Decimal>,
pub expected_return: Decimal,
pub risk: Decimal,
pub sharpe_ratio: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeanVarianceOutput {
pub optimal_weights: Vec<AssetWeight>,
pub portfolio_return: Decimal,
pub portfolio_risk: Decimal,
pub sharpe_ratio: Decimal,
pub efficient_frontier: Vec<FrontierPoint>,
pub tangency_portfolio: PortfolioPoint,
pub min_variance_portfolio: PortfolioPoint,
pub diversification_ratio: Decimal,
pub hhi_concentration: Decimal,
}
pub fn optimize_mean_variance(
input: &MeanVarianceInput,
) -> CorpFinanceResult<ComputationOutput<MeanVarianceOutput>> {
let start = Instant::now();
let mut warnings: Vec<String> = Vec::new();
let n = input.asset_names.len();
validate_input(input, n)?;
let sigma = &input.covariance_matrix;
let mu = &input.expected_returns;
let rf = input.risk_free_rate;
let min_var_weights = compute_min_variance_weights(sigma, n, &input.constraints)?;
let min_var_ret = vec_dot(&min_var_weights, mu);
let min_var_risk = portfolio_std(&min_var_weights, sigma);
let min_var_sharpe = compute_sharpe(min_var_ret, rf, min_var_risk);
let min_variance_portfolio = PortfolioPoint {
weights: min_var_weights.clone(),
expected_return: min_var_ret,
risk: min_var_risk,
sharpe_ratio: min_var_sharpe,
};
let tang_weights = compute_tangency_weights(sigma, mu, rf, n, &input.constraints)?;
let tang_ret = vec_dot(&tang_weights, mu);
let tang_risk = portfolio_std(&tang_weights, sigma);
let tang_sharpe = compute_sharpe(tang_ret, rf, tang_risk);
let tangency_portfolio = PortfolioPoint {
weights: tang_weights.clone(),
expected_return: tang_ret,
risk: tang_risk,
sharpe_ratio: tang_sharpe,
};
let optimal_w = if let Some(target_ret) = input.target_return {
solve_target_return(sigma, mu, target_ret, n, &input.constraints)?
} else if let Some(target_risk) = input.target_risk {
solve_target_risk(sigma, mu, rf, target_risk, n, &input.constraints)?
} else {
tang_weights.clone()
};
let port_ret = vec_dot(&optimal_w, mu);
let port_risk = portfolio_std(&optimal_w, sigma);
let port_sharpe = compute_sharpe(port_ret, rf, port_risk);
let sigma_w = mat_vec_multiply(sigma, &optimal_w);
let optimal_weights: Vec<AssetWeight> = (0..n)
.map(|i| {
let mcr = if port_risk.is_zero() {
Decimal::ZERO
} else {
sigma_w[i] / port_risk
};
let rc = optimal_w[i] * mcr;
AssetWeight {
name: input.asset_names[i].clone(),
weight: optimal_w[i],
contribution_to_risk: rc,
contribution_to_return: optimal_w[i] * mu[i],
}
})
.collect();
let individual_vols: Vec<Decimal> = (0..n).map(|i| sqrt_decimal(sigma[i][i])).collect();
let weighted_avg_vol: Decimal = (0..n).map(|i| optimal_w[i] * individual_vols[i]).sum();
let diversification_ratio = if port_risk.is_zero() {
Decimal::ONE
} else {
weighted_avg_vol / port_risk
};
let hhi_concentration: Decimal = optimal_w.iter().map(|w| *w * *w).sum();
let num_points = input.frontier_points.unwrap_or(20) as usize;
let efficient_frontier = compute_efficient_frontier(
sigma,
mu,
rf,
n,
&input.constraints,
num_points,
min_var_ret,
)?;
for aw in &optimal_weights {
if aw.weight > dec!(0.40) {
warnings.push(format!(
"Concentrated position: {} has weight {:.4}",
aw.name, aw.weight
));
}
if aw.weight < dec!(-0.10) {
warnings.push(format!(
"Short position: {} has weight {:.4}",
aw.name, aw.weight
));
}
}
if hhi_concentration > dec!(0.5) {
warnings.push(format!(
"High concentration: HHI = {:.4}",
hhi_concentration
));
}
if port_risk > dec!(0.30) {
warnings.push(format!("High portfolio volatility: {:.4}", port_risk));
}
let output = MeanVarianceOutput {
optimal_weights,
portfolio_return: port_ret,
portfolio_risk: port_risk,
sharpe_ratio: port_sharpe,
efficient_frontier,
tangency_portfolio,
min_variance_portfolio,
diversification_ratio,
hhi_concentration,
};
let elapsed = start.elapsed().as_micros() as u64;
Ok(with_metadata(
"Markowitz Mean-Variance Optimization",
&serde_json::json!({
"n_assets": n,
"risk_free_rate": rf.to_string(),
"long_only": input.constraints.long_only,
"frontier_points": num_points,
}),
warnings,
elapsed,
output,
))
}
fn compute_min_variance_weights(
sigma: &[Vec<Decimal>],
n: usize,
constraints: &OptimizationConstraints,
) -> CorpFinanceResult<Vec<Decimal>> {
let sigma_inv = mat_inverse(sigma)?;
let ones = vec![Decimal::ONE; n];
let sigma_inv_ones = mat_vec_multiply(&sigma_inv, &ones);
let denom: Decimal = sigma_inv_ones.iter().sum();
if denom.is_zero() {
return Err(CorpFinanceError::DivisionByZero {
context: "min_variance_weights: 1' * Sigma^-1 * 1 is zero".into(),
});
}
let unconstrained: Vec<Decimal> = sigma_inv_ones.iter().map(|v| *v / denom).collect();
if is_feasible(&unconstrained, constraints) {
return Ok(unconstrained);
}
let init = equal_weights(n);
project_gradient_min_variance(sigma, &init, n, constraints)
}
fn compute_tangency_weights(
sigma: &[Vec<Decimal>],
mu: &[Decimal],
rf: Decimal,
n: usize,
constraints: &OptimizationConstraints,
) -> CorpFinanceResult<Vec<Decimal>> {
let excess: Vec<Decimal> = mu.iter().map(|r| *r - rf).collect();
let sigma_inv = mat_inverse(sigma)?;
let sigma_inv_excess = mat_vec_multiply(&sigma_inv, &excess);
let denom: Decimal = sigma_inv_excess.iter().sum();
if denom.abs() < dec!(0.0000000001) {
return compute_min_variance_weights(sigma, n, constraints);
}
let unconstrained: Vec<Decimal> = sigma_inv_excess.iter().map(|v| *v / denom).collect();
if is_feasible(&unconstrained, constraints) {
return Ok(unconstrained);
}
project_gradient_max_sharpe(sigma, mu, rf, n, constraints)
}
fn solve_target_return(
sigma: &[Vec<Decimal>],
mu: &[Decimal],
target_ret: Decimal,
n: usize,
constraints: &OptimizationConstraints,
) -> CorpFinanceResult<Vec<Decimal>> {
let mut w = equal_weights(n);
project_onto_constraints(&mut w, constraints);
let step = dec!(0.005);
let return_penalty = dec!(100);
for _ in 0..200 {
let sigma_w = mat_vec_multiply(sigma, &w);
let grad_var: Vec<Decimal> = sigma_w.iter().map(|v| dec!(2) * *v).collect();
let cur_ret = vec_dot(&w, mu);
let ret_diff = cur_ret - target_ret;
let grad_penalty: Vec<Decimal> = mu
.iter()
.map(|m| dec!(2) * return_penalty * ret_diff * *m)
.collect();
let grad: Vec<Decimal> = grad_var
.iter()
.zip(grad_penalty.iter())
.map(|(a, b)| *a + *b)
.collect();
let mut w_new: Vec<Decimal> = w
.iter()
.zip(grad.iter())
.map(|(wi, gi)| *wi - step * *gi)
.collect();
project_onto_constraints(&mut w_new, constraints);
normalize_weights(&mut w_new);
w = w_new;
}
Ok(w)
}
fn solve_target_risk(
sigma: &[Vec<Decimal>],
mu: &[Decimal],
rf: Decimal,
target_risk: Decimal,
n: usize,
constraints: &OptimizationConstraints,
) -> CorpFinanceResult<Vec<Decimal>> {
let min_var_w = compute_min_variance_weights(sigma, n, constraints)?;
let min_ret = vec_dot(&min_var_w, mu);
let max_ret = mu
.iter()
.copied()
.fold(Decimal::MIN, |a, b| if b > a { b } else { a });
let num_points = 50usize;
let mut best_w = min_var_w;
let mut best_diff = Decimal::MAX;
if max_ret > min_ret {
let step = (max_ret - min_ret) / Decimal::from(num_points as i64);
for i in 0..=num_points {
let tr = min_ret + step * Decimal::from(i as i64);
let w = solve_target_return(sigma, mu, tr, n, constraints)?;
let risk = portfolio_std(&w, sigma);
let diff = (risk - target_risk).abs();
if diff < best_diff {
best_diff = diff;
best_w = w;
}
}
}
let _ = rf; Ok(best_w)
}
fn project_gradient_min_variance(
sigma: &[Vec<Decimal>],
init: &[Decimal],
n: usize,
constraints: &OptimizationConstraints,
) -> CorpFinanceResult<Vec<Decimal>> {
let mut w: Vec<Decimal> = init.to_vec();
project_onto_constraints(&mut w, constraints);
normalize_weights(&mut w);
let step = dec!(0.005);
for _ in 0..100 {
let sigma_w = mat_vec_multiply(sigma, &w);
let grad: Vec<Decimal> = sigma_w.iter().map(|v| dec!(2) * *v).collect();
let mut w_new: Vec<Decimal> = (0..n).map(|i| w[i] - step * grad[i]).collect();
project_onto_constraints(&mut w_new, constraints);
normalize_weights(&mut w_new);
w = w_new;
}
Ok(w)
}
fn project_gradient_max_sharpe(
sigma: &[Vec<Decimal>],
mu: &[Decimal],
rf: Decimal,
n: usize,
constraints: &OptimizationConstraints,
) -> CorpFinanceResult<Vec<Decimal>> {
let mut w = equal_weights(n);
project_onto_constraints(&mut w, constraints);
normalize_weights(&mut w);
let step = dec!(0.001);
let mut best_sharpe = Decimal::MIN;
let mut best_w = w.clone();
for _ in 0..200 {
let port_ret = vec_dot(&w, mu);
let port_risk = portfolio_std(&w, sigma);
let sharpe = compute_sharpe(port_ret, rf, port_risk);
if sharpe > best_sharpe {
best_sharpe = sharpe;
best_w = w.clone();
}
if port_risk.is_zero() {
break;
}
let sigma_w = mat_vec_multiply(sigma, &w);
let excess = port_ret - rf;
let risk_cubed = port_risk * port_risk * port_risk;
let grad: Vec<Decimal> = (0..n)
.map(|i| {
if risk_cubed.is_zero() {
Decimal::ZERO
} else {
-(mu[i] - rf) / port_risk + excess * sigma_w[i] / risk_cubed
}
})
.collect();
let mut w_new: Vec<Decimal> = (0..n).map(|i| w[i] - step * grad[i]).collect();
project_onto_constraints(&mut w_new, constraints);
normalize_weights(&mut w_new);
w = w_new;
}
Ok(best_w)
}
fn compute_efficient_frontier(
sigma: &[Vec<Decimal>],
mu: &[Decimal],
rf: Decimal,
n: usize,
constraints: &OptimizationConstraints,
num_points: usize,
min_var_ret: Decimal,
) -> CorpFinanceResult<Vec<FrontierPoint>> {
let max_ret = mu
.iter()
.copied()
.fold(Decimal::MIN, |a, b| if b > a { b } else { a });
if num_points <= 1 || max_ret <= min_var_ret {
let w = solve_target_return(sigma, mu, min_var_ret, n, constraints)?;
let risk = portfolio_std(&w, sigma);
let sharpe = compute_sharpe(min_var_ret, rf, risk);
return Ok(vec![FrontierPoint {
expected_return: min_var_ret,
risk,
sharpe_ratio: sharpe,
weights: w,
}]);
}
let step = (max_ret - min_var_ret) / Decimal::from(num_points as i64 - 1);
let mut frontier = Vec::with_capacity(num_points);
for i in 0..num_points {
let target_ret = min_var_ret + step * Decimal::from(i as i64);
let w = solve_target_return(sigma, mu, target_ret, n, constraints)?;
let ret = vec_dot(&w, mu);
let risk = portfolio_std(&w, sigma);
let sharpe = compute_sharpe(ret, rf, risk);
frontier.push(FrontierPoint {
expected_return: ret,
risk,
sharpe_ratio: sharpe,
weights: w,
});
}
Ok(frontier)
}
fn is_feasible(w: &[Decimal], constraints: &OptimizationConstraints) -> bool {
let n = w.len();
if constraints.long_only {
for wi in w {
if *wi < -dec!(0.0001) {
return false;
}
}
}
if let Some(ref mins) = constraints.min_weights {
for i in 0..n.min(mins.len()) {
if w[i] < mins[i] - dec!(0.0001) {
return false;
}
}
}
if let Some(ref maxs) = constraints.max_weights {
for i in 0..n.min(maxs.len()) {
if w[i] > maxs[i] + dec!(0.0001) {
return false;
}
}
}
if let Some(max_short) = constraints.max_total_short {
let total_short: Decimal = w
.iter()
.filter(|wi| **wi < Decimal::ZERO)
.map(|wi| -wi)
.sum();
if total_short > max_short + dec!(0.0001) {
return false;
}
}
if let Some(ref sectors) = constraints.sector_constraints {
for sc in sectors {
let sector_weight: Decimal = sc.asset_indices.iter().filter_map(|&i| w.get(i)).sum();
if sector_weight < sc.min_weight - dec!(0.0001)
|| sector_weight > sc.max_weight + dec!(0.0001)
{
return false;
}
}
}
true
}
fn project_onto_constraints(w: &mut [Decimal], constraints: &OptimizationConstraints) {
let n = w.len();
if let Some(ref mins) = constraints.min_weights {
for i in 0..n.min(mins.len()) {
if w[i] < mins[i] {
w[i] = mins[i];
}
}
}
if let Some(ref maxs) = constraints.max_weights {
for i in 0..n.min(maxs.len()) {
if w[i] > maxs[i] {
w[i] = maxs[i];
}
}
}
if constraints.long_only {
for wi in w.iter_mut() {
if *wi < Decimal::ZERO {
*wi = Decimal::ZERO;
}
}
}
if let Some(max_short) = constraints.max_total_short {
let total_short: Decimal = w
.iter()
.filter(|wi| **wi < Decimal::ZERO)
.map(|wi| -wi)
.sum();
if total_short > max_short && total_short > Decimal::ZERO {
let scale = max_short / total_short;
for wi in w.iter_mut() {
if *wi < Decimal::ZERO {
*wi *= scale;
}
}
}
}
if let Some(ref sectors) = constraints.sector_constraints {
for sc in sectors {
let sector_weight: Decimal = sc
.asset_indices
.iter()
.filter_map(|&i| w.get(i).copied())
.sum();
if sector_weight > sc.max_weight && sector_weight > Decimal::ZERO {
let scale = sc.max_weight / sector_weight;
for &i in &sc.asset_indices {
if i < n {
w[i] *= scale;
}
}
} else if sector_weight < sc.min_weight {
let deficit = sc.min_weight - sector_weight;
let cnt = sc.asset_indices.len();
if cnt > 0 {
let per_asset = deficit / Decimal::from(cnt as i64);
for &i in &sc.asset_indices {
if i < n {
w[i] += per_asset;
}
}
}
}
}
}
}
fn normalize_weights(w: &mut [Decimal]) {
let total: Decimal = w.iter().sum();
if !total.is_zero() {
for wi in w.iter_mut() {
*wi /= total;
}
}
}
fn equal_weights(n: usize) -> Vec<Decimal> {
let w = Decimal::ONE / Decimal::from(n as i64);
vec![w; n]
}
fn validate_input(input: &MeanVarianceInput, n: usize) -> CorpFinanceResult<()> {
if n == 0 {
return Err(CorpFinanceError::InsufficientData(
"At least one asset required".into(),
));
}
if input.expected_returns.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: "expected_returns".into(),
reason: format!(
"Expected {} returns but got {}",
n,
input.expected_returns.len()
),
});
}
validate_covariance_matrix(&input.covariance_matrix, n)?;
if let Some(ref mins) = input.constraints.min_weights {
if mins.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: "constraints.min_weights".into(),
reason: format!("Expected {} values but got {}", n, mins.len()),
});
}
}
if let Some(ref maxs) = input.constraints.max_weights {
if maxs.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: "constraints.max_weights".into(),
reason: format!("Expected {} values but got {}", n, maxs.len()),
});
}
}
if let Some(ref sectors) = input.constraints.sector_constraints {
for (si, sc) in sectors.iter().enumerate() {
for &idx in &sc.asset_indices {
if idx >= n {
return Err(CorpFinanceError::InvalidInput {
field: format!("constraints.sector_constraints[{}]", si),
reason: format!("Asset index {} out of range (n={})", idx, n),
});
}
}
if sc.min_weight > sc.max_weight {
return Err(CorpFinanceError::InvalidInput {
field: format!("constraints.sector_constraints[{}]", si),
reason: "min_weight > max_weight".into(),
});
}
}
}
Ok(())
}
#[allow(clippy::needless_range_loop)]
fn validate_covariance_matrix(cov: &[Vec<Decimal>], n: usize) -> CorpFinanceResult<()> {
if cov.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: "covariance_matrix".into(),
reason: format!("Expected {}x{} matrix but got {} rows", n, n, cov.len()),
});
}
for (i, row) in cov.iter().enumerate() {
if row.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: "covariance_matrix".into(),
reason: format!("Row {} has {} columns, expected {}", i, row.len(), n),
});
}
}
let tolerance = dec!(0.0000001);
for i in 0..n {
for j in (i + 1)..n {
if (cov[i][j] - cov[j][i]).abs() > tolerance {
return Err(CorpFinanceError::InvalidInput {
field: "covariance_matrix".into(),
reason: format!(
"Not symmetric: [{},{}]={} != [{},{}]={}",
i, j, cov[i][j], j, i, cov[j][i]
),
});
}
}
}
Ok(())
}
fn compute_sharpe(ret: Decimal, rf: Decimal, risk: Decimal) -> Decimal {
if risk.is_zero() {
Decimal::ZERO
} else {
(ret - rf) / risk
}
}
fn portfolio_std(w: &[Decimal], sigma: &[Vec<Decimal>]) -> Decimal {
let sigma_w = mat_vec_multiply(sigma, w);
let var = vec_dot(w, &sigma_w);
sqrt_decimal(var)
}
fn mat_vec_multiply(mat: &[Vec<Decimal>], v: &[Decimal]) -> Vec<Decimal> {
mat.iter().map(|row| vec_dot(row, v)).collect()
}
fn vec_dot(a: &[Decimal], b: &[Decimal]) -> Decimal {
a.iter().zip(b.iter()).map(|(x, y)| *x * *y).sum()
}
#[allow(clippy::needless_range_loop)]
fn mat_inverse(mat: &[Vec<Decimal>]) -> CorpFinanceResult<Vec<Vec<Decimal>>> {
let n = mat.len();
if n == 0 {
return Ok(Vec::new());
}
let mut aug: Vec<Vec<Decimal>> = Vec::with_capacity(n);
for i in 0..n {
let mut row = Vec::with_capacity(2 * n);
row.extend_from_slice(&mat[i]);
for j in 0..n {
row.push(if i == j { Decimal::ONE } else { Decimal::ZERO });
}
aug.push(row);
}
for col in 0..n {
let mut max_row = col;
let mut max_val = aug[col][col].abs();
for row in (col + 1)..n {
let val = aug[row][col].abs();
if val > max_val {
max_val = val;
max_row = row;
}
}
if max_val < dec!(0.0000000001) {
return Err(CorpFinanceError::FinancialImpossibility(
"Singular matrix cannot be inverted".into(),
));
}
if max_row != col {
aug.swap(col, max_row);
}
let pivot = aug[col][col];
for cell in aug[col].iter_mut() {
*cell /= pivot;
}
let pivot_row = aug[col].clone();
for row in 0..n {
if row == col {
continue;
}
let factor = aug[row][col];
for (cell, &pv) in aug[row].iter_mut().zip(pivot_row.iter()) {
*cell -= factor * pv;
}
}
}
let inv: Vec<Vec<Decimal>> = aug.iter().map(|row| row[n..].to_vec()).collect();
Ok(inv)
}
#[allow(dead_code)]
fn mat_multiply(a: &[Vec<Decimal>], b: &[Vec<Decimal>]) -> Vec<Vec<Decimal>> {
let m = a.len();
let p = if m > 0 { a[0].len() } else { 0 };
let n_cols = if !b.is_empty() { b[0].len() } else { 0 };
let mut c = vec![vec![Decimal::ZERO; n_cols]; m];
for i in 0..m {
for j in 0..n_cols {
let mut sum = Decimal::ZERO;
for k in 0..p {
sum += a[i][k] * b[k][j];
}
c[i][j] = sum;
}
}
c
}
fn sqrt_decimal(val: Decimal) -> Decimal {
if val <= Decimal::ZERO {
return Decimal::ZERO;
}
if val == Decimal::ONE {
return Decimal::ONE;
}
let two = dec!(2);
let mut guess = val / two;
if guess.is_zero() {
guess = dec!(0.0000001);
}
for _ in 0..20 {
if guess.is_zero() {
return Decimal::ZERO;
}
guess = (guess + val / guess) / two;
}
guess
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn unconstrained() -> OptimizationConstraints {
OptimizationConstraints {
min_weights: None,
max_weights: None,
long_only: false,
max_total_short: None,
sector_constraints: None,
}
}
fn long_only() -> OptimizationConstraints {
OptimizationConstraints {
min_weights: None,
max_weights: None,
long_only: true,
max_total_short: None,
sector_constraints: None,
}
}
fn two_asset_input(constraints: OptimizationConstraints) -> MeanVarianceInput {
let vol_a = dec!(0.20);
let vol_b = dec!(0.10);
let corr = dec!(0.3);
MeanVarianceInput {
asset_names: vec!["A".into(), "B".into()],
expected_returns: vec![dec!(0.10), dec!(0.06)],
covariance_matrix: vec![
vec![vol_a * vol_a, corr * vol_a * vol_b],
vec![corr * vol_a * vol_b, vol_b * vol_b],
],
risk_free_rate: dec!(0.02),
constraints,
frontier_points: Some(10),
target_return: None,
target_risk: None,
}
}
fn three_asset_input(constraints: OptimizationConstraints) -> MeanVarianceInput {
let v1 = dec!(0.15);
let v2 = dec!(0.20);
let v3 = dec!(0.25);
let c12 = dec!(0.3) * v1 * v2;
let c13 = dec!(0.1) * v1 * v3;
let c23 = dec!(0.5) * v2 * v3;
MeanVarianceInput {
asset_names: vec!["Equity".into(), "Bonds".into(), "Commodities".into()],
expected_returns: vec![dec!(0.10), dec!(0.04), dec!(0.07)],
covariance_matrix: vec![
vec![v1 * v1, c12, c13],
vec![c12, v2 * v2, c23],
vec![c13, c23, v3 * v3],
],
risk_free_rate: dec!(0.02),
constraints,
frontier_points: Some(15),
target_return: None,
target_risk: None,
}
}
#[test]
fn test_two_asset_unconstrained() {
let input = two_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
assert_eq!(out.optimal_weights.len(), 2);
let total: Decimal = out.optimal_weights.iter().map(|w| w.weight).sum();
assert!((total - Decimal::ONE).abs() < dec!(0.01));
}
#[test]
fn test_weights_sum_to_one() {
let input = three_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let total: Decimal = result.result.optimal_weights.iter().map(|w| w.weight).sum();
assert!(
(total - Decimal::ONE).abs() < dec!(0.02),
"Weights should sum to ~1, got {}",
total
);
}
#[test]
fn test_min_variance_lower_risk() {
let input = two_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
assert!(
out.min_variance_portfolio.risk <= out.tangency_portfolio.risk + dec!(0.01),
"Min variance risk {} should be <= tangency risk {}",
out.min_variance_portfolio.risk,
out.tangency_portfolio.risk
);
}
#[test]
fn test_tangency_higher_sharpe() {
let input = two_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
assert!(
out.tangency_portfolio.sharpe_ratio
>= out.min_variance_portfolio.sharpe_ratio - dec!(0.01),
"Tangency Sharpe {} should be >= min variance Sharpe {}",
out.tangency_portfolio.sharpe_ratio,
out.min_variance_portfolio.sharpe_ratio
);
}
#[test]
fn test_long_only_no_negative_weights() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
for w in &result.result.optimal_weights {
assert!(
w.weight >= -dec!(0.001),
"Long-only constraint violated: {} has weight {}",
w.name,
w.weight
);
}
}
#[test]
fn test_long_only_min_variance() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
for w in &result.result.min_variance_portfolio.weights {
assert!(*w >= -dec!(0.001));
}
}
#[test]
fn test_three_asset_unconstrained() {
let input = three_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
assert_eq!(out.optimal_weights.len(), 3);
assert!(out.portfolio_risk > Decimal::ZERO);
assert!(out.portfolio_return != Decimal::ZERO);
}
#[test]
fn test_frontier_monotonic_return() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let frontier = &result.result.efficient_frontier;
assert!(!frontier.is_empty());
for i in 1..frontier.len() {
assert!(
frontier[i].expected_return >= frontier[i - 1].expected_return - dec!(0.005),
"Frontier not monotonic at point {}: {} < {}",
i,
frontier[i].expected_return,
frontier[i - 1].expected_return
);
}
}
#[test]
fn test_frontier_length() {
let mut input = two_asset_input(long_only());
input.frontier_points = Some(15);
let result = optimize_mean_variance(&input).unwrap();
assert_eq!(result.result.efficient_frontier.len(), 15);
}
#[test]
fn test_sharpe_ratio_computation() {
let input = two_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
if out.portfolio_risk > Decimal::ZERO {
let expected = (out.portfolio_return - input.risk_free_rate) / out.portfolio_risk;
assert!(
(out.sharpe_ratio - expected).abs() < dec!(0.001),
"Sharpe mismatch: got {}, expected {}",
out.sharpe_ratio,
expected
);
}
}
#[test]
fn test_hhi_range() {
let input = three_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let hhi = result.result.hhi_concentration;
assert!(hhi >= Decimal::ZERO);
assert!(hhi <= Decimal::ONE + dec!(0.001));
}
#[test]
fn test_diversification_ratio_gte_one() {
let input = three_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
assert!(
result.result.diversification_ratio >= dec!(0.99),
"Diversification ratio should be >= 1, got {}",
result.result.diversification_ratio
);
}
#[test]
fn test_risk_contributions_sum() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
let total_rc: Decimal = out
.optimal_weights
.iter()
.map(|w| w.contribution_to_risk)
.sum();
let diff = (total_rc - out.portfolio_risk).abs();
assert!(
diff < dec!(0.01),
"Risk contributions sum {} != portfolio risk {}",
total_rc,
out.portfolio_risk
);
}
#[test]
fn test_return_contributions_sum() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
let total_ret: Decimal = out
.optimal_weights
.iter()
.map(|w| w.contribution_to_return)
.sum();
let diff = (total_ret - out.portfolio_return).abs();
assert!(
diff < dec!(0.001),
"Return contributions sum {} != portfolio return {}",
total_ret,
out.portfolio_return
);
}
#[test]
fn test_target_return() {
let mut input = two_asset_input(long_only());
input.target_return = Some(dec!(0.08));
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
let diff = (out.portfolio_return - dec!(0.08)).abs();
assert!(
diff < dec!(0.02),
"Portfolio return {} should be near target 0.08",
out.portfolio_return
);
}
#[test]
fn test_target_risk() {
let mut input = two_asset_input(long_only());
input.target_risk = Some(dec!(0.12));
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
let diff = (out.portfolio_risk - dec!(0.12)).abs();
assert!(
diff < dec!(0.03),
"Portfolio risk {} should be near target 0.12",
out.portfolio_risk
);
}
#[test]
fn test_box_constraints() {
let constraints = OptimizationConstraints {
min_weights: Some(vec![dec!(0.1), dec!(0.1)]),
max_weights: Some(vec![dec!(0.6), dec!(0.6)]),
long_only: true,
max_total_short: None,
sector_constraints: None,
};
let input = two_asset_input(constraints);
let result = optimize_mean_variance(&input).unwrap();
for w in &result.result.optimal_weights {
assert!(
w.weight >= dec!(0.1) - dec!(0.02),
"{} weight {} below min 0.1",
w.name,
w.weight
);
assert!(
w.weight <= dec!(0.6) + dec!(0.02),
"{} weight {} above max 0.6",
w.name,
w.weight
);
}
}
#[test]
fn test_validation_empty_assets() {
let input = MeanVarianceInput {
asset_names: vec![],
expected_returns: vec![],
covariance_matrix: vec![],
risk_free_rate: dec!(0.02),
constraints: unconstrained(),
frontier_points: None,
target_return: None,
target_risk: None,
};
assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_validation_mismatched_returns() {
let mut input = two_asset_input(unconstrained());
input.expected_returns = vec![dec!(0.10)]; assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_validation_non_square_cov() {
let mut input = two_asset_input(unconstrained());
input.covariance_matrix = vec![vec![dec!(0.04), dec!(0.006)]]; assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_validation_asymmetric_cov() {
let mut input = two_asset_input(unconstrained());
input.covariance_matrix = vec![vec![dec!(0.04), dec!(0.01)], vec![dec!(0.006), dec!(0.01)]];
assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_validation_mismatched_min_weights() {
let constraints = OptimizationConstraints {
min_weights: Some(vec![dec!(0.1)]), max_weights: None,
long_only: false,
max_total_short: None,
sector_constraints: None,
};
let input = two_asset_input(constraints);
assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_validation_sector_index_out_of_range() {
let constraints = OptimizationConstraints {
min_weights: None,
max_weights: None,
long_only: false,
max_total_short: None,
sector_constraints: Some(vec![SectorConstraint {
name: "Tech".into(),
asset_indices: vec![0, 5], min_weight: dec!(0),
max_weight: dec!(0.5),
}]),
};
let input = two_asset_input(constraints);
assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_validation_sector_min_gt_max() {
let constraints = OptimizationConstraints {
min_weights: None,
max_weights: None,
long_only: false,
max_total_short: None,
sector_constraints: Some(vec![SectorConstraint {
name: "Tech".into(),
asset_indices: vec![0],
min_weight: dec!(0.6),
max_weight: dec!(0.3),
}]),
};
let input = two_asset_input(constraints);
assert!(optimize_mean_variance(&input).is_err());
}
#[test]
fn test_single_asset() {
let input = MeanVarianceInput {
asset_names: vec!["Only".into()],
expected_returns: vec![dec!(0.08)],
covariance_matrix: vec![vec![dec!(0.04)]],
risk_free_rate: dec!(0.02),
constraints: long_only(),
frontier_points: Some(5),
target_return: None,
target_risk: None,
};
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
assert_eq!(out.optimal_weights.len(), 1);
assert!((out.optimal_weights[0].weight - Decimal::ONE).abs() < dec!(0.01));
assert!((out.portfolio_return - dec!(0.08)).abs() < dec!(0.01));
}
#[test]
fn test_equal_returns_equal_vol() {
let v = dec!(0.15);
let input = MeanVarianceInput {
asset_names: vec!["A".into(), "B".into()],
expected_returns: vec![dec!(0.08), dec!(0.08)],
covariance_matrix: vec![
vec![v * v, dec!(0.3) * v * v],
vec![dec!(0.3) * v * v, v * v],
],
risk_free_rate: dec!(0.02),
constraints: long_only(),
frontier_points: Some(5),
target_return: None,
target_risk: None,
};
let result = optimize_mean_variance(&input).unwrap();
let w = &result.result.tangency_portfolio.weights;
let diff = (w[0] - w[1]).abs();
assert!(
diff < dec!(0.05),
"Equal return+vol assets should have ~equal weights: {} vs {}",
w[0],
w[1]
);
}
#[test]
fn test_higher_sharpe_higher_weight() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let tang = &result.result.tangency_portfolio;
assert!(
tang.weights[1] > tang.weights[0],
"Lower-vol asset with equal Sharpe should have higher weight: B={} vs A={}",
tang.weights[1],
tang.weights[0]
);
}
#[test]
fn test_portfolio_risk_non_negative() {
let input = three_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
assert!(result.result.portfolio_risk >= Decimal::ZERO);
assert!(result.result.min_variance_portfolio.risk >= Decimal::ZERO);
assert!(result.result.tangency_portfolio.risk >= Decimal::ZERO);
}
#[test]
fn test_frontier_starts_at_min_var() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
let out = &result.result;
if !out.efficient_frontier.is_empty() {
let diff = (out.efficient_frontier[0].expected_return
- out.min_variance_portfolio.expected_return)
.abs();
assert!(
diff < dec!(0.02),
"Frontier start {} should be near min var return {}",
out.efficient_frontier[0].expected_return,
out.min_variance_portfolio.expected_return
);
}
}
#[test]
fn test_methodology() {
let input = two_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
assert_eq!(result.methodology, "Markowitz Mean-Variance Optimization");
}
#[test]
fn test_metadata_precision() {
let input = two_asset_input(unconstrained());
let result = optimize_mean_variance(&input).unwrap();
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
}
#[test]
fn test_warning_concentrated() {
let input = MeanVarianceInput {
asset_names: vec!["Winner".into(), "Loser".into()],
expected_returns: vec![dec!(0.30), dec!(0.01)],
covariance_matrix: vec![vec![dec!(0.04), dec!(0.005)], vec![dec!(0.005), dec!(0.01)]],
risk_free_rate: dec!(0.02),
constraints: long_only(),
frontier_points: Some(5),
target_return: None,
target_risk: None,
};
let result = optimize_mean_variance(&input).unwrap();
let has_concentrated = result.warnings.iter().any(|w| w.contains("Concentrated"));
assert!(has_concentrated || result.result.optimal_weights[0].weight > dec!(0.4));
}
#[test]
fn test_sector_constraints() {
let constraints = OptimizationConstraints {
min_weights: None,
max_weights: None,
long_only: true,
max_total_short: None,
sector_constraints: Some(vec![SectorConstraint {
name: "Equity".into(),
asset_indices: vec![0],
min_weight: dec!(0.2),
max_weight: dec!(0.4),
}]),
};
let input = three_asset_input(constraints);
let result = optimize_mean_variance(&input).unwrap();
let w0 = result.result.optimal_weights[0].weight;
assert!(
w0 >= dec!(0.15) && w0 <= dec!(0.50),
"Sector constraint: equity weight {} outside approximate bounds",
w0
);
}
#[test]
fn test_is_feasible() {
let c = long_only();
assert!(is_feasible(&[dec!(0.5), dec!(0.5)], &c));
assert!(!is_feasible(&[dec!(-0.1), dec!(1.1)], &c));
}
#[test]
fn test_equal_weights() {
let w = equal_weights(4);
assert_eq!(w.len(), 4);
for wi in &w {
assert!((wi - dec!(0.25)).abs() < dec!(0.0001));
}
}
#[test]
fn test_matrix_inverse() {
let a = vec![vec![dec!(2), dec!(1)], vec![dec!(5), dec!(3)]];
let inv = mat_inverse(&a).unwrap();
let product = mat_multiply(&a, &inv);
for i in 0..2 {
for j in 0..2 {
let expected = if i == j { Decimal::ONE } else { Decimal::ZERO };
assert!(
(product[i][j] - expected).abs() < dec!(0.0000001),
"Product[{}][{}] = {}, expected {}",
i,
j,
product[i][j],
expected
);
}
}
}
#[test]
fn test_sqrt_decimal() {
assert!((sqrt_decimal(dec!(4)) - dec!(2)).abs() < dec!(0.0000001));
assert!((sqrt_decimal(dec!(9)) - dec!(3)).abs() < dec!(0.0000001));
assert_eq!(sqrt_decimal(Decimal::ZERO), Decimal::ZERO);
assert_eq!(sqrt_decimal(dec!(-1)), Decimal::ZERO);
assert_eq!(sqrt_decimal(Decimal::ONE), Decimal::ONE);
}
#[test]
fn test_vec_dot() {
let a = vec![dec!(1), dec!(2), dec!(3)];
let b = vec![dec!(4), dec!(5), dec!(6)];
assert_eq!(vec_dot(&a, &b), dec!(32)); }
#[test]
fn test_four_asset() {
let v = vec![dec!(0.15), dec!(0.20), dec!(0.25), dec!(0.10)];
let mut cov = vec![vec![Decimal::ZERO; 4]; 4];
for i in 0..4 {
for j in 0..4 {
if i == j {
cov[i][j] = v[i] * v[i];
} else {
cov[i][j] = dec!(0.2) * v[i] * v[j];
}
}
}
let input = MeanVarianceInput {
asset_names: vec!["A".into(), "B".into(), "C".into(), "D".into()],
expected_returns: vec![dec!(0.12), dec!(0.08), dec!(0.06), dec!(0.10)],
covariance_matrix: cov,
risk_free_rate: dec!(0.02),
constraints: long_only(),
frontier_points: Some(10),
target_return: None,
target_risk: None,
};
let result = optimize_mean_variance(&input).unwrap();
assert_eq!(result.result.optimal_weights.len(), 4);
assert!(result.result.portfolio_risk > Decimal::ZERO);
}
#[test]
fn test_normalize_weights() {
let mut w = vec![dec!(2), dec!(3), dec!(5)];
normalize_weights(&mut w);
let total: Decimal = w.iter().sum();
assert!((total - Decimal::ONE).abs() < dec!(0.0001));
assert!((w[0] - dec!(0.2)).abs() < dec!(0.0001));
assert!((w[1] - dec!(0.3)).abs() < dec!(0.0001));
assert!((w[2] - dec!(0.5)).abs() < dec!(0.0001));
}
#[test]
fn test_sharpe_zero_risk() {
assert_eq!(
compute_sharpe(dec!(0.08), dec!(0.02), Decimal::ZERO),
Decimal::ZERO
);
}
#[test]
fn test_max_total_short() {
let constraints = OptimizationConstraints {
min_weights: None,
max_weights: None,
long_only: false,
max_total_short: Some(dec!(0.30)),
sector_constraints: None,
};
let mut w = vec![dec!(0.8), dec!(0.5), dec!(-0.3)];
project_onto_constraints(&mut w, &constraints);
let total_short: Decimal = w
.iter()
.filter(|wi| **wi < Decimal::ZERO)
.map(|wi| -wi)
.sum();
assert!(total_short <= dec!(0.31));
}
#[test]
fn test_frontier_sharpe_values() {
let input = two_asset_input(long_only());
let result = optimize_mean_variance(&input).unwrap();
for pt in &result.result.efficient_frontier {
if pt.risk > Decimal::ZERO {
let expected = (pt.expected_return - input.risk_free_rate) / pt.risk;
assert!(
(pt.sharpe_ratio - expected).abs() < dec!(0.01),
"Frontier point sharpe mismatch"
);
}
}
}
#[test]
fn test_hhi_equal_weights() {
let w = vec![dec!(0.25); 4];
let hhi: Decimal = w.iter().map(|wi| *wi * *wi).sum();
assert!((hhi - dec!(0.25)).abs() < dec!(0.001));
}
#[test]
fn test_validation_cov_row_length() {
let mut input = two_asset_input(unconstrained());
input.covariance_matrix = vec![
vec![dec!(0.04), dec!(0.006)],
vec![dec!(0.006)], ];
assert!(optimize_mean_variance(&input).is_err());
}
}