Initial commit
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
//! Risk model for portfolio analysis.
|
||||
//!
|
||||
//! Calculates volatility, VaR, and other risk metrics.
|
||||
|
||||
use crate::RiskFlowError;
|
||||
use riskflow_shared::{FactorExposures, Portfolio, PositionRisk, RiskMetrics, SectorRisk};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Risk model for portfolio analysis.
|
||||
#[derive(Debug)]
|
||||
pub struct RiskModel {
|
||||
/// Market volatility (annualized).
|
||||
market_volatility: f64,
|
||||
/// Risk-free rate.
|
||||
risk_free_rate: f64,
|
||||
/// Asset volatility lookup (simplified).
|
||||
#[allow(dead_code)]
|
||||
asset_volatilities: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl Default for RiskModel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RiskModel {
|
||||
/// Create a new risk model.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
market_volatility: 0.16, // 16% annualized
|
||||
risk_free_rate: 0.04,
|
||||
asset_volatilities: default_volatilities(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate overall risk metrics.
|
||||
pub fn calculate_metrics(&self, portfolio: &Portfolio) -> Result<RiskMetrics, RiskFlowError> {
|
||||
if portfolio.positions.is_empty() {
|
||||
return Err(RiskFlowError::EmptyPortfolio);
|
||||
}
|
||||
|
||||
// Calculate portfolio beta
|
||||
let portfolio_beta = self.calculate_portfolio_beta(portfolio);
|
||||
|
||||
// Estimate portfolio volatility (simplified: beta * market vol + idiosyncratic)
|
||||
let systematic_vol = portfolio_beta * self.market_volatility;
|
||||
let idiosyncratic_vol = self.estimate_idiosyncratic_vol(portfolio);
|
||||
let portfolio_vol = (systematic_vol.powi(2) + idiosyncratic_vol.powi(2)).sqrt();
|
||||
|
||||
// VaR calculations (parametric)
|
||||
let z_95 = 1.645;
|
||||
let z_99 = 2.326;
|
||||
let var_95 = portfolio_vol * z_95 / (252.0_f64).sqrt() * 100.0; // 1-day VaR in %
|
||||
let var_99 = portfolio_vol * z_99 / (252.0_f64).sqrt() * 100.0;
|
||||
|
||||
// CVaR (expected shortfall) - approximate
|
||||
let cvar_95 = var_95 * 1.25; // Simplified approximation
|
||||
|
||||
// Max drawdown (estimated from vol)
|
||||
let max_drawdown = portfolio_vol * 2.5; // Simplified estimate
|
||||
|
||||
Ok(RiskMetrics {
|
||||
volatility: portfolio_vol * 100.0, // Convert to %
|
||||
beta: portfolio_beta,
|
||||
var_95,
|
||||
var_99,
|
||||
cvar_95,
|
||||
max_drawdown,
|
||||
tracking_error: self.estimate_tracking_error(portfolio),
|
||||
active_share: self.estimate_active_share(portfolio),
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate portfolio beta.
|
||||
fn calculate_portfolio_beta(&self, portfolio: &Portfolio) -> f64 {
|
||||
let total_value = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.market_value)
|
||||
.sum::<f64>();
|
||||
|
||||
if total_value <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.beta * p.market_value / total_value)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Estimate idiosyncratic volatility.
|
||||
fn estimate_idiosyncratic_vol(&self, portfolio: &Portfolio) -> f64 {
|
||||
// Simplified: diversification reduces specific risk
|
||||
let num_positions = portfolio.positions.len() as f64;
|
||||
let base_specific = 0.20; // 20% per stock
|
||||
|
||||
// Diversification benefit
|
||||
base_specific / num_positions.sqrt()
|
||||
}
|
||||
|
||||
/// Estimate tracking error vs market.
|
||||
fn estimate_tracking_error(&self, portfolio: &Portfolio) -> f64 {
|
||||
let beta = self.calculate_portfolio_beta(portfolio);
|
||||
let active_beta = (beta - 1.0).abs();
|
||||
|
||||
// Simplified: tracking error from beta deviation + stock selection
|
||||
(active_beta * self.market_volatility + 0.03) * 100.0
|
||||
}
|
||||
|
||||
/// Estimate active share (how different from benchmark).
|
||||
fn estimate_active_share(&self, portfolio: &Portfolio) -> f64 {
|
||||
// Simplified: based on concentration
|
||||
let total = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.market_value)
|
||||
.sum::<f64>();
|
||||
let max_weight = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.market_value)
|
||||
.fold(0.0_f64, f64::max);
|
||||
|
||||
if total > 0.0 {
|
||||
((max_weight / total) * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate factor exposures.
|
||||
#[must_use]
|
||||
pub fn calculate_exposures(&self, portfolio: &Portfolio) -> FactorExposures {
|
||||
let beta = self.calculate_portfolio_beta(portfolio);
|
||||
|
||||
// Calculate sector tilts for factor approximations
|
||||
let sector_weights = self.sector_weights(portfolio);
|
||||
|
||||
// Simplified factor exposures based on sector composition
|
||||
let tech_weight = sector_weights.get("Technology").copied().unwrap_or(0.0);
|
||||
let fin_weight = sector_weights.get("Financials").copied().unwrap_or(0.0);
|
||||
let healthcare_weight = sector_weights.get("Healthcare").copied().unwrap_or(0.0);
|
||||
let staples_weight = sector_weights
|
||||
.get("Consumer Staples")
|
||||
.copied()
|
||||
.unwrap_or(0.0);
|
||||
|
||||
FactorExposures {
|
||||
market: beta,
|
||||
size: (tech_weight - 0.25) * 0.5, // Tech tends to be larger cap
|
||||
value: (fin_weight + staples_weight - 0.15) * 0.8,
|
||||
momentum: (tech_weight - 0.25) * 0.3,
|
||||
low_volatility: (staples_weight + healthcare_weight - 0.15) * 0.6,
|
||||
quality: (tech_weight + healthcare_weight - 0.20) * 0.4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate position-level risk.
|
||||
pub fn position_risk(&self, portfolio: &Portfolio) -> Result<Vec<PositionRisk>, RiskFlowError> {
|
||||
let total_value = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.market_value)
|
||||
.sum::<f64>();
|
||||
let metrics = self.calculate_metrics(portfolio)?;
|
||||
|
||||
let mut position_risks = Vec::with_capacity(portfolio.positions.len());
|
||||
|
||||
for position in &portfolio.positions {
|
||||
let weight = if total_value > 0.0 {
|
||||
position.market_value / total_value
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Position contribution to variance (simplified)
|
||||
let position_vol = position.beta * self.market_volatility
|
||||
+ 0.25 / (portfolio.positions.len() as f64).sqrt();
|
||||
let risk_contribution = weight * position_vol * 100.0;
|
||||
|
||||
// Marginal risk (how much risk changes if we add more)
|
||||
let marginal_risk = position.beta * self.market_volatility * 100.0;
|
||||
|
||||
// Percentage of total risk
|
||||
let risk_pct = if metrics.volatility > 0.0 {
|
||||
risk_contribution / metrics.volatility * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// VaR contribution
|
||||
let var_contribution = weight * metrics.var_95;
|
||||
|
||||
position_risks.push(PositionRisk {
|
||||
symbol: position.symbol.clone(),
|
||||
risk_contribution,
|
||||
marginal_risk,
|
||||
risk_pct,
|
||||
var_contribution,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(position_risks)
|
||||
}
|
||||
|
||||
/// Calculate sector-level risk breakdown.
|
||||
#[must_use]
|
||||
pub fn sector_risk(&self, portfolio: &Portfolio) -> Vec<SectorRisk> {
|
||||
let sector_weights = self.sector_weights(portfolio);
|
||||
let _total_value = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.market_value)
|
||||
.sum::<f64>();
|
||||
|
||||
let mut sector_counts: HashMap<String, usize> = HashMap::new();
|
||||
for position in &portfolio.positions {
|
||||
*sector_counts.entry(position.sector.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut sector_risks: Vec<SectorRisk> = sector_weights
|
||||
.into_iter()
|
||||
.map(|(sector, weight)| {
|
||||
// Risk contribution proportional to weight and beta
|
||||
let sector_beta = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.filter(|p| p.sector == sector)
|
||||
.map(|p| p.beta * p.market_value)
|
||||
.sum::<f64>()
|
||||
/ portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.filter(|p| p.sector == sector)
|
||||
.map(|p| p.market_value)
|
||||
.sum::<f64>()
|
||||
.max(0.001);
|
||||
|
||||
let risk_contribution = weight * sector_beta * self.market_volatility * 100.0;
|
||||
|
||||
SectorRisk {
|
||||
sector: sector.clone(),
|
||||
weight: weight * 100.0,
|
||||
risk_contribution,
|
||||
position_count: *sector_counts.get(§or).unwrap_or(&0),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
sector_risks.sort_by(|a, b| {
|
||||
b.weight
|
||||
.partial_cmp(&a.weight)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
sector_risks
|
||||
}
|
||||
|
||||
/// Calculate sector weights.
|
||||
fn sector_weights(&self, portfolio: &Portfolio) -> HashMap<String, f64> {
|
||||
let total_value = portfolio
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| p.market_value)
|
||||
.sum::<f64>();
|
||||
let mut weights = HashMap::new();
|
||||
|
||||
if total_value <= 0.0 {
|
||||
return weights;
|
||||
}
|
||||
|
||||
for position in &portfolio.positions {
|
||||
*weights.entry(position.sector.clone()).or_insert(0.0) +=
|
||||
position.market_value / total_value;
|
||||
}
|
||||
|
||||
weights
|
||||
}
|
||||
}
|
||||
|
||||
/// Default asset volatilities.
|
||||
fn default_volatilities() -> HashMap<String, f64> {
|
||||
let mut vols = HashMap::new();
|
||||
vols.insert("AAPL".to_string(), 0.28);
|
||||
vols.insert("MSFT".to_string(), 0.25);
|
||||
vols.insert("GOOGL".to_string(), 0.27);
|
||||
vols.insert("JPM".to_string(), 0.30);
|
||||
vols.insert("JNJ".to_string(), 0.18);
|
||||
vols.insert("PG".to_string(), 0.16);
|
||||
vols.insert("XOM".to_string(), 0.25);
|
||||
vols.insert("VZ".to_string(), 0.20);
|
||||
vols
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use riskflow_shared::get_sample_portfolio;
|
||||
|
||||
#[test]
|
||||
fn test_risk_model_creation() {
|
||||
let model = RiskModel::new();
|
||||
assert!(model.market_volatility > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_metrics() {
|
||||
let model = RiskModel::new();
|
||||
let portfolio = get_sample_portfolio();
|
||||
|
||||
let result = model.calculate_metrics(&portfolio);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let metrics = result.unwrap();
|
||||
assert!(metrics.volatility > 0.0);
|
||||
assert!(metrics.var_95 > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_beta() {
|
||||
let model = RiskModel::new();
|
||||
let portfolio = get_sample_portfolio();
|
||||
|
||||
let beta = model.calculate_portfolio_beta(&portfolio);
|
||||
// Portfolio beta should be reasonable (0.5 to 1.5)
|
||||
assert!(beta > 0.5);
|
||||
assert!(beta < 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_factor_exposures() {
|
||||
let model = RiskModel::new();
|
||||
let portfolio = get_sample_portfolio();
|
||||
|
||||
let exposures = model.calculate_exposures(&portfolio);
|
||||
// Market exposure should equal beta
|
||||
assert!(exposures.market > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_position_risk() {
|
||||
let model = RiskModel::new();
|
||||
let portfolio = get_sample_portfolio();
|
||||
|
||||
let result = model.position_risk(&portfolio);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let pos_risks = result.unwrap();
|
||||
assert_eq!(pos_risks.len(), portfolio.positions.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sector_risk() {
|
||||
let model = RiskModel::new();
|
||||
let portfolio = get_sample_portfolio();
|
||||
|
||||
let sector_risks = model.sector_risk(&portfolio);
|
||||
assert!(!sector_risks.is_empty());
|
||||
|
||||
// Total weights should sum to ~100%
|
||||
let total_weight: f64 = sector_risks.iter().map(|s| s.weight).sum();
|
||||
assert!((total_weight - 100.0).abs() < 5.0); // Allow for rounding
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user