//! Activity Bonus CLI Configuration Tests (Wave 16S V13) //! //! Validates CLI parameter handling, boundary conditions, and integration //! with ExtrinsicRewardCalculator. use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency}; use ml::dqn::reward_elite::ExtrinsicRewardCalculator; /// Test default activity bonus values (0.10 weight, 0.05 bonus, -0.10 penalty) #[test] fn test_default_activity_bonus_values() { let calc = ExtrinsicRewardCalculator::new(); // BUY action (zero P&L, isolate activity bonus) let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let reward_buy = calc.clone().calculate_extrinsic_reward( buy_action, 100.0, // entry_price 100.0, // exit_price (no P&L) 10.0, // position_size 10000.0, // portfolio_value 0.0, // max_drawdown ); // HOLD action (zero P&L, isolate activity penalty) let hold_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); let reward_hold = calc.clone().calculate_extrinsic_reward( hold_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); // Expected: BUY = 0.10 * 0.05 = 0.005 // Expected: HOLD = 0.10 * (-0.10) = -0.01 assert!((reward_buy - 0.005).abs() < 1e-6, "BUY reward should be 0.005, got {}", reward_buy); assert!((reward_hold - (-0.01)).abs() < 1e-6, "HOLD reward should be -0.01, got {}", reward_hold); // Difference should be 0.015 (0.10 * (0.05 - (-0.10))) let diff = reward_buy - reward_hold; assert!((diff - 0.015).abs() < 1e-6, "Difference should be 0.015, got {}", diff); } /// Test custom activity bonus values #[test] fn test_custom_activity_bonus_values() { let calc = ExtrinsicRewardCalculator::with_config( 100, // sharpe_window 0.20, // activity_bonus_weight (20% instead of 10%) 0.10, // activity_bonus_value (0.10 instead of 0.05) -0.20, // activity_penalty_value (-0.20 instead of -0.10) ); let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let hold_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); let reward_buy = calc.clone().calculate_extrinsic_reward( buy_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); let reward_hold = calc.clone().calculate_extrinsic_reward( hold_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); // Expected: BUY = 0.20 * 0.10 = 0.02 // Expected: HOLD = 0.20 * (-0.20) = -0.04 assert!((reward_buy - 0.02).abs() < 1e-6, "BUY reward should be 0.02, got {}", reward_buy); assert!((reward_hold - (-0.04)).abs() < 1e-6, "HOLD reward should be -0.04, got {}", reward_hold); } /// Test disabled activity bonus (weight = 0.0) #[test] fn test_disabled_activity_bonus() { let calc = ExtrinsicRewardCalculator::with_config( 100, // sharpe_window 0.0, // activity_bonus_weight (disabled) 0.05, // activity_bonus_value (irrelevant when disabled) -0.10, // activity_penalty_value (irrelevant when disabled) ); let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let hold_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); let reward_buy = calc.clone().calculate_extrinsic_reward( buy_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); let reward_hold = calc.clone().calculate_extrinsic_reward( hold_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); // Expected: both should be 0.0 (no P&L, no activity bonus) assert!(reward_buy.abs() < 1e-6, "BUY reward should be 0.0 when activity bonus disabled, got {}", reward_buy); assert!(reward_hold.abs() < 1e-6, "HOLD reward should be 0.0 when activity bonus disabled, got {}", reward_hold); } /// Test boundary values: activity_bonus_weight = 1.0 (maximum) #[test] fn test_max_activity_bonus_weight() { let calc = ExtrinsicRewardCalculator::with_config( 100, // sharpe_window 1.0, // activity_bonus_weight (100%) 0.05, -0.10, ); let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let reward_buy = calc.clone().calculate_extrinsic_reward( buy_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); // Expected: BUY = 1.0 * 0.05 = 0.05 (activity bonus dominates) assert!((reward_buy - 0.05).abs() < 1e-6, "BUY reward should be 0.05 with 100% weight, got {}", reward_buy); } /// Test boundary values: negative bonus (penalize BUY/SELL, reward HOLD) #[test] fn test_inverted_activity_bonus() { let calc = ExtrinsicRewardCalculator::with_config( 100, // sharpe_window 0.10, // activity_bonus_weight -0.05, // activity_bonus_value (negative for BUY/SELL) 0.10, // activity_penalty_value (positive for HOLD) ); let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let hold_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal); let reward_buy = calc.clone().calculate_extrinsic_reward( buy_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); let reward_hold = calc.clone().calculate_extrinsic_reward( hold_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); // Expected: BUY = 0.10 * (-0.05) = -0.005 (penalized) // Expected: HOLD = 0.10 * 0.10 = 0.01 (rewarded) assert!((reward_buy - (-0.005)).abs() < 1e-6, "BUY reward should be -0.005, got {}", reward_buy); assert!((reward_hold - 0.01).abs() < 1e-6, "HOLD reward should be 0.01, got {}", reward_hold); } /// Test all exposure levels get BUY/SELL bonus (except Flat) #[test] fn test_all_exposure_levels() { let calc = ExtrinsicRewardCalculator::new(); let exposure_levels = vec![ (ExposureLevel::Short100, "Short100"), (ExposureLevel::Short50, "Short50"), (ExposureLevel::Flat, "Flat"), (ExposureLevel::Long50, "Long50"), (ExposureLevel::Long100, "Long100"), ]; for (exposure, name) in exposure_levels { let action = FactoredAction::new(exposure, OrderType::Market, Urgency::Normal); let reward = calc.clone().calculate_extrinsic_reward( action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); if matches!(exposure, ExposureLevel::Flat) { // Flat should get HOLD penalty assert!( (reward - (-0.01)).abs() < 1e-6, "{} should get HOLD penalty (-0.01), got {}", name, reward ); } else { // All others should get BUY/SELL bonus assert!( (reward - 0.005).abs() < 1e-6, "{} should get BUY/SELL bonus (0.005), got {}", name, reward ); } } } /// Test activity bonus with non-zero P&L #[test] fn test_activity_bonus_with_pnl() { let calc = ExtrinsicRewardCalculator::new(); // BUY with 5% profit let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let reward = calc.clone().calculate_extrinsic_reward( buy_action, 100.0, // entry 105.0, // exit (+5% profit) 10.0, // position_size 10000.0, // portfolio_value 0.0, ); // Expected calculation: // P&L = (105 - 100) * 10 = 50.0 // Normalized P&L = 50.0 / 10000.0 = 0.005 // P&L component = 0.40 * 0.005 = 0.002 // Sharpe = 0.0 (first call, insufficient data) // Drawdown = 0.0 // Activity bonus = 0.10 * 0.05 = 0.005 // Total = 0.002 + 0.0 + 0.0 + 0.005 = 0.007 assert!((reward - 0.007).abs() < 1e-6, "Expected 0.007, got {}", reward); } /// Test activity bonus does not interfere with other reward components #[test] fn test_activity_bonus_independence() { let calc_default = ExtrinsicRewardCalculator::new(); // 10% activity bonus let calc_disabled = ExtrinsicRewardCalculator::with_config(100, 0.0, 0.0, 0.0); // 0% activity bonus let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); // Same profitable trade let reward_default = calc_default.clone().calculate_extrinsic_reward( buy_action, 100.0, 110.0, // 10% profit 100.0, 10000.0, 0.0, ); let reward_disabled = calc_disabled.clone().calculate_extrinsic_reward( buy_action, 100.0, 110.0, 100.0, 10000.0, 0.0, ); // Difference should be exactly the activity bonus contribution // P&L = 1000.0, normalized = 0.10 // P&L component = 0.40 * 0.10 = 0.04 // Activity bonus = 0.10 * 0.05 = 0.005 // Default total = 0.04 + 0.005 = 0.045 // Disabled total = 0.04 let diff = reward_default - reward_disabled; assert!((diff - 0.005).abs() < 1e-6, "Difference should be 0.005 (activity bonus), got {}", diff); } /// Test validation: activity_bonus_weight out of range (should be validated at CLI layer) #[test] #[should_panic(expected = "Sharpe window must be >= 2")] fn test_invalid_sharpe_window() { let _calc = ExtrinsicRewardCalculator::with_config( 1, // Invalid: sharpe_window < 2 0.10, 0.05, -0.10, ); } /// Test backward compatibility: existing code using `new()` gets default values #[test] fn test_backward_compatibility() { let calc_new = ExtrinsicRewardCalculator::new(); let calc_explicit = ExtrinsicRewardCalculator::with_config(100, 0.10, 0.05, -0.10); let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Aggressive); let reward_new = calc_new.clone().calculate_extrinsic_reward( buy_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); let reward_explicit = calc_explicit.clone().calculate_extrinsic_reward( buy_action, 100.0, 100.0, 10.0, 10000.0, 0.0, ); // Should be identical assert_eq!(reward_new, reward_explicit, "new() and explicit defaults should produce identical rewards"); }