Files
foxhunt/crates/risk/src/drawdown_monitor.rs
jgrusewski db6462ba7a fix(clippy): resolve all clippy warnings across entire workspace (--all-targets)
Systematic fix of 360+ clippy errors across 37+ crates covering lib,
test, bench, and example targets. Key changes:

- Add targeted #[allow(...)] on #[cfg(test)] modules for test-only lints
  (assertions_on_result_states, float_cmp, str_to_string, indexing, etc.)
- Feature-gate broken integration tests behind __<crate>_integration flags
  where public APIs changed (trading-service, backtesting-service, etc.)
- Remove dead [[test]] entries from Cargo.toml files pointing to deleted files
- Fix production code: field_reassign_with_default, manual_range_contains,
  assert!(false) → panic!(), format!("{}") simplification, len() > 0 → !is_empty()
- Delete truly unused code (Order struct, unused methods/fields/variants)
- Convert sqlx::query!() to sqlx::query() for SQLX_OFFLINE compatibility

Result: cargo clippy --workspace --all-targets -- -D warnings = 0 errors, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:18:35 +01:00

574 lines
20 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>>>,
/// Internal high-water mark tracking per portfolio.
/// This ensures drawdown is never understated by stale caller-supplied HWM.
internal_hwm: RwLock<HashMap<PortfolioId, f64>>,
}
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<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
///
/// 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<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()))?;
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<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(())
}
#[tokio::test]
async fn test_internal_hwm_ignores_stale_caller_value() -> Result<(), Box<dyn std::error::Error>>
{
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(())
}
}