use std::collections::HashMap;
use rust_decimal::Decimal;
use crate::composition::ReturnPoint;
use crate::risk_metrics::{cvar, var};
pub struct DiversificationMetrics {
pub portfolio_max_dd: Decimal,
pub avg_leg_max_dd: Decimal,
pub max_dd_reduction_pct: Decimal,
}
pub struct TailRiskMetrics {
pub var_95: Decimal,
pub var_99: Decimal,
pub cvar_95: Decimal,
pub cvar_99: Decimal,
}
pub struct PortfolioAnalytics {
pub attribution: HashMap<String, Decimal>,
pub correlation_matrix: Vec<Vec<f64>>,
pub leg_labels: Vec<String>,
pub diversification: DiversificationMetrics,
pub drawdown_overlap_periods: usize,
pub tail_risk: TailRiskMetrics,
}
pub fn attribution(legs: &[(&str, Decimal, &[ReturnPoint])]) -> HashMap<String, Decimal> {
let mut result = HashMap::new();
let mut weighted_returns: Vec<(String, Decimal)> = Vec::new();
for &(name, weight, points) in legs {
let cumulative = points
.iter()
.fold(Decimal::ONE, |acc, rp| acc * (Decimal::ONE + rp.value))
- Decimal::ONE;
weighted_returns.push((name.to_string(), weight * cumulative));
}
let total_weighted: Decimal = weighted_returns.iter().map(|(_, wr)| wr).sum();
if total_weighted == Decimal::ZERO {
let equal = Decimal::from(100) / Decimal::from(legs.len() as u32);
for &(name, _, _) in legs {
result.insert(name.to_string(), equal);
}
} else {
for (name, wr) in weighted_returns {
let contribution = (wr / total_weighted) * Decimal::from(100);
result.insert(name, contribution);
}
}
result
}
pub fn correlation_matrix(series: &[&[ReturnPoint]]) -> Vec<Vec<f64>> {
let n = series.len();
let f64_series: Vec<Vec<f64>> = series
.iter()
.map(|pts| {
pts.iter()
.map(|rp| rp.value.try_into().unwrap_or(0.0))
.collect()
})
.collect();
let mut matrix = vec![vec![0.0; n]; n];
for i in 0..n {
matrix[i][i] = 1.0;
for j in (i + 1)..n {
let corr = crate::cointegration::pearson_correlation(&f64_series[i], &f64_series[j])
.unwrap_or(0.0);
matrix[i][j] = corr;
matrix[j][i] = corr;
}
}
matrix
}
pub fn diversification_metrics(legs: &[(&str, Decimal, &[ReturnPoint])]) -> DiversificationMetrics {
let mut leg_max_dds = Vec::new();
for &(_, _, points) in legs {
let equity = returns_to_equity(points);
let max_dd = crate::max_drawdown(&equity).unwrap_or(Decimal::ZERO);
leg_max_dds.push(max_dd);
}
let portfolio_equity = build_weighted_equity(legs);
let portfolio_max_dd = crate::max_drawdown(&portfolio_equity).unwrap_or(Decimal::ZERO);
let avg_leg_max_dd: Decimal = if leg_max_dds.is_empty() {
Decimal::ZERO
} else {
leg_max_dds.iter().sum::<Decimal>() / Decimal::from(leg_max_dds.len() as u32)
};
let reduction_pct = if avg_leg_max_dd == Decimal::ZERO {
Decimal::ZERO
} else {
((avg_leg_max_dd - portfolio_max_dd) / avg_leg_max_dd) * Decimal::from(100)
};
DiversificationMetrics {
portfolio_max_dd,
avg_leg_max_dd,
max_dd_reduction_pct: reduction_pct,
}
}
pub fn drawdown_overlap_count(series: &[&[ReturnPoint]]) -> usize {
if series.is_empty() {
return 0;
}
let len = series.iter().map(|s| s.len()).min().unwrap_or(0);
let drawdown_flags: Vec<Vec<bool>> = series
.iter()
.map(|pts| {
let equity = returns_to_equity(pts);
let dd = crate::drawdown_series(&equity).unwrap_or_default();
dd.iter().skip(1).map(|&d| d < Decimal::ZERO).collect()
})
.collect();
let mut count = 0;
for t in 0..len {
let legs_in_dd = drawdown_flags
.iter()
.filter(|flags| flags.get(t).copied().unwrap_or(false))
.count();
if legs_in_dd >= 2 {
count += 1;
}
}
count
}
pub fn portfolio_analytics(
legs: &[(&str, Decimal, &[ReturnPoint])],
portfolio_returns: &[Decimal],
) -> PortfolioAnalytics {
let attr = attribution(legs);
let series: Vec<&[ReturnPoint]> = legs.iter().map(|(_, _, pts)| *pts).collect();
let corr = correlation_matrix(&series);
let labels: Vec<String> = legs.iter().map(|(n, _, _)| n.to_string()).collect();
let div = diversification_metrics(legs);
let overlap = drawdown_overlap_count(&series);
let tail = TailRiskMetrics {
var_95: var(portfolio_returns, 95),
var_99: var(portfolio_returns, 99),
cvar_95: cvar(portfolio_returns, 95),
cvar_99: cvar(portfolio_returns, 99),
};
PortfolioAnalytics {
attribution: attr,
correlation_matrix: corr,
leg_labels: labels,
diversification: div,
drawdown_overlap_periods: overlap,
tail_risk: tail,
}
}
fn returns_to_equity(points: &[ReturnPoint]) -> Vec<Decimal> {
let mut equity = Vec::with_capacity(points.len() + 1);
equity.push(Decimal::from(100));
let mut current = Decimal::from(100);
for rp in points {
current *= Decimal::ONE + rp.value;
equity.push(current);
}
equity
}
fn build_weighted_equity(legs: &[(&str, Decimal, &[ReturnPoint])]) -> Vec<Decimal> {
if legs.is_empty() {
return vec![];
}
let len = legs.iter().map(|(_, _, pts)| pts.len()).min().unwrap_or(0);
let capital = Decimal::from(100);
let mut equity = Vec::with_capacity(len + 1);
equity.push(capital);
let mut current = capital;
for t in 0..len {
let mut portfolio_return = Decimal::ZERO;
for &(_, weight, points) in legs {
if t < points.len() {
portfolio_return += weight * points[t].value;
}
}
current *= Decimal::ONE + portfolio_return;
equity.push(current);
}
equity
}
#[cfg(test)]
#[path = "analytics_tests.rs"]
mod tests;