210 lines
6.6 KiB
Rust
210 lines
6.6 KiB
Rust
//! 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<RiskAttribution, RiskFlowError> {
|
||
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);
|
||
}
|
||
}
|