#![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, )] //! Regime Transition Matrix Tests //! //! TDD tests for regime transition probability matrix implementation. //! Tests cover: //! - Transition probability updates //! - Stationary distribution convergence //! - Real data regime sequence analysis use ml::ensemble::MarketRegime; use ml::regime::transition_matrix::RegimeTransitionMatrix; #[test] fn test_transition_matrix_initialization() { let regimes = vec![ MarketRegime::Bull, MarketRegime::Bear, MarketRegime::Sideways, MarketRegime::HighVolatility, ]; let matrix = RegimeTransitionMatrix::new(regimes.clone(), 0.1, 10); // Verify all regimes are tracked assert_eq!(matrix.regime_count(), 4); // Initial transition probabilities should be uniform (1/N for each regime) for from in ®imes { for to in ®imes { let prob = matrix.get_transition_prob(*from, *to); assert!( (prob - 0.25).abs() < 1e-6, "Initial probability should be ~0.25 (uniform), got {}", prob ); } } } #[test] fn test_single_transition_update() { let regimes = vec![MarketRegime::Bull, MarketRegime::Bear]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.5, 1); // Update: Bull -> Bear matrix.update(MarketRegime::Bull, MarketRegime::Bear); // After 1 observation with alpha=0.5: // P(Bull->Bear) should increase from 0.5 to ~0.75 // P(Bull->Bull) should decrease from 0.5 to ~0.25 let p_bull_to_bear = matrix.get_transition_prob(MarketRegime::Bull, MarketRegime::Bear); let p_bull_to_bull = matrix.get_transition_prob(MarketRegime::Bull, MarketRegime::Bull); assert!( p_bull_to_bear > 0.6, "P(Bull->Bear) should increase, got {}", p_bull_to_bear ); assert!( p_bull_to_bull < 0.4, "P(Bull->Bull) should decrease, got {}", p_bull_to_bull ); // Row should sum to 1.0 let row_sum = p_bull_to_bear + p_bull_to_bull; assert!( (row_sum - 1.0).abs() < 1e-6, "Row sum should be 1.0, got {}", row_sum ); } #[test] fn test_multiple_transitions_same_path() { let regimes = vec![MarketRegime::Bull, MarketRegime::Bear]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.2, 1); // Repeat Bull -> Bear 10 times for _ in 0..10 { matrix.update(MarketRegime::Bull, MarketRegime::Bear); } // P(Bull->Bear) should approach 1.0 let p_bull_to_bear = matrix.get_transition_prob(MarketRegime::Bull, MarketRegime::Bear); assert!( p_bull_to_bear > 0.8, "After 10 observations, P(Bull->Bear) should be >0.8, got {}", p_bull_to_bear ); } #[test] fn test_self_transitions() { let regimes = vec![MarketRegime::Sideways, MarketRegime::HighVolatility]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.3, 1); // Update: Sideways -> Sideways (persistence) for _ in 0..5 { matrix.update(MarketRegime::Sideways, MarketRegime::Sideways); } // P(Sideways->Sideways) should be high (regime persistence) let p_sideways_persist = matrix.get_transition_prob(MarketRegime::Sideways, MarketRegime::Sideways); assert!( p_sideways_persist > 0.7, "Sideways should persist, P(Sideways->Sideways) = {}", p_sideways_persist ); } #[test] fn test_row_normalization() { let regimes = vec![ MarketRegime::Bull, MarketRegime::Bear, MarketRegime::Sideways, ]; let mut matrix = RegimeTransitionMatrix::new(regimes.clone(), 0.25, 1); // Add various transitions matrix.update(MarketRegime::Bull, MarketRegime::Bear); matrix.update(MarketRegime::Bull, MarketRegime::Sideways); matrix.update(MarketRegime::Bear, MarketRegime::Bull); // Check that all rows sum to 1.0 for from in ®imes { let row_sum: f64 = regimes .iter() .map(|to| matrix.get_transition_prob(*from, *to)) .sum(); assert!( (row_sum - 1.0).abs() < 1e-6, "Row {:?} sum should be 1.0, got {}", from, row_sum ); } } #[test] fn test_minimum_observations_threshold() { let regimes = vec![MarketRegime::Bull, MarketRegime::Bear]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.2, 5); // min_obs = 5 // Add only 2 observations (below threshold) matrix.update(MarketRegime::Bull, MarketRegime::Bear); matrix.update(MarketRegime::Bull, MarketRegime::Bear); // Should still use uniform priors until min_observations reached let p_bull_to_bear = matrix.get_transition_prob(MarketRegime::Bull, MarketRegime::Bear); // With insufficient data, probability should be close to prior (0.5) // The exact behavior depends on implementation (Laplace smoothing) assert!( p_bull_to_bear >= 0.4 && p_bull_to_bear <= 0.8, "With insufficient observations, probability should use smoothing, got {}", p_bull_to_bear ); } #[test] fn test_stationary_distribution_uniform() { let regimes = vec![MarketRegime::Bull, MarketRegime::Bear]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.2, 1); // Create perfectly symmetric transitions: P(Bull->Bear) = P(Bear->Bull) = 0.5 // This should yield stationary distribution [0.5, 0.5] for _ in 0..10 { matrix.update(MarketRegime::Bull, MarketRegime::Bear); matrix.update(MarketRegime::Bear, MarketRegime::Bull); } let stationary = matrix.get_stationary_distribution(); let bull_prob = stationary.get(&MarketRegime::Bull).unwrap_or(&0.0); let bear_prob = stationary.get(&MarketRegime::Bear).unwrap_or(&0.0); // Should be approximately equal assert!( (bull_prob - 0.5).abs() < 0.15, "Bull stationary probability should be ~0.5, got {}", bull_prob ); assert!( (bear_prob - 0.5).abs() < 0.15, "Bear stationary probability should be ~0.5, got {}", bear_prob ); // Should sum to 1.0 let total: f64 = stationary.values().sum(); assert!( (total - 1.0).abs() < 1e-6, "Stationary distribution should sum to 1.0, got {}", total ); } #[test] fn test_stationary_distribution_absorbing() { let regimes = vec![MarketRegime::Bull, MarketRegime::Bear]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.3, 1); // Create Bull as absorbing state: P(Bull->Bull) = 1.0 for _ in 0..20 { matrix.update(MarketRegime::Bull, MarketRegime::Bull); matrix.update(MarketRegime::Bear, MarketRegime::Bull); } let stationary = matrix.get_stationary_distribution(); let bull_prob = stationary.get(&MarketRegime::Bull).unwrap_or(&0.0); // Bull should dominate stationary distribution assert!( *bull_prob > 0.7, "Bull should dominate as absorbing state, got {}", bull_prob ); } #[test] fn test_expected_duration_high_persistence() { let regimes = vec![MarketRegime::Sideways, MarketRegime::HighVolatility]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.2, 1); // Make Sideways highly persistent: P(Sideways->Sideways) = 0.9 for _ in 0..20 { matrix.update(MarketRegime::Sideways, MarketRegime::Sideways); matrix.update(MarketRegime::Sideways, MarketRegime::Sideways); matrix.update(MarketRegime::Sideways, MarketRegime::HighVolatility); } // Expected duration = 1 / (1 - P(i->i)) // If P(Sideways->Sideways) = 0.9, duration = 1 / 0.1 = 10 let duration = matrix.get_expected_duration(MarketRegime::Sideways); assert!( duration > 3.0, "High persistence should yield long duration, got {}", duration ); assert!( duration < 50.0, "Duration should be finite, got {}", duration ); } #[test] fn test_expected_duration_low_persistence() { let regimes = vec![MarketRegime::HighVolatility, MarketRegime::Sideways]; let mut matrix = RegimeTransitionMatrix::new(regimes, 0.3, 1); // Make HighVolatility transient: P(HV->HV) = 0.2 for _ in 0..20 { matrix.update(MarketRegime::HighVolatility, MarketRegime::Sideways); matrix.update(MarketRegime::HighVolatility, MarketRegime::Sideways); matrix.update(MarketRegime::HighVolatility, MarketRegime::Sideways); matrix.update(MarketRegime::HighVolatility, MarketRegime::HighVolatility); } // Low persistence -> short duration let duration = matrix.get_expected_duration(MarketRegime::HighVolatility); assert!( duration >= 1.0 && duration < 3.0, "Low persistence should yield short duration, got {}", duration ); } #[test] fn test_four_regime_matrix() { let regimes = vec![ MarketRegime::Bull, MarketRegime::Bear, MarketRegime::Sideways, MarketRegime::HighVolatility, ]; let mut matrix = RegimeTransitionMatrix::new(regimes.clone(), 0.15, 1); // Simulate realistic regime transitions let transitions = vec![ (MarketRegime::Sideways, MarketRegime::Bull), // Breakout to bull (MarketRegime::Bull, MarketRegime::Bull), // Bull persistence (MarketRegime::Bull, MarketRegime::HighVolatility), // Volatility spike (MarketRegime::HighVolatility, MarketRegime::Bear), // Crash (MarketRegime::Bear, MarketRegime::Bear), // Bear persistence (MarketRegime::Bear, MarketRegime::Sideways), // Stabilization ]; for (from, to) in transitions { matrix.update(from, to); } // Verify all rows still sum to 1.0 for from in ®imes { let row_sum: f64 = regimes .iter() .map(|to| matrix.get_transition_prob(*from, *to)) .sum(); assert!( (row_sum - 1.0).abs() < 1e-6, "Row {:?} sum should be 1.0, got {}", from, row_sum ); } // Verify stationary distribution sums to 1.0 let stationary = matrix.get_stationary_distribution(); let total: f64 = stationary.values().sum(); assert!( (total - 1.0).abs() < 1e-6, "Stationary distribution should sum to 1.0, got {}", total ); }