1use std::collections::HashMap;
31
32use crate::error::FinError;
33
34#[derive(Debug, Clone)]
43pub struct BhbSegment {
44 pub name: String,
46 pub portfolio_weight: f64,
48 pub benchmark_weight: f64,
50 pub portfolio_return: f64,
52 pub benchmark_return: f64,
54}
55
56#[derive(Debug, Clone)]
58pub struct BhbSegmentResult {
59 pub name: String,
61 pub allocation_effect: f64,
63 pub selection_effect: f64,
65 pub interaction_effect: f64,
67 pub total_active: f64,
69}
70
71#[derive(Debug, Clone)]
106pub struct BhbResult {
107 pub segments: Vec<BhbSegmentResult>,
109 pub total_allocation: f64,
111 pub total_selection: f64,
113 pub total_interaction: f64,
115 pub total_active_return: f64,
117 pub portfolio_return: f64,
119 pub benchmark_return: f64,
121}
122
123pub struct BrinsonHoodBeebower;
125
126impl BrinsonHoodBeebower {
127 pub fn compute(segments: &[BhbSegment]) -> Result<BhbResult, FinError> {
135 if segments.is_empty() {
136 return Err(FinError::InvalidInput(
137 "BHB attribution requires at least one segment".to_owned(),
138 ));
139 }
140
141 for seg in segments {
142 if !seg.portfolio_weight.is_finite() || seg.portfolio_weight < 0.0 {
143 return Err(FinError::InvalidInput(format!(
144 "segment '{}': portfolio_weight must be non-negative finite",
145 seg.name
146 )));
147 }
148 if !seg.benchmark_weight.is_finite() || seg.benchmark_weight < 0.0 {
149 return Err(FinError::InvalidInput(format!(
150 "segment '{}': benchmark_weight must be non-negative finite",
151 seg.name
152 )));
153 }
154 }
155
156 let benchmark_total: f64 = segments
158 .iter()
159 .map(|s| s.benchmark_weight * s.benchmark_return)
160 .sum();
161
162 let portfolio_total: f64 = segments
164 .iter()
165 .map(|s| s.portfolio_weight * s.portfolio_return)
166 .sum();
167
168 let mut seg_results = Vec::with_capacity(segments.len());
169 let mut total_allocation = 0.0f64;
170 let mut total_selection = 0.0f64;
171 let mut total_interaction = 0.0f64;
172
173 for seg in segments {
174 let alloc = (seg.portfolio_weight - seg.benchmark_weight)
175 * (seg.benchmark_return - benchmark_total);
176 let select = seg.benchmark_weight * (seg.portfolio_return - seg.benchmark_return);
177 let interact =
178 (seg.portfolio_weight - seg.benchmark_weight) * (seg.portfolio_return - seg.benchmark_return);
179 let total_active = alloc + select + interact;
180
181 total_allocation += alloc;
182 total_selection += select;
183 total_interaction += interact;
184
185 seg_results.push(BhbSegmentResult {
186 name: seg.name.clone(),
187 allocation_effect: alloc,
188 selection_effect: select,
189 interaction_effect: interact,
190 total_active,
191 });
192 }
193
194 Ok(BhbResult {
195 segments: seg_results,
196 total_allocation,
197 total_selection,
198 total_interaction,
199 total_active_return: total_allocation + total_selection + total_interaction,
200 portfolio_return: portfolio_total,
201 benchmark_return: benchmark_total,
202 })
203 }
204}
205
206#[derive(Debug, Clone)]
215pub struct FactorReturns {
216 pub market: f64,
218 pub size: f64,
220 pub value: f64,
222 pub momentum: f64,
224 pub quality: f64,
226}
227
228#[derive(Debug, Clone)]
230pub struct FactorAttributionResult {
231 pub market_contribution: f64,
233 pub size_contribution: f64,
235 pub value_contribution: f64,
237 pub momentum_contribution: f64,
239 pub quality_contribution: f64,
241 pub total_factor_return: f64,
243 pub alpha: f64,
245 pub portfolio_return: f64,
247}
248
249pub struct FactorAttribution;
266
267impl FactorAttribution {
268 pub fn compute(
276 betas: &FactorReturns,
277 factor_returns: &FactorReturns,
278 portfolio_return: f64,
279 ) -> Result<FactorAttributionResult, FinError> {
280 Self::validate_finite(betas, "betas")?;
281 Self::validate_finite(factor_returns, "factor_returns")?;
282 if !portfolio_return.is_finite() {
283 return Err(FinError::InvalidInput(
284 "portfolio_return must be finite".to_owned(),
285 ));
286 }
287
288 let market_c = betas.market * factor_returns.market;
289 let size_c = betas.size * factor_returns.size;
290 let value_c = betas.value * factor_returns.value;
291 let momentum_c = betas.momentum * factor_returns.momentum;
292 let quality_c = betas.quality * factor_returns.quality;
293 let total = market_c + size_c + value_c + momentum_c + quality_c;
294 let alpha = portfolio_return - total;
295
296 Ok(FactorAttributionResult {
297 market_contribution: market_c,
298 size_contribution: size_c,
299 value_contribution: value_c,
300 momentum_contribution: momentum_c,
301 quality_contribution: quality_c,
302 total_factor_return: total,
303 alpha,
304 portfolio_return,
305 })
306 }
307
308 fn validate_finite(f: &FactorReturns, label: &str) -> Result<(), FinError> {
309 let fields = [
310 ("market", f.market),
311 ("size", f.size),
312 ("value", f.value),
313 ("momentum", f.momentum),
314 ("quality", f.quality),
315 ];
316 for (name, val) in fields {
317 if !val.is_finite() {
318 return Err(FinError::InvalidInput(format!(
319 "{label}.{name} must be finite"
320 )));
321 }
322 }
323 Ok(())
324 }
325}
326
327#[derive(Debug, Clone)]
344pub struct PositionRiskContribution {
345 pub id: String,
347 pub weight: f64,
349 pub marginal_risk_contribution: f64,
351 pub pct_risk_contribution: f64,
353}
354
355pub struct RiskContribution;
374
375impl RiskContribution {
376 pub fn compute(
391 ids: &[String],
392 weights: &[f64],
393 cov: &[Vec<f64>],
394 ) -> Result<Vec<PositionRiskContribution>, FinError> {
395 let n = ids.len();
396 if n == 0 {
397 return Err(FinError::InvalidInput(
398 "RiskContribution requires at least one position".to_owned(),
399 ));
400 }
401 if weights.len() != n {
402 return Err(FinError::InvalidInput(format!(
403 "weights length ({}) must match ids length ({n})",
404 weights.len()
405 )));
406 }
407 if cov.len() != n || cov.iter().any(|row| row.len() != n) {
408 return Err(FinError::InvalidInput(format!(
409 "covariance matrix must be {n}×{n}"
410 )));
411 }
412 for (i, &w) in weights.iter().enumerate() {
413 if !w.is_finite() {
414 return Err(FinError::InvalidInput(format!(
415 "weights[{i}] must be finite"
416 )));
417 }
418 }
419
420 let sigma_w: Vec<f64> = (0..n)
422 .map(|i| {
423 (0..n).map(|j| cov[i][j] * weights[j]).sum::<f64>()
424 })
425 .collect();
426
427 let port_var: f64 = weights.iter().zip(sigma_w.iter()).map(|(w, sw)| w * sw).sum();
429 if port_var <= 0.0 {
430 return Err(FinError::InvalidInput(
431 "portfolio variance is zero or negative; cannot compute risk contributions".to_owned(),
432 ));
433 }
434 let port_vol = port_var.sqrt();
435
436 let results: Vec<PositionRiskContribution> = (0..n)
437 .map(|i| {
438 let mrc = weights[i] * sigma_w[i] / port_vol;
439 let pct = mrc / port_vol * 100.0;
440 PositionRiskContribution {
441 id: ids[i].clone(),
442 weight: weights[i],
443 marginal_risk_contribution: mrc,
444 pct_risk_contribution: pct,
445 }
446 })
447 .collect();
448
449 Ok(results)
450 }
451}
452
453#[derive(Debug, Clone)]
463pub struct AttributionReport {
464 pub period_label: String,
466 pub bhb: Option<BhbResult>,
468 pub factor: Option<FactorAttributionResult>,
470 pub risk_contributions: Option<Vec<PositionRiskContribution>>,
472 pub portfolio_return: f64,
474 pub benchmark_return: f64,
476 pub excess_return: f64,
478}
479
480impl AttributionReport {
481 pub fn new(
488 period_label: impl Into<String>,
489 portfolio_return: f64,
490 benchmark_return: f64,
491 bhb: Option<BhbResult>,
492 factor: Option<FactorAttributionResult>,
493 risk_contributions: Option<Vec<PositionRiskContribution>>,
494 ) -> Result<Self, FinError> {
495 if !portfolio_return.is_finite() {
496 return Err(FinError::InvalidInput(
497 "portfolio_return must be finite".to_owned(),
498 ));
499 }
500 if !benchmark_return.is_finite() {
501 return Err(FinError::InvalidInput(
502 "benchmark_return must be finite".to_owned(),
503 ));
504 }
505 Ok(Self {
506 period_label: period_label.into(),
507 bhb,
508 factor,
509 risk_contributions,
510 portfolio_return,
511 benchmark_return,
512 excess_return: portfolio_return - benchmark_return,
513 })
514 }
515
516 pub fn information_ratio(&self, tracking_error: f64) -> Option<f64> {
520 if tracking_error <= 0.0 || !tracking_error.is_finite() {
521 return None;
522 }
523 Some(self.excess_return / tracking_error)
524 }
525}
526
527#[derive(Debug, Clone)]
533pub struct ReturnObservation {
534 pub label: String,
536 pub portfolio_return: f64,
538 pub benchmark_return: f64,
540}
541
542#[derive(Debug, Clone)]
561pub struct PerformanceTearsheet {
562 pub observation_count: usize,
564 pub total_return: f64,
566 pub annualised_return: f64,
568 pub annualised_volatility: f64,
570 pub sharpe_ratio: Option<f64>,
573 pub max_drawdown: f64,
575 pub calmar_ratio: Option<f64>,
578 pub annualised_excess_return: f64,
580 pub tracking_error: f64,
582 pub information_ratio: Option<f64>,
585 pub sortino_ratio: Option<f64>,
588 pub win_rate: f64,
590 pub return_min: f64,
592 pub return_max: f64,
594 pub return_mean: f64,
596}
597
598impl PerformanceTearsheet {
600 pub fn compute(
614 observations: &[ReturnObservation],
615 periods_per_year: usize,
616 ) -> Result<Self, FinError> {
617 if observations.is_empty() {
618 return Err(FinError::InvalidInput(
619 "PerformanceTearsheet requires at least one observation".to_owned(),
620 ));
621 }
622 if periods_per_year == 0 {
623 return Err(FinError::InvalidInput(
624 "periods_per_year must be at least 1".to_owned(),
625 ));
626 }
627 for (i, obs) in observations.iter().enumerate() {
628 if !obs.portfolio_return.is_finite() || !obs.benchmark_return.is_finite() {
629 return Err(FinError::InvalidInput(format!(
630 "observation[{i}] contains non-finite return"
631 )));
632 }
633 }
634
635 let n = observations.len();
636 let ppy = periods_per_year as f64;
637 let port_returns: Vec<f64> = observations.iter().map(|o| o.portfolio_return).collect();
638 let bench_returns: Vec<f64> = observations.iter().map(|o| o.benchmark_return).collect();
639 let excess_returns: Vec<f64> = port_returns
640 .iter()
641 .zip(bench_returns.iter())
642 .map(|(p, b)| p - b)
643 .collect();
644
645 let total_return = port_returns.iter().fold(1.0f64, |acc, r| acc * (1.0 + r)) - 1.0;
647
648 let years = n as f64 / ppy;
650 let annualised_return = if years > 0.0 {
651 (1.0 + total_return).powf(1.0 / years) - 1.0
652 } else {
653 total_return
654 };
655
656 let mean_r = port_returns.iter().sum::<f64>() / n as f64;
658 let var_r = if n > 1 {
659 port_returns.iter().map(|r| (r - mean_r).powi(2)).sum::<f64>() / (n - 1) as f64
660 } else {
661 0.0
662 };
663 let annualised_volatility = var_r.sqrt() * ppy.sqrt();
664
665 let sharpe_ratio = if annualised_volatility > 0.0 {
667 Some(annualised_return / annualised_volatility)
668 } else {
669 None
670 };
671
672 let max_drawdown = Self::max_drawdown(&port_returns);
674
675 let calmar_ratio = if max_drawdown < 0.0 {
677 Some(annualised_return / max_drawdown.abs())
678 } else {
679 None
680 };
681
682 let excess_mean = excess_returns.iter().sum::<f64>() / n as f64;
684 let total_excess = excess_returns.iter().fold(1.0f64, |acc, r| acc * (1.0 + r)) - 1.0;
685 let annualised_excess_return = if years > 0.0 {
686 (1.0 + total_excess).powf(1.0 / years) - 1.0
687 } else {
688 excess_mean * ppy
689 };
690
691 let te_var = if n > 1 {
693 excess_returns
694 .iter()
695 .map(|r| (r - excess_mean).powi(2))
696 .sum::<f64>()
697 / (n - 1) as f64
698 } else {
699 0.0
700 };
701 let tracking_error = te_var.sqrt() * ppy.sqrt();
702
703 let information_ratio = if tracking_error > 0.0 {
705 Some(annualised_excess_return / tracking_error)
706 } else {
707 None
708 };
709
710 let downside_sq_sum: f64 = port_returns.iter().map(|r| r.min(0.0).powi(2)).sum();
712 let downside_dev = if n > 1 {
713 (downside_sq_sum / (n - 1) as f64).sqrt() * ppy.sqrt()
714 } else {
715 0.0
716 };
717 let sortino_ratio = if downside_dev > 0.0 {
718 Some(annualised_return / downside_dev)
719 } else {
720 None
721 };
722
723 let wins = port_returns.iter().filter(|&&r| r > 0.0).count();
725 let win_rate = wins as f64 / n as f64;
726
727 let return_min = port_returns.iter().cloned().fold(f64::INFINITY, f64::min);
729 let return_max = port_returns.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
730 let return_mean = mean_r;
731
732 Ok(Self {
733 observation_count: n,
734 total_return,
735 annualised_return,
736 annualised_volatility,
737 sharpe_ratio,
738 max_drawdown,
739 calmar_ratio,
740 annualised_excess_return,
741 tracking_error,
742 information_ratio,
743 sortino_ratio,
744 win_rate,
745 return_min,
746 return_max,
747 return_mean,
748 })
749 }
750
751 fn max_drawdown(returns: &[f64]) -> f64 {
755 let mut peak = 1.0f64;
756 let mut equity = 1.0f64;
757 let mut max_dd = 0.0f64;
758
759 for &r in returns {
760 equity *= 1.0 + r;
761 if equity > peak {
762 peak = equity;
763 }
764 let dd = (equity - peak) / peak;
765 if dd < max_dd {
766 max_dd = dd;
767 }
768 }
769
770 max_dd
771 }
772
773 #[must_use]
775 pub fn summary(&self) -> String {
776 let sharpe = self
777 .sharpe_ratio
778 .map(|s| format!("{s:.2}"))
779 .unwrap_or_else(|| "n/a".to_owned());
780 let ir = self
781 .information_ratio
782 .map(|s| format!("{s:.2}"))
783 .unwrap_or_else(|| "n/a".to_owned());
784 format!(
785 "n={} | ann_ret={:.2}% | vol={:.2}% | sharpe={sharpe} | maxDD={:.2}% | \
786 ann_excess={:.2}% | TE={:.2}% | IR={ir} | win_rate={:.1}%",
787 self.observation_count,
788 self.annualised_return * 100.0,
789 self.annualised_volatility * 100.0,
790 self.max_drawdown * 100.0,
791 self.annualised_excess_return * 100.0,
792 self.tracking_error * 100.0,
793 self.win_rate * 100.0,
794 )
795 }
796}
797
798#[derive(Debug, Default)]
806pub struct AttributionSeries {
807 reports: Vec<AttributionReport>,
808 index: HashMap<String, usize>,
809}
810
811impl AttributionSeries {
812 #[must_use]
814 pub fn new() -> Self {
815 Self::default()
816 }
817
818 pub fn push(&mut self, report: AttributionReport) -> Result<(), FinError> {
825 if self.index.contains_key(&report.period_label) {
826 return Err(FinError::InvalidInput(format!(
827 "duplicate period label '{}'",
828 report.period_label
829 )));
830 }
831 self.index
832 .insert(report.period_label.clone(), self.reports.len());
833 self.reports.push(report);
834 Ok(())
835 }
836
837 #[must_use]
839 pub fn get(&self, label: &str) -> Option<&AttributionReport> {
840 self.index.get(label).and_then(|&i| self.reports.get(i))
841 }
842
843 #[must_use]
845 pub fn reports(&self) -> &[AttributionReport] {
846 &self.reports
847 }
848
849 #[must_use]
851 pub fn len(&self) -> usize {
852 self.reports.len()
853 }
854
855 #[must_use]
857 pub fn is_empty(&self) -> bool {
858 self.reports.is_empty()
859 }
860}
861
862#[cfg(test)]
867mod tests {
868 use super::*;
869
870 fn two_segment_bhb() -> Vec<BhbSegment> {
873 vec![
874 BhbSegment {
875 name: "Tech".to_owned(),
876 portfolio_weight: 0.40,
877 benchmark_weight: 0.30,
878 portfolio_return: 0.12,
879 benchmark_return: 0.10,
880 },
881 BhbSegment {
882 name: "Energy".to_owned(),
883 portfolio_weight: 0.60,
884 benchmark_weight: 0.70,
885 portfolio_return: 0.04,
886 benchmark_return: 0.05,
887 },
888 ]
889 }
890
891 #[test]
892 fn test_bhb_sum_equals_active_return() {
893 let segs = two_segment_bhb();
894 let r = BrinsonHoodBeebower::compute(&segs).unwrap();
895 let expected_active = r.portfolio_return - r.benchmark_return;
896 assert!(
897 (r.total_active_return - expected_active).abs() < 1e-10,
898 "total_active={} expected_active={}",
899 r.total_active_return,
900 expected_active
901 );
902 }
903
904 #[test]
905 fn test_bhb_empty_segments_fails() {
906 assert!(BrinsonHoodBeebower::compute(&[]).is_err());
907 }
908
909 #[test]
910 fn test_bhb_negative_weight_fails() {
911 let segs = vec![BhbSegment {
912 name: "X".to_owned(),
913 portfolio_weight: -0.1,
914 benchmark_weight: 0.5,
915 portfolio_return: 0.05,
916 benchmark_return: 0.04,
917 }];
918 assert!(BrinsonHoodBeebower::compute(&segs).is_err());
919 }
920
921 #[test]
922 fn test_bhb_segment_count() {
923 let segs = two_segment_bhb();
924 let r = BrinsonHoodBeebower::compute(&segs).unwrap();
925 assert_eq!(r.segments.len(), 2);
926 }
927
928 fn sample_betas() -> FactorReturns {
931 FactorReturns { market: 1.0, size: 0.2, value: -0.1, momentum: 0.3, quality: 0.05 }
932 }
933
934 fn sample_factor_returns() -> FactorReturns {
935 FactorReturns {
936 market: 0.01,
937 size: 0.002,
938 value: -0.001,
939 momentum: 0.003,
940 quality: 0.001,
941 }
942 }
943
944 #[test]
945 fn test_factor_attribution_alpha_consistency() {
946 let b = sample_betas();
947 let fr = sample_factor_returns();
948 let port_ret = 0.015;
949 let r = FactorAttribution::compute(&b, &fr, port_ret).unwrap();
950 assert!((r.alpha - (port_ret - r.total_factor_return)).abs() < 1e-12);
951 }
952
953 #[test]
954 fn test_factor_attribution_nan_fails() {
955 let b = FactorReturns { market: f64::NAN, size: 0.0, value: 0.0, momentum: 0.0, quality: 0.0 };
956 let fr = sample_factor_returns();
957 assert!(FactorAttribution::compute(&b, &fr, 0.01).is_err());
958 }
959
960 #[test]
961 fn test_factor_attribution_inf_portfolio_return_fails() {
962 let b = sample_betas();
963 let fr = sample_factor_returns();
964 assert!(FactorAttribution::compute(&b, &fr, f64::INFINITY).is_err());
965 }
966
967 #[test]
970 fn test_risk_contribution_two_equal_uncorrelated() {
971 let ids = vec!["A".to_owned(), "B".to_owned()];
972 let weights = vec![0.5, 0.5];
973 let cov = vec![vec![0.04, 0.0], vec![0.0, 0.04]];
974 let r = RiskContribution::compute(&ids, &weights, &cov).unwrap();
975 assert!((r[0].pct_risk_contribution - r[1].pct_risk_contribution).abs() < 0.01);
977 let port_vol = 0.2 * 0.5f64.sqrt(); let mrc_sum: f64 = r.iter().map(|rc| rc.marginal_risk_contribution).sum();
980 assert!((mrc_sum - port_vol).abs() < 1e-10, "mrc_sum={mrc_sum} port_vol={port_vol}");
981 }
982
983 #[test]
984 fn test_risk_contribution_empty_fails() {
985 assert!(RiskContribution::compute(&[], &[], &[]).is_err());
986 }
987
988 #[test]
989 fn test_risk_contribution_mismatched_lengths_fails() {
990 let ids = vec!["A".to_owned()];
991 let weights = vec![0.5, 0.5];
992 let cov = vec![vec![0.04]];
993 assert!(RiskContribution::compute(&ids, &weights, &cov).is_err());
994 }
995
996 #[test]
997 fn test_risk_contribution_zero_variance_fails() {
998 let ids = vec!["A".to_owned()];
999 let weights = vec![0.0];
1000 let cov = vec![vec![0.04]];
1001 assert!(RiskContribution::compute(&ids, &weights, &cov).is_err());
1002 }
1003
1004 #[test]
1007 fn test_attribution_report_excess_return() {
1008 let r = AttributionReport::new("2024-Q1", 0.08, 0.05, None, None, None).unwrap();
1009 assert!((r.excess_return - 0.03).abs() < 1e-10);
1010 }
1011
1012 #[test]
1013 fn test_attribution_report_information_ratio() {
1014 let r = AttributionReport::new("2024-Q1", 0.08, 0.05, None, None, None).unwrap();
1015 let ir = r.information_ratio(0.06);
1016 assert!(ir.is_some());
1017 assert!((ir.unwrap() - 0.5).abs() < 1e-10);
1018 }
1019
1020 #[test]
1021 fn test_attribution_report_zero_te_returns_none() {
1022 let r = AttributionReport::new("X", 0.05, 0.03, None, None, None).unwrap();
1023 assert!(r.information_ratio(0.0).is_none());
1024 }
1025
1026 #[test]
1027 fn test_attribution_report_nan_fails() {
1028 assert!(AttributionReport::new("X", f64::NAN, 0.0, None, None, None).is_err());
1029 }
1030
1031 fn daily_obs() -> Vec<ReturnObservation> {
1034 vec![
1035 ReturnObservation { label: "d1".to_owned(), portfolio_return: 0.01, benchmark_return: 0.008 },
1036 ReturnObservation { label: "d2".to_owned(), portfolio_return: -0.005, benchmark_return: -0.003 },
1037 ReturnObservation { label: "d3".to_owned(), portfolio_return: 0.02, benchmark_return: 0.015 },
1038 ReturnObservation { label: "d4".to_owned(), portfolio_return: -0.01, benchmark_return: -0.005 },
1039 ReturnObservation { label: "d5".to_owned(), portfolio_return: 0.015, benchmark_return: 0.012 },
1040 ]
1041 }
1042
1043 #[test]
1044 fn test_tearsheet_basic_metrics_finite() {
1045 let ts = PerformanceTearsheet::compute(&daily_obs(), 252).unwrap();
1046 assert!(ts.annualised_return.is_finite());
1047 assert!(ts.annualised_volatility.is_finite());
1048 assert!(ts.max_drawdown <= 0.0);
1049 assert!(ts.win_rate >= 0.0 && ts.win_rate <= 1.0);
1050 }
1051
1052 #[test]
1053 fn test_tearsheet_max_drawdown_non_positive() {
1054 let ts = PerformanceTearsheet::compute(&daily_obs(), 252).unwrap();
1055 assert!(ts.max_drawdown <= 0.0);
1056 }
1057
1058 #[test]
1059 fn test_tearsheet_total_return_compounded() {
1060 let obs = vec![
1061 ReturnObservation { label: "a".to_owned(), portfolio_return: 0.10, benchmark_return: 0.08 },
1062 ReturnObservation { label: "b".to_owned(), portfolio_return: 0.10, benchmark_return: 0.08 },
1063 ];
1064 let ts = PerformanceTearsheet::compute(&obs, 252).unwrap();
1065 let expected = 1.1 * 1.1 - 1.0;
1066 assert!((ts.total_return - expected).abs() < 1e-10);
1067 }
1068
1069 #[test]
1070 fn test_tearsheet_empty_fails() {
1071 assert!(PerformanceTearsheet::compute(&[], 252).is_err());
1072 }
1073
1074 #[test]
1075 fn test_tearsheet_zero_periods_per_year_fails() {
1076 assert!(PerformanceTearsheet::compute(&daily_obs(), 0).is_err());
1077 }
1078
1079 #[test]
1080 fn test_tearsheet_summary_non_empty() {
1081 let ts = PerformanceTearsheet::compute(&daily_obs(), 252).unwrap();
1082 assert!(!ts.summary().is_empty());
1083 }
1084
1085 #[test]
1088 fn test_attribution_series_push_and_get() {
1089 let mut series = AttributionSeries::new();
1090 let r = AttributionReport::new("2024-Q1", 0.08, 0.05, None, None, None).unwrap();
1091 series.push(r).unwrap();
1092 assert!(series.get("2024-Q1").is_some());
1093 assert!(series.get("2024-Q2").is_none());
1094 }
1095
1096 #[test]
1097 fn test_attribution_series_duplicate_label_fails() {
1098 let mut series = AttributionSeries::new();
1099 let r1 = AttributionReport::new("2024-Q1", 0.08, 0.05, None, None, None).unwrap();
1100 let r2 = AttributionReport::new("2024-Q1", 0.09, 0.06, None, None, None).unwrap();
1101 series.push(r1).unwrap();
1102 assert!(series.push(r2).is_err());
1103 }
1104
1105 #[test]
1106 fn test_attribution_series_len() {
1107 let mut series = AttributionSeries::new();
1108 assert!(series.is_empty());
1109 for i in 0..3u32 {
1110 let r = AttributionReport::new(format!("period-{i}"), 0.05, 0.04, None, None, None).unwrap();
1111 series.push(r).unwrap();
1112 }
1113 assert_eq!(series.len(), 3);
1114 }
1115}
1116
1117#[derive(Debug, Clone)]
1126pub struct Sector {
1127 pub name: String,
1129 pub portfolio_weight: f64,
1131 pub benchmark_weight: f64,
1133 pub portfolio_return: f64,
1135 pub benchmark_return: f64,
1137}
1138
1139#[derive(Debug, Clone)]
1141pub struct SectorAttribution {
1142 pub sector: Sector,
1144 pub allocation_effect: f64,
1146 pub selection_effect: f64,
1148 pub interaction_effect: f64,
1150 pub total_active_return: f64,
1152}
1153
1154#[derive(Debug, Clone)]
1156pub struct SectorAttributionReport {
1157 pub sectors: Vec<SectorAttribution>,
1159 pub total_allocation: f64,
1161 pub total_selection: f64,
1163 pub total_interaction: f64,
1165 pub total_active_return: f64,
1167 pub benchmark_return: f64,
1169 pub portfolio_return: f64,
1171}
1172
1173pub struct Attribution {
1178 pub benchmark_return: f64,
1180}
1181
1182impl Attribution {
1183 pub fn new(benchmark_return: f64) -> Self {
1185 Self { benchmark_return }
1186 }
1187
1188 pub fn allocation_effect(&self, s: &Sector) -> f64 {
1193 (s.portfolio_weight - s.benchmark_weight) * (s.benchmark_return - self.benchmark_return)
1194 }
1195
1196 pub fn selection_effect(&self, s: &Sector) -> f64 {
1200 s.benchmark_weight * (s.portfolio_return - s.benchmark_return)
1201 }
1202
1203 pub fn interaction_effect(&self, s: &Sector) -> f64 {
1207 (s.portfolio_weight - s.benchmark_weight) * (s.portfolio_return - s.benchmark_return)
1208 }
1209
1210 pub fn total_active_return(&self, s: &Sector) -> f64 {
1212 self.allocation_effect(s) + self.selection_effect(s) + self.interaction_effect(s)
1213 }
1214}
1215
1216pub fn run_attribution(sectors: &[Sector]) -> SectorAttributionReport {
1240 let benchmark_return: f64 = sectors
1242 .iter()
1243 .map(|s| s.benchmark_weight * s.benchmark_return)
1244 .sum();
1245
1246 let portfolio_return: f64 = sectors
1248 .iter()
1249 .map(|s| s.portfolio_weight * s.portfolio_return)
1250 .sum();
1251
1252 let calc = Attribution::new(benchmark_return);
1253
1254 let mut sector_results: Vec<SectorAttribution> = Vec::with_capacity(sectors.len());
1255 let mut total_allocation = 0.0_f64;
1256 let mut total_selection = 0.0_f64;
1257 let mut total_interaction = 0.0_f64;
1258
1259 for s in sectors {
1260 let alloc = calc.allocation_effect(s);
1261 let sel = calc.selection_effect(s);
1262 let inter = calc.interaction_effect(s);
1263 let total = alloc + sel + inter;
1264
1265 total_allocation += alloc;
1266 total_selection += sel;
1267 total_interaction += inter;
1268
1269 sector_results.push(SectorAttribution {
1270 sector: s.clone(),
1271 allocation_effect: alloc,
1272 selection_effect: sel,
1273 interaction_effect: inter,
1274 total_active_return: total,
1275 });
1276 }
1277
1278 let total_active_return = total_allocation + total_selection + total_interaction;
1279
1280 SectorAttributionReport {
1281 sectors: sector_results,
1282 total_allocation,
1283 total_selection,
1284 total_interaction,
1285 total_active_return,
1286 benchmark_return,
1287 portfolio_return,
1288 }
1289}
1290
1291impl SectorAttributionReport {
1292 pub fn to_table(&self) -> String {
1296 let header = format!(
1297 "{:<18} {:>8} {:>8} {:>9} {:>9} {:>8} {:>8} {:>9} {:>8}",
1298 "Sector", "P.Wt%", "B.Wt%", "P.Ret%", "B.Ret%", "Alloc%", "Select%", "Interact%", "Active%"
1299 );
1300 let sep = "-".repeat(header.len());
1301
1302 let mut rows = vec![header.clone(), sep.clone()];
1303
1304 for sa in &self.sectors {
1305 let s = &sa.sector;
1306 rows.push(format!(
1307 "{:<18} {:>8.2} {:>8.2} {:>9.2} {:>9.2} {:>8.4} {:>8.4} {:>9.4} {:>8.4}",
1308 s.name,
1309 s.portfolio_weight * 100.0,
1310 s.benchmark_weight * 100.0,
1311 s.portfolio_return * 100.0,
1312 s.benchmark_return * 100.0,
1313 sa.allocation_effect * 100.0,
1314 sa.selection_effect * 100.0,
1315 sa.interaction_effect * 100.0,
1316 sa.total_active_return * 100.0,
1317 ));
1318 }
1319
1320 rows.push(sep.clone());
1321 rows.push(format!(
1322 "{:<18} {:>8} {:>8} {:>9.2} {:>9.2} {:>8.4} {:>8.4} {:>9.4} {:>8.4}",
1323 "TOTAL",
1324 "",
1325 "",
1326 self.portfolio_return * 100.0,
1327 self.benchmark_return * 100.0,
1328 self.total_allocation * 100.0,
1329 self.total_selection * 100.0,
1330 self.total_interaction * 100.0,
1331 self.total_active_return * 100.0,
1332 ));
1333
1334 rows.join("\n")
1335 }
1336
1337 pub fn top_contributors(&self, n: usize) -> Vec<&SectorAttribution> {
1341 let mut sorted: Vec<&SectorAttribution> = self.sectors.iter().collect();
1342 sorted.sort_by(|a, b| {
1343 b.total_active_return
1344 .abs()
1345 .partial_cmp(&a.total_active_return.abs())
1346 .unwrap_or(std::cmp::Ordering::Equal)
1347 });
1348 sorted.truncate(n);
1349 sorted
1350 }
1351}
1352
1353#[cfg(test)]
1354mod bhb_sector_tests {
1355 use super::*;
1356
1357 fn two_sector_example() -> Vec<Sector> {
1358 vec![
1359 Sector {
1360 name: "Tech".into(),
1361 portfolio_weight: 0.40,
1362 benchmark_weight: 0.30,
1363 portfolio_return: 0.12,
1364 benchmark_return: 0.10,
1365 },
1366 Sector {
1367 name: "Energy".into(),
1368 portfolio_weight: 0.60,
1369 benchmark_weight: 0.70,
1370 portfolio_return: 0.04,
1371 benchmark_return: 0.05,
1372 },
1373 ]
1374 }
1375
1376 #[test]
1377 fn bhb_decomposition_identity() {
1378 let report = run_attribution(&two_sector_example());
1379 let decomposed =
1380 report.total_allocation + report.total_selection + report.total_interaction;
1381 assert!(
1382 (report.total_active_return - decomposed).abs() < 1e-12,
1383 "active return must equal sum of three effects: {} vs {}",
1384 report.total_active_return,
1385 decomposed
1386 );
1387 }
1388
1389 #[test]
1390 fn bhb_active_return_matches_port_minus_bench() {
1391 let report = run_attribution(&two_sector_example());
1392 let expected_active = report.portfolio_return - report.benchmark_return;
1396 assert!(
1397 (report.total_active_return - expected_active).abs() < 1e-10,
1398 "total active return {:.6} != port-bench {:.6}",
1399 report.total_active_return,
1400 expected_active
1401 );
1402 }
1403
1404 #[test]
1405 fn bhb_known_example() {
1406 let sectors = vec![Sector {
1408 name: "EM".into(),
1409 portfolio_weight: 0.50,
1410 benchmark_weight: 0.40,
1411 portfolio_return: 0.08,
1412 benchmark_return: 0.06,
1413 }];
1414 let rb = 0.40 * 0.06; let report = run_attribution(§ors);
1416 let sa = &report.sectors[0];
1417 assert!((sa.allocation_effect - 0.0036).abs() < 1e-10, "alloc={}", sa.allocation_effect);
1419 assert!((sa.selection_effect - 0.008).abs() < 1e-10, "sel={}", sa.selection_effect);
1421 assert!((sa.interaction_effect - 0.002).abs() < 1e-10, "inter={}", sa.interaction_effect);
1423 let _ = rb; }
1425
1426 #[test]
1427 fn bhb_top_contributors_order() {
1428 let report = run_attribution(&two_sector_example());
1429 let top = report.top_contributors(1);
1430 assert_eq!(top.len(), 1);
1431 }
1432
1433 #[test]
1434 fn bhb_empty_sectors() {
1435 let report = run_attribution(&[]);
1436 assert_eq!(report.sectors.len(), 0);
1437 assert!((report.total_active_return).abs() < 1e-15);
1438 }
1439
1440 #[test]
1441 fn bhb_table_contains_total_row() {
1442 let report = run_attribution(&two_sector_example());
1443 let table = report.to_table();
1444 assert!(table.contains("TOTAL"), "table should have a TOTAL row:\n{table}");
1445 assert!(table.contains("Tech"), "table should have Tech sector:\n{table}");
1446 }
1447}