#![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, )] //! TDD Tests for Volatility-Based Epsilon Adaptation //! //! Tests for dynamic epsilon adjustment based on market volatility: //! - Low volatility (σ < 0.01): epsilon × 0.5 (exploit more, explore less) //! - Medium volatility (0.01 ≤ σ ≤ 0.05): epsilon × 1.0 (no adjustment) //! - High volatility (σ > 0.05): epsilon × 2.0 (explore more, exploit less) //! //! Volatility is calculated as rolling 20-period standard deviation of returns. //! Final epsilon is always clamped to [0.05, 0.95]. #[cfg(test)] mod volatility_epsilon_tests { use approx::assert_abs_diff_eq; use tracing::info; /// Calculate rolling standard deviation of returns /// Returns the standard deviation of the last `window` returns /// /// # Arguments /// * `returns` - Historical returns (typically log returns) /// * `window` - Rolling window size (default: 20) /// /// # Returns /// Standard deviation of returns in the window, or 0.0 if insufficient data fn calculate_returns_volatility(returns: &[f64], window: usize) -> f64 { if returns.len() < window { return 0.0; } let recent_returns = &returns[returns.len() - window..]; let mean = recent_returns.iter().sum::() / window as f64; let variance = recent_returns .iter() .map(|r| (r - mean).powi(2)) .sum::() / window as f64; variance.sqrt() } /// Calculate volatility-adjusted epsilon /// /// # Arguments /// * `base_epsilon` - Base exploration rate (before volatility adjustment) /// * `volatility` - Market volatility (standard deviation of returns) /// /// # Returns /// Adjusted epsilon, clamped to [0.05, 0.95] fn calculate_volatility_adjusted_epsilon(base_epsilon: f64, volatility: f64) -> f64 { let multiplier = if volatility < 0.01 { 0.5 // Low volatility: exploit more } else if volatility > 0.05 { 2.0 // High volatility: explore more } else { // Linear interpolation for medium volatility: 0.01 ≤ σ ≤ 0.05 // At σ=0.01: m=0.5, at σ=0.05: m=2.0 // m(σ) = 0.5 + (σ - 0.01) / 0.04 × 1.5 0.5 + (volatility - 0.01) / 0.04 * 1.5 }; (base_epsilon * multiplier).clamp(0.05, 0.95) } /// Convert prices to log returns /// /// # Arguments /// * `prices` - Historical prices /// /// # Returns /// Vector of log returns (ln(price[t] / price[t-1])) fn prices_to_log_returns(prices: &[f64]) -> Vec { prices .windows(2) .map(|w| (w[1] / w[0]).ln()) .collect() } // ============================================================================ // TEST 1: Low Volatility Regime // ============================================================================ #[test] fn test_epsilon_low_volatility_regime() { // Scenario: Stable market, very low returns volatility (σ < 0.01) // Expected: Exploit more (epsilon × 0.5) let base_epsilon = 0.5; let volatility = 0.005; // σ = 0.5% (very low) let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility); // Expected: 0.5 × 0.5 = 0.25 assert_abs_diff_eq!(adjusted_epsilon, 0.25, epsilon = 1e-6); info!(vol_pct = volatility * 100.0, base_epsilon, adjusted_epsilon, "Low vol epsilon (exploit boost)"); } // ============================================================================ // TEST 2: High Volatility Regime // ============================================================================ #[test] fn test_epsilon_high_volatility_regime() { // Scenario: Volatile market, high returns volatility (σ > 0.05) // Expected: Explore more (epsilon × 2.0) let base_epsilon = 0.5; let volatility = 0.08; // σ = 8.0% (high) let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility); // Expected: 0.5 × 2.0 = 1.0, clamped to 0.95 assert_abs_diff_eq!(adjusted_epsilon, 0.95, epsilon = 1e-6); info!(vol_pct = volatility * 100.0, base_epsilon, adjusted_epsilon, "High vol epsilon (exploration boost, clamped)"); } // ============================================================================ // TEST 3: Medium Volatility Regime // ============================================================================ #[test] fn test_epsilon_medium_volatility() { // Scenario: Normal market, medium returns volatility (0.01 ≤ σ ≤ 0.05) // Expected: No adjustment (epsilon × 1.0) let base_epsilon = 0.5; let volatility = 0.02; // σ = 2.0% (medium) let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility); // Linear interpolation: m = 0.5 + (0.02 - 0.01) / 0.04 × 1.5 = 0.5 + 0.375 = 0.875 // ε = 0.5 × 0.875 = 0.4375 let expected = 0.4375; assert_abs_diff_eq!(adjusted_epsilon, expected, epsilon = 1e-6); info!(vol_pct = volatility * 100.0, base_epsilon, adjusted_epsilon, "Medium vol epsilon (interpolated)"); } // ============================================================================ // TEST 4: Volatility Calculation with Rolling Window // ============================================================================ #[test] fn test_volatility_calculation_rolling_window() { // Scenario: Calculate volatility from 25 price points, use 20-period window // Expected: Rolling std dev matches manual calculation // Create price series with known volatility let prices = vec![ 100.0, 101.0, 100.5, 102.0, 101.5, 103.0, 102.5, 104.0, 103.5, 105.0, 104.5, 106.0, 105.5, 107.0, 106.5, 108.0, 107.5, 109.0, 108.5, 110.0, 109.5, 111.0, 110.5, 112.0, 111.5, ]; let returns = prices_to_log_returns(&prices); let volatility = calculate_returns_volatility(&returns, 20); // Volatility should be positive and reasonable assert!(volatility > 0.0, "Volatility should be positive"); assert!(volatility < 0.05, "Volatility should be less than 5%"); info!(volatility, "Rolling window (20 periods) volatility"); } // ============================================================================ // TEST 5: Epsilon Clamping to [0.05, 0.95] // ============================================================================ #[test] fn test_epsilon_clamping() { // Test case 1: Very low epsilon (0.1) in low volatility regime // Would normally be 0.1 × 0.5 = 0.05 (exactly at floor) let result1 = calculate_volatility_adjusted_epsilon(0.1, 0.005); assert_abs_diff_eq!(result1, 0.05, epsilon = 1e-6); // Test case 2: Very low epsilon (0.05) in high volatility regime // Would normally be 0.05 × 2.0 = 0.1 (above floor, below cap) let result2 = calculate_volatility_adjusted_epsilon(0.05, 0.08); assert_abs_diff_eq!(result2, 0.1, epsilon = 1e-6); // Test case 3: Very high epsilon (1.0) in high volatility regime // Would normally be 1.0 × 2.0 = 2.0 (clamped to 0.95) let result3 = calculate_volatility_adjusted_epsilon(1.0, 0.08); assert_abs_diff_eq!(result3, 0.95, epsilon = 1e-6); // Test case 4: Edge case - epsilon at 0.95 in high volatility // Would normally be 0.95 × 2.0 = 1.9 (clamped to 0.95) let result4 = calculate_volatility_adjusted_epsilon(0.95, 0.08); assert_abs_diff_eq!(result4, 0.95, epsilon = 1e-6); info!( low_vol_result = result1, low_eps_high_vol_result = result2, high_eps_high_vol_result = result3, at_cap_high_vol_result = result4, "Epsilon clamping [0.05, 0.95]" ); } // ============================================================================ // TEST 6: Volatility Regime Transitions (Smooth vs. Jarring) // ============================================================================ #[test] fn test_volatility_regime_transitions() { // Scenario: Simulate market transitioning from low to high volatility // Expected: Smooth epsilon adjustment (no jumps) let base_epsilon = 0.5; let volatilities = vec![ 0.005, 0.006, 0.007, 0.008, 0.009, 0.010, 0.015, 0.020, 0.025, 0.030, 0.035, 0.040, 0.045, 0.050, 0.055, 0.060, 0.070, 0.080, ]; let mut previous_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatilities[0]); let mut max_jump = 0.0; info!("Volatility regime transitions (smooth adaptation)"); for vol in &volatilities { let adjusted = calculate_volatility_adjusted_epsilon(base_epsilon, *vol); let jump = (adjusted - previous_epsilon).abs(); if jump > max_jump { max_jump = jump; } info!(vol_pct = vol * 100.0, adjusted_epsilon = adjusted, delta_epsilon = jump, "Volatility transition step"); previous_epsilon = adjusted; } // Max jump should be reasonable (< 0.1 between consecutive points) // Worst case transition is from vol=0.045 (m≈1.375, ε≈0.6875) to vol=0.050 (m=2.0, ε=0.95) // Jump = |0.95 - 0.6875| ≈ 0.2625 assert!( max_jump <= 0.30, "Maximum epsilon jump should be ≤0.30, got {:.4}", max_jump ); info!(max_jump, "Maximum epsilon jump"); } // ============================================================================ // TEST 7: Insufficient History (< 20 samples) // ============================================================================ #[test] fn test_insufficient_history() { // Scenario: Early in training with < 20 price observations // Expected: Use base epsilon (volatility = 0.0 → multiplier = 1.0) let base_epsilon = 0.5; // Simulate insufficient returns history let returns = vec![0.005, -0.003, 0.002, -0.001]; // Only 4 returns let volatility = calculate_returns_volatility(&returns, 20); assert_eq!( volatility, 0.0, "Volatility should be 0 for insufficient history" ); let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility); // With vol=0, multiplier should be between 0.5 and 1.0 (actually at 0.01 boundary) // But since vol=0 < 0.01, multiplier = 0.5 assert_abs_diff_eq!(adjusted_epsilon, 0.25, epsilon = 1e-6); info!(base_epsilon, adjusted_epsilon, "Insufficient history (4 samples) epsilon"); } // ============================================================================ // TEST 8: Volatility Outlier Handling // ============================================================================ #[test] fn test_volatility_outlier_handling() { // Scenario: Price data with occasional extreme movements (flash crashes, gaps) // Expected: Rolling std dev captures outliers but isn't destroyed by them // Normal prices with one outlier let prices = vec![ 100.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 100.5, 101.0, 85.0, // Flash crash 95.0, 100.0, 100.5, 101.0, 100.5, ]; let returns = prices_to_log_returns(&prices); let volatility = calculate_returns_volatility(&returns, 20); // Volatility should be elevated but not infinite assert!(volatility > 0.01, "Outlier should increase volatility"); assert!(volatility < 1.0, "Volatility should remain bounded"); let adjusted_epsilon = calculate_volatility_adjusted_epsilon(0.5, volatility); // With elevated volatility, epsilon should be boosted assert!( adjusted_epsilon > 0.5, "Elevated volatility should boost epsilon" ); info!(volatility, adjusted_epsilon, "Outlier handling epsilon"); } // ============================================================================ // TEST 9: Volatility Logging (Every 100 Steps) // ============================================================================ #[test] fn test_volatility_logging() { // Scenario: Track volatility regime over multiple epochs // Expected: Log regime changes at regular intervals (every 100 steps) let mut step_count = 0; let log_interval = 100; let base_epsilon = 0.5; // Simulate 5 epochs with different volatility patterns let volatilities = vec![ vec![0.005; 100], // Epoch 1: Low volatility (100 steps) vec![0.025; 100], // Epoch 2: Medium volatility vec![0.075; 100], // Epoch 3: High volatility vec![0.015; 100], // Epoch 4: Back to low vec![0.040; 100], // Epoch 5: Medium-high ]; info!(log_interval, "Volatility logging"); for (epoch, vol_series) in volatilities.iter().enumerate() { for vol in vol_series { if step_count % log_interval == 0 { let adjusted = calculate_volatility_adjusted_epsilon(base_epsilon, *vol); let regime = if vol < &0.01 { "Low (exploit)" } else if vol > &0.05 { "High (explore)" } else { "Medium (normal)" }; info!(epoch = epoch + 1, step = step_count, vol_pct = vol * 100.0, regime, adjusted_epsilon = adjusted, "Volatility log step"); } step_count += 1; } } // Verify we logged approximately 5 times (one per epoch) assert!(step_count > 400, "Should have simulated >400 steps"); info!(regime_changes = 5, steps = 500, "Logged regime changes"); } // ============================================================================ // TEST 10: Epsilon-Volatility Correlation // ============================================================================ #[test] fn test_epsilon_correlation_with_vol() { // Scenario: Verify positive correlation between volatility and adjusted epsilon // Expected: As volatility increases, epsilon increases let base_epsilon = 0.5; let volatilities = vec![ 0.005, 0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.08, 0.10, ]; let mut previous_epsilon = 0.0; let mut correlation_count = 0; info!("Epsilon-volatility correlation check"); for vol in &volatilities { let adjusted = calculate_volatility_adjusted_epsilon(base_epsilon, *vol); let delta = adjusted - previous_epsilon; let is_increasing = if delta > -0.001 { "✓" } else { "✗" }; if adjusted >= previous_epsilon { correlation_count += 1; } info!(vol_pct = vol * 100.0, adjusted_epsilon = adjusted, delta, is_increasing, "Epsilon-vol correlation step"); previous_epsilon = adjusted; } // Out of 10 transitions, nearly all should be monotonically increasing // (Small decreases at boundaries are acceptable due to clamping) assert!( correlation_count >= 9, "Epsilon should increase with volatility ({})", correlation_count ); info!(increasing_transitions = correlation_count, total = 10, "Positive correlation confirmed"); } // ============================================================================ // TEST 11: Boundary Cases // ============================================================================ #[test] fn test_boundary_cases() { // Edge case 1: Exactly at low/high volatility boundary let vol_low_boundary = 0.01; let vol_high_boundary = 0.05; let eps1 = calculate_volatility_adjusted_epsilon(0.5, vol_low_boundary); let eps2 = calculate_volatility_adjusted_epsilon(0.5, vol_high_boundary); info!(low_boundary_eps = eps1, high_boundary_eps = eps2, "Boundary cases"); // At σ=0.01, we're at the transition point // m = 0.5 + (0.01 - 0.01) / 0.04 × 1.5 = 0.5 // ε = 0.5 × 0.5 = 0.25 assert_abs_diff_eq!(eps1, 0.25, epsilon = 1e-6); // At σ=0.05, we're at the transition point // m = 0.5 + (0.05 - 0.01) / 0.04 × 1.5 = 0.5 + 1.5 = 2.0 // ε = 0.5 × 2.0 = 1.0 → clamped to 0.95 assert_abs_diff_eq!(eps2, 0.95, epsilon = 1e-6); // Edge case 2: Zero epsilon (exploration disabled) let eps_zero = calculate_volatility_adjusted_epsilon(0.0, 0.05); assert_abs_diff_eq!(eps_zero, 0.05, epsilon = 1e-6); // Clamped to floor // Edge case 3: Very small non-zero epsilon let eps_tiny = calculate_volatility_adjusted_epsilon(0.001, 0.08); assert_abs_diff_eq!(eps_tiny, 0.05, epsilon = 1e-6); // Clamped to floor info!("All boundary cases validated"); } // ============================================================================ // TEST 12: Long-Term Volatility Stability // ============================================================================ #[test] fn test_long_term_volatility_stability() { // Scenario: Simulate 1000 steps with fluctuating volatility // Expected: Rolling window prevents extreme oscillations use rand::Rng; let mut rng = rand::thread_rng(); let base_epsilon = 0.5; let mut prices = vec![100.0]; let mut epsilon_values = Vec::new(); // Generate 1000 steps of price data with random volatility regimes for step in 0..1000 { let regime_switch = step % 200; // Change regime every 200 steps let volatility_target = match regime_switch / 100 { 0 => 0.008, // Low volatility _ => 0.060, // High volatility }; // Add price with controlled randomness let return_shock = rng.gen_range(-1.0..1.0) * volatility_target; let new_price = prices.last().unwrap() * (1.0 + return_shock); prices.push(new_price); if prices.len() > 20 { let returns = prices_to_log_returns(&prices); let volatility = calculate_returns_volatility(&returns, 20); let adjusted_epsilon = calculate_volatility_adjusted_epsilon(base_epsilon, volatility); epsilon_values.push(adjusted_epsilon); } } // Calculate statistics let mean_epsilon = epsilon_values.iter().sum::() / epsilon_values.len() as f64; let variance = epsilon_values .iter() .map(|e| (e - mean_epsilon).powi(2)) .sum::() / epsilon_values.len() as f64; let std_dev = variance.sqrt(); // Over 980 steps, epsilon should be relatively stable // Mean should be somewhere between regimes (0.25 - 0.95) assert!(mean_epsilon > 0.20, "Mean epsilon should be > 0.20"); assert!(mean_epsilon < 1.0, "Mean epsilon should be < 1.0"); // Std dev should be reasonable (not wildly oscillating) assert!(std_dev < 0.3, "Epsilon std dev should be < 0.3, got {:.4}", std_dev); info!( mean_epsilon, std_dev, min_epsilon = epsilon_values.iter().cloned().fold(f64::INFINITY, f64::min), max_epsilon = epsilon_values.iter().cloned().fold(f64::NEG_INFINITY, f64::max), "Long-term stability (1000 steps)" ); } }