//! Test: DQN Hyperopt Movement Threshold Parameter //! //! Verifies that `movement_threshold` is properly exposed to the hyperopt search space //! and sampled correctly during optimization. //! //! **Context**: The production DQN training script uses `--movement-threshold 0.02` but //! this parameter was HARDCODED in the hyperopt adapter. It should be part of the search //! space so hyperopt can optimize it. //! //! **Search Range**: [0.01, 0.05] (1% to 5% price movement) //! **Default Value**: 0.02 (2% price movement) //! //! This test ensures: //! 1. movement_threshold is sampled from [0.01, 0.05] range //! 2. Different trials get different threshold values //! 3. The value is correctly passed to the reward function use ml::hyperopt::adapters::dqn::DQNParams; use ml::hyperopt::traits::ParameterSpace; #[test] fn test_movement_threshold_in_search_space() { // Test that DQNParams includes movement_threshold in continuous bounds let bounds = DQNParams::continuous_bounds(); // Expected: 6 parameters (learning_rate, batch_size, gamma, epsilon_decay, buffer_size, movement_threshold) assert_eq!( bounds.len(), 6, "Expected 6 parameters including movement_threshold, got {}", bounds.len() ); // movement_threshold should be at index 5 with bounds [0.01, 0.05] let (min_threshold, max_threshold) = bounds[5]; assert_eq!( min_threshold, 0.01, "Minimum movement_threshold should be 0.01 (1%), got {}", min_threshold ); assert_eq!( max_threshold, 0.05, "Maximum movement_threshold should be 0.05 (5%), got {}", max_threshold ); } #[test] fn test_movement_threshold_param_names() { // Verify parameter names include movement_threshold let names = DQNParams::param_names(); assert_eq!( names.len(), 6, "Expected 6 parameter names, got {}", names.len() ); assert_eq!( names[5], "movement_threshold", "Parameter at index 5 should be 'movement_threshold', got '{}'", names[5] ); } #[test] fn test_movement_threshold_roundtrip() { // Test that movement_threshold survives to_continuous/from_continuous conversion let params = DQNParams { learning_rate: 0.0001, batch_size: 128, gamma: 0.99, epsilon_decay: 0.995, buffer_size: 100_000, movement_threshold: 0.03, // 3% threshold }; let continuous = params.to_continuous(); let recovered = DQNParams::from_continuous(&continuous).unwrap(); assert_eq!( continuous.len(), 6, "Continuous representation should have 6 values, got {}", continuous.len() ); // movement_threshold should be at index 5 (linear scale, no transformation) assert_eq!( continuous[5], 0.03, "Continuous movement_threshold should be 0.03, got {}", continuous[5] ); assert!( (recovered.movement_threshold - params.movement_threshold).abs() < 1e-10, "Recovered movement_threshold should match original: expected {}, got {}", params.movement_threshold, recovered.movement_threshold ); } #[test] fn test_movement_threshold_default_value() { // Test that default movement_threshold is 0.02 (2%) let params = DQNParams::default(); assert_eq!( params.movement_threshold, 0.02, "Default movement_threshold should be 0.02 (2%), got {}", params.movement_threshold ); } #[test] fn test_movement_threshold_clamping() { // Test that movement_threshold is clamped to [0.01, 0.05] range // Test lower bound clamping let continuous_low = vec![ 0.0001_f64.ln(), // learning_rate (log scale) 128.0, // batch_size 0.99, // gamma 0.995_f64.ln(), // epsilon_decay (log scale) 100_000_f64.ln(), // buffer_size (log scale) 0.005, // movement_threshold (below min) ]; let params_low = DQNParams::from_continuous(&continuous_low).unwrap(); assert_eq!( params_low.movement_threshold, 0.01, "movement_threshold below 0.01 should be clamped to 0.01, got {}", params_low.movement_threshold ); // Test upper bound clamping let continuous_high = vec![ 0.0001_f64.ln(), // learning_rate (log scale) 128.0, // batch_size 0.99, // gamma 0.995_f64.ln(), // epsilon_decay (log scale) 100_000_f64.ln(), // buffer_size (log scale) 0.10, // movement_threshold (above max) ]; let params_high = DQNParams::from_continuous(&continuous_high).unwrap(); assert_eq!( params_high.movement_threshold, 0.05, "movement_threshold above 0.05 should be clamped to 0.05, got {}", params_high.movement_threshold ); } #[test] fn test_movement_threshold_sampling_range() { // Test that different continuous values produce different movement_threshold values let test_cases = vec![ (0.01, 0.01), // Min (0.02, 0.02), // Default (0.03, 0.03), // Mid-range (0.04, 0.04), // Upper-mid (0.05, 0.05), // Max ]; for (continuous_value, expected_threshold) in test_cases { let continuous = vec![ 0.0001_f64.ln(), // learning_rate 128.0, // batch_size 0.99, // gamma 0.995_f64.ln(), // epsilon_decay 100_000_f64.ln(), // buffer_size continuous_value, // movement_threshold ]; let params = DQNParams::from_continuous(&continuous).unwrap(); assert_eq!( params.movement_threshold, expected_threshold, "Continuous value {} should produce movement_threshold {}, got {}", continuous_value, expected_threshold, params.movement_threshold ); } } #[test] fn test_movement_threshold_affects_different_trials() { // Simulate 5 different hyperopt trials with different movement_threshold values let trial_thresholds = vec![0.01, 0.02, 0.03, 0.04, 0.05]; for (trial_num, expected_threshold) in trial_thresholds.iter().enumerate() { let continuous = vec![ (-4.0 + trial_num as f64 * 0.1).ln().max(1e-5_f64.ln()), // learning_rate (varying) (128.0 + trial_num as f64 * 10.0), // batch_size (varying) 0.99, // gamma (fixed) 0.995_f64.ln(), // epsilon_decay (fixed) 100_000_f64.ln(), // buffer_size (fixed) *expected_threshold, // movement_threshold (varying) ]; let params = DQNParams::from_continuous(&continuous).unwrap(); assert_eq!( params.movement_threshold, *expected_threshold, "Trial {} should have movement_threshold {}, got {}", trial_num, expected_threshold, params.movement_threshold ); } } #[test] fn test_movement_threshold_integration() { // Test end-to-end: Create DQNParams, verify movement_threshold is accessible let params = DQNParams { learning_rate: 0.00001, batch_size: 207, gamma: 0.950, epsilon_decay: 0.99900, buffer_size: 162_739, movement_threshold: 0.025, // 2.5% (mid-range) }; // Verify all fields are set correctly assert_eq!(params.learning_rate, 0.00001); assert_eq!(params.batch_size, 207); assert_eq!(params.gamma, 0.950); assert_eq!(params.epsilon_decay, 0.99900); assert_eq!(params.buffer_size, 162_739); assert_eq!(params.movement_threshold, 0.025); // Verify continuous conversion preserves value let continuous = params.to_continuous(); let recovered = DQNParams::from_continuous(&continuous).unwrap(); assert!( (recovered.movement_threshold - 0.025).abs() < 1e-10, "movement_threshold should survive roundtrip: expected 0.025, got {}", recovered.movement_threshold ); }