Skip to main content

fin_primitives/
attribution.rs

1//! Portfolio performance attribution: Brinson-Hood-Beebower decomposition, multi-factor
2//! attribution, marginal risk contribution, and comprehensive performance tearsheet.
3//!
4//! ## Responsibility
5//! Portfolio performance attribution: decomposes returns into allocation,
6//! selection, interaction, and factor-level contributions so the source of
7//! excess returns is transparent and auditable.
8//!
9//! ## Models Implemented
10//!
11//! | Type | Description |
12//! |------|-------------|
13//! | [`BrinsonHoodBeebower`] | Classic allocation/selection/interaction decomposition |
14//! | [`FactorAttribution`] | Multi-factor return decomposition (market, size, value, momentum, quality) |
15//! | [`RiskContribution`] | Marginal risk contribution per position for risk budgeting |
16//! | [`AttributionReport`] | Structured report aggregating all attribution results |
17//! | [`PerformanceTearsheet`] | Comprehensive performance summary with annualised statistics |
18//!
19//! ## Guarantees
20//! - Zero panics; all fallible operations return `Result<_, FinError>`.
21//! - All arithmetic uses `f64` (attribution math is intentionally float-based —
22//!   the precision requirements here are statistical, not monetary).
23//! - Rolling windows use `VecDeque`; no unbounded allocation.
24//!
25//! ## NOT Responsible For
26//! - Position management (see: position module)
27//! - Risk rule enforcement (see: risk module)
28//! - Order execution
29
30use std::collections::HashMap;
31
32use crate::error::FinError;
33
34// ---------------------------------------------------------------------------
35// BrinsonHoodBeebower
36// ---------------------------------------------------------------------------
37
38/// A single-period segment used in Brinson-Hood-Beebower attribution.
39///
40/// A "segment" is a market sector, asset class, or any other grouping used
41/// for attribution decomposition.
42#[derive(Debug, Clone)]
43pub struct BhbSegment {
44    /// Name of the segment (e.g., "Technology", "Energy").
45    pub name: String,
46    /// Portfolio weight in this segment; range `[0.0, 1.0]`.
47    pub portfolio_weight: f64,
48    /// Benchmark weight in this segment; range `[0.0, 1.0]`.
49    pub benchmark_weight: f64,
50    /// Portfolio return within this segment (not annualised).
51    pub portfolio_return: f64,
52    /// Benchmark return within this segment (not annualised).
53    pub benchmark_return: f64,
54}
55
56/// Allocation effect, selection effect, and interaction effect for one segment.
57#[derive(Debug, Clone)]
58pub struct BhbSegmentResult {
59    /// Segment name.
60    pub name: String,
61    /// Allocation effect: `(w_p - w_b) * (r_b_seg - r_b_total)`.
62    pub allocation_effect: f64,
63    /// Selection effect: `w_b * (r_p_seg - r_b_seg)`.
64    pub selection_effect: f64,
65    /// Interaction effect: `(w_p - w_b) * (r_p_seg - r_b_seg)`.
66    pub interaction_effect: f64,
67    /// Total active contribution: `allocation + selection + interaction`.
68    pub total_active: f64,
69}
70
71/// Brinson-Hood-Beebower performance attribution (1986).
72///
73/// Decomposes portfolio excess return into three effects at the segment level:
74/// - **Allocation**: did the manager over/under-weight profitable sectors?
75/// - **Selection**: did the manager pick better stocks within each sector?
76/// - **Interaction**: joint effect of weight and selection decisions.
77///
78/// Total active return = sum of `total_active` across all segments
79/// ≈ portfolio return − benchmark return.
80///
81/// # Example
82///
83/// ```rust
84/// use fin_primitives::attribution::{BrinsonHoodBeebower, BhbSegment};
85///
86/// let segments = vec![
87///     BhbSegment {
88///         name: "Tech".to_owned(),
89///         portfolio_weight: 0.40,
90///         benchmark_weight: 0.30,
91///         portfolio_return: 0.12,
92///         benchmark_return: 0.10,
93///     },
94///     BhbSegment {
95///         name: "Energy".to_owned(),
96///         portfolio_weight: 0.60,
97///         benchmark_weight: 0.70,
98///         portfolio_return: 0.04,
99///         benchmark_return: 0.05,
100///     },
101/// ];
102/// let result = BrinsonHoodBeebower::compute(&segments).unwrap();
103/// assert!((result.total_active_return - (result.total_allocation + result.total_selection + result.total_interaction)).abs() < 1e-10);
104/// ```
105#[derive(Debug, Clone)]
106pub struct BhbResult {
107    /// Per-segment decomposition.
108    pub segments: Vec<BhbSegmentResult>,
109    /// Total allocation effect summed across segments.
110    pub total_allocation: f64,
111    /// Total selection effect summed across segments.
112    pub total_selection: f64,
113    /// Total interaction effect summed across segments.
114    pub total_interaction: f64,
115    /// Total active return (allocation + selection + interaction).
116    pub total_active_return: f64,
117    /// Weighted portfolio return.
118    pub portfolio_return: f64,
119    /// Weighted benchmark return.
120    pub benchmark_return: f64,
121}
122
123/// Brinson-Hood-Beebower attribution calculator.
124pub struct BrinsonHoodBeebower;
125
126impl BrinsonHoodBeebower {
127    /// Compute attribution for a slice of [`BhbSegment`]s.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`FinError::InvalidInput`] if:
132    /// - `segments` is empty.
133    /// - Any `portfolio_weight` or `benchmark_weight` is negative or non-finite.
134    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        // Benchmark total return: sum(w_b * r_b_seg)
157        let benchmark_total: f64 = segments
158            .iter()
159            .map(|s| s.benchmark_weight * s.benchmark_return)
160            .sum();
161
162        // Portfolio total return: sum(w_p * r_p_seg)
163        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// ---------------------------------------------------------------------------
207// FactorAttribution
208// ---------------------------------------------------------------------------
209
210/// Factor exposures for a single return observation.
211///
212/// Each field represents the factor return (not the portfolio's factor loading;
213/// multiply by position weight externally before passing here).
214#[derive(Debug, Clone)]
215pub struct FactorReturns {
216    /// Market (beta) factor return.
217    pub market: f64,
218    /// Size (SMB) factor return.
219    pub size: f64,
220    /// Value (HML) factor return.
221    pub value: f64,
222    /// Momentum (WML) factor return.
223    pub momentum: f64,
224    /// Quality (profitability) factor return.
225    pub quality: f64,
226}
227
228/// Per-factor attribution decomposition for one period.
229#[derive(Debug, Clone)]
230pub struct FactorAttributionResult {
231    /// Return explained by the market factor.
232    pub market_contribution: f64,
233    /// Return explained by the size factor.
234    pub size_contribution: f64,
235    /// Return explained by the value factor.
236    pub value_contribution: f64,
237    /// Return explained by the momentum factor.
238    pub momentum_contribution: f64,
239    /// Return explained by the quality factor.
240    pub quality_contribution: f64,
241    /// Total factor-explained return.
242    pub total_factor_return: f64,
243    /// Idiosyncratic (alpha) residual: `portfolio_return - total_factor_return`.
244    pub alpha: f64,
245    /// The portfolio return passed to this computation.
246    pub portfolio_return: f64,
247}
248
249/// Multi-factor return attribution.
250///
251/// Given factor exposures (betas) and factor returns, decomposes portfolio return
252/// into factor contributions and an idiosyncratic residual (alpha).
253///
254/// # Example
255///
256/// ```rust
257/// use fin_primitives::attribution::{FactorAttribution, FactorReturns};
258///
259/// let betas = FactorReturns { market: 1.1, size: 0.2, value: -0.1, momentum: 0.3, quality: 0.05 };
260/// let factor_returns = FactorReturns { market: 0.01, size: 0.002, value: -0.001, momentum: 0.003, quality: 0.001 };
261/// let portfolio_return = 0.015;
262/// let result = FactorAttribution::compute(&betas, &factor_returns, portfolio_return).unwrap();
263/// assert!(result.total_factor_return.is_finite());
264/// ```
265pub struct FactorAttribution;
266
267impl FactorAttribution {
268    /// Decompose `portfolio_return` into factor contributions and alpha.
269    ///
270    /// Each factor contribution is `beta_i * factor_return_i`.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`FinError::InvalidInput`] if any beta or factor return is non-finite.
275    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// ---------------------------------------------------------------------------
328// RiskContribution
329// ---------------------------------------------------------------------------
330
331/// Per-position marginal risk contribution.
332///
333/// For a portfolio with covariance matrix `Σ` and weight vector `w`, the
334/// marginal risk contribution of position `i` is:
335///
336/// ```text
337/// MRC_i = w_i * (Σw)_i / σ_p
338/// ```
339///
340/// where `σ_p = sqrt(w' Σ w)` is portfolio volatility.
341///
342/// The sum of all `MRC_i` equals portfolio volatility (Euler decomposition).
343#[derive(Debug, Clone)]
344pub struct PositionRiskContribution {
345    /// Position identifier (e.g., ticker symbol).
346    pub id: String,
347    /// Portfolio weight in `[0.0, 1.0]` (short positions as negative).
348    pub weight: f64,
349    /// Marginal risk contribution as a fraction of portfolio volatility.
350    pub marginal_risk_contribution: f64,
351    /// Percentage risk contribution: `MRC_i / σ_p * 100`.
352    pub pct_risk_contribution: f64,
353}
354
355/// Computes per-position marginal risk contributions from a covariance matrix.
356///
357/// # Example
358///
359/// ```rust
360/// use fin_primitives::attribution::RiskContribution;
361///
362/// // Two-asset portfolio: equal weights, uncorrelated, same vol
363/// let weights = vec![0.5, 0.5];
364/// let cov = vec![
365///     vec![0.04, 0.0],  // var(A) = 0.04, cov(A,B) = 0
366///     vec![0.0,  0.04], // cov(B,A) = 0,  var(B) = 0.04
367/// ];
368/// let ids = vec!["A".to_owned(), "B".to_owned()];
369/// let result = RiskContribution::compute(&ids, &weights, &cov).unwrap();
370/// // Each asset contributes 50% of portfolio risk
371/// assert!((result[0].pct_risk_contribution - 50.0).abs() < 0.01);
372/// ```
373pub struct RiskContribution;
374
375impl RiskContribution {
376    /// Compute per-position marginal risk contributions.
377    ///
378    /// # Parameters
379    ///
380    /// * `ids`     — Position identifiers, length N.
381    /// * `weights` — Portfolio weights, length N.
382    /// * `cov`     — N×N covariance matrix (row-major).
383    ///
384    /// # Errors
385    ///
386    /// Returns [`FinError::InvalidInput`] if:
387    /// - `ids`, `weights`, and `cov` lengths are inconsistent.
388    /// - Portfolio volatility is zero (all-zero weights or flat covariance).
389    /// - Any weight or covariance entry is non-finite.
390    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        // Σw — matrix-vector product
421        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        // Portfolio variance: w' Σ w
428        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// ---------------------------------------------------------------------------
454// AttributionReport
455// ---------------------------------------------------------------------------
456
457/// Structured performance attribution report for a single period.
458///
459/// Aggregates the outputs of [`BrinsonHoodBeebower`], [`FactorAttribution`],
460/// and [`RiskContribution`] into a single report value for serialisation,
461/// logging, or display.
462#[derive(Debug, Clone)]
463pub struct AttributionReport {
464    /// Label for this attribution period (e.g., "2024-Q1").
465    pub period_label: String,
466    /// BHB allocation/selection/interaction decomposition (if computed).
467    pub bhb: Option<BhbResult>,
468    /// Factor-level return decomposition (if computed).
469    pub factor: Option<FactorAttributionResult>,
470    /// Per-position risk contributions (if computed).
471    pub risk_contributions: Option<Vec<PositionRiskContribution>>,
472    /// Total portfolio return for the period.
473    pub portfolio_return: f64,
474    /// Total benchmark return for the period.
475    pub benchmark_return: f64,
476    /// Excess return: `portfolio_return - benchmark_return`.
477    pub excess_return: f64,
478}
479
480impl AttributionReport {
481    /// Construct an attribution report from raw returns and optional attribution results.
482    ///
483    /// # Errors
484    ///
485    /// Returns [`FinError::InvalidInput`] if `portfolio_return` or
486    /// `benchmark_return` is non-finite.
487    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    /// Returns the information ratio proxy: `excess_return / tracking_error`.
517    ///
518    /// Returns `None` if `tracking_error` is zero or non-finite.
519    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// ---------------------------------------------------------------------------
528// PerformanceTearsheet
529// ---------------------------------------------------------------------------
530
531/// A single return observation used to build a [`PerformanceTearsheet`].
532#[derive(Debug, Clone)]
533pub struct ReturnObservation {
534    /// Identifier for this period (e.g., "2024-01-15" or bar index).
535    pub label: String,
536    /// Portfolio return for this period.
537    pub portfolio_return: f64,
538    /// Benchmark return for the same period.
539    pub benchmark_return: f64,
540}
541
542/// Comprehensive performance summary computed from a return series.
543///
544/// Covers absolute performance, risk-adjusted metrics, and drawdown analysis.
545///
546/// # Example
547///
548/// ```rust
549/// use fin_primitives::attribution::{PerformanceTearsheet, ReturnObservation};
550///
551/// let obs = vec![
552///     ReturnObservation { label: "d1".to_owned(), portfolio_return: 0.01, benchmark_return: 0.008 },
553///     ReturnObservation { label: "d2".to_owned(), portfolio_return: -0.005, benchmark_return: -0.003 },
554///     ReturnObservation { label: "d3".to_owned(), portfolio_return: 0.02, benchmark_return: 0.015 },
555/// ];
556/// let ts = PerformanceTearsheet::compute(&obs, 252).unwrap();
557/// assert!(ts.annualised_return.is_finite());
558/// assert!(ts.max_drawdown <= 0.0);
559/// ```
560#[derive(Debug, Clone)]
561pub struct PerformanceTearsheet {
562    /// Number of observations used.
563    pub observation_count: usize,
564    /// Compounded total portfolio return over all periods.
565    pub total_return: f64,
566    /// Annualised portfolio return (geometric).
567    pub annualised_return: f64,
568    /// Annualised volatility of portfolio returns.
569    pub annualised_volatility: f64,
570    /// Sharpe ratio: `annualised_return / annualised_volatility`.
571    /// `None` if volatility is zero.
572    pub sharpe_ratio: Option<f64>,
573    /// Maximum peak-to-trough drawdown (always <= 0).
574    pub max_drawdown: f64,
575    /// Calmar ratio: `annualised_return / |max_drawdown|`.
576    /// `None` if max_drawdown is zero.
577    pub calmar_ratio: Option<f64>,
578    /// Annualised excess return over the benchmark.
579    pub annualised_excess_return: f64,
580    /// Annualised tracking error (std dev of excess returns).
581    pub tracking_error: f64,
582    /// Information ratio: `annualised_excess_return / tracking_error`.
583    /// `None` if tracking error is zero.
584    pub information_ratio: Option<f64>,
585    /// Sortino ratio: `annualised_return / downside_deviation`.
586    /// `None` if downside deviation is zero.
587    pub sortino_ratio: Option<f64>,
588    /// Win rate: fraction of periods with positive portfolio return.
589    pub win_rate: f64,
590    /// Per-period return statistics: min, max, mean.
591    pub return_min: f64,
592    /// Maximum single-period return.
593    pub return_max: f64,
594    /// Mean single-period return.
595    pub return_mean: f64,
596}
597
598/// Calculates a [`PerformanceTearsheet`] from a sequence of return observations.
599impl PerformanceTearsheet {
600    /// Compute all performance metrics from a return series.
601    ///
602    /// # Parameters
603    ///
604    /// * `observations`       — Ordered sequence of return observations.
605    /// * `periods_per_year`   — Annualisation factor: 252 for daily, 12 for monthly, 4 for quarterly.
606    ///
607    /// # Errors
608    ///
609    /// Returns [`FinError::InvalidInput`] if:
610    /// - `observations` is empty.
611    /// - `periods_per_year` is zero.
612    /// - Any return value is non-finite.
613    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        // Total compounded return
646        let total_return = port_returns.iter().fold(1.0f64, |acc, r| acc * (1.0 + r)) - 1.0;
647
648        // Annualised return (geometric)
649        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        // Volatility (sample std dev, annualised)
657        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        // Sharpe ratio
666        let sharpe_ratio = if annualised_volatility > 0.0 {
667            Some(annualised_return / annualised_volatility)
668        } else {
669            None
670        };
671
672        // Max drawdown
673        let max_drawdown = Self::max_drawdown(&port_returns);
674
675        // Calmar ratio
676        let calmar_ratio = if max_drawdown < 0.0 {
677            Some(annualised_return / max_drawdown.abs())
678        } else {
679            None
680        };
681
682        // Excess return (annualised)
683        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        // Tracking error (sample std dev of excess returns, annualised)
692        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        // Information ratio
704        let information_ratio = if tracking_error > 0.0 {
705            Some(annualised_excess_return / tracking_error)
706        } else {
707            None
708        };
709
710        // Sortino ratio (downside deviation, target = 0)
711        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        // Win rate
724        let wins = port_returns.iter().filter(|&&r| r > 0.0).count();
725        let win_rate = wins as f64 / n as f64;
726
727        // Return stats
728        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    /// Compute peak-to-trough maximum drawdown.
752    ///
753    /// Returns a value <= 0. Returns 0.0 if the equity curve never declines.
754    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    /// Format a brief one-line summary of the tearsheet.
774    #[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// ---------------------------------------------------------------------------
799// AttributionSeries
800// ---------------------------------------------------------------------------
801
802/// Accumulates multiple [`AttributionReport`]s over time, keyed by period label.
803///
804/// Useful for persisting attribution through a rolling backtest or live run.
805#[derive(Debug, Default)]
806pub struct AttributionSeries {
807    reports: Vec<AttributionReport>,
808    index: HashMap<String, usize>,
809}
810
811impl AttributionSeries {
812    /// Create a new empty series.
813    #[must_use]
814    pub fn new() -> Self {
815        Self::default()
816    }
817
818    /// Append an attribution report to the series.
819    ///
820    /// # Errors
821    ///
822    /// Returns [`FinError::InvalidInput`] if a report with the same
823    /// `period_label` already exists (duplicate labels are not allowed).
824    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    /// Look up a report by period label. Returns `None` if not found.
838    #[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    /// All reports in insertion order.
844    #[must_use]
845    pub fn reports(&self) -> &[AttributionReport] {
846        &self.reports
847    }
848
849    /// Number of periods in the series.
850    #[must_use]
851    pub fn len(&self) -> usize {
852        self.reports.len()
853    }
854
855    /// Returns `true` if no periods have been recorded.
856    #[must_use]
857    pub fn is_empty(&self) -> bool {
858        self.reports.is_empty()
859    }
860}
861
862// ---------------------------------------------------------------------------
863// Tests
864// ---------------------------------------------------------------------------
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    // ── BrinsonHoodBeebower ───────────────────────────────────────────────
871
872    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    // ── FactorAttribution ─────────────────────────────────────────────────
929
930    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    // ── RiskContribution ──────────────────────────────────────────────────
968
969    #[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        // By symmetry both contributions should be equal
976        assert!((r[0].pct_risk_contribution - r[1].pct_risk_contribution).abs() < 0.01);
977        // Sum of MRC should equal portfolio volatility
978        let port_vol = 0.2 * 0.5f64.sqrt(); // sqrt(0.5^2*0.04 + 0.5^2*0.04)
979        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    // ── AttributionReport ─────────────────────────────────────────────────
1005
1006    #[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    // ── PerformanceTearsheet ───────────────────────────────────────────────
1032
1033    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    // ── AttributionSeries ─────────────────────────────────────────────────
1086
1087    #[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// ---------------------------------------------------------------------------
1118// BHB Sector-level API
1119// ---------------------------------------------------------------------------
1120
1121/// A single sector used in Brinson-Hood-Beebower performance attribution.
1122///
1123/// Holds the portfolio and benchmark weights and returns for one sector
1124/// (e.g. "Technology", "Healthcare") within a single measurement period.
1125#[derive(Debug, Clone)]
1126pub struct Sector {
1127    /// Human-readable sector name.
1128    pub name: String,
1129    /// Portfolio weight in this sector; expected in `[0, 1]`.
1130    pub portfolio_weight: f64,
1131    /// Benchmark weight in this sector; expected in `[0, 1]`.
1132    pub benchmark_weight: f64,
1133    /// Portfolio return within this sector for the period.
1134    pub portfolio_return: f64,
1135    /// Benchmark return within this sector for the period.
1136    pub benchmark_return: f64,
1137}
1138
1139/// Computed attribution effects for a single sector.
1140#[derive(Debug, Clone)]
1141pub struct SectorAttribution {
1142    /// Sector inputs used in computation.
1143    pub sector: Sector,
1144    /// Allocation effect: `(w_p - w_b) * (r_b_sector - R_b)`.
1145    pub allocation_effect: f64,
1146    /// Selection effect: `w_b * (r_p - r_b)`.
1147    pub selection_effect: f64,
1148    /// Interaction effect: `(w_p - w_b) * (r_p - r_b)`.
1149    pub interaction_effect: f64,
1150    /// Total active return for this sector: allocation + selection + interaction.
1151    pub total_active_return: f64,
1152}
1153
1154/// Aggregate attribution report across all sectors.
1155#[derive(Debug, Clone)]
1156pub struct SectorAttributionReport {
1157    /// Per-sector decomposition results.
1158    pub sectors: Vec<SectorAttribution>,
1159    /// Sum of allocation effects across all sectors.
1160    pub total_allocation: f64,
1161    /// Sum of selection effects across all sectors.
1162    pub total_selection: f64,
1163    /// Sum of interaction effects across all sectors.
1164    pub total_interaction: f64,
1165    /// Total active return: total_allocation + total_selection + total_interaction.
1166    pub total_active_return: f64,
1167    /// Benchmark portfolio return (weighted sum of sector benchmark returns).
1168    pub benchmark_return: f64,
1169    /// Portfolio return (weighted sum of sector portfolio returns).
1170    pub portfolio_return: f64,
1171}
1172
1173/// Brinson-Hood-Beebower attribution calculator that works directly with [`Sector`] slices.
1174///
1175/// This struct holds the total benchmark return (`R_b`) used in the allocation effect
1176/// formula and exposes the three BHB effect computations as methods.
1177pub struct Attribution {
1178    /// Total benchmark return used as the benchmark reference in the allocation effect.
1179    pub benchmark_return: f64,
1180}
1181
1182impl Attribution {
1183    /// Create a new `Attribution` calculator using the given total benchmark return.
1184    pub fn new(benchmark_return: f64) -> Self {
1185        Self { benchmark_return }
1186    }
1187
1188    /// Allocation effect for a sector: `(w_p - w_b) * (r_b_sector - R_b)`.
1189    ///
1190    /// Measures whether the manager added value by over/under-weighting the sector
1191    /// relative to the benchmark, evaluated against the sector's benchmark return.
1192    pub fn allocation_effect(&self, s: &Sector) -> f64 {
1193        (s.portfolio_weight - s.benchmark_weight) * (s.benchmark_return - self.benchmark_return)
1194    }
1195
1196    /// Selection effect for a sector: `w_b * (r_p - r_b)`.
1197    ///
1198    /// Measures whether the manager picked better securities within the sector.
1199    pub fn selection_effect(&self, s: &Sector) -> f64 {
1200        s.benchmark_weight * (s.portfolio_return - s.benchmark_return)
1201    }
1202
1203    /// Interaction effect for a sector: `(w_p - w_b) * (r_p - r_b)`.
1204    ///
1205    /// Joint effect of simultaneous over/under-weighting and stock selection.
1206    pub fn interaction_effect(&self, s: &Sector) -> f64 {
1207        (s.portfolio_weight - s.benchmark_weight) * (s.portfolio_return - s.benchmark_return)
1208    }
1209
1210    /// Total active return for a sector: allocation + selection + interaction.
1211    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
1216/// Run full Brinson-Hood-Beebower attribution over a slice of sectors.
1217///
1218/// The total benchmark return (`R_b`) is computed as the benchmark-weight-averaged
1219/// return across all sectors.  Per-sector effects are then computed using that
1220/// aggregate benchmark return, and results are aggregated into a
1221/// [`SectorAttributionReport`].
1222///
1223/// # Example
1224///
1225/// ```rust
1226/// use fin_primitives::attribution::{Sector, run_attribution};
1227///
1228/// let sectors = vec![
1229///     Sector { name: "Tech".into(), portfolio_weight: 0.40, benchmark_weight: 0.30,
1230///               portfolio_return: 0.12, benchmark_return: 0.10 },
1231///     Sector { name: "Energy".into(), portfolio_weight: 0.60, benchmark_weight: 0.70,
1232///               portfolio_return: 0.04, benchmark_return: 0.05 },
1233/// ];
1234/// let report = run_attribution(&sectors);
1235/// let active = report.total_active_return;
1236/// let decomposed = report.total_allocation + report.total_selection + report.total_interaction;
1237/// assert!((active - decomposed).abs() < 1e-12);
1238/// ```
1239pub fn run_attribution(sectors: &[Sector]) -> SectorAttributionReport {
1240    // Compute total benchmark return as benchmark-weight-averaged sector benchmark returns.
1241    let benchmark_return: f64 = sectors
1242        .iter()
1243        .map(|s| s.benchmark_weight * s.benchmark_return)
1244        .sum();
1245
1246    // Compute total portfolio return as portfolio-weight-averaged sector portfolio returns.
1247    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    /// Render the attribution report as a plain ASCII table.
1293    ///
1294    /// Columns: Sector | Port.Wt | Bench.Wt | Port.Ret | Bench.Ret | Alloc | Select | Interact | Active
1295    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    /// Return the top `n` sectors sorted by absolute total active return (descending).
1338    ///
1339    /// If `n` exceeds the number of sectors, all sectors are returned.
1340    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        // Portfolio return: 0.40*0.12 + 0.60*0.04 = 0.048 + 0.024 = 0.072
1393        // Benchmark return: 0.30*0.10 + 0.70*0.05 = 0.030 + 0.035 = 0.065
1394        // Active = 0.072 - 0.065 = 0.007
1395        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        // Single-sector sanity check.
1407        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; // = 0.024
1415        let report = run_attribution(&sectors);
1416        let sa = &report.sectors[0];
1417        // Allocation = (0.50-0.40)*(0.06-0.024) = 0.10*0.036 = 0.0036
1418        assert!((sa.allocation_effect - 0.0036).abs() < 1e-10, "alloc={}", sa.allocation_effect);
1419        // Selection = 0.40*(0.08-0.06) = 0.40*0.02 = 0.008
1420        assert!((sa.selection_effect - 0.008).abs() < 1e-10, "sel={}", sa.selection_effect);
1421        // Interaction = (0.50-0.40)*(0.08-0.06) = 0.10*0.02 = 0.002
1422        assert!((sa.interaction_effect - 0.002).abs() < 1e-10, "inter={}", sa.interaction_effect);
1423        let _ = rb; // used implicitly above
1424    }
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}