Files
foxhunt/risk/src/drawdown_monitor.rs
jgrusewski 6093eac7bf 🔧 Tonic 0.14 Upgrade: Auto-generated and build system changes
Wave 64-65 cleanup: Proto regeneration and build system updates from Tonic 0.12→0.14 upgrade

Files updated:
- Cargo.lock: Dependency resolution for Tonic 0.14.2
- All build.rs: Updated for tonic-prost-build
- Proto files: Regenerated with tonic-prost 0.14
- Examples/tests: Updated for new gRPC API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 07:34:26 +02:00

492 lines
16 KiB
Rust

//! 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<Utc>,
}
/// 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<HashMap<PortfolioId, DrawdownAlertConfig>>,
/// Broadcast channel for alerts
alert_sender: broadcast::Sender<DrawdownAlert>,
/// Historical P&L tracking for drawdown calculation
pnl_history: RwLock<HashMap<PortfolioId, Vec<PnLMetrics>>>,
}
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()),
}
}
}
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<Vec<DrawdownAlert>> {
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
async fn check_drawdown_thresholds(
&self,
metrics: &PnLMetrics,
config: &DrawdownAlertConfig,
) -> RiskResult<()> {
// Calculate drawdown percentage properly
// Drawdown = (HWM - Current P&L) / HWM * 100
let hwm = metrics.high_water_mark.to_f64();
let current_pnl = metrics.total_pnl.to_f64();
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<DrawdownAlert> {
self.alert_sender.subscribe()
}
/// Get current alert configuration for a portfolio
pub async fn get_alert_config(&self, portfolio_id: &str) -> Option<DrawdownAlertConfig> {
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<PnLMetrics> {
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<DrawdownStats> {
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()))?;
// Calculate drawdown percentage properly
// Drawdown = (HWM - Current P&L) / HWM * 100
let hwm = latest.high_water_mark.to_f64();
let current_pnl = latest.total_pnl.to_f64();
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: latest.high_water_mark.to_f64(),
days_in_drawdown: 0, // Would need more complex calculation based on history
})
}
}
#[cfg(test)]
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
}