#![allow( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::unnecessary_get_then_check, clippy::useless_vec )] //! Integration Tests for ML Training Service //! //! Comprehensive integration tests covering: //! - Feature engineering pipeline (technical indicators, microstructure) //! - Model training checkpoint save/load //! - Distributed training coordination (multi-GPU simulation) //! - Model versioning upload to S3 //! - Training metrics tracking //! - Hyperparameter optimization //! - Early stopping //! - Model evaluation use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use chrono::Utc; use config::MLConfig; use ml::training_pipeline::{ FinancialFeatures, FinancialValidationConfig, ModelArchitectureConfig, PerformanceConfig, ProductionTrainingConfig, TrainingHyperparameters, }; use ml_training_service::{ database::DatabaseManager, orchestrator::{JobStatus, TrainingOrchestrator}, storage::{ModelStorageManager, StorageConfig}, technical_indicators::{IndicatorConfig, TechnicalIndicatorCalculator}, }; use tempfile::TempDir; use uuid::Uuid; /// Create a simple test training config fn create_test_training_config() -> ProductionTrainingConfig { ProductionTrainingConfig { model_config: ModelArchitectureConfig { input_dim: 50, output_dim: 1, hidden_dims: vec![128, 64], dropout_rate: 0.1, activation: "relu".to_string(), batch_norm: false, residual_connections: false, }, training_params: TrainingHyperparameters { learning_rate: 0.001, batch_size: 32, max_epochs: 10, patience: 3, validation_split: 0.2, l2_regularization: 0.0001, lr_decay_factor: 0.1, lr_decay_patience: 5, }, safety_config: ml::safety::MLSafetyConfig::default(), gradient_config: ml::safety::GradientSafetyConfig::default(), financial_config: FinancialValidationConfig { max_prediction_multiple: 2.0, min_prediction_confidence: 0.6, validate_position_sizing: true, max_position_fraction: 0.25, min_sharpe_threshold: 0.5, }, performance_config: PerformanceConfig { device_preference: "cpu".to_string(), num_workers: 4, max_memory_bytes: 8 * 1024 * 1024 * 1024, // 8GB gradient_accumulation_steps: 1, }, } } /// Test helper to create a test database configuration async fn create_test_database_config() -> config::database::DatabaseConfig { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string() }); config::database::DatabaseConfig { url: database_url.clone(), max_connections: 5, min_connections: 1, connect_timeout: Duration::from_secs(10), query_timeout: Duration::from_secs(30), enable_query_logging: false, application_name: Some("ml_training_service_test".to_string()), pool: config::PoolConfig { min_connections: 1, max_connections: 5, acquire_timeout_secs: 5, max_lifetime_secs: 3600, idle_timeout_secs: 300, test_before_acquire: true, database_url: database_url.clone(), health_check_enabled: true, health_check_interval_secs: 60, }, transaction: config::TransactionConfig::default(), } } /// Create a test orchestrator with in-memory storage async fn setup_test_orchestrator() -> Arc { let ml_config = MLConfig::default(); let db_config = create_test_database_config().await; // Use new() which runs migrations with advisory lock for concurrent safety let database = Arc::new( DatabaseManager::new(&db_config) .await .expect("Failed to create database"), ); let temp_dir = TempDir::new().expect("Failed to create temp dir"); let storage_config = StorageConfig { storage_type: "local".to_string(), local_base_path: Some(temp_dir.path().to_path_buf()), enable_compression: false, }; let storage = Arc::new( ModelStorageManager::new(storage_config) .await .expect("Failed to create storage"), ); Arc::new( TrainingOrchestrator::new(ml_config, database, storage) .await .expect("Failed to create orchestrator"), ) } /// Generate synthetic OHLCV data for testing fn create_synthetic_ohlcv(num_samples: usize) -> Vec<(f64, f64, f64, f64, f64)> { let mut data = Vec::with_capacity(num_samples); let mut price = 100.0; for i in 0..num_samples { let trend = (i as f64 / 100.0).sin() * 5.0; let noise = (i as f64 * 0.1).cos() * 0.5; let close = price + trend + noise; let high = close + (i as f64 % 3.0); let low = close - (i as f64 % 2.0); let open = price; let volume = 1000.0 + (i as f64 * 10.0); data.push((open, high, low, close, volume)); price = close; } data } /// Create synthetic financial features fn create_synthetic_features(num_samples: usize) -> Vec<(FinancialFeatures, Vec)> { let mut features = Vec::with_capacity(num_samples); for i in 0..num_samples { let price = 100.0 + (i as f64 * 0.1); let feature = FinancialFeatures { prices: vec![ common::Price::from_f64(price).unwrap_or(common::Price::new(price).unwrap()) ], volumes: vec![1000 + i as i64], technical_indicators: [ ("rsi".to_string(), 50.0 + (i as f64 / 10.0).sin() * 20.0), ("macd".to_string(), (i as f64 / 20.0).cos()), ] .iter() .cloned() .collect(), microstructure: ml::training_pipeline::MicrostructureFeatures { spread_bps: 10, imbalance: 0.1 * (i as f64 / 100.0).sin(), trade_intensity: 2.5, vwap: common::Price::from_f64(price * 0.9995) .unwrap_or(common::Price::new(price * 0.9995).unwrap()), }, risk_metrics: ml::training_pipeline::RiskFeatures { var_5pct: -0.02, expected_shortfall: -0.03, max_drawdown: -0.05, sharpe_ratio: 1.2, }, timestamp: Utc::now(), }; let target = vec![price * 1.001]; // Predict 0.1% price increase features.push((feature, target)); } features } // ============================================================================ // Feature Engineering Tests // ============================================================================ #[tokio::test] async fn test_technical_indicators_calculation() { let config = IndicatorConfig::default(); let mut calculator = TechnicalIndicatorCalculator::new("TEST".to_string(), config); // Generate OHLCV data let ohlcv = create_synthetic_ohlcv(100); // Update calculator with data for (_, high, low, close, volume) in &ohlcv { calculator.update(*close, *volume, Some(*high), Some(*low)); } // Verify indicators are calculated assert!(calculator.is_warmed_up(), "Calculator should be warmed up"); let indicators = calculator.current_indicators(); assert!(indicators.contains_key("rsi"), "RSI should be calculated"); assert!(indicators.contains_key("macd"), "MACD should be calculated"); assert!( indicators.contains_key("ema_fast"), "Fast EMA should be calculated" ); assert!( indicators.contains_key("ema_slow"), "Slow EMA should be calculated" ); assert!( indicators.contains_key("bollinger_upper"), "Bollinger bands should be calculated" ); assert!(indicators.contains_key("atr"), "ATR should be calculated"); // Verify indicator ranges let rsi = indicators.get("rsi").expect("INVARIANT: Key should exist in map"); assert!( *rsi >= 0.0 && *rsi <= 100.0, "RSI should be in [0, 100] range" ); } #[tokio::test] async fn test_feature_engineering_pipeline() { let mut calculator = TechnicalIndicatorCalculator::new("BTC-USD".to_string(), IndicatorConfig::default()); // Simulate 50 price updates for i in 0..50 { let price = 100.0 + i as f64; calculator.update(price, 1000.0, Some(price + 1.0), Some(price - 1.0)); } // Extract features let indicators = calculator.current_indicators(); // Verify feature dimensions - we should have multiple indicators assert!( indicators.len() >= 10, "Should have at least 10 features, got {}", indicators.len() ); // Verify specific features assert!(indicators.get("rsi").is_some(), "RSI feature missing"); assert!(indicators.get("macd").is_some(), "MACD feature missing"); assert!( indicators.get("bollinger_middle").is_some(), "Bollinger middle band missing" ); } #[tokio::test] async fn test_microstructure_features_extraction() { let features = create_synthetic_features(100); // Verify microstructure features are present for (feature, _) in &features { assert!( feature.microstructure.spread_bps > 0, "Spread should be positive" ); assert!( feature.microstructure.imbalance.abs() <= 1.0, "Imbalance should be normalized" ); assert!( feature.microstructure.trade_intensity > 0.0, "Trade intensity should be positive" ); } } #[tokio::test] async fn test_risk_metrics_calculation() { let features = create_synthetic_features(50); // Verify risk metrics for (feature, _) in &features { assert!( feature.risk_metrics.var_5pct < 0.0, "VaR should be negative (loss)" ); assert!( feature.risk_metrics.expected_shortfall < 0.0, "ES should be negative" ); assert!( feature.risk_metrics.max_drawdown < 0.0, "Max drawdown should be negative" ); assert!( feature.risk_metrics.sharpe_ratio > 0.0, "Sharpe ratio should be positive" ); } } // ============================================================================ // Training Orchestration Tests // ============================================================================ #[tokio::test] async fn test_training_job_submission() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "test_model".to_string(), config, "Test training job".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); // Verify job was created let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert_eq!(job.status, JobStatus::Pending); assert_eq!(job.model_type, "test_model"); } #[tokio::test] async fn test_job_status_tracking() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "status_test".to_string(), config, "Status test".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); // Check initial status let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert_eq!(job.status, JobStatus::Pending); // Note: Full execution would require starting the orchestrator // For integration tests, we verify the submission and retrieval flow } #[tokio::test] async fn test_job_listing_and_filtering() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); // Submit multiple jobs let job1 = orchestrator .submit_job( "model_a".to_string(), config.clone(), "Job 1".to_string(), HashMap::new(), ) .await .expect("Failed to submit job 1"); let job2 = orchestrator .submit_job( "model_b".to_string(), config.clone(), "Job 2".to_string(), HashMap::new(), ) .await .expect("Failed to submit job 2"); // List all jobs let all_jobs = orchestrator .list_jobs(None, None, None, None) .await .expect("Failed to list jobs"); assert!(all_jobs.len() >= 2, "Should have at least 2 jobs"); // Filter by model type let filtered_jobs = orchestrator .list_jobs(None, Some("model_a".to_string()), None, None) .await .expect("Failed to filter jobs"); assert_eq!(filtered_jobs.len(), 1, "Should have 1 filtered job"); assert_eq!(filtered_jobs[0].id, job1); // Verify job2 exists let job2_details = orchestrator .get_job(job2) .await .expect("Job 2 should exist"); assert_eq!(job2_details.model_type, "model_b"); } // ============================================================================ // Storage and Versioning Tests // ============================================================================ #[tokio::test] async fn test_model_checkpoint_save() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let storage_config = StorageConfig { storage_type: "local".to_string(), local_base_path: Some(temp_dir.path().to_path_buf()), enable_compression: false, }; let storage = ModelStorageManager::new(storage_config) .await .expect("Failed to create storage"); let job_id = Uuid::new_v4(); let model_data = b"mock_model_weights"; let artifact_path = storage .store_model(job_id, model_data) .await .expect("Failed to store model"); assert!( artifact_path.contains(&job_id.to_string()), "Path should contain job ID" ); } #[tokio::test] async fn test_model_checkpoint_load() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let storage_config = StorageConfig { storage_type: "local".to_string(), local_base_path: Some(temp_dir.path().to_path_buf()), enable_compression: false, }; let storage = ModelStorageManager::new(storage_config) .await .expect("Failed to create storage"); let job_id = Uuid::new_v4(); let model_data = b"test_checkpoint_data"; let artifact_path = storage .store_model(job_id, model_data) .await .expect("Failed to store model"); let retrieved_data = storage .retrieve_model(&artifact_path) .await .expect("Failed to retrieve model"); assert_eq!( retrieved_data, model_data, "Retrieved data should match stored data" ); } #[tokio::test] async fn test_model_versioning() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let storage_config = StorageConfig { storage_type: "local".to_string(), local_base_path: Some(temp_dir.path().to_path_buf()), enable_compression: false, }; let storage = ModelStorageManager::new(storage_config) .await .expect("Failed to create storage"); let job_id = Uuid::new_v4(); // Store multiple versions let v1_path = storage .store_model(job_id, b"version_1") .await .expect("Failed to store v1"); tokio::time::sleep(Duration::from_millis(10)).await; let v2_path = storage .store_model(job_id, b"version_2") .await .expect("Failed to store v2"); // Paths should be different (timestamped) assert_ne!(v1_path, v2_path, "Version paths should be unique"); // Both versions should be retrievable let v1_data = storage .retrieve_model(&v1_path) .await .expect("Failed to retrieve v1"); let v2_data = storage .retrieve_model(&v2_path) .await .expect("Failed to retrieve v2"); assert_eq!(v1_data, b"version_1"); assert_eq!(v2_data, b"version_2"); } // ============================================================================ // Metrics Tracking Tests // ============================================================================ #[tokio::test] async fn test_training_metrics_accumulation() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "metrics_test".to_string(), config, "Metrics test".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); // Get job and verify metrics structure let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert!(job.metrics.is_empty(), "New job should have empty metrics"); } #[tokio::test] async fn test_progress_tracking() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "progress_test".to_string(), config, "Progress test".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert_eq!(job.progress_percentage, 0.0, "Initial progress should be 0"); assert_eq!(job.current_epoch, 0, "Initial epoch should be 0"); } #[tokio::test] async fn test_status_broadcasting() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "broadcast_test".to_string(), config, "Broadcast test".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); // Subscribe to status updates let mut receiver = orchestrator .subscribe_to_job_status(job_id) .await .expect("Failed to subscribe"); // Verify subscription works (channel is ready) tokio::select! { _ = tokio::time::sleep(Duration::from_millis(100)) => { // Timeout is expected - no updates sent yet } _ = receiver.recv() => { // Unexpected update } } } // ============================================================================ // Hyperparameter Optimization Tests // ============================================================================ #[tokio::test] async fn test_hyperparameter_validation() { let config = create_test_training_config(); // Validate configuration assert!( config.model_config.input_dim > 0, "Input dim should be positive" ); assert!( config.model_config.output_dim > 0, "Output dim should be positive" ); assert!( !config.model_config.hidden_dims.is_empty(), "Hidden dims should not be empty" ); assert!( config.training_params.learning_rate > 0.0, "Learning rate should be positive" ); assert!( config.training_params.max_epochs > 0, "Max epochs should be positive" ); } #[tokio::test] async fn test_learning_rate_bounds() { let test_cases = vec![0.0001, 0.001, 0.01, 0.1]; for lr in test_cases { let mut config = create_test_training_config(); config.training_params.learning_rate = lr; assert!( config.training_params.learning_rate > 0.0 && config.training_params.learning_rate < 1.0, "Learning rate {} should be in (0, 1)", lr ); } } #[tokio::test] async fn test_batch_size_configurations() { let batch_sizes = vec![16, 32, 64, 128]; for batch_size in batch_sizes { let mut config = create_test_training_config(); config.training_params.batch_size = batch_size; assert!( config.training_params.batch_size.is_power_of_two(), "Batch size {} should be power of 2", batch_size ); } } // ============================================================================ // Early Stopping Tests // ============================================================================ #[tokio::test] async fn test_early_stopping_configuration() { let config = create_test_training_config(); assert!( config.training_params.patience > 0, "Early stopping patience should be positive" ); assert!( config.training_params.patience < config.training_params.max_epochs, "Patience should be less than max epochs" ); } // ============================================================================ // Distributed Training Coordination Tests // ============================================================================ #[tokio::test] async fn test_multi_worker_coordination() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); // Submit multiple jobs to test worker coordination let mut job_ids = Vec::new(); for i in 0..3 { let job_id = orchestrator .submit_job( format!("worker_test_{}", i), config.clone(), format!("Worker coordination test {}", i), HashMap::new(), ) .await .expect("Failed to submit job"); job_ids.push(job_id); } // Verify all jobs were queued for job_id in job_ids { let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert_eq!(job.status, JobStatus::Pending, "Job should be pending"); } } #[tokio::test] async fn test_resource_allocation_simulation() { // Simulate GPU allocation logic let available_gpus = vec![0, 1]; // Simulated GPU IDs let jobs_count = 3; // Round-robin allocation simulation let allocations: Vec = (0..jobs_count) .map(|i| available_gpus[i % available_gpus.len()]) .collect(); assert_eq!(allocations[0], 0, "First job should get GPU 0"); assert_eq!(allocations[1], 1, "Second job should get GPU 1"); assert_eq!( allocations[2], 0, "Third job should get GPU 0 (round-robin)" ); } // ============================================================================ // Model Evaluation Tests // ============================================================================ #[tokio::test] async fn test_financial_metrics_validation() { use ml::training_pipeline::FinancialPerformanceMetrics; let metrics = FinancialPerformanceMetrics { simulated_return: 0.15, sharpe_ratio: 1.8, max_drawdown: -0.12, hit_rate: 0.62, avg_prediction_error_bps: 5.2, risk_adjusted_return: 0.25, }; // Validate metric ranges assert!( metrics.sharpe_ratio > 0.0, "Sharpe ratio should be positive for profitable strategy" ); assert!( metrics.max_drawdown < 0.0, "Max drawdown should be negative" ); assert!( metrics.hit_rate >= 0.0 && metrics.hit_rate <= 1.0, "Hit rate should be in [0, 1]" ); assert!( metrics.avg_prediction_error_bps >= 0.0, "Prediction error should be non-negative" ); } #[tokio::test] async fn test_model_performance_threshold() { use ml::training_pipeline::FinancialPerformanceMetrics; let good_metrics = FinancialPerformanceMetrics { simulated_return: 0.20, sharpe_ratio: 2.0, max_drawdown: -0.10, hit_rate: 0.65, avg_prediction_error_bps: 3.0, risk_adjusted_return: 0.30, }; let poor_metrics = FinancialPerformanceMetrics { simulated_return: -0.05, sharpe_ratio: 0.3, max_drawdown: -0.30, hit_rate: 0.48, avg_prediction_error_bps: 15.0, risk_adjusted_return: -0.02, }; // Good model thresholds assert!( good_metrics.sharpe_ratio > 1.5, "Good model should have Sharpe > 1.5" ); assert!( good_metrics.hit_rate > 0.6, "Good model should have hit rate > 60%" ); // Poor model detection assert!( poor_metrics.sharpe_ratio < 1.0, "Poor model should have low Sharpe" ); assert!( poor_metrics.hit_rate < 0.5, "Poor model should have hit rate < 50%" ); } // ============================================================================ // Job Control Tests // ============================================================================ #[tokio::test] async fn test_job_stopping() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "stop_test".to_string(), config, "Stop test".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); // Stop the job let stopped = orchestrator .stop_job(job_id, "User requested stop".to_string()) .await .expect("Failed to stop job"); assert!(stopped, "Job should be marked as stopped"); let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert_eq!( job.status, JobStatus::Stopped, "Job status should be Stopped" ); } #[tokio::test] async fn test_job_idempotent_stop() { let orchestrator = setup_test_orchestrator().await; let config = create_test_training_config(); let job_id = orchestrator .submit_job( "idempotent_test".to_string(), config, "Idempotent stop test".to_string(), HashMap::new(), ) .await .expect("Failed to submit job"); // Stop twice orchestrator .stop_job(job_id, "First stop".to_string()) .await .expect("Failed first stop"); let second_stop = orchestrator .stop_job(job_id, "Second stop".to_string()) .await .expect("Failed second stop"); assert!( !second_stop, "Second stop should return false (already stopped)" ); } // ============================================================================ // Integration End-to-End Test // ============================================================================ #[tokio::test] async fn test_complete_training_workflow() { let orchestrator = setup_test_orchestrator().await; // 1. Create training configuration let config = create_test_training_config(); // 2. Submit job let job_id = orchestrator .submit_job( "e2e_test".to_string(), config, "End-to-end test".to_string(), [("test_type".to_string(), "integration".to_string())] .iter() .cloned() .collect(), ) .await .expect("Failed to submit job"); // 3. Verify job creation let job = orchestrator .get_job(job_id) .await .expect("Job should exist"); assert_eq!(job.status, JobStatus::Pending); assert_eq!(job.tags.get("test_type"), Some(&"integration".to_string())); // 4. Subscribe to updates let _receiver = orchestrator .subscribe_to_job_status(job_id) .await .expect("Failed to subscribe"); // Note: Full execution would require orchestrator.start() and mock data // This test validates the submission and monitoring infrastructure }