/// Bug #8: Action Selection Frequency Test Suite /// /// **Bug Description**: /// Experience collection in DQN training incorrectly executes portfolio actions /// for ALL training samples (16,819 samples/epoch), causing 522,713 total actions /// in 1 epoch instead of expected 0 actions during training. /// /// **Root Cause**: /// Line 928 of trainers/dqn.rs calls `portfolio_tracker.execute_action()` during /// experience collection phase. Experience collection should SIMULATE rewards /// without executing portfolio actions. /// /// **Expected Behavior**: /// - Experience collection: 0 portfolio executions (simulation only) /// - Evaluation/Backtesting: N portfolio executions (actual trading simulation) use anyhow::Result; use ml::trainers::dqn::{DQNTrainer, DQNHyperparameters}; use ml::trainers::TargetUpdateMode; use std::path::PathBuf; /// Helper: Get path to ES.FUT test data fn get_test_data_dir() -> Result { let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .ok_or_else(|| anyhow::anyhow!("Failed to get workspace root"))? .to_path_buf(); let data_dir = workspace_root.join("test_data/real/databento/ml_training_small"); if !data_dir.exists() { anyhow::bail!( "ES.FUT data directory not found: {}. Skipping Bug #8 tests.", data_dir.display() ); } Ok(data_dir.to_string_lossy().to_string()) } /// Helper: Create checkpoint directory for tests fn create_checkpoint_dir() -> Result { let checkpoint_dir = PathBuf::from("/tmp/bug8_checkpoints"); std::fs::create_dir_all(&checkpoint_dir)?; Ok(checkpoint_dir) } /// Helper: Create test hyperparameters for Bug #8 validation fn create_test_hyperparams(epochs: usize) -> DQNHyperparameters { DQNHyperparameters { learning_rate: 0.0001, batch_size: 32, gamma: 0.99, epsilon_start: 1.0, epsilon_end: 0.05, epsilon_decay: 0.995, buffer_size: 1000, min_replay_size: 32, epochs, checkpoint_frequency: 10, early_stopping_enabled: false, q_value_floor: 0.5, min_loss_improvement_pct: 0.1, plateau_window: 30, min_epochs_before_stopping: 50, hold_penalty: 0.01, use_huber_loss: true, huber_delta: 1.0, use_double_dqn: true, gradient_clip_norm: Some(10.0), hold_penalty_weight: 1.0, movement_threshold: 0.02, enable_preprocessing: false, preprocessing_window: 50, preprocessing_clip_sigma: 5.0, target_update_mode: TargetUpdateMode::Hard, target_update_frequency: 1000, tau: 0.005, warmup_steps: 0, // No warmup for fast testing initial_capital: 100_000.0, // $100K initial capital cash_reserve_percent: 0.0, // No reserve requirement } } #[tokio::test] async fn test_no_transaction_costs_during_training() -> Result<()> { // Test: Transaction costs are NOT charged during training experience collection // Expected: transaction_costs() remains 0.0 after training // Bug Scenario: With bug, 522K actions × ~0.15% fee = massive cost inflation let data_dir = match get_test_data_dir() { Ok(dir) => dir, Err(e) => { eprintln!("⚠️ Skipping Bug #8 test - data not available: {}", e); return Ok(()); } }; let checkpoint_dir = create_checkpoint_dir()?; let hyperparams = create_test_hyperparams(1); let mut trainer = DQNTrainer::new(hyperparams) .expect("Failed to create DQN trainer"); // Get initial fees via accessor method (Wave 8 API change) let initial_fees = trainer.portfolio_tracker.transaction_costs(); // Run training let _metrics = trainer .train(&data_dir, |epoch, checkpoint_data, _is_best| { let path = checkpoint_dir.join(format!("test_checkpoint_{}.safetensors", epoch)); std::fs::write(&path, checkpoint_data)?; Ok(path.to_string_lossy().to_string()) }) .await .expect("Training failed"); // Verify zero transaction costs (use method, not field) let final_fees = trainer.portfolio_tracker.transaction_costs(); assert_eq!( final_fees, initial_fees, "Bug #8: Training should NOT charge transaction costs (experience collection is simulation only). \ Got {} fees (expected {})", final_fees, initial_fees ); Ok(()) } #[tokio::test] async fn test_no_portfolio_execution_during_training() -> Result<()> { // Test: Portfolio position is NOT modified during training // Expected: current_position() remains 0.0 after training // Bug Scenario: With bug, executes 16,819 actions per epoch let data_dir = match get_test_data_dir() { Ok(dir) => dir, Err(e) => { eprintln!("⚠️ Skipping Bug #8 test - data not available: {}", e); return Ok(()); } }; let checkpoint_dir = create_checkpoint_dir()?; let hyperparams = create_test_hyperparams(1); let mut trainer = DQNTrainer::new(hyperparams) .expect("Failed to create DQN trainer"); // Get initial position via accessor method (Wave 8 API change) let initial_position = trainer.portfolio_tracker.current_position(); // Run training let _metrics = trainer .train(&data_dir, |epoch, checkpoint_data, _is_best| { let path = checkpoint_dir.join(format!("test_checkpoint_{}.safetensors", epoch)); std::fs::write(&path, checkpoint_data)?; Ok(path.to_string_lossy().to_string()) }) .await .expect("Training failed"); // Verify zero position change (use method, not field) let final_position = trainer.portfolio_tracker.current_position(); assert_eq!( final_position, initial_position, "Bug #8: Training should NOT modify portfolio position. Got position={}, expected={}", final_position, initial_position ); Ok(()) } #[tokio::test] async fn test_integration_1epoch_small_dataset() -> Result<()> { // Integration Test: Complete 1-epoch training with verification // Dataset: ES.FUT test data (DBN format) // Expected: // - 0 portfolio executions // - 0 transaction costs // - Valid training metrics // - No circuit breaker triggers let data_dir = match get_test_data_dir() { Ok(dir) => dir, Err(e) => { eprintln!("⚠️ Skipping Bug #8 test - data not available: {}", e); return Ok(()); } }; let checkpoint_dir = create_checkpoint_dir()?; let hyperparams = create_test_hyperparams(1); let mut trainer = DQNTrainer::new(hyperparams) .expect("Failed to create DQN trainer"); // Capture initial state (Wave 8 API: use methods, not fields) let initial_fees = trainer.portfolio_tracker.transaction_costs(); let initial_position = trainer.portfolio_tracker.current_position(); let metrics = trainer .train(&data_dir, |epoch, checkpoint_data, _is_best| { let path = checkpoint_dir.join(format!("test_checkpoint_integration_{}.safetensors", epoch)); std::fs::write(&path, checkpoint_data)?; Ok(path.to_string_lossy().to_string()) }) .await .expect("Training failed"); // Comprehensive verification assert_eq!(metrics.epochs_trained, 1, "Should complete 1 epoch"); // Bug #8 validation: Zero portfolio actions during training assert_eq!( trainer.portfolio_tracker.transaction_costs(), initial_fees, "Zero transaction costs during training" ); assert_eq!( trainer.portfolio_tracker.current_position(), initial_position, "Zero portfolio position changes during training" ); // Training metrics sanity checks assert!( metrics.loss.is_finite(), "Loss should be finite, got: {}", metrics.loss ); println!("✅ Bug #8 Integration Test PASSED:"); println!(" - 0 transaction costs (initial={}, final={})", initial_fees, trainer.portfolio_tracker.transaction_costs()); println!(" - 0 position changes (initial={}, final={})", initial_position, trainer.portfolio_tracker.current_position()); println!(" - Final loss: {:.6}", metrics.loss); Ok(()) }