#[derive(Debug, Clone)]
pub struct Segment {
pub name: String,
pub portfolio_weight: f64,
pub benchmark_weight: f64,
pub portfolio_return: f64,
pub benchmark_return: f64,
}
impl Segment {
pub fn new(
name: impl Into<String>,
portfolio_weight: f64,
benchmark_weight: f64,
portfolio_return: f64,
benchmark_return: f64,
) -> Self {
Self {
name: name.into(),
portfolio_weight,
benchmark_weight,
portfolio_return,
benchmark_return,
}
}
pub fn allocation_effect(&self) -> f64 {
(self.portfolio_weight - self.benchmark_weight) * self.benchmark_return
}
pub fn selection_effect(&self) -> f64 {
self.benchmark_weight * (self.portfolio_return - self.benchmark_return)
}
pub fn interaction_effect(&self) -> f64 {
(self.portfolio_weight - self.benchmark_weight)
* (self.portfolio_return - self.benchmark_return)
}
pub fn bf_allocation_effect(&self, benchmark_total_return: f64) -> f64 {
(self.portfolio_weight - self.benchmark_weight)
* (self.benchmark_return - benchmark_total_return)
}
}
#[derive(Debug, Clone)]
pub struct BHBAttribution {
pub segments: Vec<Segment>,
pub total_allocation: f64,
pub total_selection: f64,
pub total_interaction: f64,
pub total_active_return: f64,
}
impl BHBAttribution {
pub fn residual(&self) -> f64 {
(self.total_allocation + self.total_selection + self.total_interaction)
- self.total_active_return
}
}
pub struct BHBAttributor {
segments: Vec<Segment>,
}
impl BHBAttributor {
pub fn new(segments: Vec<Segment>) -> Self {
Self { segments }
}
pub fn compute(&self) -> BHBAttribution {
let total_allocation: f64 = self.segments.iter().map(|s| s.allocation_effect()).sum();
let total_selection: f64 = self.segments.iter().map(|s| s.selection_effect()).sum();
let total_interaction: f64 = self.segments.iter().map(|s| s.interaction_effect()).sum();
let portfolio_total: f64 = self
.segments
.iter()
.map(|s| s.portfolio_weight * s.portfolio_return)
.sum();
let benchmark_total: f64 = self
.segments
.iter()
.map(|s| s.benchmark_weight * s.benchmark_return)
.sum();
let total_active_return = portfolio_total - benchmark_total;
BHBAttribution {
segments: self.segments.clone(),
total_allocation,
total_selection,
total_interaction,
total_active_return,
}
}
pub fn brinson_fachler_allocations(&self) -> Vec<(String, f64)> {
let benchmark_total: f64 = self
.segments
.iter()
.map(|s| s.benchmark_weight * s.benchmark_return)
.sum();
self.segments
.iter()
.map(|s| (s.name.clone(), s.bf_allocation_effect(benchmark_total)))
.collect()
}
pub fn sector_summary(&self) -> Vec<(String, f64, f64, f64)> {
self.segments
.iter()
.map(|s| {
(
s.name.clone(),
s.allocation_effect(),
s.selection_effect(),
s.interaction_effect(),
)
})
.collect()
}
}
pub fn bf_allocation_effect(segment: &Segment, benchmark_total_return: f64) -> f64 {
segment.bf_allocation_effect(benchmark_total_return)
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(wp: f64, wb: f64, rp: f64, rb: f64) -> Segment {
Segment::new("test", wp, wb, rp, rb)
}
#[test]
fn single_segment_zero_active_when_equal() {
let segments = vec![seg(0.5, 0.5, 0.10, 0.10), seg(0.5, 0.5, 0.05, 0.05)];
let attr = BHBAttributor::new(segments).compute();
assert!(attr.total_active_return.abs() < 1e-12, "zero active return expected");
assert!(attr.total_allocation.abs() < 1e-12);
assert!(attr.total_selection.abs() < 1e-12);
assert!(attr.total_interaction.abs() < 1e-12);
}
#[test]
fn allocation_effect_correct() {
let s = seg(0.6, 0.4, 0.15, 0.10);
assert!((s.allocation_effect() - 0.02).abs() < 1e-12);
}
#[test]
fn selection_effect_correct() {
let s = seg(0.5, 0.5, 0.15, 0.10);
assert!((s.selection_effect() - 0.025).abs() < 1e-12);
}
#[test]
fn interaction_effect_correct() {
let s = seg(0.6, 0.4, 0.15, 0.10);
assert!((s.interaction_effect() - 0.01).abs() < 1e-12);
}
#[test]
fn multi_segment_attribution_identity() {
let segments = vec![
Segment::new("Tech", 0.30, 0.25, 0.20, 0.15),
Segment::new("Energy", 0.20, 0.25, 0.08, 0.10),
Segment::new("Finance", 0.50, 0.50, 0.12, 0.11),
];
let attr = BHBAttributor::new(segments).compute();
let residual = attr.residual();
assert!(
residual.abs() < 1e-10,
"BHB identity violated: residual={residual:.2e}"
);
}
#[test]
fn sector_summary_length_matches() {
let segments = vec![
Segment::new("A", 0.5, 0.4, 0.10, 0.08),
Segment::new("B", 0.5, 0.6, 0.06, 0.07),
];
let attributor = BHBAttributor::new(segments);
let summary = attributor.sector_summary();
assert_eq!(summary.len(), 2);
}
#[test]
fn bf_differs_from_bhb_when_benchmark_return_nonzero() {
let segments = vec![
Segment::new("A", 0.6, 0.5, 0.15, 0.12),
Segment::new("B", 0.4, 0.5, 0.08, 0.06),
];
let benchmark_total: f64 = segments
.iter()
.map(|s| s.benchmark_weight * s.benchmark_return)
.sum();
for s in &segments {
let diff = s.allocation_effect() - s.bf_allocation_effect(benchmark_total);
let expected = (s.portfolio_weight - s.benchmark_weight) * benchmark_total;
assert!((diff - expected).abs() < 1e-12);
assert!(diff.abs() > 1e-6, "BF and BHB segment effects should differ when Rb != 0");
}
let bhb_alloc: f64 = segments.iter().map(|s| s.allocation_effect()).sum();
let bf_alloc: f64 = segments
.iter()
.map(|s| s.bf_allocation_effect(benchmark_total))
.sum();
assert!((bhb_alloc - bf_alloc).abs() < 1e-12, "totals must agree when weights sum to 1");
}
#[test]
fn bf_allocation_via_free_function() {
let s = Segment::new("X", 0.6, 0.4, 0.10, 0.08);
let rb_total = 0.05;
let expected = (0.6 - 0.4) * (0.08 - 0.05); assert!((bf_allocation_effect(&s, rb_total) - expected).abs() < 1e-12);
}
}