Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
+367
View File
@@ -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(&sector).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
}
}