Files
rustytorch/demos/rtx-riskflow-demo/src/attribution.rs
T
2026-03-04 00:08:42 +00:00

210 lines
6.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}