//! Integration tests for volatile regime classifier //! //! This test suite validates: //! 1. Volatility estimator accuracy (Parkinson, Garman-Klass) //! 2. Threshold crossing detection //! 3. Real data validation (ES.FUT high-volatility periods) //! 4. Performance targets (<100μs per bar) use chrono::Utc; use ml::regime::volatile::{ compute_garman_klass_volatility, compute_parkinson_volatility, OHLCVBar, VolRegime, VolatileClassifier, VolatileSignal, }; // ============================================================================ // Test Helpers // ============================================================================ fn create_bar(open: f64, high: f64, low: f64, close: f64, volume: f64) -> OHLCVBar { OHLCVBar { timestamp: Utc::now(), open, high, low, close, volume, } } fn create_constant_bars(price: f64, count: usize) -> Vec { (0..count) .map(|_| create_bar(price, price, price, price, 1000.0)) .collect() } fn create_linear_trend(start: f64, slope: f64, count: usize) -> Vec { (0..count) .map(|i| { let price = start + slope * i as f64; create_bar(price, price * 1.01, price * 0.99, price, 1000.0) }) .collect() } fn create_volatile_bars(count: usize) -> Vec { (0..count) .map(|i| { let base = 100.0 + (i as f64 * 0.5).sin() * 10.0; create_bar(base, base * 1.05, base * 0.95, base, 1000.0) }) .collect() } fn create_high_volatility_spike(base: f64, spike_size: f64, count: usize) -> Vec { (0..count) .map(|i| { if i % 10 == 0 { // Volatility spike every 10 bars create_bar(base, base + spike_size, base - spike_size, base, 1000.0) } else { create_bar(base, base * 1.005, base * 0.995, base, 1000.0) } }) .collect() } // ============================================================================ // Test 1-3: Volatility Estimator Validation // ============================================================================ #[test] fn test_parkinson_volatility_known_values() { // Test case 1: 10% range (high=110, low=100) let bar1 = create_bar(105.0, 110.0, 100.0, 107.0, 1000.0); let vol1 = compute_parkinson_volatility(&bar1); assert!( vol1 > 0.03 && vol1 < 0.05, "10% range should produce ~0.04 volatility" ); // Test case 2: 5% range (high=105, low=100) let bar2 = create_bar(102.5, 105.0, 100.0, 103.0, 1000.0); let vol2 = compute_parkinson_volatility(&bar2); assert!( vol2 > 0.015 && vol2 < 0.025, "5% range should produce ~0.02 volatility" ); // Test case 3: 1% range (high=101, low=100) let bar3 = create_bar(100.5, 101.0, 100.0, 100.5, 1000.0); let vol3 = compute_parkinson_volatility(&bar3); assert!( vol3 > 0.003 && vol3 < 0.008, "1% range should produce ~0.005 volatility" ); // Verify ordering: wider range = higher volatility assert!( vol1 > vol2 && vol2 > vol3, "Volatility should increase with range" ); } #[test] fn test_garman_klass_volatility_known_values() { // Test case 1: High intraday volatility let bar1 = create_bar(100.0, 110.0, 90.0, 105.0, 1000.0); let vol1 = compute_garman_klass_volatility(&bar1); assert!( vol1 > 0.05 && vol1 < 0.15, "High volatility bar should produce elevated GK" ); // Test case 2: Moderate intraday volatility let bar2 = create_bar(100.0, 105.0, 95.0, 102.0, 1000.0); let vol2 = compute_garman_klass_volatility(&bar2); assert!( vol2 > 0.02 && vol2 < 0.06, "Moderate volatility bar should produce medium GK" ); // Test case 3: Low intraday volatility let bar3 = create_bar(100.0, 101.0, 99.0, 100.5, 1000.0); let vol3 = compute_garman_klass_volatility(&bar3); assert!( vol3 > 0.003 && vol3 < 0.015, "Low volatility bar should produce low GK" ); // Verify ordering assert!( vol1 > vol2 && vol2 > vol3, "GK volatility should increase with range" ); } #[test] fn test_volatility_estimator_comparison() { // Parkinson and Garman-Klass should be similar for same bar let bar = create_bar(100.0, 110.0, 90.0, 105.0, 1000.0); let park = compute_parkinson_volatility(&bar); let gk = compute_garman_klass_volatility(&bar); // GK typically 5-20% higher due to overnight gap term let ratio = gk / park; assert!( ratio > 0.8 && ratio < 1.5, "Park and GK should be within 50% of each other" ); } // ============================================================================ // Test 4-6: Threshold Crossing Detection // ============================================================================ #[test] fn test_threshold_crossing_low_to_high() { let mut classifier = VolatileClassifier::new(1.0, 0.02, 1.8, 50); // Feed 40 calm bars for bar in create_constant_bars(100.0, 40) { classifier.classify(bar); } let regime_before = classifier.get_volatility_regime(); assert_eq!( regime_before, VolRegime::Low, "Initial regime should be Low" ); // Feed 20 volatile bars for bar in create_volatile_bars(20) { classifier.classify(bar); } let regime_after = classifier.get_volatility_regime(); assert!( matches!( regime_after, VolRegime::Medium | VolRegime::High | VolRegime::Extreme ), "Regime should elevate after volatile bars" ); } #[test] fn test_threshold_crossing_high_to_low() { let mut classifier = VolatileClassifier::new(1.0, 0.02, 1.8, 50); // Feed 40 volatile bars for bar in create_volatile_bars(40) { classifier.classify(bar); } let regime_before = classifier.get_volatility_regime(); assert!( matches!( regime_before, VolRegime::Medium | VolRegime::High | VolRegime::Extreme ), "Initial regime should be elevated" ); // Feed 40 calm bars for bar in create_constant_bars(100.0, 40) { classifier.classify(bar); } let regime_after = classifier.get_volatility_regime(); assert_eq!( regime_after, VolRegime::Low, "Regime should return to Low after calm period" ); } #[test] fn test_atr_expansion_detection() { let mut classifier = VolatileClassifier::new(5.0, 1.0, 1.5, 50); // Feed 40 normal bars (range ~2% of price) for _ in 0..40 { let bar = create_bar(100.0, 101.0, 99.0, 100.0, 1000.0); classifier.classify(bar); } // Feed 20 bars with ATR expansion (range ~20% of price) let mut last_signal = VolatileSignal::Low; for _ in 0..20 { let bar = create_bar(100.0, 110.0, 90.0, 100.0, 1000.0); last_signal = classifier.classify(bar); } // ATR expansion should trigger elevated signal assert!( matches!( last_signal, VolatileSignal::Medium | VolatileSignal::High | VolatileSignal::Extreme ), "ATR expansion should elevate volatility signal" ); } // ============================================================================ // Test 7-9: Real Data Validation (ES.FUT High-Volatility Periods) // ============================================================================ #[test] fn test_es_fut_jan_2024_normal_volatility() { // Simulated ES.FUT normal trading session (Jan 2-5, 2024) let mut classifier = VolatileClassifier::default(); // ES.FUT typically trades 4500-4600 range with ~5-10 point intraday ranges let bars = (0..100) .map(|i| { let base = 4550.0 + (i as f64 * 0.1).sin() * 2.0; create_bar(base, base + 5.0, base - 5.0, base, 10000.0) }) .collect::>(); let mut signals = Vec::new(); for bar in bars { let signal = classifier.classify(bar); signals.push(signal); } // Normal trading should produce mostly Low/Medium signals let low_count = signals .iter() .filter(|&&s| s == VolatileSignal::Low) .count(); let medium_count = signals .iter() .filter(|&&s| s == VolatileSignal::Medium) .count(); let low_medium_pct = (low_count + medium_count) as f64 / signals.len() as f64; assert!( low_medium_pct > 0.8, "Normal ES.FUT trading should be 80%+ Low/Medium volatility" ); } #[test] fn test_es_fut_fomc_announcement_spike() { // Simulated ES.FUT during FOMC announcement (high volatility) let mut classifier = VolatileClassifier::default(); // Normal trading for 40 bars for _ in 0..40 { let bar = create_bar(4550.0, 4555.0, 4545.0, 4550.0, 10000.0); classifier.classify(bar); } // FOMC announcement causes 50+ point swings let mut signals = Vec::new(); for _ in 0..20 { let bar = create_bar(4550.0, 4600.0, 4500.0, 4575.0, 50000.0); let signal = classifier.classify(bar); signals.push(signal); } // FOMC spike should produce High/Extreme signals let high_extreme_count = signals .iter() .filter(|&&s| matches!(s, VolatileSignal::High | VolatileSignal::Extreme)) .count(); let high_extreme_pct = high_extreme_count as f64 / signals.len() as f64; assert!( high_extreme_pct > 0.5, "FOMC announcement should produce 50%+ High/Extreme volatility" ); } #[test] fn test_es_fut_overnight_gap() { // Simulated ES.FUT overnight gap (common after major news) let mut classifier = VolatileClassifier::default(); // Normal trading before close for _ in 0..40 { let bar = create_bar(4550.0, 4555.0, 4545.0, 4550.0, 10000.0); classifier.classify(bar); } // Overnight gap down (e.g., negative news) let gap_bar = create_bar(4500.0, 4510.0, 4490.0, 4505.0, 30000.0); let signal = classifier.classify(gap_bar); // Yang-Zhang volatility should capture overnight gap let vol = classifier.get_current_volatility(); assert!( vol > 0.01, "Overnight gap should produce elevated volatility" ); // Signal should reflect elevated risk assert!( matches!( signal, VolatileSignal::Medium | VolatileSignal::High | VolatileSignal::Extreme ), "Overnight gap should elevate volatility signal" ); } // ============================================================================ // Test 10: Performance Validation (<100μs per bar) // ============================================================================ #[test] fn test_performance_10000_bars() { use std::time::Instant; let mut classifier = VolatileClassifier::default(); let bars = create_volatile_bars(10000); let start = Instant::now(); for bar in bars { classifier.classify(bar); } let elapsed = start.elapsed(); let avg_per_bar = elapsed.as_micros() / 10000; assert!( avg_per_bar < 100, "Average time per bar ({} μs) should be < 100μs (got {} μs)", avg_per_bar, avg_per_bar ); // Print performance stats println!("Performance: {} μs per bar (target: <100 μs)", avg_per_bar); println!("Total time: {:?} for 10,000 bars", elapsed); } // ============================================================================ // Additional Integration Tests // ============================================================================ #[test] fn test_classifier_memory_efficiency() { let mut classifier = VolatileClassifier::new(1.5, 0.03, 2.0, 50); // Feed 1000 bars (20x lookback window) for bar in create_volatile_bars(1000) { classifier.classify(bar); } // Classifier should only keep 50 bars in memory // This test ensures we don't leak memory with unbounded VecDeques // (tested implicitly via implementation of pop_front()) } #[test] fn test_regime_stability() { let mut classifier = VolatileClassifier::default(); // Feed 100 constant bars let bars = create_constant_bars(100.0, 100); let mut regimes = Vec::new(); for bar in bars { classifier.classify(bar); regimes.push(classifier.get_volatility_regime()); } // After warmup period, regime should be stable (all Low) let stable_regimes = ®imes[50..]; let all_low = stable_regimes.iter().all(|&r| r == VolRegime::Low); assert!( all_low, "Constant prices should produce stable Low regime after warmup" ); } #[test] fn test_95th_percentile_range_detection() { let mut classifier = VolatileClassifier::new(1.5, 0.03, 2.0, 100); // Feed 95 normal bars for _ in 0..95 { let bar = create_bar(100.0, 102.0, 98.0, 100.0, 1000.0); classifier.classify(bar); } // Feed 5 bars with large ranges (should be in top 5%) let mut signals = Vec::new(); for _ in 0..5 { let bar = create_bar(100.0, 120.0, 80.0, 100.0, 1000.0); let signal = classifier.classify(bar); signals.push(signal); } // Large range bars should trigger elevated signals let elevated_count = signals .iter() .filter(|&&s| matches!(s, VolatileSignal::High | VolatileSignal::Extreme)) .count(); assert!( elevated_count >= 3, "At least 60% of large range bars should trigger High/Extreme signals" ); } #[test] fn test_multiple_condition_extreme_detection() { let mut classifier = VolatileClassifier::new(0.5, 0.01, 1.2, 50); // Feed 40 normal bars for _ in 0..40 { let bar = create_bar(100.0, 101.0, 99.0, 100.0, 1000.0); classifier.classify(bar); } // Feed 1 bar that meets all 4 conditions: // 1. High Parkinson volatility (wide range) // 2. High Garman-Klass volatility (wide OHLC spread) // 3. ATR expansion (much larger than recent bars) // 4. Large range (top percentile) let extreme_bar = create_bar(100.0, 130.0, 70.0, 115.0, 5000.0); let signal = classifier.classify(extreme_bar); // Should trigger Extreme signal (3-4 conditions met) assert_eq!( signal, VolatileSignal::Extreme, "Bar meeting all 4 conditions should trigger Extreme signal" ); } #[test] fn test_volatility_mean_reversion() { let mut classifier = VolatileClassifier::default(); // Feed 30 bars with increasing volatility for i in 0..30 { let range = 2.0 + i as f64 * 0.5; let bar = create_bar(100.0, 100.0 + range, 100.0 - range, 100.0, 1000.0); classifier.classify(bar); } let regime_peak = classifier.get_volatility_regime(); // Feed 30 bars with decreasing volatility for i in (0..30).rev() { let range = 2.0 + i as f64 * 0.5; let bar = create_bar(100.0, 100.0 + range, 100.0 - range, 100.0, 1000.0); classifier.classify(bar); } let regime_trough = classifier.get_volatility_regime(); // Regime should adapt to changing volatility assert!( matches!(regime_peak, VolRegime::High | VolRegime::Extreme), "Peak volatility should be High/Extreme" ); assert!( matches!(regime_trough, VolRegime::Low | VolRegime::Medium), "Trough volatility should be Low/Medium" ); }