//! Drawdown monitoring system for real-time risk tracking // #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // COMMENTED: Crate-level allows applied use std::collections::HashMap; use chrono::{DateTime, Utc}; // REMOVED: Direct Decimal usage - use canonical types use tokio::sync::{broadcast, RwLock}; use tracing::warn; use crate::error::{RiskError, RiskResult}; use crate::risk_types::{DrawdownAlertConfig, PnLMetrics, PortfolioId, RiskSeverity}; // Import canonical types /// Drawdown alert event #[derive(Debug, Clone)] pub struct DrawdownAlert { /// Portfolio identifier that triggered the alert pub portfolio_id: PortfolioId, /// Severity level of the drawdown alert pub severity: RiskSeverity, /// Current drawdown percentage from high water mark pub current_drawdown_pct: f64, /// Threshold percentage that was breached pub threshold_pct: f64, /// Human-readable alert message pub message: String, /// Timestamp when the alert was generated pub timestamp: DateTime, } /// Drawdown statistics for a portfolio #[derive(Debug, Clone)] pub struct DrawdownStats { /// Current drawdown percentage from peak pub current_drawdown_pct: f64, /// Maximum drawdown percentage ever recorded pub max_drawdown_pct: f64, /// Highest portfolio value achieved (high water mark) pub high_water_mark: f64, /// Number of consecutive days in drawdown pub days_in_drawdown: i32, } /// Drawdown monitor for tracking portfolio drawdowns #[derive(Debug)] pub struct DrawdownMonitor { /// Configuration for drawdown alerts per portfolio alert_configs: RwLock>, /// Broadcast channel for alerts alert_sender: broadcast::Sender, /// Historical P&L tracking for drawdown calculation pnl_history: RwLock>>, /// Internal high-water mark tracking per portfolio. /// This ensures drawdown is never understated by stale caller-supplied HWM. internal_hwm: RwLock>, } impl Default for DrawdownMonitor { fn default() -> Self { let (alert_sender, _) = broadcast::channel(1000); Self { alert_configs: RwLock::new(HashMap::new()), alert_sender, pnl_history: RwLock::new(HashMap::new()), internal_hwm: RwLock::new(HashMap::new()), } } } impl DrawdownMonitor { /// Create a new `DrawdownMonitor` #[must_use] pub fn new() -> Self { Self::default() } /// Configure alerts for a portfolio pub async fn configure_alerts(&self, config: DrawdownAlertConfig) -> RiskResult<()> { let mut configs = self.alert_configs.write().await; configs.insert(config.portfolio_id.clone().unwrap_or_default(), config); Ok(()) } /// Update P&L and return any alerts triggered pub async fn update_pnl(&self, metrics: &PnLMetrics) -> RiskResult> { let mut alerts = Vec::new(); // Get current alert subscriber to catch any alerts let mut alert_receiver = self.subscribe_alerts(); // Process the P&L self.process_pnl(metrics).await?; // Try to receive any alerts that were sent while let Ok(alert) = alert_receiver.try_recv() { alerts.push(alert); } Ok(alerts) } /// Process P&L metrics and check for drawdown alerts pub async fn process_pnl(&self, metrics: &PnLMetrics) -> RiskResult<()> { // Store P&L history { let mut history = self.pnl_history.write().await; let portfolio_history = history .entry(metrics.portfolio_id.clone()) .or_insert_with(Vec::new); portfolio_history.push(metrics.clone()); // Keep only recent history (last 1000 entries) if portfolio_history.len() > 1000 { portfolio_history.drain(0..portfolio_history.len() - 1000); } } // Check for drawdown alerts let configs = self.alert_configs.read().await; if let Some(config) = configs.get(&metrics.portfolio_id) { if config.enabled { self.check_drawdown_thresholds(metrics, config).await?; } } Ok(()) } /// Check if drawdown has exceeded configured thresholds /// /// Uses internally tracked high-water mark instead of the caller-supplied /// value to prevent stale or incorrect HWM from understating drawdown. async fn check_drawdown_thresholds( &self, metrics: &PnLMetrics, config: &DrawdownAlertConfig, ) -> RiskResult<()> { let current_pnl = metrics.total_pnl.to_f64(); let caller_hwm = metrics.high_water_mark.to_f64(); // Use internal HWM tracking: always update to the running maximum // of (current_pnl, caller-supplied HWM, previously tracked HWM). // This ensures drawdown is never understated by stale caller data, // while still accepting a higher caller HWM on the first update // (e.g. when the portfolio already had a peak before monitoring started). let hwm = { let mut hwm_map = self.internal_hwm.write().await; let initial = current_pnl.max(caller_hwm); let entry = hwm_map .entry(metrics.portfolio_id.clone()) .or_insert(initial); // On subsequent updates, only allow HWM to increase let candidate = current_pnl.max(caller_hwm); if candidate > *entry { *entry = candidate; } *entry }; let current_drawdown_pct = if hwm > 0.0 { ((hwm - current_pnl) / hwm) * 100.0 } else { 0.0 }; let current_drawdown_pct = current_drawdown_pct.abs(); // Check thresholds in order of severity if current_drawdown_pct >= config.emergency_threshold { self.send_alert( &metrics.portfolio_id, RiskSeverity::Critical, current_drawdown_pct, config.emergency_threshold, "Emergency drawdown threshold exceeded", ) .await; } else if current_drawdown_pct >= config.critical_threshold { self.send_alert( &metrics.portfolio_id, RiskSeverity::High, current_drawdown_pct, config.critical_threshold, "Critical drawdown threshold exceeded", ) .await; } else if current_drawdown_pct >= config.warning_threshold { self.send_alert( &metrics.portfolio_id, RiskSeverity::Medium, current_drawdown_pct, config.warning_threshold, "Warning drawdown threshold exceeded", ) .await; } Ok(()) } /// Send a drawdown alert async fn send_alert( &self, portfolio_id: &str, severity: RiskSeverity, current_pct: f64, threshold_pct: f64, message: &str, ) { let alert = DrawdownAlert { portfolio_id: portfolio_id.to_owned(), severity, current_drawdown_pct: current_pct, threshold_pct, message: message.to_owned(), timestamp: Utc::now(), }; if let Err(e) = self.alert_sender.send(alert) { warn!("Failed to send drawdown alert: {}", e); } } /// Subscribe to drawdown alerts pub fn subscribe_alerts(&self) -> broadcast::Receiver { self.alert_sender.subscribe() } /// Get current alert configuration for a portfolio pub async fn get_alert_config(&self, portfolio_id: &str) -> Option { let configs = self.alert_configs.read().await; configs.get(portfolio_id).cloned() } /// Get P&L history for a portfolio pub async fn get_pnl_history(&self, portfolio_id: &str) -> Vec { let history = self.pnl_history.read().await; history.get(portfolio_id).cloned().unwrap_or_default() } /// Get drawdown statistics for a portfolio pub async fn get_drawdown_stats(&self, portfolio_id: &str) -> RiskResult { let history = self.pnl_history.read().await; let empty_vec = Vec::new(); let portfolio_history = history.get(portfolio_id).unwrap_or(&empty_vec); if portfolio_history.is_empty() { return Ok(DrawdownStats { current_drawdown_pct: 0.0, max_drawdown_pct: 0.0, high_water_mark: 0.0, days_in_drawdown: 0, }); } let latest = portfolio_history .last() .ok_or_else(|| RiskError::CalculationError("Portfolio history is empty".to_owned()))?; let current_pnl = latest.total_pnl.to_f64(); // Use internal HWM if available, falling back to caller-supplied value let hwm = { let hwm_map = self.internal_hwm.read().await; hwm_map .get(portfolio_id) .copied() .unwrap_or_else(|| latest.high_water_mark.to_f64()) }; // Calculate drawdown percentage properly // Drawdown = (HWM - Current P&L) / HWM * 100 let current_drawdown_pct = if hwm > 0.0 { ((hwm - current_pnl) / hwm) * 100.0 } else { 0.0 }; // For max drawdown, use the max_drawdown value from metrics let max_dd = latest.max_drawdown.to_f64(); let max_drawdown_pct = if hwm > 0.0 { (max_dd.abs() / hwm) * 100.0 } else { 0.0 }; Ok(DrawdownStats { current_drawdown_pct, max_drawdown_pct, high_water_mark: hwm, days_in_drawdown: 0, // Would need more complex calculation based on history }) } } #[cfg(test)] #[allow(clippy::float_cmp, clippy::get_first, clippy::str_to_string)] mod tests { use super::*; use common::Price; // operations module removed - use direct imports from common fn create_test_pnl_metrics(portfolio_id: &str, pnl: i64) -> PnLMetrics { PnLMetrics { portfolio_id: portfolio_id.to_string(), realized_pnl: Price::from_f64(pnl as f64 * 0.6).unwrap_or(Price::ZERO), unrealized_pnl: Price::from_f64(pnl as f64 * 0.4).unwrap_or(Price::ZERO), total_unrealized_pnl: Price::from_f64(pnl as f64 * 0.4).unwrap_or(Price::ZERO), total_pnl: Price::from_f64(pnl as f64).unwrap_or(Price::ZERO), daily_pnl: Price::from_f64(pnl as f64 * 0.1).unwrap_or(Price::ZERO), inception_pnl: Price::from_f64(pnl as f64).unwrap_or(Price::ZERO), max_drawdown: Price::ZERO, current_drawdown_pct: 0.0, high_water_mark: Price::from_f64(1000000.0).unwrap_or(Price::ZERO), roi_pct: 0.0, timestamp: Utc::now().timestamp(), } } #[tokio::test] async fn test_drawdown_monitor_creation() { let _monitor = DrawdownMonitor::default(); // Test passes if no panic } #[tokio::test] async fn test_alert_configuration() { let monitor = DrawdownMonitor::default(); let config = DrawdownAlertConfig { portfolio_id: Some("test_portfolio".to_string()), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: true, }; let _ = monitor.configure_alerts(config).await; let configs = monitor.alert_configs.read().await; assert!(configs.contains_key("test_portfolio")); } #[tokio::test] async fn test_drawdown_calculation() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); // Configure alerts let config = DrawdownAlertConfig { portfolio_id: Some("test_portfolio".to_string()), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: true, }; let _ = monitor.configure_alerts(config).await; // Simulate P&L progression with drawdown let mut pnl_metrics = create_test_pnl_metrics("test_portfolio", 1000000); monitor.update_pnl(&pnl_metrics).await?; // Simulate drawdown pnl_metrics.total_pnl = Price::from_f64(900000.0).unwrap_or(Price::ZERO); // 10% drawdown let alerts = monitor.update_pnl(&pnl_metrics).await?; assert!(!alerts.is_empty()); assert_eq!( alerts.get(0).map(|a| &a.severity), Some(&RiskSeverity::High) ); // Should trigger critical alert let stats = monitor.get_drawdown_stats("test_portfolio").await?; assert!(stats.current_drawdown_pct >= 10.0); Ok(()) } #[tokio::test] async fn test_drawdown_emergency_threshold() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); let config = DrawdownAlertConfig { portfolio_id: Some("test_portfolio".to_string()), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: true, }; let _ = monitor.configure_alerts(config).await; // Simulate large drawdown let mut pnl_metrics = create_test_pnl_metrics("test_portfolio", 1000000); pnl_metrics.total_pnl = Price::from_f64(750000.0).unwrap_or(Price::ZERO); // 25% drawdown let alerts = monitor.update_pnl(&pnl_metrics).await?; assert!(!alerts.is_empty()); assert_eq!( alerts.get(0).map(|a| &a.severity), Some(&RiskSeverity::Critical) ); // Should trigger emergency alert Ok(()) } #[tokio::test] async fn test_drawdown_disabled_alerts() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); let config = DrawdownAlertConfig { portfolio_id: Some("test_portfolio".to_string()), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: false, // Disabled }; let _ = monitor.configure_alerts(config).await; // Simulate drawdown let mut pnl_metrics = create_test_pnl_metrics("test_portfolio", 1000000); pnl_metrics.total_pnl = Price::from_f64(900000.0).unwrap_or(Price::ZERO); let alerts = monitor.update_pnl(&pnl_metrics).await?; assert!(alerts.is_empty()); // No alerts when disabled Ok(()) } #[tokio::test] async fn test_drawdown_zero_hwm() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); // Metrics with zero high water mark let mut pnl_metrics = create_test_pnl_metrics("test_portfolio", 1000000); pnl_metrics.high_water_mark = Price::ZERO; monitor.update_pnl(&pnl_metrics).await?; let stats = monitor.get_drawdown_stats("test_portfolio").await?; assert_eq!(stats.current_drawdown_pct, 0.0); // Should be 0 when HWM is 0 Ok(()) } #[tokio::test] async fn test_drawdown_multiple_portfolios() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); // Configure alerts for multiple portfolios for i in 1..=3 { let config = DrawdownAlertConfig { portfolio_id: Some(format!("portfolio_{}", i)), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: true, }; let _ = monitor.configure_alerts(config).await; } // Update P&L for each portfolio for i in 1..=3 { let pnl_metrics = create_test_pnl_metrics(&format!("portfolio_{}", i), 1000000 - (i * 50000)); monitor.update_pnl(&pnl_metrics).await?; } // Verify each portfolio has its own stats for i in 1..=3 { let stats = monitor .get_drawdown_stats(&format!("portfolio_{}", i)) .await?; assert!(stats.high_water_mark > 0.0); } Ok(()) } #[tokio::test] async fn test_drawdown_history_limit() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); // Add more than 1000 P&L entries for i in 0..1100 { let pnl_metrics = create_test_pnl_metrics("test_portfolio", 1000000 - i); monitor.process_pnl(&pnl_metrics).await?; } let history = monitor.get_pnl_history("test_portfolio").await; assert!(history.len() <= 1000); // Should be capped at 1000 Ok(()) } #[tokio::test] async fn test_get_alert_config() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); let config = DrawdownAlertConfig { portfolio_id: Some("test_portfolio".to_string()), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: true, }; let _ = monitor.configure_alerts(config.clone()).await; let retrieved_config = monitor.get_alert_config("test_portfolio").await; assert!(retrieved_config.is_some()); assert_eq!(retrieved_config.unwrap().warning_threshold, 5.0); Ok(()) } #[tokio::test] async fn test_drawdown_stats_empty_portfolio() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); let stats = monitor.get_drawdown_stats("nonexistent_portfolio").await?; assert_eq!(stats.current_drawdown_pct, 0.0); assert_eq!(stats.max_drawdown_pct, 0.0); assert_eq!(stats.high_water_mark, 0.0); Ok(()) } #[tokio::test] async fn test_internal_hwm_ignores_stale_caller_value() -> Result<(), Box> { let monitor = DrawdownMonitor::default(); // Configure alerts so check_drawdown_thresholds runs let config = DrawdownAlertConfig { portfolio_id: Some("hwm_test".to_string()), warning_threshold: 5.0, critical_threshold: 10.0, emergency_threshold: 20.0, enabled: true, }; let _ = monitor.configure_alerts(config).await; // Step 1: Portfolio reaches 1,000,000 total PnL (this sets the real HWM) let mut metrics = create_test_pnl_metrics("hwm_test", 1_000_000); metrics.high_water_mark = Price::from_f64(1_000_000.0).unwrap_or(Price::ZERO); monitor.update_pnl(&metrics).await?; // Step 2: Portfolio drops to 900,000 but caller sends stale HWM of 900,000 // (understating the drawdown from 10% to 0%) metrics.total_pnl = Price::from_f64(900_000.0).unwrap_or(Price::ZERO); metrics.high_water_mark = Price::from_f64(900_000.0).unwrap_or(Price::ZERO); // STALE! let alerts = monitor.update_pnl(&metrics).await?; // The internal HWM should still be 1,000,000, so drawdown = 10% // which should trigger critical_threshold (10.0) assert!( !alerts.is_empty(), "Expected drawdown alert because internal HWM is 1,000,000 not stale 900,000" ); // Verify get_drawdown_stats also uses the internal HWM let stats = monitor.get_drawdown_stats("hwm_test").await?; assert!( (stats.high_water_mark - 1_000_000.0).abs() < 1.0, "Internal HWM should be 1,000,000 but was {}", stats.high_water_mark ); assert!( stats.current_drawdown_pct >= 9.0, "Drawdown should be ~10% but was {}", stats.current_drawdown_pct ); Ok(()) } }