Skip to main content

quant_metrics/
analytics.rs

1//! Portfolio analytics: attribution, correlation, diversification, tail risk.
2//!
3//! Higher-level portfolio analysis functions that operate on return series
4//! and produce attribution, correlation, and risk metrics. Pure math, no I/O.
5
6use std::collections::HashMap;
7
8use rust_decimal::Decimal;
9
10use crate::composition::ReturnPoint;
11use crate::risk_metrics::{cvar, var};
12
13/// Diversification metrics for a portfolio.
14pub struct DiversificationMetrics {
15    /// Portfolio MaxDD (negative percentage).
16    pub portfolio_max_dd: Decimal,
17    /// Average leg MaxDD (negative percentage).
18    pub avg_leg_max_dd: Decimal,
19    /// Percentage reduction in MaxDD vs average leg MaxDD.
20    /// Positive means the portfolio reduced drawdown.
21    pub max_dd_reduction_pct: Decimal,
22}
23
24/// Tail risk metrics for a portfolio.
25pub struct TailRiskMetrics {
26    /// VaR at 95% confidence (historical).
27    pub var_95: Decimal,
28    /// VaR at 99% confidence (historical).
29    pub var_99: Decimal,
30    /// CVaR (expected shortfall) at 95% confidence.
31    pub cvar_95: Decimal,
32    /// CVaR (expected shortfall) at 99% confidence.
33    pub cvar_99: Decimal,
34}
35
36/// Full portfolio analytics result.
37pub struct PortfolioAnalytics {
38    /// Per-leg P&L contribution (name -> percentage).
39    pub attribution: HashMap<String, Decimal>,
40    /// Pairwise correlation matrix (NxN).
41    pub correlation_matrix: Vec<Vec<f64>>,
42    /// Leg labels (in order matching correlation matrix indices).
43    pub leg_labels: Vec<String>,
44    /// Diversification metrics.
45    pub diversification: DiversificationMetrics,
46    /// Number of periods where 2+ legs are simultaneously in drawdown.
47    pub drawdown_overlap_periods: usize,
48    /// Tail risk metrics.
49    pub tail_risk: TailRiskMetrics,
50}
51
52/// Per-leg P&L attribution.
53///
54/// Returns a map of leg name -> contribution percentage.
55/// Contribution = (w_i * cumulative_return_i) / portfolio_cumulative_return * 100.
56pub fn attribution(legs: &[(&str, Decimal, &[ReturnPoint])]) -> HashMap<String, Decimal> {
57    let mut result = HashMap::new();
58
59    // Compute cumulative return for each leg: product of (1 + r_t) - 1
60    let mut weighted_returns: Vec<(String, Decimal)> = Vec::new();
61    for &(name, weight, points) in legs {
62        let cumulative = points
63            .iter()
64            .fold(Decimal::ONE, |acc, rp| acc * (Decimal::ONE + rp.value))
65            - Decimal::ONE;
66        weighted_returns.push((name.to_string(), weight * cumulative));
67    }
68
69    let total_weighted: Decimal = weighted_returns.iter().map(|(_, wr)| wr).sum();
70
71    if total_weighted == Decimal::ZERO {
72        // Equal attribution if total return is zero
73        let equal = Decimal::from(100) / Decimal::from(legs.len() as u32);
74        for &(name, _, _) in legs {
75            result.insert(name.to_string(), equal);
76        }
77    } else {
78        for (name, wr) in weighted_returns {
79            let contribution = (wr / total_weighted) * Decimal::from(100);
80            result.insert(name, contribution);
81        }
82    }
83
84    result
85}
86
87/// Pairwise Pearson correlation matrix on return series.
88///
89/// Returns an NxN matrix where entry [i][j] is the correlation between
90/// series i and series j.
91pub fn correlation_matrix(series: &[&[ReturnPoint]]) -> Vec<Vec<f64>> {
92    let n = series.len();
93
94    // Convert Decimal returns to f64 for correlation computation
95    let f64_series: Vec<Vec<f64>> = series
96        .iter()
97        .map(|pts| {
98            pts.iter()
99                .map(|rp| rp.value.try_into().unwrap_or(0.0))
100                .collect()
101        })
102        .collect();
103
104    let mut matrix = vec![vec![0.0; n]; n];
105
106    for i in 0..n {
107        matrix[i][i] = 1.0;
108        for j in (i + 1)..n {
109            let corr = crate::cointegration::pearson_correlation(&f64_series[i], &f64_series[j])
110                .unwrap_or(0.0);
111            matrix[i][j] = corr;
112            matrix[j][i] = corr;
113        }
114    }
115
116    matrix
117}
118
119/// Compute diversification metrics: MaxDD reduction compared to average leg MaxDD.
120pub fn diversification_metrics(legs: &[(&str, Decimal, &[ReturnPoint])]) -> DiversificationMetrics {
121    // Build per-leg equity curves and compute their MaxDD
122    let mut leg_max_dds = Vec::new();
123    for &(_, _, points) in legs {
124        let equity = returns_to_equity(points);
125        let max_dd = crate::max_drawdown(&equity).unwrap_or(Decimal::ZERO);
126        leg_max_dds.push(max_dd);
127    }
128
129    // Build portfolio equity curve (equal or given weights)
130    let portfolio_equity = build_weighted_equity(legs);
131    let portfolio_max_dd = crate::max_drawdown(&portfolio_equity).unwrap_or(Decimal::ZERO);
132
133    // Average leg MaxDD (these are negative, so avg is also negative)
134    let avg_leg_max_dd: Decimal = if leg_max_dds.is_empty() {
135        Decimal::ZERO
136    } else {
137        leg_max_dds.iter().sum::<Decimal>() / Decimal::from(leg_max_dds.len() as u32)
138    };
139
140    let reduction_pct = if avg_leg_max_dd == Decimal::ZERO {
141        Decimal::ZERO
142    } else {
143        // Both values are negative. Reduction = (avg - portfolio) / avg * 100
144        // If portfolio_max_dd is less extreme (closer to 0), reduction is positive.
145        ((avg_leg_max_dd - portfolio_max_dd) / avg_leg_max_dd) * Decimal::from(100)
146    };
147
148    DiversificationMetrics {
149        portfolio_max_dd,
150        avg_leg_max_dd,
151        max_dd_reduction_pct: reduction_pct,
152    }
153}
154
155/// Count periods where 2+ legs are simultaneously in drawdown.
156pub fn drawdown_overlap_count(series: &[&[ReturnPoint]]) -> usize {
157    if series.is_empty() {
158        return 0;
159    }
160
161    let len = series.iter().map(|s| s.len()).min().unwrap_or(0);
162
163    // For each leg, compute whether it's in drawdown at each period
164    let drawdown_flags: Vec<Vec<bool>> = series
165        .iter()
166        .map(|pts| {
167            let equity = returns_to_equity(pts);
168            let dd = crate::drawdown_series(&equity).unwrap_or_default();
169            dd.iter().skip(1).map(|&d| d < Decimal::ZERO).collect()
170        })
171        .collect();
172
173    let mut count = 0;
174    for t in 0..len {
175        let legs_in_dd = drawdown_flags
176            .iter()
177            .filter(|flags| flags.get(t).copied().unwrap_or(false))
178            .count();
179        if legs_in_dd >= 2 {
180            count += 1;
181        }
182    }
183
184    count
185}
186
187/// Compute all portfolio analytics in one call.
188pub fn portfolio_analytics(
189    legs: &[(&str, Decimal, &[ReturnPoint])],
190    portfolio_returns: &[Decimal],
191) -> PortfolioAnalytics {
192    let attr = attribution(legs);
193    let series: Vec<&[ReturnPoint]> = legs.iter().map(|(_, _, pts)| *pts).collect();
194    let corr = correlation_matrix(&series);
195    let labels: Vec<String> = legs.iter().map(|(n, _, _)| n.to_string()).collect();
196    let div = diversification_metrics(legs);
197    let overlap = drawdown_overlap_count(&series);
198
199    let tail = TailRiskMetrics {
200        var_95: var(portfolio_returns, 95),
201        var_99: var(portfolio_returns, 99),
202        cvar_95: cvar(portfolio_returns, 95),
203        cvar_99: cvar(portfolio_returns, 99),
204    };
205
206    PortfolioAnalytics {
207        attribution: attr,
208        correlation_matrix: corr,
209        leg_labels: labels,
210        diversification: div,
211        drawdown_overlap_periods: overlap,
212        tail_risk: tail,
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Internal helpers
218// ---------------------------------------------------------------------------
219
220/// Convert a return series to an equity curve starting at 100.
221fn returns_to_equity(points: &[ReturnPoint]) -> Vec<Decimal> {
222    let mut equity = Vec::with_capacity(points.len() + 1);
223    equity.push(Decimal::from(100));
224    let mut current = Decimal::from(100);
225    for rp in points {
226        current *= Decimal::ONE + rp.value;
227        equity.push(current);
228    }
229    equity
230}
231
232/// Build a weighted portfolio equity curve from legs.
233fn build_weighted_equity(legs: &[(&str, Decimal, &[ReturnPoint])]) -> Vec<Decimal> {
234    if legs.is_empty() {
235        return vec![];
236    }
237
238    let len = legs.iter().map(|(_, _, pts)| pts.len()).min().unwrap_or(0);
239    let capital = Decimal::from(100);
240
241    let mut equity = Vec::with_capacity(len + 1);
242    equity.push(capital);
243
244    let mut current = capital;
245    for t in 0..len {
246        let mut portfolio_return = Decimal::ZERO;
247        for &(_, weight, points) in legs {
248            if t < points.len() {
249                portfolio_return += weight * points[t].value;
250            }
251        }
252        current *= Decimal::ONE + portfolio_return;
253        equity.push(current);
254    }
255
256    equity
257}
258
259#[cfg(test)]
260#[path = "analytics_tests.rs"]
261mod tests;