quant_metrics/
analytics.rs1use std::collections::HashMap;
7
8use rust_decimal::Decimal;
9
10use crate::composition::ReturnPoint;
11use crate::risk_metrics::{cvar, var};
12
13pub struct DiversificationMetrics {
15 pub portfolio_max_dd: Decimal,
17 pub avg_leg_max_dd: Decimal,
19 pub max_dd_reduction_pct: Decimal,
22}
23
24pub struct TailRiskMetrics {
26 pub var_95: Decimal,
28 pub var_99: Decimal,
30 pub cvar_95: Decimal,
32 pub cvar_99: Decimal,
34}
35
36pub struct PortfolioAnalytics {
38 pub attribution: HashMap<String, Decimal>,
40 pub correlation_matrix: Vec<Vec<f64>>,
42 pub leg_labels: Vec<String>,
44 pub diversification: DiversificationMetrics,
46 pub drawdown_overlap_periods: usize,
48 pub tail_risk: TailRiskMetrics,
50}
51
52pub fn attribution(legs: &[(&str, Decimal, &[ReturnPoint])]) -> HashMap<String, Decimal> {
57 let mut result = HashMap::new();
58
59 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 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
87pub fn correlation_matrix(series: &[&[ReturnPoint]]) -> Vec<Vec<f64>> {
92 let n = series.len();
93
94 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
119pub fn diversification_metrics(legs: &[(&str, Decimal, &[ReturnPoint])]) -> DiversificationMetrics {
121 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 let portfolio_equity = build_weighted_equity(legs);
131 let portfolio_max_dd = crate::max_drawdown(&portfolio_equity).unwrap_or(Decimal::ZERO);
132
133 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 ((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
155pub 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 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
187pub 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
216fn 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
232fn 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;