//! Multi-CUSUM Integration Tests //! //! Tests multi-feature structural break detection with: //! - Unit tests for all detection modes //! - Real Databento market data (ES.FUT) //! - Performance benchmarks //! - Edge case validation use ml::regime::multi_cusum::{CUSUMConfig, DetectionMode, MultiCUSUM}; // ============================================================================ // Unit Tests // ============================================================================ #[test] fn test_multi_cusum_any_mode_single_feature_trigger() { // Test: ANY mode should detect when any single feature triggers let configs = vec![ CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.5, 0.3, 0.2]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap(); // Stable phase for i in 0..50 { let features = vec![0.0, 0.0, 0.0]; assert!(detector.update(&features, i).is_none()); } // Trigger only first feature let mut detected = false; for i in 50..100 { let features = vec![0.05, 0.0, 0.0]; // Only first breaks (5 std devs) if let Some(multi_break) = detector.update(&features, i) { assert_eq!(multi_break.triggered_features.len(), 1); assert_eq!(multi_break.triggered_features[0], 0); assert_eq!(multi_break.detection_score, 1.0); detected = true; break; } } assert!( detected, "ANY mode should detect with single feature trigger" ); } #[test] fn test_multi_cusum_all_mode_requires_all_features() { // Test: ALL mode should require all features to trigger let configs = vec![ CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.5, 0.5]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::All).unwrap(); // Stable phase for i in 0..50 { let features = vec![0.0, 0.0]; assert!(detector.update(&features, i).is_none()); } // Trigger only first feature (should NOT detect) for i in 50..80 { let features = vec![0.05, 0.0]; assert!( detector.update(&features, i).is_none(), "ALL mode should not detect with partial triggers" ); } // Trigger both features let mut detected = false; for i in 80..150 { let features = vec![0.05, 0.05]; if let Some(multi_break) = detector.update(&features, i) { assert_eq!(multi_break.triggered_features.len(), 2); assert_eq!(multi_break.detection_score, 1.0); detected = true; break; } } assert!(detected, "ALL mode should detect when all features trigger"); } #[test] fn test_multi_cusum_weighted_vote_threshold() { // Test: WEIGHTED_VOTE mode with importance-based threshold let configs = vec![ CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.5, 0.3, 0.2]; // Returns > Volatility > Volume let mode = DetectionMode::WeightedVote { threshold: 0.6 }; let mut detector = MultiCUSUM::new(configs, weights, mode).unwrap(); // Stable data for i in 0..50 { let features = vec![0.0, 0.0, 0.0]; assert!(detector.update(&features, i).is_none()); } // Trigger only volume (weight=0.2, below 0.6 threshold) for i in 50..80 { let features = vec![0.0, 0.0, 0.05]; assert!( detector.update(&features, i).is_none(), "Score 0.2 < 0.6 threshold" ); } // Trigger returns + volatility (0.5 + 0.3 = 0.8 > 0.6) let mut detected = false; for i in 80..150 { let features = vec![0.05, 0.05, 0.0]; if let Some(multi_break) = detector.update(&features, i) { assert!(multi_break.detection_score >= 0.6); assert!(multi_break.detection_score <= 1.0); assert_eq!(multi_break.triggered_features.len(), 2); detected = true; break; } } assert!( detected, "Weighted vote should detect when score >= threshold" ); } #[test] fn test_multi_cusum_different_thresholds_per_feature() { // Test: Different sensitivity per feature (different thresholds) let configs = vec![ CUSUMConfig { threshold: 5.0, // Less sensitive (higher threshold) baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 2.0, // More sensitive (lower threshold) baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.5, 0.5]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap(); // Moderate shift should trigger second feature only let mut detected = false; for i in 0..100 { let features = vec![0.02, 0.02]; // 2 std devs - only triggers feature 1 if let Some(multi_break) = detector.update(&features, i) { assert_eq!(multi_break.triggered_features, vec![1]); detected = true; break; } } assert!(detected, "More sensitive feature should trigger first"); } #[test] fn test_multi_cusum_upward_and_downward_breaks() { // Test: Detect both upward and downward structural breaks let configs = vec![ CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.5, 0.5]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap(); // Upward break let mut upward_detected = false; for i in 0..100 { let features = vec![0.05, 0.0]; if detector.update(&features, i).is_some() { upward_detected = true; break; } } assert!(upward_detected); // Downward break (after reset) let mut downward_detected = false; for i in 100..200 { let features = vec![-0.05, 0.0]; if detector.update(&features, i).is_some() { downward_detected = true; break; } } assert!(downward_detected); } #[test] fn test_multi_cusum_feature_status_tracking() { // Test: Track status of all features let configs = vec![ CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.4, 0.35, 0.25]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap(); // Process data for i in 0..100 { let features = vec![0.0, 0.0, 0.0]; detector.update(&features, i); } // Check all statuses let statuses = detector.get_feature_statuses(); assert_eq!(statuses.len(), 3); for status in &statuses { assert_eq!(status.total_bars, 100); assert!(status.bars_since_reset <= 100); assert!(status.last_break.is_none()); } } #[test] fn test_multi_cusum_baseline_update() { // Test: Update baseline for adaptive detection let configs = vec![ CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 3.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, ]; let weights = vec![0.6, 0.4]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap(); // Update baseline for first feature detector.update_feature_baseline(0, 0.05, 0.02); // New baseline means previous "break" values are now normal for i in 0..50 { let features = vec![0.05, 0.0]; // Now aligned with new baseline assert!(detector.update(&features, i).is_none()); } } #[test] fn test_multi_cusum_zero_features_rejection() { // Test: Reject empty feature configuration let configs = Vec::new(); let weights = Vec::new(); let result = MultiCUSUM::new(configs, weights, DetectionMode::Any); assert!(result.is_err()); assert!(result.unwrap_err().contains("At least one feature")); } #[test] fn test_multi_cusum_weight_sum_validation() { // Test: Weights must sum to 1.0 let configs = vec![ CUSUMConfig::default(), CUSUMConfig::default(), CUSUMConfig::default(), ]; // Weights sum to 0.9 (invalid) let bad_weights = vec![0.3, 0.3, 0.3]; let result = MultiCUSUM::new(configs.clone(), bad_weights, DetectionMode::Any); assert!(result.is_err()); // Weights sum to 1.0 (valid) let good_weights = vec![0.4, 0.35, 0.25]; let result = MultiCUSUM::new(configs, good_weights, DetectionMode::Any); assert!(result.is_ok()); } #[test] fn test_multi_cusum_negative_weight_rejection() { // Test: Negative weights are rejected let configs = vec![CUSUMConfig::default(), CUSUMConfig::default()]; let bad_weights = vec![0.7, -0.3]; // Sum to 0.4, but negative weight let result = MultiCUSUM::new(configs, bad_weights, DetectionMode::Any); assert!(result.is_err()); assert!(result.unwrap_err().contains("non-negative")); } #[test] fn test_multi_cusum_performance_benchmark() { // Test: Verify <100μs per update for 3-5 features use std::time::Instant; let configs = vec![ CUSUMConfig { threshold: 4.0, baseline_mean: 0.0, baseline_std: 0.01, min_bars_between: 10, }, CUSUMConfig { threshold: 3.5, baseline_mean: 0.015, baseline_std: 0.005, min_bars_between: 10, }, CUSUMConfig { threshold: 3.0, baseline_mean: 100000.0, baseline_std: 50000.0, min_bars_between: 10, }, ]; let weights = vec![0.5, 0.3, 0.2]; let mut detector = MultiCUSUM::new(configs, weights, DetectionMode::Any).unwrap(); let n_iterations = 1000; let start = Instant::now(); for i in 0..n_iterations { let features = vec![ (i as f64) * 0.0001, 0.015 + (i as f64) * 0.00001, 100000.0 + (i as f64) * 10.0, ]; detector.update(&features, i); } let duration = start.elapsed(); let avg_latency_us = duration.as_micros() as f64 / n_iterations as f64; println!( "Multi-CUSUM average latency: {:.2}μs per update (n={})", avg_latency_us, n_iterations ); assert!( avg_latency_us < 100.0, "Performance target: <100μs per update (got {:.2}μs)", avg_latency_us ); }