diff --git a/services/trading_service/src/services/enhanced_ml.rs b/services/trading_service/src/services/enhanced_ml.rs index e8271c826..30b4a2737 100644 --- a/services/trading_service/src/services/enhanced_ml.rs +++ b/services/trading_service/src/services/enhanced_ml.rs @@ -2075,3 +2075,245 @@ mod feature_preprocessor_tests { assert!(result >= -10.0); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod enhanced_ml_tests { + use super::*; + + // ----------------------------------------------------------------------- + // 1. EnsembleConfig::default() values + // ----------------------------------------------------------------------- + #[test] + fn test_ensemble_config_defaults() { + let config = EnsembleConfig::default(); + assert_eq!(config.min_models, 2, "min_models should default to 2"); + assert!( + (config.confidence_threshold - 0.7).abs() < 1e-10, + "confidence_threshold should default to 0.7" + ); + assert!( + config.use_weighted_voting, + "use_weighted_voting should default to true" + ); + assert_eq!( + config.fallback_timeout_ms, 50, + "fallback_timeout_ms should default to 50" + ); + assert!( + (config.consensus_threshold - 0.6).abs() < 1e-10, + "consensus_threshold should default to 0.6" + ); + } + + // ----------------------------------------------------------------------- + // 2. FeaturePreprocessor::classify_feature_type + // ----------------------------------------------------------------------- + #[test] + fn test_classify_price_features() { + let pp = FeaturePreprocessor::new(); + assert_eq!(pp.classify_feature_type("price_momentum") as i32, FeatureType::Price as i32); + assert_eq!(pp.classify_feature_type("close_price") as i32, FeatureType::Price as i32); + assert_eq!(pp.classify_feature_type("momentum_5m") as i32, FeatureType::Price as i32); + } + + #[test] + fn test_classify_volume_features() { + let pp = FeaturePreprocessor::new(); + assert_eq!(pp.classify_feature_type("volume") as i32, FeatureType::Volume as i32); + assert_eq!(pp.classify_feature_type("volume_ratio") as i32, FeatureType::Volume as i32); + assert_eq!(pp.classify_feature_type("orderbook_depth") as i32, FeatureType::Volume as i32); + assert_eq!(pp.classify_feature_type("depth_imbalance") as i32, FeatureType::Volume as i32); + } + + #[test] + fn test_classify_technical_features() { + let pp = FeaturePreprocessor::new(); + assert_eq!(pp.classify_feature_type("volatility") as i32, FeatureType::Technical as i32); + assert_eq!(pp.classify_feature_type("rsi_14") as i32, FeatureType::Technical as i32); + assert_eq!(pp.classify_feature_type("ma_20") as i32, FeatureType::Technical as i32); + assert_eq!(pp.classify_feature_type("spread_bps") as i32, FeatureType::Technical as i32); + assert_eq!(pp.classify_feature_type("liquidity_score") as i32, FeatureType::Technical as i32); + } + + #[test] + fn test_classify_sentiment_features() { + let pp = FeaturePreprocessor::new(); + assert_eq!(pp.classify_feature_type("sentiment_score") as i32, FeatureType::Sentiment as i32); + assert_eq!(pp.classify_feature_type("news_impact") as i32, FeatureType::Sentiment as i32); + } + + #[test] + fn test_classify_unknown_defaults_to_technical() { + let pp = FeaturePreprocessor::new(); + assert_eq!(pp.classify_feature_type("foo_bar_baz") as i32, FeatureType::Technical as i32); + assert_eq!(pp.classify_feature_type("") as i32, FeatureType::Technical as i32); + } + + // ----------------------------------------------------------------------- + // 3. FeaturePreprocessor normalization (z-score, tanh fallback) + // ----------------------------------------------------------------------- + #[test] + fn test_normalize_known_feature_zscore() { + let pp = FeaturePreprocessor::new(); + // price_momentum: mean=0.0, std_dev=0.1 + // z-score for value=0.05: (0.05 - 0.0) / 0.1 = 0.5 + let result = pp.normalize("price_momentum", 0.05); + assert!((result - 0.5).abs() < 1e-10, "Expected 0.5; got {}", result); + } + + #[test] + fn test_normalize_volume_zscore() { + let pp = FeaturePreprocessor::new(); + // volume: mean=1_000_000, std_dev=500_000 + // z-score for value=1_500_000: (1_500_000 - 1_000_000) / 500_000 = 1.0 + let result = pp.normalize("volume", 1_500_000.0); + assert!((result - 1.0).abs() < 1e-10, "Expected 1.0; got {}", result); + } + + #[test] + fn test_normalize_unknown_feature_uses_tanh() { + let pp = FeaturePreprocessor::new(); + // Unknown feature uses value.tanh() + let value: f64 = 0.5; + let expected = value.tanh(); + let result = pp.normalize("unknown_feature_xyz", value); + assert!((result - expected).abs() < 1e-10, "Expected tanh({})={}; got {}", value, expected, result); + } + + // ----------------------------------------------------------------------- + // 4. FeaturePreprocessor default stats + // ----------------------------------------------------------------------- + #[test] + fn test_preprocessor_default_has_three_features() { + let pp = FeaturePreprocessor::default(); + assert_eq!(pp.stats.len(), 3, "Default preprocessor should have 3 feature stats"); + assert!(pp.stats.contains_key("price_momentum")); + assert!(pp.stats.contains_key("volume")); + assert!(pp.stats.contains_key("volatility")); + } + + #[test] + fn test_preprocessor_new_equals_default() { + let pp_new = FeaturePreprocessor::new(); + let pp_default = FeaturePreprocessor::default(); + assert_eq!(pp_new.stats.len(), pp_default.stats.len()); + for (key, new_stat) in &pp_new.stats { + let default_stat = pp_default.stats.get(key).unwrap(); + assert!((new_stat.mean - default_stat.mean).abs() < 1e-10); + assert!((new_stat.std_dev - default_stat.std_dev).abs() < 1e-10); + } + } + + // ----------------------------------------------------------------------- + // 5. RuntimeModelInfo creation + // ----------------------------------------------------------------------- + #[test] + fn test_runtime_model_info_creation() { + let info = RuntimeModelInfo { + model_id: "test-dqn-v1".to_string(), + version: "1.0.0".to_string(), + load_time: SystemTime::now(), + last_inference: None, + inference_count: 0, + error_count: 0, + avg_latency_us: 0.0, + confidence_threshold: 0.7, + weight_in_ensemble: 1.0, + fallback_priority: 0, + model_type: ModelType::DQN, + supported_symbols: vec!["EURUSD".to_string(), "GBPUSD".to_string()], + supported_horizons: vec![1, 5, 15], + feature_count: 16, + model_instance: None, + }; + + assert_eq!(info.model_id, "test-dqn-v1"); + assert_eq!(info.model_type, ModelType::DQN); + assert_eq!(info.feature_count, 16); + assert_eq!(info.supported_symbols.len(), 2); + assert_eq!(info.supported_horizons.len(), 3); + assert!(info.last_inference.is_none()); + assert!(info.model_instance.is_none()); + assert_eq!(info.inference_count, 0); + assert_eq!(info.error_count, 0); + } + + #[test] + fn test_runtime_model_info_various_model_types() { + let model_types = vec![ + (ModelType::DQN, "DQN"), + (ModelType::PPO, "PPO"), + (ModelType::TFT, "TFT"), + (ModelType::Mamba, "Mamba"), + (ModelType::LNN, "LNN"), + ]; + + for (model_type, name) in model_types { + let info = RuntimeModelInfo { + model_id: format!("test-{}", name), + version: "1.0.0".to_string(), + load_time: SystemTime::now(), + last_inference: None, + inference_count: 0, + error_count: 0, + avg_latency_us: 0.0, + confidence_threshold: 0.7, + weight_in_ensemble: 0.25, + fallback_priority: 1, + model_type, + supported_symbols: vec![], + supported_horizons: vec![], + feature_count: 16, + model_instance: None, + }; + assert_eq!(info.model_type, model_type, "Model type mismatch for {}", name); + assert_eq!(info.weight_in_ensemble, 0.25); + } + } + + // ----------------------------------------------------------------------- + // 6. ModelPerformanceMetrics defaults + // ----------------------------------------------------------------------- + #[test] + fn test_model_performance_metrics_default() { + let metrics = ModelPerformanceMetrics::default(); + assert_eq!(metrics.total_predictions, 0); + assert_eq!(metrics.successful_predictions, 0); + assert_eq!(metrics.failed_predictions, 0); + assert!((metrics.avg_latency_us - 0.0).abs() < 1e-10); + assert!((metrics.p95_latency_us - 0.0).abs() < 1e-10); + assert!((metrics.accuracy_percentage - 0.0).abs() < 1e-10); + assert!(metrics.last_health_check.is_none()); + } + + // ----------------------------------------------------------------------- + // 7. FeatureNormStats bounds validation + // ----------------------------------------------------------------------- + #[test] + fn test_feature_norm_stats_volatility_defaults() { + let pp = FeaturePreprocessor::default(); + let vol_stats = pp.stats.get("volatility").unwrap(); + assert!((vol_stats.mean - 0.02).abs() < 1e-10, "Volatility mean should be 0.02"); + assert!((vol_stats.std_dev - 0.01).abs() < 1e-10, "Volatility std_dev should be 0.01"); + assert!((vol_stats.min - 0.0).abs() < 1e-10, "Volatility min should be 0.0"); + assert!((vol_stats.max - 0.5).abs() < 1e-10, "Volatility max should be 0.5"); + } + + #[test] + fn test_normalize_zero_std_dev_returns_raw() { + // Create a feature with zero std_dev -- should return value as-is + let mut pp = FeaturePreprocessor::new(); + pp.stats.insert( + "zero_std".to_string(), + FeatureNormStats { + mean: 5.0, + std_dev: 0.0, + min: 0.0, + max: 10.0, + }, + ); + let result = pp.normalize("zero_std", 7.0); + assert!((result - 7.0).abs() < 1e-10, "Zero std_dev should return raw value; got {}", result); + } +}