//! Risk management unit tests //! //! Tests risk controls, position limits, and safety mechanisms //! with focus on preventing financial losses. // CANONICAL TYPE IMPORTS - Use types::prelude::Decimal use std::collections::HashMap; #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_position_limit_validation() { let mut risk_engine = RiskEngine::new(); risk_engine.set_position_limit( Symbol::new("AAPL".to_string()).unwrap(), Quantity::new(Decimal::from(10000)).map_err(|e| format!("Failed to create position limit: {}", e)).unwrap() ); let large_order = Order { id: OrderId::from("LARGE-001"), symbol: Symbol::new("AAPL".to_string()).unwrap(), side: Side::Buy, order_type: OrderType::Market, quantity: Quantity::new(Decimal::from(15000)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), // Exceeds limit price: None, time_in_force: TimeInForce::ImmediateOrCancel, timestamp: chrono::Utc::now(), status: OrderStatus::New, client_id: ClientId::new("CLIENT-001".to_string()), }; let validation_result = risk_engine.validate_order(&large_order).await; assert!(validation_result.is_err()); let error = validation_result.unwrap_err(); assert!(error.to_string().contains("position limit")); } #[tokio::test] async fn test_concentration_risk_check() { let mut risk_engine = RiskEngine::new(); risk_engine.set_concentration_limit(Decimal::from_str("0.25").unwrap()); // 25% max // Current portfolio value: $1,000,000 let portfolio_value = Money::new(Decimal::from(1_000_000), Currency::USD); risk_engine.set_portfolio_value(portfolio_value); // Existing position: AAPL $200,000 (20% of portfolio) let existing_position = Position { symbol: Symbol::new("AAPL".to_string()).unwrap(), quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create position quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(200)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }; risk_engine.add_position(existing_position); // New order would add $100,000 more AAPL (total would be 30%, exceeding 25% limit) let concentration_order = Order { id: OrderId::from("CONC-001"), symbol: Symbol::new("AAPL".to_string()).unwrap(), side: Side::Buy, order_type: OrderType::Limit, quantity: Quantity::new(Decimal::from(500)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), price: Some(Price::new(Decimal::from(200)).unwrap()), time_in_force: TimeInForce::Day, timestamp: chrono::Utc::now(), status: OrderStatus::New, client_id: ClientId::new("CLIENT-001".to_string()), }; let validation_result = risk_engine.validate_order(&concentration_order).await; assert!(validation_result.is_err()); let error = validation_result.unwrap_err(); assert!(error.to_string().contains("concentration")); } #[tokio::test] async fn test_daily_loss_limit() { let mut risk_engine = RiskEngine::new(); risk_engine.set_daily_loss_limit(Money::new(Decimal::from(50_000), Currency::USD)); // Simulate daily losses approaching the limit risk_engine.record_pnl(PnL::new(Decimal::from(-45_000)).map_err(|e| format!("Failed to create PnL: {}", e)).unwrap()); // Order that would cause additional loss if it goes against us let risky_order = Order { id: OrderId::from("RISKY-001"), symbol: Symbol::new("VOLATILE".to_string()).unwrap(), side: Side::Buy, order_type: OrderType::Market, quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), price: None, time_in_force: TimeInForce::ImmediateOrCancel, timestamp: chrono::Utc::now(), status: OrderStatus::New, client_id: ClientId::new("CLIENT-001".to_string()), }; // Set high volatility for the symbol risk_engine.set_symbol_volatility( Symbol::new("VOLATILE".to_string()).unwrap(), Decimal::from_str("0.20").unwrap() // 20% daily volatility ); let validation_result = risk_engine.validate_order(&risky_order).await; // Should be rejected due to potential additional loss assert!(validation_result.is_err()); } #[tokio::test] async fn test_var_calculation() { let positions = vec![ Position { symbol: Symbol::new("AAPL".to_string()).unwrap(), quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create position quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(150)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }, Position { symbol: Symbol::new("MSFT".to_string()).unwrap(), quantity: Quantity::new(Decimal::from(500)).map_err(|e| format!("Failed to create position quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(300)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }, ]; // Mock volatilities and correlations let mut volatilities = HashMap::new(); volatilities.insert(Symbol::new("AAPL".to_string()).unwrap(), Decimal::from_str("0.25").unwrap()); volatilities.insert(Symbol::new("MSFT".to_string()).unwrap(), Decimal::from_str("0.20").unwrap()); let mut correlations = HashMap::new(); correlations.insert( (Symbol::new("AAPL".to_string()).unwrap(), Symbol::new("MSFT".to_string()).unwrap()), Decimal::from_str("0.7").unwrap() ); let var_engine = VarEngine::new(volatilities, correlations); let var_result = var_engine.calculate_portfolio_var(&positions, Decimal::from_str("0.95").unwrap()) .expect("Should calculate VaR"); // VaR should be positive and reasonable assert!(var_result.var_1_day.amount > Decimal::ZERO); assert!(var_result.var_1_day.amount < Decimal::from(100_000)); // Sanity check // 5-day VaR should be higher than 1-day VaR assert!(var_result.var_5_day.amount > var_result.var_1_day.amount); } #[tokio::test] async fn test_margin_requirements() { let mut risk_engine = RiskEngine::new(); // Set margin requirements for different instruments risk_engine.set_margin_requirement( Symbol::new("AAPL".to_string()).unwrap(), Decimal::from_str("0.25").unwrap() // 25% margin ); risk_engine.set_margin_requirement( Symbol::new("VOLATILE_ETF".to_string()).unwrap(), Decimal::from_str("0.50").unwrap() // 50% margin for volatile instruments ); // Available cash: $100,000 risk_engine.set_available_cash(Money::new(Decimal::from(100_000), Currency::USD)); // Order requiring $50,000 margin (25% of $200,000 order) let margin_order = Order { id: OrderId::from("MARGIN-001"), symbol: Symbol::new("AAPL".to_string()).unwrap(), side: Side::Buy, order_type: OrderType::Limit, quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), price: Some(Price::new(Decimal::from(200)).unwrap()), time_in_force: TimeInForce::Day, timestamp: chrono::Utc::now(), status: OrderStatus::New, client_id: ClientId::new("CLIENT-001".to_string()), }; let validation_result = risk_engine.validate_order(&margin_order).await; assert!(validation_result.is_ok()); // Should pass - $50k margin available // Order requiring $100,000 margin (50% of $200,000 order) - should fail let high_margin_order = Order { id: OrderId::from("HIGH-MARGIN-001"), symbol: Symbol::new("VOLATILE_ETF".to_string()).unwrap(), side: Side::Buy, order_type: OrderType::Limit, quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), price: Some(Price::new(Decimal::from(200)).unwrap()), time_in_force: TimeInForce::Day, timestamp: chrono::Utc::now(), status: OrderStatus::New, client_id: ClientId::new("CLIENT-001".to_string()), }; let high_validation_result = risk_engine.validate_order(&high_margin_order).await; assert!(high_validation_result.is_err()); } #[tokio::test] async fn test_circuit_breaker() { let mut risk_engine = RiskEngine::new(); // Set circuit breaker at 5% portfolio loss risk_engine.set_circuit_breaker_threshold(Decimal::from_str("0.05").unwrap()); risk_engine.set_portfolio_value(Money::new(Decimal::from(1_000_000), Currency::USD)); // Simulate losses approaching threshold risk_engine.record_pnl(PnL::new(Decimal::from(-40_000)).map_err(|e| format!("Failed to create PnL: {}", e)).unwrap()); assert!(!risk_engine.is_circuit_breaker_triggered()); // Additional loss that triggers circuit breaker risk_engine.record_pnl(PnL::new(Decimal::from(-15_000)).map_err(|e| format!("Failed to create PnL: {}", e)).unwrap()); assert!(risk_engine.is_circuit_breaker_triggered()); // All new orders should be rejected let order_after_breaker = Order { id: OrderId::from("AFTER-BREAKER"), symbol: Symbol::new("ANY".to_string()).unwrap(), side: Side::Buy, order_type: OrderType::Market, quantity: Quantity::new(Decimal::from(100)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), price: None, time_in_force: TimeInForce::ImmediateOrCancel, timestamp: chrono::Utc::now(), status: OrderStatus::New, client_id: ClientId::new("CLIENT-001".to_string()), }; let validation_result = risk_engine.validate_order(&order_after_breaker).await; assert!(validation_result.is_err()); assert!(validation_result.unwrap_err().to_string().contains("circuit breaker")); } #[tokio::test] async fn test_stress_testing() { let positions = vec![ Position { symbol: Symbol::new("TECH_STOCK".to_string()).unwrap(), quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(100)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }, ]; let stress_tester = StressTester::new(); // Test market crash scenario: -30% tech stocks let crash_scenario = StressScenario { name: "Tech Crash".to_string(), price_shocks: { let mut shocks = HashMap::new(); shocks.insert( Symbol::new("TECH_STOCK".to_string()).unwrap(), Decimal::from_str("-0.30").unwrap() ); shocks }, correlation_changes: HashMap::new(), volatility_multipliers: HashMap::new(), }; let stress_result = stress_tester.run_stress_test(&positions, &crash_scenario) .expect("Should run stress test"); // Portfolio should lose $30,000 (30% of $100,000) let expected_loss = Decimal::from(-30_000); let tolerance = Decimal::from(1000); assert!((stress_result.total_pnl.value() - expected_loss).abs() < tolerance); assert_eq!(stress_result.scenario_name, "Tech Crash"); } #[tokio::test] async fn test_real_time_risk_monitoring() { let mut risk_monitor = RealTimeRiskMonitor::new(); // Set up initial portfolio risk_monitor.set_portfolio_value(Money::new(Decimal::from(1_000_000), Currency::USD)); risk_monitor.add_position(Position { symbol: Symbol::new("SPY".to_string()).unwrap(), quantity: Quantity::new(Decimal::from(1000)).map_err(|e| format!("Failed to create test quantity: {}", e)).unwrap(), average_price: Price::new(Decimal::from(400)).unwrap(), realized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), unrealized_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), last_updated: chrono::Utc::now(), }); // Simulate rapid price movement let price_update = MarketDataEvent { symbol: Symbol::new("SPY".to_string()).unwrap(), price: Price::new(Decimal::from(380)).unwrap(), // 5% drop timestamp: chrono::Utc::now(), volume: Volume::new(Decimal::from(1_000_000)).map_err(|e| format!("Failed to create volume: {}", e)).unwrap(), }; let risk_alert = risk_monitor.process_price_update(&price_update).await .expect("Should process price update"); // Should generate risk alert for significant position movement assert!(risk_alert.is_some()); let alert = risk_alert.unwrap(); assert_eq!(alert.severity, AlertSeverity::High); assert!(alert.message.contains("position loss")); } } // Production implementations for testing #[derive(Debug)] struct RiskEngine { position_limits: HashMap, concentration_limit: Option, daily_loss_limit: Option, portfolio_value: Option, positions: Vec, daily_pnl: PnL, circuit_breaker_threshold: Option, circuit_breaker_triggered: bool, margin_requirements: HashMap, available_cash: Option, symbol_volatilities: HashMap, } impl RiskEngine { fn new() -> Self { Self { position_limits: HashMap::new(), concentration_limit: None, daily_loss_limit: None, portfolio_value: None, positions: Vec::new(), daily_pnl: PnL::new(Decimal::ZERO).map_err(|e| format!("Failed to create zero PnL: {}", e)).unwrap(), circuit_breaker_threshold: None, circuit_breaker_triggered: false, margin_requirements: HashMap::new(), available_cash: None, symbol_volatilities: HashMap::new(), } } fn set_position_limit(&mut self, symbol: Symbol, limit: Quantity) { self.position_limits.insert(symbol, limit); } fn set_concentration_limit(&mut self, limit: Decimal) { self.concentration_limit = Some(limit); } fn set_daily_loss_limit(&mut self, limit: Money) { self.daily_loss_limit = Some(limit); } fn set_portfolio_value(&mut self, value: Money) { self.portfolio_value = Some(value); } fn add_position(&mut self, position: Position) { self.positions.push(position); } fn record_pnl(&mut self, pnl: PnL) { self.daily_pnl = PnL::new(self.daily_pnl.value() + pnl.value()).map_err(|e| format!("Failed to create daily PnL: {}", e)).unwrap(); // Check circuit breaker if let (Some(threshold), Some(portfolio_value)) = (&self.circuit_breaker_threshold, &self.portfolio_value) { let loss_percentage = (-self.daily_pnl.value()) / portfolio_value.amount; if loss_percentage >= *threshold { self.circuit_breaker_triggered = true; } } } fn set_circuit_breaker_threshold(&mut self, threshold: Decimal) { self.circuit_breaker_threshold = Some(threshold); } fn is_circuit_breaker_triggered(&self) -> bool { self.circuit_breaker_triggered } fn set_margin_requirement(&mut self, symbol: Symbol, requirement: Decimal) { self.margin_requirements.insert(symbol, requirement); } fn set_available_cash(&mut self, cash: Money) { self.available_cash = Some(cash); } fn set_symbol_volatility(&mut self, symbol: Symbol, volatility: Decimal) { self.symbol_volatilities.insert(symbol, volatility); } async fn validate_order(&self, order: &Order) -> Result<(), Box> { // Circuit breaker check if self.circuit_breaker_triggered { return Err("Order rejected: circuit breaker triggered".into()); } // Position limit check if let Some(limit) = self.position_limits.get(&order.symbol) { if order.quantity.value() > limit.value() { return Err("Order rejected: exceeds position limit".into()); } } // Concentration risk check if let (Some(conc_limit), Some(portfolio_value)) = (&self.concentration_limit, &self.portfolio_value) { let current_position_value = self.positions.iter() .find(|p| p.symbol == order.symbol) .map(|p| p.quantity.value() * p.average_price.value()) .unwrap_or(Decimal::ZERO); let order_value = order.quantity.value() * order.price.as_ref().unwrap_or(&Price::new(Decimal::from(100)).unwrap()).value(); let total_position_value = current_position_value + order_value; let concentration = total_position_value / portfolio_value.amount; if concentration > *conc_limit { return Err("Order rejected: exceeds concentration limit".into()); } } // Daily loss limit check with volatility consideration if let Some(loss_limit) = &self.daily_loss_limit { let current_loss = -self.daily_pnl.value(); if current_loss > loss_limit.amount * Decimal::from_str("0.8").unwrap() { // 80% of limit if let Some(volatility) = self.symbol_volatilities.get(&order.symbol) { if *volatility > Decimal::from_str("0.15").unwrap() { // High volatility return Err("Order rejected: approaching daily loss limit with high volatility".into()); } } } } // Margin requirement check if let (Some(margin_req), Some(available_cash)) = (self.margin_requirements.get(&order.symbol), &self.available_cash) { let order_value = order.quantity.value() * order.price.as_ref().unwrap_or(&Price::new(Decimal::from(100)).unwrap()).value(); let required_margin = order_value * margin_req; if required_margin > available_cash.amount { return Err("Order rejected: insufficient margin".into()); } } Ok(()) } } #[derive(Debug)] struct VarEngine { volatilities: HashMap, correlations: HashMap<(Symbol, Symbol), Decimal>, } impl VarEngine { fn new(volatilities: HashMap, correlations: HashMap<(Symbol, Symbol), Decimal>) -> Self { Self { volatilities, correlations, } } fn calculate_portfolio_var(&self, positions: &[Position], confidence_level: Decimal) -> Result> { // Simplified VaR calculation let z_score = if confidence_level == Decimal::from_str("0.95").unwrap() { Decimal::from_str("1.645").unwrap() // 95% confidence } else { Decimal::from_str("2.33").unwrap() // 99% confidence }; // Calculate portfolio volatility (simplified - assuming uncorrelated for simplicity) let mut portfolio_variance = Decimal::ZERO; for position in positions { let position_value = position.quantity.value() * position.average_price.value(); let volatility = self.volatilities.get(&position.symbol).unwrap_or(&Decimal::from_str("0.20").unwrap()); let position_variance = (position_value * volatility).powi(2); portfolio_variance += position_variance; } let portfolio_volatility = portfolio_variance.sqrt().unwrap_or(Decimal::ZERO); let var_1_day = portfolio_volatility * z_score; let var_5_day = var_1_day * Decimal::from_str("2.236").unwrap(); // sqrt(5) Ok(VarPrediction { confidence_level, var_1_day: Money::new(var_1_day, Currency::USD), var_5_day: Money::new(var_5_day, Currency::USD), expected_shortfall: Money::new(var_1_day * Decimal::from_str("1.2").unwrap(), Currency::USD), calculation_timestamp: chrono::Utc::now(), }) } } #[derive(Debug)] struct StressTester; #[derive(Debug)] struct StressScenario { name: String, price_shocks: HashMap, correlation_changes: HashMap<(Symbol, Symbol), Decimal>, volatility_multipliers: HashMap, } #[derive(Debug)] struct StressTestResult { scenario_name: String, total_pnl: PnL, position_impacts: HashMap, max_drawdown: Decimal, } impl StressTester { fn new() -> Self { Self } fn run_stress_test(&self, positions: &[Position], scenario: &StressScenario) -> Result> { let mut total_pnl = Decimal::ZERO; let mut position_impacts = HashMap::new(); for position in positions { if let Some(price_shock) = scenario.price_shocks.get(&position.symbol) { let position_value = position.quantity.value() * position.average_price.value(); let impact = position_value * price_shock; total_pnl += impact; position_impacts.insert(position.symbol.clone(), PnL::new(impact).map_err(|e| format!("Failed to create position impact PnL: {}", e)).unwrap()); } } Ok(StressTestResult { scenario_name: scenario.name.clone(), total_pnl: PnL::new(total_pnl).map_err(|e| format!("Failed to create total PnL: {}", e)).unwrap(), position_impacts, max_drawdown: (-total_pnl / Decimal::from(1_000_000)).abs(), // Assuming $1M portfolio }) } } #[derive(Debug)] struct RealTimeRiskMonitor { portfolio_value: Option, positions: Vec, } #[derive(Debug)] struct RiskAlert { severity: AlertSeverity, message: String, timestamp: chrono::DateTime, } impl RealTimeRiskMonitor { fn new() -> Self { Self { portfolio_value: None, positions: Vec::new(), } } fn set_portfolio_value(&mut self, value: Money) { self.portfolio_value = Some(value); } fn add_position(&mut self, position: Position) { self.positions.push(position); } async fn process_price_update(&self, event: &MarketDataEvent) -> Result, Box> { // Find position affected by price update if let Some(position) = self.positions.iter().find(|p| p.symbol == event.symbol) { let old_value = position.quantity.value() * position.average_price.value(); let new_value = position.quantity.value() * event.price.value(); let change = new_value - old_value; let change_percent = change / old_value; // Generate alert for significant moves if change_percent.abs() > Decimal::from_str("0.03").unwrap() { // 3% threshold let severity = if change_percent.abs() > Decimal::from_str("0.05").unwrap() { AlertSeverity::High } else { AlertSeverity::Medium }; return Ok(Some(RiskAlert { severity, message: format!( "Significant position loss in {}: {}%", event.symbol.as_str(), (change_percent * Decimal::from(100)).round_dp(2) ), timestamp: chrono::Utc::now(), })); } } Ok(None) } }