#![allow( clippy::assertions_on_constants, clippy::assertions_on_result_states, clippy::clone_on_copy, clippy::decimal_literal_representation, clippy::doc_markdown, clippy::empty_line_after_doc_comments, clippy::field_reassign_with_default, clippy::get_unwrap, clippy::identity_op, clippy::inconsistent_digit_grouping, clippy::indexing_slicing, clippy::integer_division, clippy::len_zero, clippy::let_underscore_must_use, clippy::manual_div_ceil, clippy::manual_let_else, clippy::manual_range_contains, clippy::modulo_arithmetic, clippy::needless_range_loop, clippy::non_ascii_literal, clippy::redundant_clone, clippy::shadow_reuse, clippy::shadow_same, clippy::shadow_unrelated, clippy::single_match_else, clippy::str_to_string, clippy::string_slice, clippy::tests_outside_test_module, clippy::too_many_lines, clippy::unnecessary_wraps, clippy::unseparated_literal_suffix, clippy::use_debug, clippy::useless_vec, clippy::wildcard_enum_match_arm, clippy::else_if_without_else, clippy::expect_used, clippy::missing_const_for_fn, clippy::similar_names, clippy::type_complexity, clippy::collapsible_else_if, clippy::doc_lazy_continuation, clippy::items_after_test_module, clippy::map_clone, clippy::multiple_unsafe_ops_per_block, clippy::unwrap_or_default, clippy::assign_op_pattern, clippy::needless_borrow, clippy::println_empty_string, clippy::unnecessary_cast, clippy::used_underscore_binding, clippy::create_dir, clippy::implicit_saturating_sub, clippy::exit, clippy::expect_fun_call, clippy::too_many_arguments, clippy::unnecessary_map_or, clippy::unwrap_used, dead_code, unused_imports, unused_variables, clippy::cloned_ref_to_slice_refs, clippy::neg_multiply, clippy::while_let_loop, clippy::bool_assert_comparison, clippy::excessive_precision, clippy::trivially_copy_pass_by_ref, clippy::op_ref, clippy::redundant_closure, clippy::unnecessary_lazy_evaluations, clippy::if_then_some_else_none, clippy::unnecessary_to_owned, clippy::single_component_path_imports, )] //! Comprehensive TDD Tests for PAGES Variance Changepoint Detection //! //! Test coverage: //! 1. Basic functionality (initialization, stable variance) //! 2. Variance change detection (increase, decrease) //! 3. Edge cases (zero variance, rapid changes) //! 4. Real market data integration (ES.FUT, NQ.FUT volatility regimes) //! 5. Performance benchmarks (<80μs target) use anyhow::Result; use ml::regime::pages_test::PAGESTest; use std::time::Instant; use tracing::info; // ============================================================================ // Unit Tests: Basic Functionality // ============================================================================ #[test] fn test_pages_default_initialization() { let pages = PAGESTest::default(); assert_eq!(pages.get_target_variance(), 1.0); assert_eq!(pages.get_drift_allowance(), 0.5); assert_eq!(pages.get_detection_threshold(), 5.0); } #[test] fn test_pages_custom_initialization() { let pages = PAGESTest::new(2.0, 1.0, 8.0, 50); assert_eq!(pages.get_target_variance(), 2.0); assert_eq!(pages.get_drift_allowance(), 1.0); assert_eq!(pages.get_detection_threshold(), 8.0); } #[test] #[should_panic(expected = "Target variance must be positive")] fn test_pages_negative_target_variance_panics() { PAGESTest::new(-1.0, 0.5, 5.0, 20); } #[test] #[should_panic(expected = "Window size must be at least 2")] fn test_pages_invalid_window_size_panics() { PAGESTest::new(1.0, 0.5, 5.0, 1); } // ============================================================================ // Unit Tests: Variance Computation // ============================================================================ #[test] fn test_pages_variance_computation_known_values() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Add values: [1, 2, 3, 4, 5] // Mean = 3, Variance = 2.5 (sample variance with Bessel correction) for val in [1.0, 2.0, 3.0, 4.0, 5.0] { pages.update(val)?; } let variance = pages.get_current_variance(); let expected_variance = 2.5; // Sample variance of [1,2,3,4,5] assert!( (variance - expected_variance).abs() < 0.01, "Expected variance ~{}, got {}", expected_variance, variance ); Ok(()) } #[test] fn test_pages_rolling_window_behavior() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 5); // Add 10 values, should keep only last 5 for i in 1..=10 { pages.update(i as f64)?; } assert_eq!(pages.get_window_fill(), 5); assert_eq!(pages.get_update_count(), 10); // Variance should be computed on [6, 7, 8, 9, 10] // Mean = 8, Variance = 2.5 let variance = pages.get_current_variance(); assert!((variance - 2.5).abs() < 0.01); Ok(()) } // ============================================================================ // Unit Tests: Stable Variance (No Detection) // ============================================================================ #[test] fn test_pages_stable_variance_no_false_alarms() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Generate 100 values from N(0, 1) distribution (variance = 1) use rand_distr::{Distribution, Normal}; let normal = Normal::new(0.0, 1.0).unwrap(); let mut rng = rand::thread_rng(); for _ in 0..100 { let value = normal.sample(&mut rng); let result = pages.update(value)?; assert!( result.is_none(), "Should not detect change when variance is stable at target" ); } // Cumulative sum should stay near zero with stable variance assert!( pages.get_cumulative_sum() < 2.0, "Cumulative sum should be low for stable variance" ); Ok(()) } #[test] fn test_pages_zero_variance_no_crash() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Feed constant value (zero variance) for _ in 0..30 { let result = pages.update(5.0)?; assert!( result.is_none(), "Zero variance should not trigger detection" ); } assert_eq!(pages.get_current_variance(), 0.0); assert_eq!(pages.get_cumulative_sum(), 0.0); Ok(()) } // ============================================================================ // Unit Tests: Variance Increase Detection // ============================================================================ #[test] fn test_pages_variance_increase_detection_synthetic() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.25, 4.0, 20); // Phase 1: Stable variance ≈ 1.0 (30 samples) use rand_distr::{Distribution, Normal}; let normal_stable = Normal::new(0.0, 1.0).unwrap(); let mut rng = rand::thread_rng(); for _ in 0..30 { pages.update(normal_stable.sample(&mut rng))?; } // Phase 2: Increased variance ≈ 4.0 (2x std dev) let normal_volatile = Normal::new(0.0, 2.0).unwrap(); let mut detected = false; let mut detection_lag = 0; for _ in 0..50 { detection_lag += 1; let value = normal_volatile.sample(&mut rng); if let Some(change) = pages.update(value)? { detected = true; assert!( change.variance_ratio > 2.0, "Should detect significant variance increase (ratio > 2.0)" ); assert_eq!(change.target_variance, 1.0); assert!(change.pages_statistic > 4.0); info!(detection_lag, variance_ratio = change.variance_ratio, "Detected variance increase"); break; } } assert!( detected, "Should detect variance increase within 50 samples" ); assert!( detection_lag < 30, "Detection lag should be reasonable (<30 samples)" ); Ok(()) } #[test] fn test_pages_large_variance_spike() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Stable phase for i in 0..20 { pages.update(if i % 2 == 0 { 1.0 } else { -1.0 })?; } // Sudden large spike (10x variance increase) let mut detected = false; for i in 0..20 { let value = if i % 2 == 0 { 10.0 } else { -10.0 }; if let Some(change) = pages.update(value)? { detected = true; assert!( change.variance_ratio > 5.0, "Should detect large variance spike" ); break; } } assert!(detected, "Should quickly detect large variance spike"); Ok(()) } // ============================================================================ // Unit Tests: Variance Decrease Detection // ============================================================================ #[test] fn test_pages_variance_decrease_detection() -> Result<()> { // PAGES test with low target variance (monitoring for increases from low baseline) // For decrease detection, we need high target variance let mut pages = PAGESTest::new(4.0, 0.5, 5.0, 20); // Phase 1: High variance ≈ 4.0 use rand_distr::{Distribution, Normal}; let normal_volatile = Normal::new(0.0, 2.0).unwrap(); let mut rng = rand::thread_rng(); for _ in 0..30 { pages.update(normal_volatile.sample(&mut rng))?; } // Phase 2: Decreased variance ≈ 1.0 let normal_stable = Normal::new(0.0, 1.0).unwrap(); // For decrease detection with one-sided CUSUM, we need to invert the logic // or use two-sided test. For now, verify that variance does decrease // but may not trigger alarm (one-sided test monitors increases) for _ in 0..30 { pages.update(normal_stable.sample(&mut rng))?; } let current_var = pages.get_current_variance(); assert!( current_var < 2.0, "Variance should have decreased from 4.0 to ~1.0" ); // Note: One-sided PAGES primarily detects increases relative to target // For comprehensive variance monitoring, use two-sided test or separate // PAGES instances for increase and decrease Ok(()) } // ============================================================================ // Unit Tests: Reset Functionality // ============================================================================ #[test] fn test_pages_reset_clears_state() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Accumulate state for i in 0..25 { pages.update((i as f64) * 2.0)?; } assert!(pages.get_window_fill() > 0); assert!(pages.get_update_count() > 0); assert!(pages.get_cumulative_sum() >= 0.0); // Reset pages.reset(); // Verify all state cleared assert_eq!(pages.get_window_fill(), 0); assert_eq!(pages.get_update_count(), 0); assert_eq!(pages.get_cumulative_sum(), 0.0); assert_eq!(pages.get_current_variance(), 0.0); // Verify can start fresh analysis pages.update(1.0)?; assert_eq!(pages.get_update_count(), 1); Ok(()) } // ============================================================================ // Unit Tests: Error Handling // ============================================================================ #[test] fn test_pages_rejects_nan() { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); let result = pages.update(f64::NAN); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("non-finite")); } #[test] fn test_pages_rejects_infinity() { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); let result = pages.update(f64::INFINITY); assert!(result.is_err()); let result = pages.update(f64::NEG_INFINITY); assert!(result.is_err()); } // ============================================================================ // Integration Tests: Real Market Data (ES.FUT, NQ.FUT) // ============================================================================ #[test] #[ignore = "Requires DBN test data files"] fn test_pages_es_fut_volatility_regimes() -> Result<()> { // This test validates PAGES test on real ES.FUT data // Expected: Detect regime changes during market open/close, news events // Load ES.FUT data (implementation depends on available test data) // let bars = load_dbn_test_data("ES.FUT")?; // Initialize PAGES with parameters tuned for ES.FUT // ES.FUT typical intraday variance: ~1.0-2.0 points² let mut pages = PAGESTest::new(1.5, 0.5, 5.0, 20); // Simulate ES.FUT price returns (replace with real data when available) let simulated_returns = vec![ // Low volatility period (09:30-10:00) 0.1, -0.05, 0.08, -0.06, 0.04, 0.03, -0.02, 0.05, -0.03, 0.06, // High volatility spike (10:00-10:30, news event) 0.8, -0.6, 0.9, -0.7, 0.85, 0.75, -0.65, 0.8, -0.5, 0.7, ]; let mut detections = Vec::new(); for (idx, &ret) in simulated_returns.iter().enumerate() { if let Some(change) = pages.update(ret)? { detections.push((idx, change)); info!(idx, variance_ratio = change.variance_ratio, pages_statistic = change.pages_statistic, "ES.FUT variance change"); } } // Should detect volatility spike assert!( !detections.is_empty(), "Should detect volatility regime change in ES.FUT" ); // First detection should be during high volatility period (indices 10+) assert!( detections[0].0 >= 10, "Should detect change during high volatility period" ); Ok(()) } #[test] #[ignore = "Requires DBN test data files"] fn test_pages_nq_fut_market_open_volatility() -> Result<()> { // NQ.FUT typically shows volatility spike at market open (09:30 ET) let mut pages = PAGESTest::new(2.0, 0.5, 5.0, 20); // Simulate pre-market (low vol) → market open (high vol) let simulated_returns = vec![ // Pre-market: low volatility 0.05, -0.03, 0.04, -0.02, 0.03, 0.02, -0.01, 0.03, -0.02, 0.04, // Market open: volatility surge 1.5, -1.2, 1.8, -1.4, 1.6, 1.3, -1.1, 1.4, -0.9, 1.2, ]; let mut detected = false; for (idx, &ret) in simulated_returns.iter().enumerate() { if let Some(change) = pages.update(ret)? { detected = true; assert!(idx >= 10, "Should detect change during market open period"); assert!( change.variance_ratio > 2.0, "Market open should show significant variance increase" ); break; } } assert!(detected, "Should detect market open volatility spike"); Ok(()) } // ============================================================================ // Performance Benchmarks // ============================================================================ #[test] fn test_pages_performance_latency() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); // Warmup for i in 0..100 { pages.update(i as f64)?; } // Benchmark 1000 updates let iterations = 1000; let start = Instant::now(); for i in 0..iterations { pages.update((i as f64) * 0.1)?; } let duration = start.elapsed(); let avg_latency_us = duration.as_micros() as f64 / iterations as f64; info!(avg_latency_us, "PAGES update latency (us per update, target <80)"); assert!( avg_latency_us < 80.0, "PAGES update latency {:.2}μs exceeds 80μs target", avg_latency_us ); Ok(()) } #[test] fn test_pages_memory_efficiency() { // PAGES should use minimal memory (VecDeque + running stats) let pages = PAGESTest::new(1.0, 0.5, 5.0, 50); let size = std::mem::size_of_val(&pages); info!(size, "PAGESTest struct size (bytes)"); // VecDeque overhead + running stats should be < 1KB even with window=50 assert!( size < 1024, "PAGESTest memory usage {} bytes exceeds 1KB", size ); } // ============================================================================ // Property-Based Tests // ============================================================================ #[test] fn test_pages_cumulative_sum_non_negative() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); use rand_distr::{Distribution, Normal}; let normal = Normal::new(0.0, 1.5).unwrap(); let mut rng = rand::thread_rng(); for _ in 0..100 { pages.update(normal.sample(&mut rng))?; // Page's statistic must always be non-negative (max with 0) assert!( pages.get_cumulative_sum() >= 0.0, "Cumulative sum should never be negative" ); } Ok(()) } #[test] fn test_pages_variance_always_non_negative() -> Result<()> { let mut pages = PAGESTest::new(1.0, 0.5, 5.0, 20); for i in -50..50 { pages.update(i as f64)?; let variance = pages.get_current_variance(); assert!( variance >= 0.0, "Variance should never be negative, got {}", variance ); } Ok(()) }