Eliminate the entire mixed_precision runtime indirection layer: - Delete crates/ml-core/src/mixed_precision.rs (training_dtype, ensure_training_dtype, align_dim_for_tensor_cores) - Inline ~100 call sites across 130 files to constants: training_dtype(&device) → candle_core::DType::BF16 ensure_training_dtype(x) → x.to_dtype(candle_core::DType::BF16) align_dim_for_tensor_cores(x, &device) → (x + 7) & !7 - Remove re-exports from ml-dqn, ml-supervised, ml lib.rs - Clean config/toml/json/shell references No CPU/Metal training path exists — BF16 is the only dtype. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
946 lines
28 KiB
Rust
946 lines
28 KiB
Rust
#![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<TrainingOrchestrator> {
|
|
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<f64>)> {
|
|
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<usize> = (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
|
|
}
|