Files
foxhunt/services/trading_service/tests/ensemble_risk_integration_test.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

558 lines
16 KiB
Rust

#![allow(unexpected_cfgs)]
#![cfg(feature = "__trading_service_integration")]
//! Integration tests for Ensemble Risk Manager
//!
//! Tests all risk management scenarios:
//! - Low confidence rejection
//! - High disagreement rejection
//! - Per-model circuit breakers
//! - Cascade failure detection
//! - VaR integration
//! - Model cooldown and recovery
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio;
use ml::ensemble::{EnsembleDecision, ModelVote, TradingAction};
use trading_service::ensemble_risk_manager::{
EnsembleRiskConfig, EnsembleRiskManager, RiskValidationResult,
};
#[tokio::test]
async fn test_low_confidence_rejection() {
let config = EnsembleRiskConfig {
min_confidence_threshold: 0.70,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Create low confidence decision
let decision = EnsembleDecision::new(
TradingAction::Buy,
0.55, // Below 70% threshold
0.60,
0.20,
HashMap::new(),
);
let result = manager
.validate_prediction(&decision, "TEST_ACCOUNT")
.await
.unwrap();
assert!(!result.approved, "Low confidence should be rejected");
assert!(
result.rejection_reason.is_some(),
"Rejection reason should be provided"
);
assert!(
result.rejection_reason.unwrap().contains("Low confidence"),
"Rejection reason should mention confidence"
);
assert_eq!(result.confidence, 0.55);
}
#[tokio::test]
async fn test_high_disagreement_rejection() {
let config = EnsembleRiskConfig {
max_disagreement_rate: 0.40,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Create high disagreement decision
let decision = EnsembleDecision::new(
TradingAction::Buy,
0.75,
0.65,
0.55, // Above 40% disagreement threshold
HashMap::new(),
);
let result = manager
.validate_prediction(&decision, "TEST_ACCOUNT")
.await
.unwrap();
assert!(!result.approved, "High disagreement should be rejected");
assert!(
result
.rejection_reason
.unwrap()
.contains("High disagreement"),
"Rejection reason should mention disagreement"
);
}
#[tokio::test]
async fn test_model_circuit_breaker_after_consecutive_errors() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 3,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Register model
manager.register_model("DQN".to_string()).await.unwrap();
// Verify model starts enabled
let health = manager.get_model_health("DQN").await.unwrap();
assert!(health.enabled, "Model should start enabled");
assert_eq!(health.consecutive_errors, 0, "Should start with 0 errors");
// Record 3 consecutive errors
for i in 1..=3 {
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
let health = manager.get_model_health("DQN").await.unwrap();
assert_eq!(health.consecutive_errors, i);
assert_eq!(health.failed_predictions, i as u64);
}
// Verify model is disabled after 3 errors
let health = manager.get_model_health("DQN").await.unwrap();
assert!(!health.enabled, "Model should be disabled after 3 errors");
assert_eq!(health.consecutive_errors, 3);
assert!(
health.disabled_at.is_some(),
"Disabled timestamp should be set"
);
assert!(
health.cooldown_until.is_some(),
"Cooldown period should be set"
);
}
#[tokio::test]
async fn test_cascade_failure_detection() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 2,
cascade_failure_threshold: 2, // 2+ models = cascade
cascade_detection_window_secs: 60,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Register 3 models
manager.register_model("DQN".to_string()).await.unwrap();
manager.register_model("PPO".to_string()).await.unwrap();
manager.register_model("TFT".to_string()).await.unwrap();
// Verify ensemble is operational
assert!(
manager.is_operational().await,
"Ensemble should start operational"
);
// Fail first model (2 errors to disable)
for _ in 0..2 {
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
}
let dqn_health = manager.get_model_health("DQN").await.unwrap();
assert!(!dqn_health.enabled, "DQN should be disabled");
// Ensemble should still be operational (only 1 model failed)
assert!(
manager.is_operational().await,
"Ensemble should be operational with 1 failed model"
);
// Fail second model to trigger cascade
for _ in 0..2 {
manager
.record_prediction_result("PPO", false)
.await
.unwrap();
}
let ppo_health = manager.get_model_health("PPO").await.unwrap();
assert!(!ppo_health.enabled, "PPO should be disabled");
// Verify cascade detected
let cascade_state = manager.get_cascade_state().await;
assert!(
cascade_state.is_cascading,
"Cascade should be detected with 2 failed models"
);
assert_eq!(
cascade_state.failed_models.len(),
2,
"Should track 2 failed models"
);
assert!(
cascade_state.failed_models.contains(&"DQN".to_string()),
"Should include DQN"
);
assert!(
cascade_state.failed_models.contains(&"PPO".to_string()),
"Should include PPO"
);
// Ensemble should NOT be operational
assert!(
!manager.is_operational().await,
"Ensemble should not be operational during cascade"
);
// Predictions should be rejected during cascade
let decision = EnsembleDecision::new(TradingAction::Buy, 0.85, 0.70, 0.15, HashMap::new());
let result = manager
.validate_prediction(&decision, "TEST_ACCOUNT")
.await
.unwrap();
assert!(
!result.approved,
"Predictions should be rejected during cascade"
);
assert!(result.cascade_detected || result.rejection_reason.is_some());
}
#[tokio::test]
async fn test_successful_predictions_reset_consecutive_errors() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 3,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
manager.register_model("DQN".to_string()).await.unwrap();
// Record 2 errors
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
let health = manager.get_model_health("DQN").await.unwrap();
assert_eq!(health.consecutive_errors, 2);
assert!(health.enabled, "Should still be enabled");
// Record successful prediction
manager.record_prediction_result("DQN", true).await.unwrap();
// Verify consecutive errors reset
let health = manager.get_model_health("DQN").await.unwrap();
assert_eq!(
health.consecutive_errors, 0,
"Consecutive errors should reset"
);
assert!(health.enabled, "Should remain enabled");
assert_eq!(health.successful_predictions, 1);
assert_eq!(health.failed_predictions, 2);
}
#[tokio::test]
async fn test_model_cooldown_and_recovery() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 2,
model_cooldown_period_secs: 1, // 1 second for fast testing
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
manager.register_model("DQN".to_string()).await.unwrap();
// Disable model with 2 errors
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
let health = manager.get_model_health("DQN").await.unwrap();
assert!(!health.enabled, "Model should be disabled");
assert!(health.is_in_cooldown(), "Should be in cooldown");
// Try to enable immediately (should fail - still in cooldown)
let enabled = manager.try_enable_model("DQN").await.unwrap();
assert!(!enabled, "Should not enable during cooldown period");
let health = manager.get_model_health("DQN").await.unwrap();
assert!(
!health.enabled,
"Model should still be disabled during cooldown"
);
// Wait for cooldown period to expire
tokio::time::sleep(Duration::from_secs(2)).await;
// Try to enable after cooldown
let enabled = manager.try_enable_model("DQN").await.unwrap();
assert!(enabled, "Should enable after cooldown period");
let health = manager.get_model_health("DQN").await.unwrap();
assert!(health.enabled, "Model should be enabled");
assert_eq!(health.consecutive_errors, 0, "Errors should be reset");
assert!(!health.is_in_cooldown(), "Should not be in cooldown");
}
#[tokio::test]
async fn test_approved_prediction_with_good_metrics() {
let config = EnsembleRiskConfig::default();
let manager = EnsembleRiskManager::new(config);
// Create high-quality decision
let decision = EnsembleDecision::new(
TradingAction::Buy,
0.85, // High confidence
0.80,
0.10, // Low disagreement
HashMap::new(),
);
let result = manager
.validate_prediction(&decision, "TEST_ACCOUNT")
.await
.unwrap();
assert!(result.approved, "Good prediction should be approved");
assert!(
result.rejection_reason.is_none(),
"No rejection reason for approved prediction"
);
assert_eq!(result.confidence, 0.85);
assert_eq!(result.disagreement_rate, 0.10);
assert!(
result.validation_latency_us > 0,
"Should track validation latency"
);
}
#[tokio::test]
async fn test_cascade_manual_reset() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 1,
cascade_failure_threshold: 2,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Register and fail 2 models
manager.register_model("DQN".to_string()).await.unwrap();
manager.register_model("PPO".to_string()).await.unwrap();
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
manager
.record_prediction_result("PPO", false)
.await
.unwrap();
// Verify cascade detected
let cascade_state = manager.get_cascade_state().await;
assert!(cascade_state.is_cascading);
// Manually reset cascade
manager.reset_cascade_state().await.unwrap();
// Verify cascade cleared
let cascade_state = manager.get_cascade_state().await;
assert!(!cascade_state.is_cascading, "Cascade should be cleared");
assert_eq!(
cascade_state.failed_models.len(),
0,
"Failed models should be cleared"
);
}
#[tokio::test]
async fn test_multiple_model_health_tracking() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 3,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Register 3 models
manager.register_model("DQN".to_string()).await.unwrap();
manager.register_model("PPO".to_string()).await.unwrap();
manager.register_model("TFT".to_string()).await.unwrap();
// Different success rates for each model
// DQN: 2 success, 1 failure
manager.record_prediction_result("DQN", true).await.unwrap();
manager.record_prediction_result("DQN", true).await.unwrap();
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
// PPO: 1 success, 2 failures (but not consecutive)
manager.record_prediction_result("PPO", true).await.unwrap();
manager
.record_prediction_result("PPO", false)
.await
.unwrap();
manager
.record_prediction_result("PPO", false)
.await
.unwrap();
// TFT: 3 successes
manager.record_prediction_result("TFT", true).await.unwrap();
manager.record_prediction_result("TFT", true).await.unwrap();
manager.record_prediction_result("TFT", true).await.unwrap();
// Check all health statuses
let all_health = manager.get_all_model_health().await;
assert_eq!(all_health.len(), 3);
let dqn_health = all_health.get("DQN").expect("INVARIANT: Key should exist in map");
assert_eq!(dqn_health.successful_predictions, 2);
assert_eq!(dqn_health.failed_predictions, 1);
assert_eq!(dqn_health.consecutive_errors, 1); // Last was failure
assert!(dqn_health.enabled);
let ppo_health = all_health.get("PPO").expect("INVARIANT: Key should exist in map");
assert_eq!(ppo_health.successful_predictions, 1);
assert_eq!(ppo_health.failed_predictions, 2);
assert_eq!(ppo_health.consecutive_errors, 2); // Last 2 were failures
assert!(ppo_health.enabled); // Not yet at threshold
let tft_health = all_health.get("TFT").expect("INVARIANT: Key should exist in map");
assert_eq!(tft_health.successful_predictions, 3);
assert_eq!(tft_health.failed_predictions, 0);
assert_eq!(tft_health.consecutive_errors, 0);
assert!(tft_health.enabled);
// Verify enabled count
assert_eq!(
manager.enabled_model_count().await,
3,
"All models should be enabled"
);
}
#[tokio::test]
async fn test_cascade_detection_window_expiry() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 1,
cascade_failure_threshold: 2,
cascade_detection_window_secs: 1, // 1 second window
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
manager.register_model("DQN".to_string()).await.unwrap();
manager.register_model("PPO".to_string()).await.unwrap();
// Fail first model
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
let cascade_state = manager.get_cascade_state().await;
assert_eq!(cascade_state.failed_models.len(), 1);
assert!(!cascade_state.is_cascading);
// Wait for detection window to expire
tokio::time::sleep(Duration::from_secs(2)).await;
// Fail second model (should reset window, not trigger cascade)
manager
.record_prediction_result("PPO", false)
.await
.unwrap();
let cascade_state = manager.get_cascade_state().await;
// Window should have reset, so only PPO in failed list
assert!(
cascade_state.failed_models.len() <= 2,
"Detection window should have reset"
);
}
#[tokio::test]
async fn test_error_rate_calculation() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 10, // High threshold to prevent disabling
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
manager.register_model("DQN".to_string()).await.unwrap();
// Record 7 successes and 3 failures
for _ in 0..7 {
manager.record_prediction_result("DQN", true).await.unwrap();
}
for _ in 0..3 {
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
}
let health = manager.get_model_health("DQN").await.unwrap();
assert_eq!(health.total_predictions, 10);
assert_eq!(health.successful_predictions, 7);
assert_eq!(health.failed_predictions, 3);
let error_rate = health.error_rate();
assert!((error_rate - 0.30).abs() < 0.01, "Error rate should be 30%");
}
#[tokio::test]
async fn test_validation_tracks_disabled_models() {
let config = EnsembleRiskConfig {
max_consecutive_errors: 2,
..Default::default()
};
let manager = EnsembleRiskManager::new(config);
// Register 3 models
manager.register_model("DQN".to_string()).await.unwrap();
manager.register_model("PPO".to_string()).await.unwrap();
manager.register_model("TFT".to_string()).await.unwrap();
// Disable DQN
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
manager
.record_prediction_result("DQN", false)
.await
.unwrap();
// Make good prediction
let decision = EnsembleDecision::new(TradingAction::Buy, 0.85, 0.80, 0.15, HashMap::new());
let result = manager
.validate_prediction(&decision, "TEST_ACCOUNT")
.await
.unwrap();
assert!(result.approved);
assert_eq!(
result.disabled_models.len(),
1,
"Should track 1 disabled model"
);
assert!(
result.disabled_models.contains(&"DQN".to_string()),
"Should list DQN as disabled"
);
}