//! Integration test for Bug #2: Portfolio Tracking in DQN Hyperopt //! //! This test verifies that the DQN hyperopt adapter properly initializes //! and uses the PortfolioTracker to provide portfolio features during training. //! //! **Bug #2 (CRITICAL)**: Empty portfolio features → P&L = 0 //! - Root cause: PortfolioTracker not initialized in hyperopt adapter //! - Impact: Reward function receives [0.0, 0.0, 0.0] for portfolio features //! - Expected: Portfolio features [value, position, spread] should be non-zero //! //! **Test Strategy**: //! 1. Create DQNTrainer for hyperopt (minimal configuration) //! 2. Train for 1 epoch with default parameters //! 3. Verify portfolio features are populated (not [0.0, 0.0, 0.0]) //! 4. Verify portfolio state updates correctly after BUY/SELL/HOLD actions use anyhow::Result; use ml::hyperopt::adapters::dqn::{DQNParams, DQNTrainer}; use ml::hyperopt::traits::HyperparameterOptimizable; #[test] fn test_dqn_hyperopt_portfolio_tracker_initialization() -> Result<()> { // Initialize tracing for debugging let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .with_test_writer() .try_init(); // Check if test data exists (skip if not available, e.g., in CI) let test_data_path = std::path::PathBuf::from("test_data/ES_FUT_unseen.parquet"); if !test_data_path.exists() { tracing::warn!("Test data not found: {:?}", test_data_path); tracing::warn!("Skipping test - this is expected in CI without test data"); return Ok(()); } // Create minimal DQN trainer for hyperopt // Use test data from unseen evaluation set let mut trainer = DQNTrainer::new( test_data_path, 5, // 5 epochs for fast test )?; // Use default parameters (reasonable defaults for testing) let params = DQNParams::default(); tracing::info!("Training DQN with default parameters to verify portfolio tracking..."); tracing::info!(" Learning rate: {}", params.learning_rate); tracing::info!(" Batch size: {}", params.batch_size); tracing::info!(" Gamma: {}", params.gamma); // Train with default parameters let metrics = trainer.train_with_params(params)?; tracing::info!("Training completed:"); tracing::info!(" Epochs completed: {}", metrics.epochs_completed); tracing::info!(" Final train loss: {:.6}", metrics.train_loss); tracing::info!(" Final val loss: {:.6}", metrics.val_loss); tracing::info!(" Avg Q-value: {:.4}", metrics.avg_q_value); tracing::info!(" Avg episode reward: {:.4}", metrics.avg_episode_reward); // CRITICAL ASSERTION #1: Episode reward should be non-zero // If portfolio features are [0.0, 0.0, 0.0], reward will be 0.0 // With proper portfolio tracking, rewards should be based on P&L assert!( metrics.avg_episode_reward.abs() > 1e-6, "Bug #2 DETECTED: Episode reward is zero! Portfolio features likely [0.0, 0.0, 0.0]. \ Reward: {:.6}", metrics.avg_episode_reward ); // CRITICAL ASSERTION #2: Training should complete at least 1 epoch assert!( metrics.epochs_completed >= 1, "Training completed 0 epochs - likely failed during initialization" ); // CRITICAL ASSERTION #3: Q-values should be non-zero // Zero Q-values indicate the network isn't learning (likely due to zero rewards) assert!( metrics.avg_q_value.abs() > 1e-6, "Bug #2 DETECTED: Q-values are zero! This indicates zero rewards from empty portfolio features. \ Q-value: {:.6}", metrics.avg_q_value ); tracing::info!("✓ Bug #2 verification PASSED: Portfolio tracking is operational"); tracing::info!( " Episode reward: {:.6} (non-zero ✓)", metrics.avg_episode_reward ); tracing::info!(" Q-value: {:.6} (non-zero ✓)", metrics.avg_q_value); Ok(()) } #[test] fn test_dqn_hyperopt_portfolio_features_populated() -> Result<()> { // This test is more comprehensive - it actually inspects the internal state // to verify portfolio features are populated correctly // Initialize tracing let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .with_test_writer() .try_init(); // Check if test data exists let test_data_path = std::path::PathBuf::from("test_data/ES_FUT_unseen.parquet"); if !test_data_path.exists() { tracing::warn!("Test data not found, skipping test"); return Ok(()); } // Create DQN trainer let mut trainer = DQNTrainer::new( test_data_path, 3, // 3 epochs for fast test )?; // Use small batch size for faster test let params = DQNParams { learning_rate: 1e-4, batch_size: 64, gamma: 0.99, epsilon_decay: 0.995, buffer_size: 10_000, movement_threshold: 0.02, // Default from production }; tracing::info!("Training DQN to verify portfolio features are populated..."); // Train and get metrics let metrics = trainer.train_with_params(params)?; // Verify metrics indicate proper portfolio tracking assert!( metrics.epochs_completed >= 1, "Training should complete at least 1 epoch" ); // If portfolio features are properly populated, we should see: // 1. Non-zero episode rewards (from P&L calculations) // 2. Non-zero Q-values (from non-zero rewards) // 3. Reasonable training loss (network is learning) tracing::info!("Portfolio tracking verification:"); tracing::info!(" Episode reward: {:.6}", metrics.avg_episode_reward); tracing::info!(" Q-value: {:.6}", metrics.avg_q_value); tracing::info!(" Train loss: {:.6}", metrics.train_loss); // ASSERTION: Portfolio features should result in non-zero metrics let portfolio_is_working = metrics.avg_episode_reward.abs() > 1e-6 && metrics.avg_q_value.abs() > 1e-6; assert!( portfolio_is_working, "Bug #2 DETECTED: Portfolio features are likely [0.0, 0.0, 0.0]. \ Episode reward: {:.6}, Q-value: {:.6}", metrics.avg_episode_reward, metrics.avg_q_value ); tracing::info!("✓ Portfolio features are properly populated"); Ok(()) } #[test] fn test_dqn_hyperopt_multiple_trials_portfolio_consistency() -> Result<()> { // Test that portfolio tracking is consistent across multiple trials // This ensures the PortfolioTracker is properly reset between trials // Initialize tracing let _ = tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .with_test_writer() .try_init(); // Check if test data exists let test_data_path = std::path::PathBuf::from("test_data/ES_FUT_unseen.parquet"); if !test_data_path.exists() { tracing::warn!("Test data not found, skipping test"); return Ok(()); } // Create DQN trainer (will be reused for 3 trials) let mut trainer = DQNTrainer::new( test_data_path, 2, // 2 epochs per trial for speed )?; // Parameters for all trials let params = DQNParams { learning_rate: 1e-4, batch_size: 64, gamma: 0.99, epsilon_decay: 0.995, buffer_size: 10_000, movement_threshold: 0.02, // Default from production }; // Run 3 trials and collect metrics let mut trial_rewards = Vec::new(); let mut trial_q_values = Vec::new(); for trial_num in 0..3 { tracing::info!("=== Trial {} ===", trial_num); let metrics = trainer.train_with_params(params.clone())?; tracing::info!(" Episode reward: {:.6}", metrics.avg_episode_reward); tracing::info!(" Q-value: {:.6}", metrics.avg_q_value); trial_rewards.push(metrics.avg_episode_reward); trial_q_values.push(metrics.avg_q_value); // Each trial should have non-zero metrics assert!( metrics.avg_episode_reward.abs() > 1e-6, "Trial {} has zero reward - portfolio tracking failed", trial_num ); assert!( metrics.avg_q_value.abs() > 1e-6, "Trial {} has zero Q-value - portfolio tracking failed", trial_num ); } // Verify all trials had non-zero results tracing::info!("=== Multi-Trial Portfolio Consistency ==="); for (i, (reward, q_val)) in trial_rewards.iter().zip(trial_q_values.iter()).enumerate() { tracing::info!(" Trial {}: reward={:.6}, q_value={:.6}", i, reward, q_val); } // All trials should have non-zero metrics (portfolio tracking working) assert!( trial_rewards.iter().all(|r| r.abs() > 1e-6), "Some trials had zero rewards - portfolio tracking inconsistent" ); assert!( trial_q_values.iter().all(|q| q.abs() > 1e-6), "Some trials had zero Q-values - portfolio tracking inconsistent" ); tracing::info!("✓ Portfolio tracking is consistent across multiple trials"); Ok(()) }