#![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, )] //! Tests for Continuous PPO Dual-Phase Backtest Tracking //! //! This test suite validates the separation of exploration phase (burn-in) from //! exploitation phase (learned strategy) in Continuous PPO training. //! //! Test-Driven Development (TDD) approach: //! 1. Write tests first (this file) //! 2. Implement code to pass tests //! 3. Run tests and verify all pass use ml::evaluation::engine::{Action, EvaluationEngine}; use ml::evaluation::metrics::{PerformanceMetrics, OHLCVBarF32}; /// Results from dual-phase backtesting (exploration vs exploitation) #[derive(Debug, Clone)] pub struct DualPhaseBacktestResults { /// Metrics from exploration phase (epochs 0 to burn_in_epochs-1) pub exploration_metrics: PerformanceMetrics, /// Metrics from exploitation phase (epochs burn_in_epochs to total_epochs-1) pub exploitation_metrics: PerformanceMetrics, /// Number of burn-in epochs used pub burn_in_epochs: usize, /// Total number of epochs pub total_epochs: usize, } impl DualPhaseBacktestResults { /// Create results with default values for testing pub fn new( burn_in_epochs: usize, total_epochs: usize, ) -> Self { Self { exploration_metrics: PerformanceMetrics::default(), exploitation_metrics: PerformanceMetrics::default(), burn_in_epochs, total_epochs, } } /// Create results from two separate engines pub fn from_engines( exploration_engine: &EvaluationEngine, exploitation_engine: &EvaluationEngine, burn_in_epochs: usize, total_epochs: usize, initial_capital: f32, ) -> Self { let exploration_metrics = if burn_in_epochs > 0 && !exploration_engine.trades.is_empty() { PerformanceMetrics::from_trades( &exploration_engine.trades, initial_capital, &[], // Empty bars - not needed for metrics calculation ) } else { PerformanceMetrics::default() }; let exploitation_metrics = if total_epochs > burn_in_epochs && !exploitation_engine.trades.is_empty() { PerformanceMetrics::from_trades( &exploitation_engine.trades, initial_capital, &[], // Empty bars - not needed for metrics calculation ) } else { PerformanceMetrics::default() }; Self { exploration_metrics, exploitation_metrics, burn_in_epochs, total_epochs, } } } // Helper function to create a dummy OHLCVBar for testing fn create_test_bar(price: f32) -> OHLCVBarF32 { OHLCVBarF32 { timestamp: 0, open: price, high: price + 1.0, low: price - 1.0, close: price, volume: 1000.0, } } /// Test 1: Burn-in CLI Flag Parsing /// /// Verifies that burn_in_epochs can be set with different values #[test] fn test_burn_in_epochs_flag_parsing() { // Test default value (50) let default_burn_in = 50; let results = DualPhaseBacktestResults::new(default_burn_in, 100); assert_eq!(results.burn_in_epochs, 50, "Default burn-in should be 50"); // Test custom value (0) let zero_burn_in = 0; let results_zero = DualPhaseBacktestResults::new(zero_burn_in, 100); assert_eq!(results_zero.burn_in_epochs, 0, "Zero burn-in should be accepted"); // Test custom value (25) let custom_burn_in = 25; let results_custom = DualPhaseBacktestResults::new(custom_burn_in, 100); assert_eq!(results_custom.burn_in_epochs, 25, "Custom burn-in (25) should be accepted"); // Test custom value (100) let full_burn_in = 100; let results_full = DualPhaseBacktestResults::new(full_burn_in, 100); assert_eq!(results_full.burn_in_epochs, 100, "Full burn-in (100) should be accepted"); } /// Test 2: Dual-Phase Metrics Structure /// /// Verifies that DualPhaseBacktestResults contains all required fields #[test] fn test_dual_phase_metrics_creation() { let burn_in = 50; let total = 100; let results = DualPhaseBacktestResults::new(burn_in, total); // Verify structure contains all fields assert_eq!(results.burn_in_epochs, 50); assert_eq!(results.total_epochs, 100); // Verify metrics exist (even if default/zero) assert!(results.exploration_metrics.sharpe_ratio.is_finite()); assert!(results.exploitation_metrics.sharpe_ratio.is_finite()); } /// Test 3: Phase Separation Logic /// /// Mock training with 100 epochs, burn_in = 50 /// Verify epochs 0-49 go to exploration, epochs 50-99 go to exploitation #[test] fn test_phase_separation() { let burn_in_epochs = 50; let total_epochs = 100; let steps_per_epoch = 10; // Small for testing // Create separate engines for each phase let mut exploration_engine = EvaluationEngine::new(10000.0); let mut exploitation_engine = EvaluationEngine::new(10000.0); // Simulate 100 epochs with 10 steps each let total_steps = total_epochs * steps_per_epoch; for step in 0..total_steps { let current_epoch = step / steps_per_epoch; // Determine which engine to use based on epoch let engine = if current_epoch < burn_in_epochs { &mut exploration_engine } else { &mut exploitation_engine }; // Simulate a trade (alternating buy/sell for testing) let action = if step % 2 == 0 { Action::Buy } else { Action::Sell }; // Mock price (oscillating) let price = 100.0 + (step % 10) as f32; let bar = create_test_bar(price); engine.process_bar(step, &bar, action); } // Close any open positions let close_bar = create_test_bar(105.0); exploration_engine.close_position(499, &close_bar); // Last step of exploration exploitation_engine.close_position(999, &close_bar); // Last step of exploitation // Verify trades were distributed correctly let exploration_trades = exploration_engine.trades.len(); let exploitation_trades = exploitation_engine.trades.len(); // Both phases should have trades (since we're alternating actions) assert!( exploration_trades > 0, "Exploration phase should have trades (got {})", exploration_trades ); assert!( exploitation_trades > 0, "Exploitation phase should have trades (got {})", exploitation_trades ); // Create results let results = DualPhaseBacktestResults::from_engines( &exploration_engine, &exploitation_engine, burn_in_epochs, total_epochs, 10000.0, ); assert_eq!(results.burn_in_epochs, 50); assert_eq!(results.total_epochs, 100); } /// Test 4: Metrics Calculation /// /// Mock scenario with different performance in each phase #[test] fn test_exploration_vs_exploitation_metrics() { // Create engines let mut exploration_engine = EvaluationEngine::new(10000.0); let mut exploitation_engine = EvaluationEngine::new(10000.0); // Exploration phase: Random/poor trades (losing money) // Simulate 50 bad trades for i in 0..50 { let action = if i % 2 == 0 { Action::Buy } else { Action::Sell }; // Declining prices (losses) let price = 100.0 - (i as f32 * 0.1); let bar = create_test_bar(price); exploration_engine.process_bar(i, &bar, action); } let close_bar = create_test_bar(95.0); exploration_engine.close_position(49, &close_bar); // Exploitation phase: Good trades (making money) // Simulate 50 good trades for i in 0..50 { let action = if i % 2 == 0 { Action::Buy } else { Action::Sell }; // Rising prices (profits) let price = 100.0 + (i as f32 * 0.1); let bar = create_test_bar(price); exploitation_engine.process_bar(i, &bar, action); } let close_bar2 = create_test_bar(105.0); exploitation_engine.close_position(49, &close_bar2); // Create results let results = DualPhaseBacktestResults::from_engines( &exploration_engine, &exploitation_engine, 50, 100, 10000.0, ); // Verify both phases have metrics assert!( results.exploration_metrics.total_trades > 0, "Exploration should have trades" ); assert!( results.exploitation_metrics.total_trades > 0, "Exploitation should have trades" ); // Verify metrics are calculated independently assert!( results.exploration_metrics.sharpe_ratio.is_finite(), "Exploration Sharpe should be finite" ); assert!( results.exploitation_metrics.sharpe_ratio.is_finite(), "Exploitation Sharpe should be finite" ); } /// Test 5: Zero Burn-In (Backward Compatibility) /// /// burn_in_epochs = 0 means all epochs are "exploitation" /// exploration_metrics should be empty/zero #[test] fn test_zero_burn_in_epochs() { let burn_in_epochs = 0; let total_epochs = 100; // Create engines let exploration_engine = EvaluationEngine::new(10000.0); // Should be empty let mut exploitation_engine = EvaluationEngine::new(10000.0); // All 100 epochs go to exploitation for i in 0..100 { let action = if i % 2 == 0 { Action::Buy } else { Action::Sell }; let price = 100.0 + (i % 10) as f32; let bar = create_test_bar(price); exploitation_engine.process_bar(i, &bar, action); } let close_bar = create_test_bar(105.0); exploitation_engine.close_position(99, &close_bar); // Create results let results = DualPhaseBacktestResults::from_engines( &exploration_engine, &exploitation_engine, burn_in_epochs, total_epochs, 10000.0, ); // Verify zero burn-in behavior assert_eq!(results.burn_in_epochs, 0, "Burn-in should be 0"); assert_eq!( results.exploration_metrics.total_trades, 0, "Exploration should have 0 trades with zero burn-in" ); assert!( results.exploitation_metrics.total_trades > 0, "Exploitation should have all trades with zero burn-in" ); } /// Test 6: Full Burn-In (All Exploration) /// /// burn_in_epochs = total_epochs means all epochs are "exploration" /// exploitation_metrics should be empty/zero #[test] fn test_full_burn_in_epochs() { let burn_in_epochs = 100; let total_epochs = 100; // Create engines let mut exploration_engine = EvaluationEngine::new(10000.0); let exploitation_engine = EvaluationEngine::new(10000.0); // Should be empty // All 100 epochs go to exploration for i in 0..100 { let action = if i % 2 == 0 { Action::Buy } else { Action::Sell }; let price = 100.0 + (i % 10) as f32; let bar = create_test_bar(price); exploration_engine.process_bar(i, &bar, action); } let close_bar = create_test_bar(105.0); exploration_engine.close_position(99, &close_bar); // Create results let results = DualPhaseBacktestResults::from_engines( &exploration_engine, &exploitation_engine, burn_in_epochs, total_epochs, 10000.0, ); // Verify full burn-in behavior assert_eq!(results.burn_in_epochs, 100, "Burn-in should be 100"); assert_eq!(results.total_epochs, 100, "Total epochs should be 100"); assert!( results.exploration_metrics.total_trades > 0, "Exploration should have all trades with full burn-in" ); assert_eq!( results.exploitation_metrics.total_trades, 0, "Exploitation should have 0 trades with full burn-in" ); } /// Test 7: Integration Test - Realistic Scenario /// /// Simulates a realistic training run with: /// - 100 total epochs /// - 50 burn-in epochs /// - Different trading strategies in each phase #[test] fn test_realistic_dual_phase_scenario() { let burn_in_epochs = 50; let total_epochs = 100; let steps_per_epoch = 100; let mut exploration_engine = EvaluationEngine::new(10000.0); let mut exploitation_engine = EvaluationEngine::new(10000.0); // Simulate realistic training for epoch in 0..total_epochs { for step in 0..steps_per_epoch { let current_step = epoch * steps_per_epoch + step; let price = 100.0 + ((current_step as f32 * 0.01) as i32 % 10) as f32; let engine = if epoch < burn_in_epochs { &mut exploration_engine } else { &mut exploitation_engine }; // Exploration: More random (50/50 buy/sell) // Exploitation: More strategic (70/30 buy/hold) let action = if epoch < burn_in_epochs { if step % 2 == 0 { Action::Buy } else { Action::Sell } } else { if step % 10 < 7 { Action::Buy } else { Action::Hold } }; let bar = create_test_bar(price); engine.process_bar(current_step, &bar, action); } } // Close positions let close_bar1 = create_test_bar(105.0); let close_bar2 = create_test_bar(110.0); exploration_engine.close_position(4999, &close_bar1); exploitation_engine.close_position(9999, &close_bar2); // Create results let results = DualPhaseBacktestResults::from_engines( &exploration_engine, &exploitation_engine, burn_in_epochs, total_epochs, 10000.0, ); // Verify realistic behavior assert_eq!(results.burn_in_epochs, 50); assert_eq!(results.total_epochs, 100); assert!(results.exploration_metrics.total_trades > 0); assert!(results.exploitation_metrics.total_trades > 0); // Both phases should have reasonable metrics assert!(results.exploration_metrics.final_equity > 0.0); assert!(results.exploitation_metrics.final_equity > 0.0); }