//! Agent 37: Kelly Criterion Position Sizing Tests //! //! Test suite for Kelly criterion integration in DQN training. //! Tests verify that position sizes adapt dynamically based on edge estimates. use anyhow::Result; use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer}; /// Test 1: Positive edge → Non-zero position size /// /// When DQN estimates positive expected value (edge), Kelly should recommend /// a non-zero position size proportional to the edge. #[tokio::test] async fn test_positive_edge_kelly_sizing() -> Result<()> { // Create DQN trainer with Kelly enabled let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_fraction = 0.25; // Conservative 25% Kelly hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; // Simulate historical rewards with 60% win rate and 2:1 win/loss ratio // Kelly = (p * b - q) / b = (0.6 * 2 - 0.4) / 2 = 0.4 // Position size = 0.4 * 0.25 * max_position = 0.1 * max_position = 10 let win_rate = 0.6; let avg_win = 2.0; let avg_loss = 1.0; let position_size = trainer.calculate_kelly_position_size(win_rate, avg_win, avg_loss)?; assert!(position_size > 0.0, "Positive edge should yield non-zero position"); assert!( position_size <= 100.0, "Position size should not exceed max position" ); // Expected: ~10.0 (with some tolerance) let expected = 10.0; assert!( (position_size - expected).abs() < 1.0, "Position size {:.2} should be close to expected {:.2}", position_size, expected ); Ok(()) } /// Test 2: Negative edge → Zero position size /// /// When expected value is negative (losing strategy), Kelly should recommend /// zero position size to avoid the trade. #[tokio::test] async fn test_negative_edge_zero_position() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_fraction = 0.25; let trainer = DQNTrainer::new(hyperparams)?; // Losing strategy: 40% win rate, 1:2 win/loss ratio // Kelly = (0.4 * 0.5 - 0.6) / 0.5 = -0.8 (negative) let win_rate = 0.4; let avg_win = 1.0; let avg_loss = 2.0; let position_size = trainer.calculate_kelly_position_size(win_rate, avg_win, avg_loss)?; assert_eq!( position_size, 0.0, "Negative edge should yield zero position" ); Ok(()) } /// Test 3: Conservative Kelly fraction prevents over-leveraging /// /// Even with strong edge, conservative Kelly fraction (0.25-0.50) should /// prevent excessive position sizes. #[tokio::test] async fn test_conservative_kelly_prevents_overleveraging() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_fraction = 0.25; // 25% of full Kelly hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; // Very strong edge: 80% win rate, 3:1 win/loss ratio // Full Kelly = (0.8 * 3 - 0.2) / 3 = 0.733 // Conservative Kelly = 0.733 * 0.25 = 0.183 // Position = 0.183 * 100 = 18.3 let win_rate = 0.8; let avg_win = 3.0; let avg_loss = 1.0; let position_size = trainer.calculate_kelly_position_size(win_rate, avg_win, avg_loss)?; // Should be capped at ~18% of max position (not 73%) let max_allowed = 20.0; assert!( position_size <= max_allowed, "Position size {:.2} should be capped by conservative fraction (max: {:.2})", position_size, max_allowed ); Ok(()) } /// Test 4: Position sizing adapts to Q-value edge estimates /// /// Position sizes should dynamically adjust as DQN learns and Q-value /// estimates change over training. #[tokio::test] async fn test_position_sizing_adapts_to_edge() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_fraction = 0.5; hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; // Scenario 1: Early training, poor edge let position_size_early = trainer.calculate_kelly_position_size(0.51, 1.1, 1.0)?; // Scenario 2: After learning, stronger edge let position_size_late = trainer.calculate_kelly_position_size(0.65, 1.5, 1.0)?; assert!( position_size_late > position_size_early, "Better edge ({:.2} vs {:.2}) should yield larger position", position_size_late, position_size_early ); Ok(()) } /// Test 5: Kelly sizing respects max position limits /// /// Even with infinite edge, position size should never exceed absolute max. #[tokio::test] async fn test_kelly_respects_max_position() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_fraction = 1.0; // Full Kelly (aggressive) hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; // Extreme edge: 99% win rate, 10:1 win/loss ratio // Full Kelly would be huge, but should be capped let win_rate = 0.99; let avg_win = 10.0; let avg_loss = 1.0; let position_size = trainer.calculate_kelly_position_size(win_rate, avg_win, avg_loss)?; assert!( position_size <= 100.0, "Position size {:.2} must not exceed max position 100.0", position_size ); Ok(()) } /// Test 6: Kelly sizing disabled by default (backward compatibility) /// /// When Kelly sizing is disabled, system should fall back to fixed position /// sizing based on exposure levels. #[tokio::test] async fn test_kelly_disabled_fallback() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = false; // Disabled hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; // Even with great edge, should use fixed sizing let win_rate = 0.8; let avg_win = 3.0; let avg_loss = 1.0; let position_size = trainer.calculate_kelly_position_size(win_rate, avg_win, avg_loss)?; // Should return fixed position size (50% default exposure when Kelly disabled) assert!( position_size > 0.0, "Should still return valid position size when Kelly disabled" ); // Should be exactly 50% of max position assert_eq!( position_size, 50.0, "Fixed sizing should be 50% of max position when Kelly disabled" ); Ok(()) } /// Test 7: Insufficient historical data handling /// /// When insufficient data exists to calculate reliable Kelly fractions, /// system should fall back to conservative default sizing. #[tokio::test] async fn test_insufficient_data_fallback() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_min_samples = 30; // Require 30 trades minimum hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; // Only 10 trades in history (insufficient) - use calculate_kelly_position_size_with_check let position_size = trainer.calculate_kelly_position_size_with_check()?; // Should use conservative default (25% of max position) assert!( position_size > 0.0, "Should use conservative default with insufficient data" ); assert_eq!( position_size, 25.0, "Conservative default should be 25% of max position" ); Ok(()) } /// Test 8: Kelly stats tracking /// /// Verify that Kelly statistics are properly tracked and accessible. #[tokio::test] async fn test_kelly_stats_tracking() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; hyperparams.kelly_fraction = 0.25; hyperparams.kelly_min_samples = 20; hyperparams.max_position = 100.0; let trainer = DQNTrainer::new(hyperparams)?; let stats = trainer.get_kelly_stats(); assert!(stats.enabled, "Kelly should be enabled"); assert_eq!(stats.total_trades, 0, "No trades yet"); assert_eq!(stats.win_count, 0, "No wins yet"); assert_eq!(stats.win_rate, 0.0, "Win rate should be 0"); assert_eq!(stats.kelly_fraction, 0.25, "Kelly fraction should be 0.25"); assert!(!stats.sufficient_data, "Insufficient data initially"); Ok(()) } /// Test 9: Input validation /// /// Verify that invalid inputs are properly rejected. #[tokio::test] async fn test_input_validation() -> Result<()> { let mut hyperparams = DQNHyperparameters::conservative(); hyperparams.enable_kelly_sizing = true; let trainer = DQNTrainer::new(hyperparams)?; // Invalid win rate (> 1.0) let result = trainer.calculate_kelly_position_size(1.5, 2.0, 1.0); assert!(result.is_err(), "Should reject invalid win rate"); // Invalid win rate (< 0.0) let result = trainer.calculate_kelly_position_size(-0.5, 2.0, 1.0); assert!(result.is_err(), "Should reject negative win rate"); // Zero average win let result = trainer.calculate_kelly_position_size(0.6, 0.0, 1.0); assert!(result.is_err(), "Should reject zero average win"); // Negative average loss let result = trainer.calculate_kelly_position_size(0.6, 2.0, -1.0); assert!(result.is_err(), "Should reject negative average loss"); Ok(()) }