//! Comprehensive Asset Selection Tests //! //! Tests for Trading Agent Service asset selection module with ML integration. //! Validates multi-factor scoring (ML 40%, momentum 30%, value 20%, liquidity 10%). use std::collections::HashMap; use trading_agent_service::assets::*; /// Test asset selection with correct factor weights mod factor_weight_tests { use super::*; #[test] fn test_ml_score_weight_40_percent() { // ML score should contribute 40% to composite score let asset = create_test_asset_score( "ES.FUT", 1.0, // ml_score = 100% 0.0, // momentum_score = 0% 0.0, // value_score = 0% 0.0, // quality_score (liquidity) = 0% ); // Expected: 1.0 * 0.40 = 0.40 assert!( (asset.composite_score - 0.40).abs() < 0.001, "ML score should contribute 40% to composite. Expected 0.40, got {}", asset.composite_score ); } #[test] fn test_momentum_score_weight_30_percent() { // Momentum score should contribute 30% to composite score let asset = create_test_asset_score( "NQ.FUT", 0.0, // ml_score = 0% 1.0, // momentum_score = 100% 0.0, // value_score = 0% 0.0, // quality_score = 0% ); // Expected: 1.0 * 0.30 = 0.30 assert!( (asset.composite_score - 0.30).abs() < 0.001, "Momentum score should contribute 30% to composite. Expected 0.30, got {}", asset.composite_score ); } #[test] fn test_value_score_weight_20_percent() { // Value score should contribute 20% to composite score let asset = create_test_asset_score( "ZN.FUT", 0.0, // ml_score = 0% 0.0, // momentum_score = 0% 1.0, // value_score = 100% 0.0, // quality_score = 0% ); // Expected: 1.0 * 0.20 = 0.20 assert!( (asset.composite_score - 0.20).abs() < 0.001, "Value score should contribute 20% to composite. Expected 0.20, got {}", asset.composite_score ); } #[test] fn test_liquidity_score_weight_10_percent() { // Liquidity (quality_score) should contribute 10% to composite score let asset = create_test_asset_score( "6E.FUT", 0.0, // ml_score = 0% 0.0, // momentum_score = 0% 0.0, // value_score = 0% 1.0, // quality_score (liquidity) = 100% ); // Expected: 1.0 * 0.10 = 0.10 assert!( (asset.composite_score - 0.10).abs() < 0.001, "Liquidity score should contribute 10% to composite. Expected 0.10, got {}", asset.composite_score ); } #[test] fn test_composite_score_all_factors() { // Test all factors contributing together let asset = create_test_asset_score( "CL.FUT", 0.9, // ml_score = 90% 0.85, // momentum_score = 85% 0.75, // value_score = 75% 0.95, // quality_score (liquidity) = 95% ); // Expected: 0.9*0.40 + 0.85*0.30 + 0.75*0.20 + 0.95*0.10 // = 0.36 + 0.255 + 0.15 + 0.095 = 0.86 let expected = 0.9 * 0.40 + 0.85 * 0.30 + 0.75 * 0.20 + 0.95 * 0.10; assert!( (asset.composite_score - expected).abs() < 0.001, "Composite score calculation incorrect. Expected {}, got {}", expected, asset.composite_score ); } #[test] fn test_zero_ml_score_still_computes() { // Asset with zero ML score should still get score from other factors let asset = create_test_asset_score( "GC.FUT", 0.0, // ml_score = 0% 0.8, // momentum_score = 80% 0.7, // value_score = 70% 0.9, // quality_score = 90% ); // Expected: 0.0*0.40 + 0.8*0.30 + 0.7*0.20 + 0.9*0.10 = 0.47 let expected = 0.8 * 0.30 + 0.7 * 0.20 + 0.9 * 0.10; assert!( (asset.composite_score - expected).abs() < 0.001, "Zero ML score should still compute composite. Expected {}, got {}", expected, asset.composite_score ); } } /// Test ML integration and prediction influence mod ml_integration_tests { use super::*; #[test] fn test_ml_predictions_actually_used() { // Verify that ML predictions influence asset selection let ml_predictions = create_mock_ml_predictions(); let asset_high_ml = create_asset_with_ml( "ES.FUT", &ml_predictions, 0.5, // momentum 0.5, // value 0.5, // liquidity ); let asset_low_ml = create_asset_with_ml( "NQ.FUT", &ml_predictions, 0.5, // momentum 0.5, // value 0.5, // liquidity ); // ES.FUT has ML confidence 0.9, NQ.FUT has 0.3 // With same other factors, ES.FUT should score higher due to ML assert!( asset_high_ml.composite_score > asset_low_ml.composite_score, "Asset with higher ML confidence should score higher. ES: {}, NQ: {}", asset_high_ml.composite_score, asset_low_ml.composite_score ); // Calculate expected difference (40% weight on ML) let ml_diff = (0.9 - 0.3) * 0.40; let score_diff = asset_high_ml.composite_score - asset_low_ml.composite_score; assert!( (score_diff - ml_diff).abs() < 0.001, "ML score difference should be reflected in composite. Expected diff {}, got {}", ml_diff, score_diff ); } #[test] fn test_multi_model_ensemble_scoring() { // Test that multiple model scores are aggregated correctly let mut model_scores = HashMap::new(); model_scores.insert("DQN".to_string(), 0.8); model_scores.insert("PPO".to_string(), 0.9); model_scores.insert("MAMBA2".to_string(), 0.85); model_scores.insert("TFT".to_string(), 0.75); let asset = create_test_asset_with_models( "ES.FUT", model_scores.clone(), 0.7, // momentum 0.6, // value 0.9, // liquidity ); // ML score should be average of model scores let expected_ml_score = (0.8 + 0.9 + 0.85 + 0.75) / 4.0; assert!( (asset.ml_score - expected_ml_score).abs() < 0.001, "ML score should be average of model scores. Expected {}, got {}", expected_ml_score, asset.ml_score ); // Verify model_scores map is populated assert_eq!(asset.model_scores.len(), 4); assert_eq!(asset.model_scores.get("DQN"), Some(&0.8)); assert_eq!(asset.model_scores.get("MAMBA2"), Some(&0.85)); } #[test] fn test_ml_confidence_weighting() { // High confidence ML signal should have more impact than low confidence let high_confidence = create_test_asset_score("ES.FUT", 0.9, 0.5, 0.5, 0.5); let medium_confidence = create_test_asset_score("NQ.FUT", 0.6, 0.5, 0.5, 0.5); let low_confidence = create_test_asset_score("ZN.FUT", 0.3, 0.5, 0.5, 0.5); // Scores should decrease with ML confidence assert!(high_confidence.composite_score > medium_confidence.composite_score); assert!(medium_confidence.composite_score > low_confidence.composite_score); // Verify exact contribution (40% weight) let high_vs_low_diff = (0.9 - 0.3) * 0.40; let actual_diff = high_confidence.composite_score - low_confidence.composite_score; assert!( (actual_diff - high_vs_low_diff).abs() < 0.001, "ML confidence difference should be 40% of score difference" ); } #[test] fn test_missing_ml_prediction_fallback() { // Test behavior when ML predictions are unavailable let asset = create_test_asset_score( "UNKNOWN.FUT", 0.0, // No ML prediction available 0.7, // momentum 0.6, // value 0.8, // liquidity ); // Should still compute score from other factors let expected = 0.0 * 0.40 + 0.7 * 0.30 + 0.6 * 0.20 + 0.8 * 0.10; assert!( (asset.composite_score - expected).abs() < 0.001, "Should handle missing ML predictions gracefully" ); } } /// Test ranking algorithm mod ranking_algorithm_tests { use super::*; #[test] fn test_top_n_selection() { let assets = vec![ create_test_asset_score("ES.FUT", 0.9, 0.8, 0.7, 0.95), // High score create_test_asset_score("NQ.FUT", 0.7, 0.6, 0.5, 0.85), // Medium score create_test_asset_score("ZN.FUT", 0.5, 0.4, 0.3, 0.75), // Low score create_test_asset_score("6E.FUT", 0.8, 0.7, 0.6, 0.90), // High-medium score create_test_asset_score("CL.FUT", 0.3, 0.2, 0.1, 0.65), // Very low score ]; let selected = select_top_n_assets(assets, 3); assert_eq!(selected.len(), 3, "Should select exactly 3 assets"); // Verify ordering (highest to lowest) assert_eq!( selected[0].symbol, "ES.FUT", "Highest score should be first" ); assert_eq!( selected[1].symbol, "6E.FUT", "Second highest should be second" ); assert_eq!( selected[2].symbol, "NQ.FUT", "Third highest should be third" ); // Verify scores are descending assert!(selected[0].composite_score > selected[1].composite_score); assert!(selected[1].composite_score > selected[2].composite_score); } #[test] fn test_ranking_consistency() { // Same assets should always rank the same way let assets1 = create_test_asset_portfolio(); let assets2 = create_test_asset_portfolio(); let selected1 = select_top_n_assets(assets1, 5); let selected2 = select_top_n_assets(assets2, 5); assert_eq!(selected1.len(), selected2.len()); for (a1, a2) in selected1.iter().zip(selected2.iter()) { assert_eq!(a1.symbol, a2.symbol, "Ranking should be deterministic"); assert_eq!(a1.composite_score, a2.composite_score); } } #[test] fn test_ranking_with_tied_scores() { // Test behavior when multiple assets have identical scores let assets = vec![ create_test_asset_score("ES.FUT", 0.8, 0.7, 0.6, 0.9), create_test_asset_score("NQ.FUT", 0.8, 0.7, 0.6, 0.9), // Exact tie create_test_asset_score("ZN.FUT", 0.9, 0.8, 0.7, 0.95), ]; let selected = select_top_n_assets(assets, 2); assert_eq!(selected.len(), 2); // Highest unique score should be selected assert_eq!(selected[0].symbol, "ZN.FUT"); // Tied assets should be stable (alphabetical or insert order) assert!( selected[1].symbol == "ES.FUT" || selected[1].symbol == "NQ.FUT", "One of the tied assets should be selected" ); } #[test] fn test_empty_asset_list() { let assets: Vec = vec![]; let selected = select_top_n_assets(assets, 3); assert_eq!(selected.len(), 0, "Empty input should return empty output"); } #[test] fn test_select_more_than_available() { let assets = vec![ create_test_asset_score("ES.FUT", 0.9, 0.8, 0.7, 0.95), create_test_asset_score("NQ.FUT", 0.7, 0.6, 0.5, 0.85), ]; let selected = select_top_n_assets(assets, 10); assert_eq!( selected.len(), 2, "Should return all available assets when N > available" ); } } /// Test edge cases and error handling mod edge_case_tests { use super::*; #[test] fn test_negative_scores_rejected() { // Negative scores should be clamped or rejected let asset = create_test_asset_score("ES.FUT", -0.5, 0.5, 0.5, 0.5); // ML score should be clamped to 0.0-1.0 range assert!( asset.ml_score >= 0.0 && asset.ml_score <= 1.0, "Scores should be clamped to valid range" ); assert!( asset.composite_score >= 0.0, "Composite score should never be negative" ); } #[test] fn test_scores_above_one_clamped() { // Scores above 1.0 should be clamped let asset = create_test_asset_score("NQ.FUT", 1.5, 1.2, 0.8, 0.9); assert!( asset.ml_score <= 1.0, "ML score should be clamped to 1.0, got {}", asset.ml_score ); assert!( asset.momentum_score <= 1.0, "Momentum score should be clamped to 1.0" ); assert!( asset.composite_score <= 1.0, "Composite score should not exceed 1.0" ); } #[test] fn test_all_zero_scores() { let asset = create_test_asset_score("ZN.FUT", 0.0, 0.0, 0.0, 0.0); assert_eq!( asset.composite_score, 0.0, "All zero scores should result in zero composite" ); } #[test] fn test_nan_score_handling() { // Test that NaN values are handled gracefully let asset = create_test_asset_score("6E.FUT", f64::NAN, 0.5, 0.5, 0.5); assert!( !asset.composite_score.is_nan(), "Composite score should not be NaN even if input is NaN" ); } #[test] fn test_infinity_score_handling() { // Test that infinity values are handled gracefully let asset = create_test_asset_score("CL.FUT", f64::INFINITY, 0.5, 0.5, 0.5); assert!( asset.composite_score.is_finite(), "Composite score should be finite even if input is infinity" ); assert!(asset.composite_score <= 1.0, "Should be clamped to 1.0"); } #[test] fn test_no_ml_predictions_available() { // Test asset selection when ML models are unavailable let assets = vec![ create_test_asset_score("ES.FUT", 0.0, 0.9, 0.8, 0.95), create_test_asset_score("NQ.FUT", 0.0, 0.7, 0.6, 0.85), create_test_asset_score("ZN.FUT", 0.0, 0.8, 0.7, 0.90), ]; let selected = select_top_n_assets(assets, 2); assert_eq!(selected.len(), 2, "Should still select assets without ML"); // Should rank by other factors (momentum 30%, value 20%, liquidity 10%) assert_eq!( selected[0].symbol, "ES.FUT", "Best non-ML asset should be selected" ); } #[test] fn test_all_negative_scores() { // Test that negative composite scores are handled let assets = vec![ create_test_asset_score("ES.FUT", 0.1, 0.1, 0.1, 0.1), create_test_asset_score("NQ.FUT", 0.05, 0.05, 0.05, 0.05), create_test_asset_score("ZN.FUT", 0.15, 0.15, 0.15, 0.15), ]; let selected = select_top_n_assets(assets, 2); assert_eq!(selected.len(), 2, "Should select even with low scores"); // Highest (least negative) should be first assert_eq!(selected[0].symbol, "ZN.FUT"); assert_eq!(selected[1].symbol, "ES.FUT"); } } /// Test performance characteristics mod performance_tests { use super::*; use std::time::Instant; #[test] fn test_selection_performance_100_assets() { // Test that asset selection completes within 2s for 100 assets let assets = (0..100) .map(|i| { create_test_asset_score( &format!("ASSET{}.FUT", i), rand_score(), rand_score(), rand_score(), rand_score(), ) }) .collect(); let start = Instant::now(); let selected = select_top_n_assets(assets, 20); let duration = start.elapsed(); assert_eq!(selected.len(), 20); assert!( duration.as_secs() < 2, "Selection should complete within 2s, took {:?}", duration ); } #[test] fn test_scoring_performance() { // Test that individual score calculation is fast let start = Instant::now(); for i in 0..1000 { let _ = create_test_asset_score( &format!("ASSET{}.FUT", i), rand_score(), rand_score(), rand_score(), rand_score(), ); } let duration = start.elapsed(); assert!( duration.as_millis() < 100, "1000 score calculations should take < 100ms, took {:?}", duration ); } #[test] fn test_ranking_performance() { // Test ranking performance with large dataset let assets: Vec<_> = (0..1000) .map(|i| { create_test_asset_score( &format!("ASSET{}.FUT", i), rand_score(), rand_score(), rand_score(), rand_score(), ) }) .collect(); let start = Instant::now(); let selected = select_top_n_assets(assets, 50); let duration = start.elapsed(); assert_eq!(selected.len(), 50); assert!( duration.as_millis() < 500, "Ranking 1000 assets should take < 500ms, took {:?}", duration ); } } /// Test integration with real market data scenarios mod market_scenario_tests { use super::*; #[test] fn test_high_volatility_market() { // In high volatility, momentum scores should be higher let assets = vec![ create_test_asset_score("ES.FUT", 0.7, 0.9, 0.5, 0.8), // High momentum create_test_asset_score("NQ.FUT", 0.7, 0.3, 0.8, 0.8), // High value create_test_asset_score("ZN.FUT", 0.7, 0.5, 0.5, 0.9), // High liquidity ]; let selected = select_top_n_assets(assets, 3); // ES.FUT should rank highest due to high momentum (30% weight) assert_eq!(selected[0].symbol, "ES.FUT"); } #[test] fn test_mean_reversion_scenario() { // In mean reversion, value scores matter more let assets = vec![ create_test_asset_score("ES.FUT", 0.6, 0.3, 0.9, 0.7), // High value create_test_asset_score("NQ.FUT", 0.6, 0.9, 0.3, 0.7), // High momentum create_test_asset_score("ZN.FUT", 0.6, 0.5, 0.5, 0.9), // Balanced ]; let selected = select_top_n_assets(assets, 3); // ES.FUT should be competitive due to value score (20% weight) assert!(selected.iter().any(|a| a.symbol == "ES.FUT")); } #[test] fn test_low_liquidity_environment() { // When liquidity is scarce, liquidity score becomes critical let assets = vec![ create_test_asset_score("ES.FUT", 0.7, 0.7, 0.7, 0.95), // High liquidity create_test_asset_score("NQ.FUT", 0.8, 0.8, 0.8, 0.3), // Low liquidity create_test_asset_score("ZN.FUT", 0.75, 0.75, 0.75, 0.6), // Medium liquidity ]; let selected = select_top_n_assets(assets, 3); // Despite NQ having slightly higher scores, ES should be preferred for liquidity // But 10% weight is small, so NQ might still win overall // Let's verify composite calculation let _es_composite = 0.7 * 0.4 + 0.7 * 0.3 + 0.7 * 0.2 + 0.95 * 0.1; // = 0.725 let _nq_composite = 0.8 * 0.4 + 0.8 * 0.3 + 0.8 * 0.2 + 0.3 * 0.1; // = 0.75 // NQ should still rank higher despite low liquidity (80% vs 70% on other factors) assert_eq!(selected[0].symbol, "NQ.FUT"); } #[test] fn test_ml_disagreement_scenario() { // When ML models disagree, use average let mut model_scores_bullish = HashMap::new(); model_scores_bullish.insert("DQN".to_string(), 0.9); model_scores_bullish.insert("PPO".to_string(), 0.85); model_scores_bullish.insert("MAMBA2".to_string(), 0.2); model_scores_bullish.insert("TFT".to_string(), 0.3); let asset = create_test_asset_with_models("ES.FUT", model_scores_bullish, 0.7, 0.6, 0.8); // Average: (0.9 + 0.85 + 0.2 + 0.3) / 4 = 0.5625 let expected_ml = (0.9 + 0.85 + 0.2 + 0.3) / 4.0; assert!( (asset.ml_score - expected_ml).abs() < 0.001, "Should average disagreeing models" ); } } // ============================================================================= // Test Helpers // ============================================================================= /// Create a test asset score with specified factor values fn create_test_asset_score( symbol: &str, ml_score: f64, momentum_score: f64, value_score: f64, quality_score: f64, ) -> AssetScore { // Use the actual implementation which handles NaN/infinity correctly AssetScore::new( symbol.to_string(), ml_score, momentum_score, value_score, quality_score, ) } /// Create asset with ML predictions fn create_asset_with_ml( symbol: &str, ml_predictions: &HashMap, momentum: f64, value: f64, liquidity: f64, ) -> AssetScore { let ml_score = ml_predictions.get(symbol).copied().unwrap_or(0.0); create_test_asset_score(symbol, ml_score, momentum, value, liquidity) } /// Create asset with multiple model scores fn create_test_asset_with_models( symbol: &str, model_scores: HashMap, momentum: f64, value: f64, liquidity: f64, ) -> AssetScore { // Average model scores for ML score let ml_score = if model_scores.is_empty() { 0.0 } else { model_scores.values().sum::() / model_scores.len() as f64 }; let mut asset = create_test_asset_score(symbol, ml_score, momentum, value, liquidity); asset.model_scores = model_scores; asset } /// Mock ML predictions fn create_mock_ml_predictions() -> HashMap { let mut predictions = HashMap::new(); predictions.insert("ES.FUT".to_string(), 0.9); predictions.insert("NQ.FUT".to_string(), 0.3); predictions.insert("ZN.FUT".to_string(), 0.7); predictions.insert("6E.FUT".to_string(), 0.6); predictions.insert("CL.FUT".to_string(), 0.8); predictions } /// Create test portfolio of assets fn create_test_asset_portfolio() -> Vec { vec![ create_test_asset_score("ES.FUT", 0.9, 0.8, 0.7, 0.95), create_test_asset_score("NQ.FUT", 0.7, 0.6, 0.5, 0.85), create_test_asset_score("ZN.FUT", 0.8, 0.7, 0.6, 0.90), create_test_asset_score("6E.FUT", 0.6, 0.5, 0.4, 0.80), create_test_asset_score("CL.FUT", 0.85, 0.75, 0.65, 0.88), create_test_asset_score("GC.FUT", 0.5, 0.4, 0.3, 0.75), ] } /// Select top N assets by composite score fn select_top_n_assets(assets: Vec, n: usize) -> Vec { let selector = AssetSelector::new(); selector.select_top_n(assets, n) } /// Generate random score in 0.0-1.0 range fn rand_score() -> f64 { use std::collections::hash_map::RandomState; use std::hash::BuildHasher; let hasher = RandomState::new(); let hash = hasher.hash_one(std::time::SystemTime::now()); (hash % 1000) as f64 / 1000.0 } #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_end_to_end_asset_selection() { // Full pipeline test: ML predictions -> scoring -> ranking -> selection let ml_predictions = create_mock_ml_predictions(); let assets = vec![ create_asset_with_ml("ES.FUT", &ml_predictions, 0.8, 0.7, 0.95), create_asset_with_ml("NQ.FUT", &ml_predictions, 0.6, 0.5, 0.85), create_asset_with_ml("ZN.FUT", &ml_predictions, 0.7, 0.6, 0.90), create_asset_with_ml("6E.FUT", &ml_predictions, 0.5, 0.4, 0.80), create_asset_with_ml("CL.FUT", &ml_predictions, 0.75, 0.65, 0.88), ]; let selected = select_top_n_assets(assets, 3); assert_eq!(selected.len(), 3, "Should select exactly 3 assets"); // Verify ML integration: ES.FUT has highest ML (0.9) assert_eq!( selected[0].symbol, "ES.FUT", "Highest ML score should rank first" ); // Verify scoring factors are all considered for asset in &selected { assert!(asset.ml_score >= 0.0 && asset.ml_score <= 1.0); assert!(asset.momentum_score >= 0.0 && asset.momentum_score <= 1.0); assert!(asset.value_score >= 0.0 && asset.value_score <= 1.0); assert!(asset.quality_score >= 0.0 && asset.quality_score <= 1.0); assert!(asset.composite_score >= 0.0 && asset.composite_score <= 1.0); } // Verify descending order assert!(selected[0].composite_score >= selected[1].composite_score); assert!(selected[1].composite_score >= selected[2].composite_score); } #[test] fn test_factor_weight_verification() { // Verify that documented weights (40/30/20/10) are actually used let test_cases = vec![ (1.0, 0.0, 0.0, 0.0, 0.40), // ML only (0.0, 1.0, 0.0, 0.0, 0.30), // Momentum only (0.0, 0.0, 1.0, 0.0, 0.20), // Value only (0.0, 0.0, 0.0, 1.0, 0.10), // Liquidity only (1.0, 1.0, 1.0, 1.0, 1.00), // All max (0.5, 0.5, 0.5, 0.5, 0.50), // All half ]; for (ml, momentum, value, liquidity, expected) in test_cases { let asset = create_test_asset_score("TEST.FUT", ml, momentum, value, liquidity); assert!( (asset.composite_score - expected).abs() < 0.001, "Factor weights incorrect: expected {}, got {} for scores ({}, {}, {}, {})", expected, asset.composite_score, ml, momentum, value, liquidity ); } } }