pub fn herfindahl_hirschman(weights: &[f64]) -> f64 {
weights.iter().map(|w| w * w).sum()
}
pub fn effective_n(weights: &[f64]) -> f64 {
let hhi = herfindahl_hirschman(weights);
if hhi.abs() < f64::EPSILON {
return 0.0;
}
1.0 / hhi
}
pub fn concentration_ratio(weights: &[f64], top_n: usize) -> f64 {
if weights.is_empty() || top_n == 0 {
return 0.0;
}
let mut sorted = weights.to_vec();
sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
sorted.iter().take(top_n).sum()
}
pub fn gini_coefficient(weights: &[f64]) -> f64 {
let n = weights.len();
if n == 0 {
return 0.0;
}
let mut sorted = weights.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let sum: f64 = sorted.iter().sum();
if sum.abs() < f64::EPSILON {
return 0.0;
}
let weighted_sum: f64 = sorted
.iter()
.enumerate()
.map(|(i, w)| (i as f64 + 1.0) * w)
.sum();
(2.0 * weighted_sum) / (n as f64 * sum) - (n as f64 + 1.0) / n as f64
}
pub fn diversification_ratio(
weights: &[f64],
individual_vols: &[f64],
portfolio_vol: f64,
) -> Option<f64> {
if portfolio_vol.abs() < f64::EPSILON {
return None;
}
let weighted_avg_vol: f64 = weights
.iter()
.zip(individual_vols.iter())
.map(|(w, v)| w * v)
.sum();
Some(weighted_avg_vol / portfolio_vol)
}
pub fn max_drawdown_portfolio(returns: &[f64]) -> f64 {
if returns.is_empty() {
return 0.0;
}
let mut peak = 1.0_f64;
let mut cumulative = 1.0_f64;
let mut max_dd = 0.0_f64;
for &r in returns {
cumulative *= 1.0 + r;
if cumulative > peak {
peak = cumulative;
}
let dd = (peak - cumulative) / peak;
if dd > max_dd {
max_dd = dd;
}
}
max_dd
}
pub fn calmar_ratio(returns: &[f64], periods_per_year: f64) -> Option<f64> {
if returns.is_empty() {
return None;
}
let mean_period = returns.iter().sum::<f64>() / returns.len() as f64;
let annualised_return = mean_period * periods_per_year;
let mdd = max_drawdown_portfolio(returns);
if mdd.abs() < f64::EPSILON {
return None;
}
Some(annualised_return / mdd)
}
pub fn sortino_ratio(returns: &[f64], target_return: f64, periods_per_year: f64) -> f64 {
if returns.is_empty() {
return 0.0;
}
let mean = returns.iter().sum::<f64>() / returns.len() as f64;
let downside_variance: f64 = returns
.iter()
.map(|&r| {
let diff = r - target_return;
if diff < 0.0 { diff * diff } else { 0.0 }
})
.sum::<f64>()
/ returns.len() as f64;
let downside_std = downside_variance.sqrt();
if downside_std.abs() < f64::EPSILON {
return 0.0;
}
(mean - target_return) / downside_std * periods_per_year.sqrt()
}
pub fn treynor_ratio(portfolio_return: f64, risk_free: f64, beta: f64) -> Option<f64> {
if beta.abs() < f64::EPSILON {
return None;
}
Some((portfolio_return - risk_free) / beta)
}
pub fn information_ratio(
portfolio_returns: &[f64],
benchmark_returns: &[f64],
) -> Option<f64> {
let n = portfolio_returns.len().min(benchmark_returns.len());
if n == 0 {
return None;
}
let active: Vec<f64> = portfolio_returns[..n]
.iter()
.zip(benchmark_returns[..n].iter())
.map(|(p, b)| p - b)
.collect();
let mean_active = active.iter().sum::<f64>() / n as f64;
let variance = active.iter().map(|a| (a - mean_active).powi(2)).sum::<f64>() / n as f64;
let tracking_error = variance.sqrt();
if tracking_error.abs() < f64::EPSILON {
return None;
}
Some(mean_active / tracking_error)
}
#[derive(Debug, Clone)]
pub struct DiversificationReport {
pub hhi: f64,
pub effective_n: f64,
pub concentration_ratio_5: f64,
pub gini: f64,
pub diversification_ratio: Option<f64>,
pub sortino_ratio: f64,
pub calmar_ratio: Option<f64>,
}
impl DiversificationReport {
pub fn compute(
weights: &[f64],
returns: &[f64],
individual_vols: Option<&[f64]>,
portfolio_vol: Option<f64>,
target_return: f64,
) -> Self {
let hhi = herfindahl_hirschman(weights);
let eff_n = effective_n(weights);
let cr5 = concentration_ratio(weights, 5);
let gini = gini_coefficient(weights);
let div_ratio = match (individual_vols, portfolio_vol) {
(Some(vols), Some(pvol)) => diversification_ratio(weights, vols, pvol),
_ => None,
};
let sortino = sortino_ratio(returns, target_return, 252.0);
let calmar = calmar_ratio(returns, 252.0);
DiversificationReport {
hhi,
effective_n: eff_n,
concentration_ratio_5: cr5,
gini,
diversification_ratio: div_ratio,
sortino_ratio: sortino,
calmar_ratio: calmar,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_weights_max_effective_n() {
let n = 10_usize;
let weights: Vec<f64> = vec![1.0 / n as f64; n];
let eff = effective_n(&weights);
assert!((eff - n as f64).abs() < 1e-9, "effective_n = {eff}");
}
#[test]
fn concentrated_portfolio_high_hhi() {
let weights = vec![0.90, 0.05, 0.03, 0.02];
let hhi = herfindahl_hirschman(&weights);
assert!(hhi > 0.80, "HHI should be high for concentrated portfolio, got {hhi}");
}
#[test]
fn equal_weights_gini_zero() {
let weights = vec![0.25, 0.25, 0.25, 0.25];
let g = gini_coefficient(&weights);
assert!(g.abs() < 1e-12, "Gini for equal weights should be 0, got {g}");
}
#[test]
fn diversification_ratio_above_one() {
let weights = vec![0.5, 0.5];
let vols = vec![0.20, 0.20];
let portfolio_vol = (0.5_f64.powi(2) * 0.04 + 0.5_f64.powi(2) * 0.04).sqrt(); let dr = diversification_ratio(&weights, &vols, portfolio_vol);
assert!(dr.is_some());
let dr = dr.unwrap();
assert!(dr > 1.0, "diversification_ratio should be > 1, got {dr}");
}
#[test]
fn concentration_ratio_top1() {
let weights = vec![0.40, 0.30, 0.20, 0.10];
let cr = concentration_ratio(&weights, 1);
assert!((cr - 0.40).abs() < 1e-12);
}
#[test]
fn concentration_ratio_full() {
let weights = vec![0.40, 0.30, 0.20, 0.10];
let cr = concentration_ratio(&weights, 10); assert!((cr - 1.00).abs() < 1e-12);
}
#[test]
fn max_drawdown_flat_returns() {
let returns = vec![0.01, 0.01, 0.01];
let mdd = max_drawdown_portfolio(&returns);
assert!(mdd.abs() < 1e-12, "no drawdown for monotone rising returns");
}
#[test]
fn max_drawdown_known_value() {
let returns = vec![0.10, -0.10];
let mdd = max_drawdown_portfolio(&returns);
let expected = (1.1 - 1.1 * 0.9) / 1.1; assert!((mdd - expected).abs() < 1e-10, "mdd = {mdd}");
}
#[test]
fn sortino_ratio_positive_for_above_target() {
let returns: Vec<f64> = vec![0.01; 252]; let s = sortino_ratio(&returns, 0.0, 252.0);
assert_eq!(s, 0.0);
}
#[test]
fn sortino_ratio_with_mixed_returns() {
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
let s = sortino_ratio(&returns, 0.0, 252.0);
assert!(s.is_finite(), "sortino ratio should be finite");
}
#[test]
fn information_ratio_none_for_identical_returns() {
let returns = vec![0.01, 0.02, 0.03];
let ir = information_ratio(&returns, &returns);
assert!(ir.is_none(), "IR should be None when tracking error is zero");
}
#[test]
fn treynor_ratio_none_for_zero_beta() {
let tr = treynor_ratio(0.10, 0.02, 0.0);
assert!(tr.is_none());
}
#[test]
fn treynor_ratio_correct() {
let tr = treynor_ratio(0.10, 0.02, 1.0).unwrap();
assert!((tr - 0.08).abs() < 1e-12);
}
#[test]
fn diversification_report_computes() {
let weights = vec![0.2, 0.2, 0.2, 0.2, 0.2];
let returns = vec![0.01, -0.005, 0.008, 0.003, -0.002];
let report = DiversificationReport::compute(&weights, &returns, None, None, 0.0);
assert!((report.hhi - 0.2).abs() < 1e-9);
assert!((report.effective_n - 5.0).abs() < 1e-9);
}
}