test(trading_service): enhanced ML service component tests

Add 16 unit tests for Enhanced ML service components:
- EnsembleConfig::default() values (min_models, thresholds, voting)
- FeaturePreprocessor::classify_feature_type() for price, volume,
  technical, sentiment, and unknown features
- FeaturePreprocessor normalization (z-score, tanh fallback, zero std_dev)
- FeaturePreprocessor default stats validation
- RuntimeModelInfo creation for all 5 model types (DQN/PPO/TFT/Mamba/LNN)
- ModelPerformanceMetrics::default() zero initialization
- FeatureNormStats volatility default bounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-23 10:50:43 +01:00
parent 2bce9859cc
commit b5fec19971

View File

@@ -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);
}
}