//! Comprehensive Unit Tests for DQN Reward Function //! //! This test suite is designed to catch three critical bugs that were discovered in production: //! - Bug #2: Empty portfolio features → P&L always 0.0 //! - Bug #3: Wrong defaults (hold_penalty=0.0, threshold=0.0) //! - Bug #4: Close price misinterpretation (log return vs price) //! //! These tests serve as regression tests to prevent future occurrences. use ml::dqn::agent::{TradingAction, TradingState}; use ml::dqn::reward::{RewardConfig, RewardFunction}; use rust_decimal::Decimal; // ============================================================================ // CATEGORY A: Portfolio Feature Validation (catch Bug #2) // ============================================================================ /// Helper to create a state with empty portfolio features (Bug #2 scenario) fn create_empty_portfolio_state() -> TradingState { TradingState { price_features: vec![5900.0, 5905.0, 5895.0, 5900.0], // ES futures OHLC technical_indicators: vec![0.5, 0.5, 0.5, 0.5], market_features: vec![0.25, 10000.0, 0.0, 0.0], // ES typical spread portfolio_features: vec![], // EMPTY - Bug #2 } } /// Helper to create a state with portfolio growth fn create_portfolio_with_gain(initial_value: f32, gain_pct: f32) -> (TradingState, TradingState) { let normalized_initial = initial_value / 10000.0; // Normalize to [0, 1] let normalized_final = (initial_value * (1.0 + gain_pct)) / 10000.0; let current_state = TradingState { price_features: vec![5900.0, 5905.0, 5895.0, 5900.0], technical_indicators: vec![0.5, 0.5, 0.5, 0.5], market_features: vec![0.25, 10000.0, 0.0, 0.0], portfolio_features: vec![normalized_initial, 0.5, 0.0, 0.0], // [value, position, ...] }; let next_state = TradingState { price_features: vec![5910.0, 5915.0, 5905.0, 5910.0], technical_indicators: vec![0.5, 0.5, 0.5, 0.5], market_features: vec![0.25, 10000.0, 0.0, 0.0], portfolio_features: vec![normalized_final, 0.6, 0.0, 0.0], // Portfolio grew }; (current_state, next_state) } /// Test A1: Verify error or zero reward when portfolio features are empty (Bug #2) #[test] fn test_pnl_reward_requires_portfolio_features() -> anyhow::Result<()> { // BUG #2: Empty portfolio_features caused P&L to always be 0.0 // This test would have caught it by asserting non-zero reward for BUY action let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); let current_state = create_empty_portfolio_state(); let next_state = create_empty_portfolio_state(); // Calculate reward for BUY action (should depend on portfolio P&L) let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; // With empty portfolio_features, reward should be 0.0 (fallback behavior) // This catches Bug #2: If portfolio_features is empty, P&L calculation returns 0 assert_eq!( reward, Decimal::ZERO, "BUG #2 CATCH: Empty portfolio_features should give zero P&L reward, got {}", reward ); Ok(()) } /// Test A2: Verify positive reward for portfolio growth (Buy action with profit) #[test] fn test_pnl_reward_positive_gain() -> anyhow::Result<()> { // BUG #2: Would fail if portfolio_features were empty (always 0.0 reward) // This verifies that non-empty portfolio features produce non-zero rewards let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); // Create states with 5% portfolio gain let (current_state, next_state) = create_portfolio_with_gain(10000.0, 0.05); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; // Expected: P&L reward = (next_value - current_value) / current_value // = (10500 - 10000) / 10000 = 0.05 = 5% gain // With pnl_weight=1.0, risk_weight=0.1, cost_weight=0.05 // Reward ≈ 0.05 (P&L component dominates) assert!( reward > Decimal::ZERO, "BUG #2 CATCH: Portfolio gain should produce positive reward, got {}", reward ); // Verify reward is approximately 5% (within tolerance) let expected = Decimal::try_from(0.05).unwrap(); let tolerance = Decimal::try_from(0.01).unwrap(); // 1% tolerance for penalties assert!( (reward - expected).abs() < tolerance, "Expected reward ~{} for 5% gain, got {}", expected, reward ); Ok(()) } /// Test A3: Verify negative reward for portfolio loss (Sell action with profit) #[test] fn test_pnl_reward_negative_loss() -> anyhow::Result<()> { // BUG #2: Would always return 0.0 if portfolio_features were empty let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); // Create states with -3% portfolio loss let (current_state, next_state) = create_portfolio_with_gain(10000.0, -0.03); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Sell, ¤t_state, &next_state, &recent_actions, )?; // Expected: P&L reward = (9700 - 10000) / 10000 = -0.03 = -3% loss // With penalties, reward should be negative assert!( reward < Decimal::ZERO, "BUG #2 CATCH: Portfolio loss should produce negative reward, got {}", reward ); Ok(()) } /// Test A4: Verify P&L reward normalization (percentage-based) #[test] fn test_pnl_reward_normalization() -> anyhow::Result<()> { // BUG #2: Percentage calculation relies on non-zero portfolio value // This verifies normalization works correctly let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); // Small portfolio: 1000 → 1020 (+2%) let (current_small, next_small) = create_portfolio_with_gain(1000.0, 0.02); // Large portfolio: 100000 → 102000 (+2%) let (current_large, next_large) = create_portfolio_with_gain(100000.0, 0.02); let recent_actions = vec![]; // No action history for unit test let reward_small = reward_fn.calculate_reward( TradingAction::Buy, ¤t_small, &next_small, &recent_actions, )?; let recent_actions = vec![]; // No action history for unit test let reward_large = reward_fn.calculate_reward( TradingAction::Buy, ¤t_large, &next_large, &recent_actions, )?; // Rewards should be approximately equal (percentage-based normalization) let tolerance = Decimal::try_from(0.001).unwrap(); assert!( (reward_small - reward_large).abs() < tolerance, "BUG #2 CATCH: Rewards should be normalized by portfolio value. Small: {}, Large: {}", reward_small, reward_large ); Ok(()) } // ============================================================================ // CATEGORY B: Default Hyperparameter Validation (catch Bug #3) // ============================================================================ /// Test B1: Assert default diversity_weight = 0.01 (Bug #3) #[test] fn test_default_hyperparameters_hold_penalty() { // BUG #3: Original code had diversity_weight=0.0 (wrong default) // This test ensures default is correctly set to 0.01 let config = RewardConfig::default(); let expected = Decimal::try_from(0.01).unwrap(); assert_eq!( config.diversity_weight, expected, "BUG #3 CATCH: Default diversity_weight should be 0.01, got {}", config.diversity_weight ); } /// Test B2: Assert default movement_threshold = 0.02 (Bug #3) #[test] fn test_default_hyperparameters_movement_threshold() { // BUG #3: Original code had movement_threshold=0.0 (wrong default) // This test ensures default is correctly set to 0.02 (2%) let config = RewardConfig::default(); let expected = Decimal::try_from(0.02).unwrap(); assert_eq!( config.movement_threshold, expected, "BUG #3 CATCH: Default movement_threshold should be 0.02 (2%), got {}", config.movement_threshold ); } /// Test B3: Verify custom hyperparameters override defaults #[test] fn test_custom_hyperparameters_override() { // Ensure custom values can be set (not hardcoded to defaults) let custom_config = RewardConfig { pnl_weight: Decimal::ONE, risk_weight: Decimal::try_from(0.2).unwrap(), cost_weight: Decimal::try_from(0.1).unwrap(), hold_reward: Decimal::try_from(0.005).unwrap(), diversity_weight: Decimal::try_from(0.05).unwrap(), // Custom: 5x default movement_threshold: Decimal::try_from(0.01).unwrap(), // Custom: 1% instead of 2% }; assert_eq!( custom_config.diversity_weight, Decimal::try_from(0.05).unwrap(), "Custom diversity_weight should be 0.05" ); assert_eq!( custom_config.movement_threshold, Decimal::try_from(0.01).unwrap(), "Custom movement_threshold should be 0.01" ); } // ============================================================================ // CATEGORY C: HOLD Penalty Calculation (catch Bug #4) // ============================================================================ /// Helper to create states with specific ES futures prices fn create_state_with_es_price(close_price: f32) -> TradingState { TradingState { price_features: vec![ close_price, close_price + 5.0, close_price - 5.0, close_price, ], // OHLC technical_indicators: vec![0.5, 0.5, 0.5, 0.5], market_features: vec![0.25, 10000.0, 0.0, 0.0], // ES typical spread portfolio_features: vec![1.0, 0.0, 0.0, 0.0], } } /// Test C1: HOLD penalty with high volatility (>2% movement) - Bug #4 #[test] fn test_hold_penalty_with_high_volatility() -> anyhow::Result<()> { // BUG #4: Original code used log returns, giving wrong penalty calculation // Correct: Use actual price change percentage let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config.clone()); // ES futures: 5900 → 6020 (+120 points = +2.03% movement) let current_state = create_state_with_es_price(5900.0); let next_state = create_state_with_es_price(6020.0); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Hold, ¤t_state, &next_state, &recent_actions, )?; // Expected: Movement 2.03% > threshold 2.0% → penalty applies // Excess: 2.03% - 2.0% = 0.03% = 0.0003 // Reward: 0.001 - (0.01 * 0.0003) = 0.001 - 0.000003 ≈ 0.001 // The penalty should be very small (just above threshold) assert!( reward < config.hold_reward, "BUG #4 CATCH: HOLD penalty should apply when movement > 2%, got reward {}", reward ); // Verify it's still positive (small excess movement) assert!( reward > Decimal::ZERO, "Reward should be positive for small excess movement: {}", reward ); Ok(()) } /// Test C2: HOLD penalty with low volatility (<2% movement) - Bug #4 #[test] fn test_hold_penalty_with_low_volatility() -> anyhow::Result<()> { // BUG #4: Would give wrong penalty if using log returns let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config.clone()); // ES futures: 5900 → 5910 (+10 points = +0.17% movement) let current_state = create_state_with_es_price(5900.0); let next_state = create_state_with_es_price(5910.0); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Hold, ¤t_state, &next_state, &recent_actions, )?; // Expected: Movement 0.17% < threshold 2.0% → NO penalty // Reward should equal hold_reward (0.001) assert_eq!( reward, config.hold_reward, "BUG #4 CATCH: No penalty when movement < 2% (movement: 0.17%), got reward {}", reward ); Ok(()) } /// Test C3: HOLD penalty calculation accuracy - Bug #4 #[test] fn test_hold_penalty_calculation_accuracy() -> anyhow::Result<()> { // BUG #4: Verify exact math for penalty calculation // Log return: ln(6000/5900) ≈ 0.0167 = 1.67% (WRONG) // Price change: (6000-5900)/5900 ≈ 0.0169 = 1.69% (CORRECT) let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config.clone()); // ES futures: 5900 → 6000 (+100 points = +1.69% movement) let current_state = create_state_with_es_price(5900.0); let next_state = create_state_with_es_price(6000.0); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Hold, ¤t_state, &next_state, &recent_actions, )?; // Expected: Movement 1.69% < threshold 2.0% → NO penalty assert_eq!( reward, config.hold_reward, "BUG #4 CATCH: 1.69% movement (100 pts on ES 5900) should not trigger penalty, got {}", reward ); // Now test with exact 2% boundary let next_state_2pct = create_state_with_es_price(6018.0); // Exactly +2% let recent_actions = vec![]; // No action history for unit test let reward_2pct = reward_fn.calculate_reward( TradingAction::Hold, ¤t_state, &next_state_2pct, &recent_actions, )?; assert_eq!( reward_2pct, config.hold_reward, "BUG #4 CATCH: Exactly 2% movement should not trigger penalty (not exceeding), got {}", reward_2pct ); Ok(()) } /// Test C4: HOLD penalty uses actual price change, not log returns - Bug #4 #[test] fn test_hold_penalty_uses_actual_price_change() -> anyhow::Result<()> { // BUG #4: Critical test to verify we're NOT using log returns // Log return: ln(new/old) ≈ (new-old)/old for small changes // But diverges significantly for large moves let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config.clone()); // Large price swing: 5900 → 7080 (+1180 points = +20% movement) let current_state = create_state_with_es_price(5900.0); let next_state = create_state_with_es_price(7080.0); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Hold, ¤t_state, &next_state, &recent_actions, )?; // Expected price change: (7080-5900)/5900 = 0.20 = 20% // Log return: ln(7080/5900) ≈ 0.182 = 18.2% (WRONG - we should NOT use this) // Excess: 20% - 2% = 18% = 0.18 // Penalty: 0.001 - (0.01 * 0.18) = 0.001 - 0.0018 = -0.0008 let expected = Decimal::try_from(-0.0008).unwrap(); let tolerance = Decimal::try_from(0.0001).unwrap(); assert!( (reward - expected).abs() < tolerance, "BUG #4 CATCH: Penalty should use actual price change (20%), not log return (18.2%). Expected {}, got {}", expected, reward ); // Verify reward is negative (strong penalty for 20% move) assert!( reward < Decimal::ZERO, "BUG #4 CATCH: 20% movement should produce negative reward, got {}", reward ); Ok(()) } /// Test C5: HOLD penalty with price drop (verify abs value) - Bug #4 #[test] fn test_hold_penalty_price_drop_abs_value() -> anyhow::Result<()> { // BUG #4: Verify penalty applies to |price_change|, not signed change let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config.clone()); // Price drop: 5900 → 5605 (-295 points = -5% movement) let current_state = create_state_with_es_price(5900.0); let next_state = create_state_with_es_price(5605.0); let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Hold, ¤t_state, &next_state, &recent_actions, )?; // Expected: |−5%| = 5% > 2% → penalty applies // Excess: 5% - 2% = 3% = 0.03 // Penalty: 0.001 - (0.01 * 0.03) = 0.001 - 0.0003 = 0.0007 let expected = Decimal::try_from(0.0007).unwrap(); let tolerance = Decimal::try_from(0.0001).unwrap(); assert!( (reward - expected).abs() < tolerance, "BUG #4 CATCH: Penalty should use |−5%| = 5%, got reward {}", reward ); Ok(()) } // ============================================================================ // CATEGORY D: Integration Tests // ============================================================================ /// Test D1: Full reward function workflow with all actions #[test] fn test_reward_function_end_to_end() -> anyhow::Result<()> { // Comprehensive end-to-end test covering all three actions let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); // Setup: Portfolio with moderate gain let (current_state, next_state) = create_portfolio_with_gain(10000.0, 0.03); // Test BUY action (should get positive reward for 3% gain) let recent_actions = vec![]; // No action history for unit test let buy_reward = reward_fn.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; assert!( buy_reward > Decimal::ZERO, "BUY with 3% gain should be positive: {}", buy_reward ); // Test SELL action (gets same P&L reward - direction-agnostic) // Note: BUY and SELL use portfolio P&L, not price direction // The reward function doesn't penalize wrong directional bets in the P&L component let recent_actions = vec![]; // No action history for unit test let sell_reward = reward_fn.calculate_reward( TradingAction::Sell, ¤t_state, &next_state, &recent_actions, )?; // SELL should get same reward as BUY (both use portfolio P&L) assert_eq!( sell_reward, buy_reward, "SELL and BUY should have same reward (portfolio-based, not direction-based): SELL={}, BUY={}", sell_reward, buy_reward ); // Test HOLD action with low volatility let hold_state_current = create_state_with_es_price(5900.0); let hold_state_next = create_state_with_es_price(5910.0); // +0.17% let recent_actions = vec![]; // No action history for unit test let hold_reward = reward_fn.calculate_reward( TradingAction::Hold, &hold_state_current, &hold_state_next, &recent_actions, )?; assert!( hold_reward > Decimal::ZERO, "HOLD with low volatility should be slightly positive: {}", hold_reward ); Ok(()) } /// Test D2: Reward consistency across episodes (deterministic behavior) #[test] fn test_reward_consistency_across_episodes() -> anyhow::Result<()> { // Verify same state transitions produce same rewards (no randomness) let config = RewardConfig::default(); let (current_state, next_state) = create_portfolio_with_gain(10000.0, 0.05); // Episode 1 let mut reward_fn_1 = RewardFunction::new(config.clone()); let recent_actions = vec![]; // No action history for unit test let reward_1 = reward_fn_1.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; // Episode 2 (fresh reward function) let mut reward_fn_2 = RewardFunction::new(config.clone()); let recent_actions = vec![]; // No action history for unit test let reward_2 = reward_fn_2.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; assert_eq!( reward_1, reward_2, "Reward function should be deterministic: Episode1={}, Episode2={}", reward_1, reward_2 ); Ok(()) } /// Test D3: Verify reward history tracking #[test] fn test_reward_history_tracking() -> anyhow::Result<()> { // Ensure reward_history is populated correctly let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); let (current_state, next_state) = create_portfolio_with_gain(10000.0, 0.02); // Calculate 5 rewards for _ in 0..5 { let recent_actions = vec![]; // No action history for unit test reward_fn.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; } // Verify history has 5 entries let stats = reward_fn.get_stats(); assert_eq!( stats.total_rewards, 5, "Should have 5 rewards in history, got {}", stats.total_rewards ); Ok(()) } /// Test D4: Edge case - Zero portfolio value #[test] fn test_zero_portfolio_value_edge_case() -> anyhow::Result<()> { // BUG #2 catch: Ensure division by zero is handled let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); let current_state = TradingState { price_features: vec![5900.0, 5905.0, 5895.0, 5900.0], technical_indicators: vec![0.5, 0.5, 0.5, 0.5], market_features: vec![0.25, 10000.0, 0.0, 0.0], portfolio_features: vec![0.0, 0.0, 0.0, 0.0], // Zero portfolio value }; let next_state = TradingState { price_features: vec![5910.0, 5915.0, 5905.0, 5910.0], technical_indicators: vec![0.5, 0.5, 0.5, 0.5], market_features: vec![0.25, 10000.0, 0.0, 0.0], portfolio_features: vec![100.0 / 10000.0, 0.5, 0.0, 0.0], // Portfolio grew from zero }; // Should not panic, P&L component should be zero (current_value = 0 fallback) // But total reward may be negative due to cost/risk penalties let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; // P&L component is zero, but risk penalty (position 0.5 is over threshold 0.0) // and cost penalty apply, so total reward is slightly negative // The key is that it doesn't panic (division by zero protection works) assert!( reward <= Decimal::ZERO, "Zero portfolio value: P&L=0 but penalties apply, got {}", reward ); Ok(()) } /// Test D5: Realistic ES futures scenario #[test] fn test_realistic_es_futures_scenario() -> anyhow::Result<()> { // Simulate realistic ES futures trading scenario let config = RewardConfig::default(); let mut reward_fn = RewardFunction::new(config); // Scenario: BUY at 5900, price moves to 5925 (+25 points = +0.42%) let current_state = TradingState { price_features: vec![5900.0, 5905.0, 5895.0, 5900.0], technical_indicators: vec![0.52, 0.48, 0.55, 0.50], // Slightly bullish indicators market_features: vec![0.25, 12000.0, 0.0, 0.0], // Normal ES spread and volume portfolio_features: vec![0.95, 0.3, 0.0, 0.0], // 95% portfolio value, 30% position }; let next_state = TradingState { price_features: vec![5925.0, 5930.0, 5920.0, 5925.0], technical_indicators: vec![0.55, 0.50, 0.58, 0.53], market_features: vec![0.25, 11500.0, 0.0, 0.0], portfolio_features: vec![0.96, 0.35, 0.0, 0.0], // Portfolio value increased slightly }; let recent_actions = vec![]; // No action history for unit test let reward = reward_fn.calculate_reward( TradingAction::Buy, ¤t_state, &next_state, &recent_actions, )?; // Expected: Small positive reward (portfolio grew ~1%) assert!( reward > Decimal::ZERO, "Profitable ES futures trade should give positive reward: {}", reward ); // Verify reward is reasonable magnitude (not extreme) let max_reasonable = Decimal::try_from(0.1).unwrap(); // 10% is max reasonable assert!( reward < max_reasonable, "Reward should be reasonable magnitude, got {}", reward ); Ok(()) }