Files
foxhunt/tests/e2e/tests/e2e_ml_training_test.rs
jgrusewski 845e77a8b0 fix(ci): Fix GitLab CI YAML syntax and PPOConfig compilation errors
Two critical fixes for successful pipeline execution:

1. GitLab CI YAML Syntax Fix (.gitlab-ci.yml:84-86)
   - Wrapped echo commands containing colons in single quotes
   - Root cause: YAML parser interprets `"text: value"` as key-value pairs
   - Solution: Single quotes force literal string interpretation
   - Impact: Enables Docker build pipeline execution

2. Trading Service Compilation Fix (trading_service/src/services/enhanced_ml.rs:1328-1348)
   - Added missing early stopping fields to PPOConfig initialization
   - Fields: early_stopping_enabled, early_stopping_patience, early_stopping_min_delta, early_stopping_min_epochs
   - Values: Disabled by default for paper trading (early_stopping_enabled: false)
   - Impact: Resolves pre-push hook compilation error

Technical Details:
- YAML Issue: Colons followed by spaces trigger mapping syntax parsing
- Single quotes preserve shell variable expansion while forcing literal YAML strings
- Early stopping config matches PPOConfig struct updates from Wave D
- Default values: patience=5, min_delta=0.001, min_epochs=10

Validated:
-  YAML syntax validated with PyYAML
-  trading_service compilation successful (cargo check)
-  Ready for GitLab CI/CD pipeline execution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 00:20:00 +01:00

733 lines
24 KiB
Rust

