//! Risk attribution to factors. //! //! Decomposes portfolio risk into factor contributions. use crate::RiskFlowError; use riskflow_shared::{FactorExposures, FactorRiskContribution, Portfolio, RiskAttribution}; /// Factor covariance matrix (simplified - diagonal). #[derive(Debug, Clone)] pub struct FactorCovariance { /// Market factor variance. pub market: f64, /// Size factor variance. pub size: f64, /// Value factor variance. pub value: f64, /// Momentum factor variance. pub momentum: f64, /// Low volatility factor variance. pub low_volatility: f64, /// Quality factor variance. pub quality: f64, } impl Default for FactorCovariance { fn default() -> Self { // Annualized factor volatilities squared Self { market: 0.16_f64.powi(2), // ~16% market vol size: 0.08_f64.powi(2), // ~8% SMB vol value: 0.07_f64.powi(2), // ~7% HML vol momentum: 0.12_f64.powi(2), // ~12% momentum vol low_volatility: 0.06_f64.powi(2), // ~6% low vol factor vol quality: 0.05_f64.powi(2), // ~5% quality vol } } } /// Risk attributor. #[derive(Debug)] pub struct RiskAttributor { /// Factor covariance. factor_covariance: FactorCovariance, /// Specific risk per asset (simplified). specific_risk_base: f64, } impl Default for RiskAttributor { fn default() -> Self { Self::new() } } impl RiskAttributor { /// Create a new risk attributor. #[must_use] pub fn new() -> Self { Self { factor_covariance: FactorCovariance::default(), specific_risk_base: 0.25, // 25% idiosyncratic vol per stock } } /// Attribute risk to factors. pub fn attribute( &self, portfolio: &Portfolio, exposures: &FactorExposures, ) -> Result { if portfolio.positions.is_empty() { return Err(RiskFlowError::EmptyPortfolio); } // Calculate factor risk contributions let factor_contributions = self.calculate_factor_contributions(exposures); // Calculate specific risk let specific_risk = self.calculate_specific_risk(portfolio); // Total factor risk let factor_risk = factor_contributions.total(); // Interaction effects (simplified - cross-factor correlations) let interaction_risk = self.calculate_interaction_risk(exposures); // Total risk let total_risk = (factor_risk.powi(2) + specific_risk.powi(2) + interaction_risk.powi(2)).sqrt(); Ok(RiskAttribution { total_risk: total_risk * 100.0, // Convert to % factor_contributions, specific_risk: specific_risk * 100.0, interaction_risk: interaction_risk * 100.0, }) } /// Calculate factor risk contributions. fn calculate_factor_contributions( &self, exposures: &FactorExposures, ) -> FactorRiskContribution { // Risk contribution = exposure² × factor_variance (simplified diagonal model) let fc = &self.factor_covariance; FactorRiskContribution { market: (exposures.market.powi(2) * fc.market).sqrt(), size: (exposures.size.powi(2) * fc.size).sqrt(), value: (exposures.value.powi(2) * fc.value).sqrt(), momentum: (exposures.momentum.powi(2) * fc.momentum).sqrt(), low_volatility: (exposures.low_volatility.powi(2) * fc.low_volatility).sqrt(), quality: (exposures.quality.powi(2) * fc.quality).sqrt(), } } /// Calculate specific (idiosyncratic) risk. fn calculate_specific_risk(&self, portfolio: &Portfolio) -> f64 { let n = portfolio.positions.len() as f64; // Diversification reduces specific risk // Assume equal-weighted for simplicity let _weight = 1.0 / n; // Specific risk = sqrt(sum of (weight² × asset_specific_var)) // With equal weights: specific_vol / sqrt(n) self.specific_risk_base / n.sqrt() } /// Calculate interaction risk (cross-factor correlations). fn calculate_interaction_risk(&self, exposures: &FactorExposures) -> f64 { // Simplified: assume some positive correlations between factors let market_size_corr = 0.2; let value_size_corr = 0.3; // Cross terms let interaction = exposures.market * exposures.size * market_size_corr + exposures.value * exposures.size * value_size_corr; interaction.abs() * 0.02 // Scale down } } #[cfg(test)] mod tests { use super::*; use riskflow_shared::get_sample_portfolio; #[test] fn test_attributor_creation() { let attributor = RiskAttributor::new(); assert!(attributor.specific_risk_base > 0.0); } #[test] fn test_factor_contributions() { let attributor = RiskAttributor::new(); let exposures = FactorExposures { market: 1.1, size: 0.1, value: -0.05, momentum: 0.15, low_volatility: 0.0, quality: 0.08, }; let contributions = attributor.calculate_factor_contributions(&exposures); // Market should have largest contribution (highest exposure × vol) assert!(contributions.market > contributions.size); assert!(contributions.market > contributions.value); } #[test] fn test_attribute() { let attributor = RiskAttributor::new(); let portfolio = get_sample_portfolio(); let exposures = FactorExposures { market: 1.0, size: 0.1, value: 0.05, momentum: 0.1, low_volatility: 0.0, quality: 0.05, }; let result = attributor.attribute(&portfolio, &exposures); assert!(result.is_ok()); let attribution = result.unwrap(); assert!(attribution.total_risk > 0.0); assert!(attribution.factor_contributions.market > 0.0); } #[test] fn test_specific_risk_diversification() { let attributor = RiskAttributor::new(); // More positions should reduce specific risk let mut small_portfolio = get_sample_portfolio(); small_portfolio.positions = small_portfolio.positions[..2].to_vec(); let mut large_portfolio = get_sample_portfolio(); let small_specific = attributor.calculate_specific_risk(&small_portfolio); let large_specific = attributor.calculate_specific_risk(&large_portfolio); assert!(large_specific < small_specific); } }