use crate::position::PositionLedger;
use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RiskFactor {
Market,
Sector,
Idiosyncratic,
Leverage,
Concentration,
Liquidity,
}
impl RiskFactor {
pub fn name(&self) -> &'static str {
match self {
Self::Market => "Market (Beta)",
Self::Sector => "Sector",
Self::Idiosyncratic => "Idiosyncratic",
Self::Leverage => "Leverage",
Self::Concentration => "Concentration",
Self::Liquidity => "Liquidity",
}
}
pub fn all() -> &'static [RiskFactor] {
&[
Self::Market,
Self::Sector,
Self::Idiosyncratic,
Self::Leverage,
Self::Concentration,
Self::Liquidity,
]
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RiskAttribution {
pub factor: RiskFactor,
pub contribution_pct: f64,
pub value: f64,
}
impl RiskAttribution {
pub fn is_dominant(&self, threshold_pct: f64) -> bool {
self.contribution_pct > threshold_pct
}
}
#[derive(Debug, Clone)]
pub struct AttributionReport {
pub attributions: Vec<RiskAttribution>,
pub total_risk: f64,
pub portfolio_equity: f64,
pub portfolio_beta: f64,
pub concentration_hhi: f64,
pub leverage_ratio: f64,
}
impl AttributionReport {
pub fn get(&self, factor: RiskFactor) -> Option<&RiskAttribution> {
self.attributions.iter().find(|a| a.factor == factor)
}
pub fn dominant_factor(&self) -> Option<&RiskAttribution> {
self.attributions
.iter()
.max_by(|a, b| a.contribution_pct.partial_cmp(&b.contribution_pct).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn factors_above(&self, threshold_pct: f64) -> Vec<&RiskAttribution> {
self.attributions
.iter()
.filter(|a| a.contribution_pct > threshold_pct)
.collect()
}
pub fn summary(&self) -> String {
let mut lines = vec![format!(
"AttributionReport [equity={:.2}, total_risk={:.4}, beta={:.3}, hhi={:.4}, leverage={:.2}x]",
self.portfolio_equity,
self.total_risk,
self.portfolio_beta,
self.concentration_hhi,
self.leverage_ratio,
)];
for attr in &self.attributions {
lines.push(format!(
" {:20} {:6.1}% ({:.4} units)",
attr.factor.name(),
attr.contribution_pct,
attr.value,
));
}
lines.join("\n")
}
}
#[derive(Debug, Clone)]
pub struct MarketData {
pub market_volatility: f64,
pub betas: HashMap<String, f64>,
pub sectors: HashMap<String, String>,
pub liquidity_scores: HashMap<String, f64>,
pub idiosyncratic_vols: HashMap<String, f64>,
}
impl Default for MarketData {
fn default() -> Self {
Self {
market_volatility: 0.15,
betas: HashMap::new(),
sectors: HashMap::new(),
liquidity_scores: HashMap::new(),
idiosyncratic_vols: HashMap::new(),
}
}
}
impl MarketData {
pub fn new(market_volatility: f64) -> Self {
Self { market_volatility, ..Default::default() }
}
pub fn with_beta(mut self, symbol: impl Into<String>, beta: f64) -> Self {
self.betas.insert(symbol.into(), beta);
self
}
pub fn with_sector(mut self, symbol: impl Into<String>, sector: impl Into<String>) -> Self {
self.sectors.insert(symbol.into(), sector.into());
self
}
pub fn with_liquidity(mut self, symbol: impl Into<String>, score: f64) -> Self {
self.liquidity_scores.insert(symbol.into(), score);
self
}
pub fn with_idio_vol(mut self, symbol: impl Into<String>, vol: f64) -> Self {
self.idiosyncratic_vols.insert(symbol.into(), vol);
self
}
pub fn beta(&self, symbol: &str) -> f64 {
self.betas.get(symbol).copied().unwrap_or(1.0)
}
pub fn liquidity(&self, symbol: &str) -> f64 {
self.liquidity_scores.get(symbol).copied().unwrap_or(1.0)
}
pub fn idio_vol(&self, symbol: &str) -> f64 {
self.idiosyncratic_vols.get(symbol).copied().unwrap_or(0.20)
}
}
#[derive(Debug, Clone)]
pub struct BhbAttribution {
pub sector_effects: Vec<SectorEffect>,
pub total_active_return: f64,
pub total_allocation: f64,
pub total_selection: f64,
pub total_interaction: f64,
}
impl BhbAttribution {
pub fn best_allocation_sector(&self) -> Option<&SectorEffect> {
self.sector_effects
.iter()
.filter(|s| s.allocation_effect > 0.0)
.max_by(|a, b| a.allocation_effect.partial_cmp(&b.allocation_effect).unwrap_or(std::cmp::Ordering::Equal))
}
pub fn best_selection_sector(&self) -> Option<&SectorEffect> {
self.sector_effects
.iter()
.filter(|s| s.selection_effect > 0.0)
.max_by(|a, b| a.selection_effect.partial_cmp(&b.selection_effect).unwrap_or(std::cmp::Ordering::Equal))
}
}
#[derive(Debug, Clone)]
pub struct SectorEffect {
pub sector: String,
pub portfolio_weight: f64,
pub benchmark_weight: f64,
pub portfolio_return: f64,
pub benchmark_return: f64,
pub allocation_effect: f64,
pub selection_effect: f64,
pub interaction_effect: f64,
}
#[derive(Debug, Clone, Default)]
pub struct BhbInput {
pub sectors: Vec<BhbSectorInput>,
pub benchmark_total_return: f64,
}
#[derive(Debug, Clone)]
pub struct BhbSectorInput {
pub sector: String,
pub portfolio_weight: f64,
pub benchmark_weight: f64,
pub portfolio_sector_return: f64,
pub benchmark_sector_return: f64,
}
pub struct RiskAttributor<'a> {
ledger: &'a PositionLedger,
market_data: MarketData,
}
impl<'a> RiskAttributor<'a> {
pub fn new(ledger: &'a PositionLedger, market_data: MarketData) -> Self {
Self { ledger, market_data }
}
pub fn compute(&self) -> AttributionReport {
let cash = self.ledger.cash();
let positions: Vec<_> = self.ledger.positions().collect();
let total_cost_basis: Decimal = positions.iter()
.map(|p| p.total_cost_basis())
.sum();
let equity_dec = cash + total_cost_basis;
let equity = equity_dec.to_f64().unwrap_or(0.0);
if equity == 0.0 {
return self.empty_report(equity);
}
let n = positions.len();
if n == 0 {
return self.empty_report(equity);
}
let gross_exposure: f64 = positions
.iter()
.map(|p| (p.quantity * p.avg_cost).to_f64().unwrap_or(0.0).abs())
.sum();
let leverage_ratio = if equity > 0.0 { gross_exposure / equity } else { 1.0 };
let weights: Vec<(String, f64)> = positions
.iter()
.map(|p| {
let notional = (p.quantity * p.avg_cost).to_f64().unwrap_or(0.0).abs();
let w = if gross_exposure > 0.0 { notional / gross_exposure } else { 0.0 };
(p.symbol.to_string(), w)
})
.collect();
let hhi: f64 = weights.iter().map(|(_, w)| w * w).sum();
let portfolio_beta: f64 = weights
.iter()
.map(|(sym, w)| w * self.market_data.beta(sym))
.sum();
let avg_liquidity: f64 = if weights.is_empty() {
1.0
} else {
weights.iter().map(|(sym, w)| w * self.market_data.liquidity(sym)).sum()
};
let idio_variance: f64 = weights
.iter()
.map(|(sym, w)| {
let vol = self.market_data.idio_vol(sym);
w * w * vol * vol
})
.sum();
let mkt_var = self.market_data.market_volatility * self.market_data.market_volatility;
let market_var = portfolio_beta * portfolio_beta * mkt_var;
let sector_var = hhi * mkt_var * 0.5; let leverage_premium = if leverage_ratio > 1.0 {
(leverage_ratio - 1.0).powi(2) * mkt_var
} else {
0.0
};
let concentration_var = hhi * mkt_var * 0.3; let liquidity_var = (1.0 - avg_liquidity.clamp(0.0, 1.0)) * mkt_var;
let total_variance =
market_var + sector_var + idio_variance + leverage_premium + concentration_var + liquidity_var;
let total_risk_vol = if total_variance > 0.0 { total_variance.sqrt() } else { 0.0 };
let total_risk_currency = total_risk_vol * equity;
let factor_variances = [
(RiskFactor::Market, market_var),
(RiskFactor::Sector, sector_var),
(RiskFactor::Idiosyncratic, idio_variance),
(RiskFactor::Leverage, leverage_premium),
(RiskFactor::Concentration, concentration_var),
(RiskFactor::Liquidity, liquidity_var),
];
let attributions = factor_variances
.iter()
.map(|(factor, var)| {
let pct = if total_variance > 0.0 { var / total_variance * 100.0 } else { 0.0 };
let value = var.sqrt() * equity;
RiskAttribution {
factor: *factor,
contribution_pct: pct,
value,
}
})
.collect();
AttributionReport {
attributions,
total_risk: total_risk_currency,
portfolio_equity: equity,
portfolio_beta,
concentration_hhi: hhi,
leverage_ratio,
}
}
pub fn compute_bhb(&self, input: &BhbInput) -> BhbAttribution {
let r_b = input.benchmark_total_return;
let sector_effects: Vec<SectorEffect> = input
.sectors
.iter()
.map(|s| {
let allocation = (s.portfolio_weight - s.benchmark_weight)
* (s.benchmark_sector_return - r_b);
let selection = s.benchmark_weight
* (s.portfolio_sector_return - s.benchmark_sector_return);
let interaction = (s.portfolio_weight - s.benchmark_weight)
* (s.portfolio_sector_return - s.benchmark_sector_return);
SectorEffect {
sector: s.sector.clone(),
portfolio_weight: s.portfolio_weight,
benchmark_weight: s.benchmark_weight,
portfolio_return: s.portfolio_sector_return,
benchmark_return: s.benchmark_sector_return,
allocation_effect: allocation,
selection_effect: selection,
interaction_effect: interaction,
}
})
.collect();
let total_allocation: f64 = sector_effects.iter().map(|s| s.allocation_effect).sum();
let total_selection: f64 = sector_effects.iter().map(|s| s.selection_effect).sum();
let total_interaction: f64 = sector_effects.iter().map(|s| s.interaction_effect).sum();
let total_active_return = total_allocation + total_selection + total_interaction;
BhbAttribution {
sector_effects,
total_active_return,
total_allocation,
total_selection,
total_interaction,
}
}
fn empty_report(&self, equity: f64) -> AttributionReport {
let attributions = RiskFactor::all()
.iter()
.map(|&factor| RiskAttribution { factor, contribution_pct: 0.0, value: 0.0 })
.collect();
AttributionReport {
attributions,
total_risk: 0.0,
portfolio_equity: equity,
portfolio_beta: 0.0,
concentration_hhi: 0.0,
leverage_ratio: 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::position::PositionLedger;
use crate::types::{NanoTimestamp, Price, Quantity, Side, Symbol};
use crate::position::Fill;
use rust_decimal_macros::dec;
fn make_fill(symbol: &str, side: Side, qty: &str, price: &str) -> Fill {
Fill::new(
Symbol::new(symbol).unwrap(),
side,
Quantity::new(qty.parse().unwrap()).unwrap(),
Price::new(price.parse().unwrap()).unwrap(),
NanoTimestamp::new(0),
)
}
fn ledger_with_position() -> PositionLedger {
let mut ledger = PositionLedger::new(dec!(100_000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "150")).unwrap();
ledger.apply_fill(make_fill("MSFT", Side::Bid, "5", "300")).unwrap();
ledger
}
#[test]
fn test_risk_factor_all_has_six_variants() {
assert_eq!(RiskFactor::all().len(), 6);
}
#[test]
fn test_risk_factor_names_non_empty() {
for factor in RiskFactor::all() {
assert!(!factor.name().is_empty());
}
}
#[test]
fn test_risk_factor_market_name() {
assert!(RiskFactor::Market.name().contains("Market") || RiskFactor::Market.name().contains("Beta"));
}
#[test]
fn test_attribution_report_has_all_six_factors() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert_eq!(report.attributions.len(), 6);
}
#[test]
fn test_attribution_report_percentages_sum_to_100() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
let total: f64 = report.attributions.iter().map(|a| a.contribution_pct).sum();
assert!((total - 100.0).abs() < 1.0, "percentages should sum to ~100, got {total}");
}
#[test]
fn test_attribution_report_no_negative_contributions() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
for attr in &report.attributions {
assert!(
attr.contribution_pct >= -0.001,
"contribution_pct should be non-negative, got {} for {:?}",
attr.contribution_pct,
attr.factor
);
}
}
#[test]
fn test_attribution_report_total_risk_positive() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert!(report.total_risk > 0.0, "total risk should be positive for a non-empty portfolio");
}
#[test]
fn test_attribution_report_empty_ledger_zero_risk() {
let ledger = PositionLedger::new(dec!(100_000));
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert_eq!(report.total_risk, 0.0);
}
#[test]
fn test_attribution_report_get_market_factor() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert!(report.get(RiskFactor::Market).is_some());
}
#[test]
fn test_attribution_report_dominant_factor() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
let dominant = report.dominant_factor();
assert!(dominant.is_some());
}
#[test]
fn test_attribution_report_leverage_ratio_no_leverage() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert!(report.leverage_ratio < 2.0, "leverage ratio should be < 2 for lightly invested portfolio");
}
#[test]
fn test_attribution_report_summary_contains_factor_names() {
let ledger = ledger_with_position();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
let summary = report.summary();
assert!(summary.contains("Market"));
assert!(summary.contains("Leverage"));
}
#[test]
fn test_attribution_report_hhi_single_position() {
let mut ledger = PositionLedger::new(dec!(100_000));
ledger.apply_fill(make_fill("AAPL", Side::Bid, "10", "100")).unwrap();
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert!(
(report.concentration_hhi - 1.0).abs() < 0.001,
"HHI should be 1.0 for a single-position portfolio, got {}",
report.concentration_hhi
);
}
#[test]
fn test_attribution_report_beta_with_custom_betas() {
let ledger = ledger_with_position();
let market_data = MarketData::default()
.with_beta("AAPL", 1.5)
.with_beta("MSFT", 1.2);
let attributor = RiskAttributor::new(&ledger, market_data);
let report = attributor.compute();
assert!(report.portfolio_beta >= 1.2, "beta should be >= 1.2, got {}", report.portfolio_beta);
assert!(report.portfolio_beta <= 1.5, "beta should be <= 1.5, got {}", report.portfolio_beta);
}
#[test]
fn test_risk_attribution_is_dominant() {
let attr = RiskAttribution {
factor: RiskFactor::Market,
contribution_pct: 60.0,
value: 1000.0,
};
assert!(attr.is_dominant(50.0));
assert!(!attr.is_dominant(70.0));
}
#[test]
fn test_bhb_total_active_return_equals_sum_of_effects() {
let ledger = PositionLedger::new(dec!(100_000));
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let input = BhbInput {
benchmark_total_return: 0.05,
sectors: vec![
BhbSectorInput {
sector: "Technology".into(),
portfolio_weight: 0.60,
benchmark_weight: 0.40,
portfolio_sector_return: 0.08,
benchmark_sector_return: 0.06,
},
BhbSectorInput {
sector: "Energy".into(),
portfolio_weight: 0.40,
benchmark_weight: 0.60,
portfolio_sector_return: 0.02,
benchmark_sector_return: 0.04,
},
],
};
let bhb = attributor.compute_bhb(&input);
let expected = bhb.total_allocation + bhb.total_selection + bhb.total_interaction;
assert!(
(bhb.total_active_return - expected).abs() < 1e-10,
"total_active_return should equal sum of effects"
);
}
#[test]
fn test_bhb_allocation_effect_positive_for_overweight_outperformer() {
let ledger = PositionLedger::new(dec!(100_000));
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let input = BhbInput {
benchmark_total_return: 0.04,
sectors: vec![BhbSectorInput {
sector: "Tech".into(),
portfolio_weight: 0.70, benchmark_weight: 0.50,
portfolio_sector_return: 0.10,
benchmark_sector_return: 0.08, }],
};
let bhb = attributor.compute_bhb(&input);
assert!(
bhb.total_allocation > 0.0,
"allocation should be positive for overweight outperforming sector"
);
}
#[test]
fn test_bhb_sector_effects_count_matches_input() {
let ledger = PositionLedger::new(dec!(100_000));
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let input = BhbInput {
benchmark_total_return: 0.05,
sectors: vec![
BhbSectorInput {
sector: "A".into(),
portfolio_weight: 0.5,
benchmark_weight: 0.5,
portfolio_sector_return: 0.05,
benchmark_sector_return: 0.05,
},
BhbSectorInput {
sector: "B".into(),
portfolio_weight: 0.5,
benchmark_weight: 0.5,
portfolio_sector_return: 0.05,
benchmark_sector_return: 0.05,
},
],
};
let bhb = attributor.compute_bhb(&input);
assert_eq!(bhb.sector_effects.len(), 2);
}
#[test]
fn test_bhb_zero_active_return_when_weights_and_returns_match_benchmark() {
let ledger = PositionLedger::new(dec!(100_000));
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let input = BhbInput {
benchmark_total_return: 0.05,
sectors: vec![BhbSectorInput {
sector: "All".into(),
portfolio_weight: 1.0,
benchmark_weight: 1.0,
portfolio_sector_return: 0.05,
benchmark_sector_return: 0.05,
}],
};
let bhb = attributor.compute_bhb(&input);
assert!(
bhb.total_active_return.abs() < 1e-10,
"active return should be zero when portfolio mirrors benchmark"
);
}
#[test]
fn test_bhb_best_allocation_sector() {
let ledger = PositionLedger::new(dec!(100_000));
let market_data = MarketData::default();
let attributor = RiskAttributor::new(&ledger, market_data);
let input = BhbInput {
benchmark_total_return: 0.04,
sectors: vec![
BhbSectorInput {
sector: "Tech".into(),
portfolio_weight: 0.70,
benchmark_weight: 0.50,
portfolio_sector_return: 0.10,
benchmark_sector_return: 0.08,
},
BhbSectorInput {
sector: "Energy".into(),
portfolio_weight: 0.30,
benchmark_weight: 0.50,
portfolio_sector_return: 0.02,
benchmark_sector_return: 0.03,
},
],
};
let bhb = attributor.compute_bhb(&input);
let best = bhb.best_allocation_sector();
assert!(best.is_some());
assert_eq!(best.unwrap().sector, "Tech");
}
#[test]
fn test_market_data_default_beta_is_one() {
let md = MarketData::default();
assert_eq!(md.beta("UNKNOWN_SYM"), 1.0);
}
#[test]
fn test_market_data_default_liquidity_is_one() {
let md = MarketData::default();
assert_eq!(md.liquidity("UNKNOWN_SYM"), 1.0);
}
#[test]
fn test_market_data_default_idio_vol() {
let md = MarketData::default();
assert_eq!(md.idio_vol("UNKNOWN_SYM"), 0.20);
}
#[test]
fn test_market_data_custom_beta() {
let md = MarketData::default().with_beta("TSLA", 2.0);
assert_eq!(md.beta("TSLA"), 2.0);
}
}