//! End-to-End ML Training Pipeline Test
//!
//! Mission: Validate complete training pipeline from DBN data → checkpoint → registry
//! Methodology: TDD (RED → GREEN → REFACTOR)
//!
//! Tests cover:
//! 1. DBN data loading → checkpoint creation → registry storage
//! 2. All 4 models training end-to-end (DQN, PPO, MAMBA2, TFT)
//! 3. Multi-symbol training
//! 4. Checkpoint loading and inference validation
//! 5. Training metrics validation
//! 6. GPU memory optimization during training
use anyhow::{Context, Result};
use candle_core::Device;
use sqlx::PgPool;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;
use tracing::{info, warn};
use uuid::Uuid;
// Import ML training infrastructure
use ml::data_loaders::dbn_sequence_loader::DbnSequenceLoader;
use ml::training::unified_trainer::{TrainingConfig, UnifiedTrainer};
use ml_training_service::checkpoint_manager::{CheckpointManager, RetentionPolicy};
use ml::checkpoint::CheckpointMetadata;
use ml::ModelType;
// ============================================================================
// Helper Functions (Test Infrastructure)
// ============================================================================
/// Get test database pool
async fn get_test_db_pool() -> PgPool {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
});
PgPool::connect(&database_url)
.await
.expect("Failed to connect to test database")
}
/// Get test data directory path
fn get_test_data_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let workspace_root = PathBuf::from(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
workspace_root.join("test_data")
}
/// Check if test data exists
fn test_data_available(symbol: &str) -> bool {
let data_path = get_test_data_path();
let dbn_file = data_path.join(format!("{}.20240102.dbn", symbol));
dbn_file.exists()
}
/// Load DBN data for testing
async fn load_dbn_bars(symbol: &str, num_bars: usize) -> Result<Vec<(f64, f64, f64, f64, f64)>> {
let data_path = get_test_data_path();
let dbn_file = data_path.join(format!("{}.20240102.dbn", symbol));
if !dbn_file.exists() {
return Err(anyhow::anyhow!(
"DBN file not found: {}",
dbn_file.display()
));
}
let loader = DbnSequenceLoader::new(
dbn_file.to_str().unwrap(),
32, // batch_size
1, // sequence_length
Some(symbol.to_string()),
).await?;
// Extract OHLCV bars (simplified for test)
let bars = vec![(4500.0, 4510.0, 4495.0, 4505.0, 1000.0); num_bars];
Ok(bars)
}
/// Create test output directory
async fn create_test_output_dir(test_name: &str) -> Result<PathBuf> {
let output_dir = PathBuf::from(format!("/tmp/foxhunt_e2e_test_{}", test_name));
if output_dir.exists() {
fs::remove_dir_all(&output_dir).await?;
}
fs::create_dir_all(&output_dir).await?;
Ok(output_dir)
}
// ============================================================================
// TEST 1: E2E Training Pipeline - DBN to Checkpoint (RED)
// ============================================================================
#[tokio::test]
#[ignore = "RED phase - will fail until full implementation exists"]
async fn test_e2e_dbn_to_checkpoint() -> Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init()
.ok();
info!("🚀 Starting E2E Training Pipeline Test: DBN → Checkpoint → Registry");
// Skip if test data not available
if !test_data_available("ES.FUT") {
warn!("Skipping test - ES.FUT test data not available");
return Ok(());
}
// ARRANGE
let pool = get_test_db_pool().await;
let output_dir = create_test_output_dir("dbn_to_checkpoint").await?;
// Step 1: Load DBN data
info!("📂 Step 1: Loading DBN data...");
let dbn_path = get_test_data_path().join("ES.FUT.20240102.dbn");
let loader = DbnSequenceLoader::new(
dbn_path.to_str().unwrap(),
32,
1,
Some("ES.FUT".to_string()),
).await?;
info!("✅ DBN loader initialized");
// Step 2: Configure training
info!("⚙️ Step 2: Configuring DQN training...");
let config = TrainingConfig {
model_type: "DQN".to_string(),
epochs: 10,
batch_size: 32,
learning_rate: 0.001,
device: Device::Cpu, // Use CPU for E2E test
checkpoint_dir: output_dir.clone(),
symbol: "ES.FUT".to_string(),
};
// Step 3: Create trainer and train model
info!("🏋️ Step 3: Training DQN model (10 epochs)...");
let mut trainer = UnifiedTrainer::new(config)?;
let start_time = std::time::Instant::now();
let metrics = trainer.train(&loader).await?;
let training_duration = start_time.elapsed();
info!(
"✅ Training completed in {:.1}s",
training_duration.as_secs_f64()
);
info!("📊 Final loss: {:.6}", metrics.final_loss);
info!("📊 Epochs trained: {}", metrics.epochs_completed);
// ACT & ASSERT: Step 4 - Verify checkpoint exists
info!("💾 Step 4: Verifying checkpoint creation...");
let checkpoint_path = output_dir.join("dqn_final.safetensors");
assert!(
checkpoint_path.exists(),
"Checkpoint file should exist at {:?}",
checkpoint_path
);
let checkpoint_size = fs::metadata(&checkpoint_path).await?.len();
info!("✅ Checkpoint created: {} bytes", checkpoint_size);
assert!(
checkpoint_size > 1_000,
"Checkpoint file should be at least 1KB, got {} bytes",
checkpoint_size
);
// Step 5: Verify checkpoint is loadable
info!("🔄 Step 5: Loading checkpoint for validation...");
let checkpoint_data = fs::read(&checkpoint_path).await?;
assert!(
!checkpoint_data.is_empty(),
"Checkpoint data should not be empty"
);
info!(
"✅ Checkpoint loaded successfully ({} bytes)",
checkpoint_data.len()
);
// Step 6: Register checkpoint in model registry
info!("📝 Step 6: Registering checkpoint in model registry...");
let manager = CheckpointManager::new(pool.clone(), RetentionPolicy::default()).await?;
// Create checkpoint metadata
let metadata = CheckpointMetadata {
checkpoint_id: format!("dqn-v{}", "1.0.0"),
model_type: ModelType::DQN,
model_name: "ES.FUT-DQN-e2e".to_string(),
version: "1.0.0".to_string(),
created_at: chrono::Utc::now(),
epoch: Some(10),
step: Some(100),
loss: Some(metrics.final_loss),
accuracy: None,
hyperparameters: std::collections::HashMap::from([
("epochs".to_string(), serde_json::json!(10)),
("batch_size".to_string(), serde_json::json!(32)),
("learning_rate".to_string(), serde_json::json!(0.001)),
]),
metrics: std::collections::HashMap::from([
("final_loss".to_string(), metrics.final_loss),
]),
architecture: std::collections::HashMap::new(),
format: ml::checkpoint::CheckpointFormat::Binary,
compression: ml::checkpoint::CompressionType::LZ4,
file_size: checkpoint_size as usize,
compressed_size: None,
checksum: "test_checksum_e2e".to_string(),
tags: vec!["e2e_test".to_string()],
custom_metadata: std::collections::HashMap::new(),
signature: None,
signature_algorithm: "none".to_string(),
signing_key_id: "test".to_string(),
signed_at: None,
};
let registration_id = manager.register_checkpoint(metadata).await?;
info!("✅ Checkpoint registered with ID: {}", registration_id);
// Verify registration in database
let record = sqlx::query!(
r#"
SELECT model_type, model_id, is_production, is_experimental
FROM ml_model_versions
WHERE model_id = $1
"#,
registration_id
)
.fetch_one(&pool)
.await?;
assert_eq!(record.model_type, "DQN", "Model type should be DQN");
assert_eq!(record.is_experimental, true, "Should be experimental");
assert_eq!(record.is_production, false, "Should not be production");
info!("✅ Database registration verified");
// Cleanup
fs::remove_dir_all(&output_dir).await?;
info!("🎉 E2E Training Pipeline Test PASSED!");
Ok(())
}
// ============================================================================
// TEST 2: E2E All Models Training (RED)
// ============================================================================
#[tokio::test]
#[ignore = "RED phase - will fail until all models implemented"]
async fn test_e2e_all_models_training() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init()
.ok();
info!("🚀 Starting E2E All Models Training Test");
// Skip if test data not available
if !test_data_available("ES.FUT") {
warn!("Skipping test - ES.FUT test data not available");
return Ok(());
}
// ARRANGE
let dbn_path = get_test_data_path().join("ES.FUT.20240102.dbn");
let models = vec!["DQN", "PPO", "MAMBA2", "TFT"];
let output_dir = create_test_output_dir("all_models").await?;
// ACT & ASSERT: Train each model
for model_name in models {
info!("\n🏋️ Training {} model...", model_name);
let model_output_dir = output_dir.join(model_name.to_lowercase());
fs::create_dir_all(&model_output_dir).await?;
let config = TrainingConfig {
model_type: model_name.to_string(),
epochs: 5, // Shorter for E2E test
batch_size: 32,
learning_rate: 0.001,
device: Device::Cpu,
checkpoint_dir: model_output_dir.clone(),
symbol: "ES.FUT".to_string(),
};
let loader = DbnSequenceLoader::new(
dbn_path.to_str().unwrap(),
32,
1,
Some("ES.FUT".to_string()),
).await?;
let mut trainer = UnifiedTrainer::new(config)?;
let metrics = trainer.train(&loader).await?;
info!(
"✅ {} trained: {} epochs, loss={:.6}",
model_name, metrics.epochs_completed, metrics.final_loss
);
// Verify checkpoint created
let checkpoint_path =
model_output_dir.join(format!("{}_final.safetensors", model_name.to_lowercase()));
assert!(
checkpoint_path.exists(),
"{} checkpoint should exist",
model_name
);
let checkpoint_size = fs::metadata(&checkpoint_path).await?.len();
info!("💾 {} checkpoint: {} bytes", model_name, checkpoint_size);
assert!(
checkpoint_size > 1_000,
"{} checkpoint should be at least 1KB",
model_name
);
}
// Cleanup
fs::remove_dir_all(&output_dir).await?;
info!("\n🎉 E2E All Models Training Test PASSED!");
Ok(())
}
// ============================================================================
// TEST 3: E2E Multi-Symbol Training (RED)
// ============================================================================
#[tokio::test]
#[ignore = "RED phase - will fail until multi-symbol support implemented"]
async fn test_e2e_multi_symbol_training() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init()
.ok();
info!("🚀 Starting E2E Multi-Symbol Training Test");
// ARRANGE
let symbols = vec!["ES.FUT", "NQ.FUT", "ZN.FUT"];
let output_dir = create_test_output_dir("multi_symbol").await?;
let pool = get_test_db_pool().await;
let manager = CheckpointManager::new(pool, RetentionPolicy::default()).await?;
let mut trained_symbols = Vec::new();
// ACT: Train DQN on each available symbol
for symbol in symbols {
if !test_data_available(symbol) {
warn!("Skipping {} - data not available", symbol);
continue;
}
info!("\n🏋️ Training DQN on {}...", symbol);
let dbn_path = get_test_data_path().join(format!("{}.20240102.dbn", symbol));
let symbol_output_dir = output_dir.join(symbol.replace(".", "_"));
fs::create_dir_all(&symbol_output_dir).await?;
let config = TrainingConfig {
model_type: "DQN".to_string(),
epochs: 5,
batch_size: 32,
learning_rate: 0.001,
device: Device::Cpu,
checkpoint_dir: symbol_output_dir.clone(),
symbol: symbol.to_string(),
};
let loader =
DbnSequenceLoader::new(dbn_path.to_str().unwrap(), 32, 1, Some(symbol.to_string())).await?;
let mut trainer = UnifiedTrainer::new(config)?;
let metrics = trainer.train(&loader).await?;
info!("✅ {} trained: loss={:.6}", symbol, metrics.final_loss);
// Register checkpoint
let checkpoint_path = symbol_output_dir.join("dqn_final.safetensors");
let checkpoint_size = fs::metadata(&checkpoint_path).await?.len();
let metadata = CheckpointMetadata {
checkpoint_id: format!("dqn-{}-v1.0.0", symbol.replace(".", "_")),
model_type: ModelType::DQN,
model_name: format!("{}-DQN-multi", symbol),
version: "1.0.0".to_string(),
created_at: chrono::Utc::now(),
epoch: Some(5),
step: Some(50),
loss: Some(metrics.final_loss),
accuracy: None,
hyperparameters: std::collections::HashMap::from([
("epochs".to_string(), serde_json::json!(5)),
("batch_size".to_string(), serde_json::json!(32)),
]),
metrics: std::collections::HashMap::from([
("final_loss".to_string(), metrics.final_loss),
]),
architecture: std::collections::HashMap::new(),
format: ml::checkpoint::CheckpointFormat::Binary,
compression: ml::checkpoint::CompressionType::LZ4,
file_size: checkpoint_size as usize,
compressed_size: None,
checksum: format!("checksum_{}", symbol),
tags: vec!["e2e_multi_symbol".to_string()],
custom_metadata: std::collections::HashMap::new(),
signature: None,
signature_algorithm: "none".to_string(),
signing_key_id: "test".to_string(),
signed_at: None,
};
let registration_id = manager.register_checkpoint(metadata).await?;
info!("📝 {} checkpoint registered: {}", symbol, registration_id);
trained_symbols.push(symbol);
}
// ASSERT: At least one symbol should be trained
assert!(
!trained_symbols.is_empty(),
"At least one symbol should be successfully trained"
);
info!(
"\n✅ Successfully trained on {} symbols: {:?}",
trained_symbols.len(),
trained_symbols
);
// Cleanup
fs::remove_dir_all(&output_dir).await?;
info!("🎉 E2E Multi-Symbol Training Test PASSED!");
Ok(())
}
// ============================================================================
// TEST 4: E2E Training Metrics Validation (RED)
// ============================================================================
#[tokio::test]
#[ignore = "RED phase - will fail until metrics validation implemented"]
async fn test_e2e_training_metrics_validation() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init()
.ok();
info!("🚀 Starting E2E Training Metrics Validation Test");
if !test_data_available("ES.FUT") {
warn!("Skipping test - ES.FUT test data not available");
return Ok(());
}
// ARRANGE
let dbn_path = get_test_data_path().join("ES.FUT.20240102.dbn");
let output_dir = create_test_output_dir("metrics_validation").await?;
let config = TrainingConfig {
model_type: "DQN".to_string(),
epochs: 10,
batch_size: 32,
learning_rate: 0.001,
device: Device::Cpu,
checkpoint_dir: output_dir.clone(),
symbol: "ES.FUT".to_string(),
};
let loader = DbnSequenceLoader::new(
dbn_path.to_str().unwrap(),
32,
1,
Some("ES.FUT".to_string()),
).await?;
// ACT: Train model and collect metrics
let mut trainer = UnifiedTrainer::new(config)?;
let metrics = trainer.train(&loader).await?;
// ASSERT: Validate metrics
info!("📊 Validating training metrics...");
// Loss should be finite and positive
assert!(
metrics.final_loss.is_finite() && metrics.final_loss >= 0.0,
"Loss should be finite and non-negative, got: {}",
metrics.final_loss
);
info!("✅ Loss is valid: {:.6}", metrics.final_loss);
// Epochs completed should match configuration
assert_eq!(
metrics.epochs_completed, 10,
"Should complete 10 epochs, got {}",
metrics.epochs_completed
);
info!("✅ Epochs completed: {}", metrics.epochs_completed);
// Training time should be reasonable
assert!(
metrics.training_time_seconds > 0.0,
"Training time should be positive, got: {}",
metrics.training_time_seconds
);
info!("✅ Training time: {:.1}s", metrics.training_time_seconds);
// Convergence metrics
if let Some(convergence) = metrics.convergence_achieved {
info!("✅ Convergence achieved: {}", convergence);
}
// Loss trajectory should show improvement
if metrics.loss_history.len() >= 2 {
let initial_loss = metrics.loss_history.first().unwrap();
let final_loss = metrics.loss_history.last().unwrap();
info!("📈 Initial loss: {:.6}", initial_loss);
info!("📉 Final loss: {:.6}", final_loss);
// Loss should generally decrease (allowing some fluctuation)
let improvement_ratio = (initial_loss - final_loss) / initial_loss;
info!("📊 Improvement: {:.1}%", improvement_ratio * 100.0);
}
// Cleanup
fs::remove_dir_all(&output_dir).await?;
info!("🎉 E2E Training Metrics Validation Test PASSED!");
Ok(())
}
// ============================================================================
// TEST 5: E2E Checkpoint Loading and Inference (RED)
// ============================================================================
#[tokio::test]
#[ignore = "RED phase - will fail until inference validation implemented"]
async fn test_e2e_checkpoint_loading_and_inference() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init()
.ok();
info!("🚀 Starting E2E Checkpoint Loading and Inference Test");
if !test_data_available("ES.FUT") {
warn!("Skipping test - ES.FUT test data not available");
return Ok(());
}
// ARRANGE: Train a model first
let dbn_path = get_test_data_path().join("ES.FUT.20240102.dbn");
let output_dir = create_test_output_dir("checkpoint_loading").await?;
info!("🏋️ Step 1: Training model to create checkpoint...");
let config = TrainingConfig {
model_type: "DQN".to_string(),
epochs: 5,
batch_size: 32,
learning_rate: 0.001,
device: Device::Cpu,
checkpoint_dir: output_dir.clone(),
symbol: "ES.FUT".to_string(),
};
let loader = DbnSequenceLoader::new(
dbn_path.to_str().unwrap(),
32,
1,
Some("ES.FUT".to_string()),
).await?;
let mut trainer = UnifiedTrainer::new(config)?;
trainer.train(&loader).await?;
let checkpoint_path = output_dir.join("dqn_final.safetensors");
info!("✅ Checkpoint created: {}", checkpoint_path.display());
// ACT: Load checkpoint and perform inference
info!("🔄 Step 2: Loading checkpoint for inference...");
// Load checkpoint data
let checkpoint_data = fs::read(&checkpoint_path).await?;
info!("✅ Checkpoint loaded: {} bytes", checkpoint_data.len());
// Create inference engine (simplified for test)
info!("🧠 Step 3: Performing inference...");
// Generate test features (256 features for MAMBA2)
let test_features = vec![0.5_f32; 256];
// In real implementation, this would load model and run inference
// For TDD RED phase, we just verify the checkpoint is valid format
// Verify safetensors format (should be valid tensors)
assert!(
checkpoint_data.len() > 100,
"Checkpoint should contain valid model weights"
);
info!("✅ Inference validation successful");
// Cleanup
fs::remove_dir_all(&output_dir).await?;
info!("🎉 E2E Checkpoint Loading and Inference Test PASSED!");
Ok(())
}
// ============================================================================
// TEST 6: E2E GPU Memory Optimization (RED)
// ============================================================================
#[tokio::test]
#[ignore = "RED phase - requires GPU"]
async fn test_e2e_gpu_memory_optimization() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init()
.ok();
info!("🚀 Starting E2E GPU Memory Optimization Test");
// Check if GPU is available
let device = match Device::cuda_if_available(0) {
Ok(dev) => dev,
Err(_) => {
warn!("GPU not available, skipping test");
return Ok(());
},
};
info!("✅ GPU detected: {:?}", device);
if !test_data_available("ES.FUT") {
warn!("Skipping test - ES.FUT test data not available");
return Ok(());
}
// ARRANGE
let dbn_path = get_test_data_path().join("ES.FUT.20240102.dbn");
let output_dir = create_test_output_dir("gpu_memory").await?;
// ACT: Train with GPU memory constraints
let config = TrainingConfig {
model_type: "MAMBA2".to_string(),
epochs: 5,
batch_size: 16, // Smaller batch for GPU memory
learning_rate: 0.001,
device,
checkpoint_dir: output_dir.clone(),
symbol: "ES.FUT".to_string(),
};
let loader = DbnSequenceLoader::new(
dbn_path.to_str().unwrap(),
16,
1,
Some("ES.FUT".to_string()),
).await?;
let mut trainer = UnifiedTrainer::new(config)?;
info!("🏋️ Training MAMBA2 on GPU with memory optimization...");
let start_time = std::time::Instant::now();
let metrics = trainer.train(&loader).await?;
let training_duration = start_time.elapsed();
// ASSERT: Training should complete without OOM
info!(
"✅ GPU training completed in {:.1}s",
training_duration.as_secs_f64()
);
info!("📊 Final loss: {:.6}", metrics.final_loss);
assert!(
metrics.epochs_completed == 5,
"Should complete all 5 epochs without OOM"
);
// Verify checkpoint created
let checkpoint_path = output_dir.join("mamba2_final.safetensors");
assert!(checkpoint_path.exists(), "GPU checkpoint should be created");
// Cleanup
fs::remove_dir_all(&output_dir).await?;
info!("🎉 E2E GPU Memory Optimization Test PASSED!");
Ok(())
}