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>
467 lines
16 KiB
Rust
467 lines
16 KiB
Rust
//! TDD Tests for Model Validation Pipeline
|
|
//!
|
|
//! Test-Driven Development approach:
|
|
//! 1. Write tests FIRST (these tests will FAIL initially)
|
|
//! 2. Implement validation_pipeline.rs to make ALL tests GREEN
|
|
//! 3. Validation triggers after training completion
|
|
//! 4. Uses backtesting service for out-of-sample validation
|
|
//! 5. Promotes models to production only if validation passes
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use chrono::Utc;
|
|
use ml::training_pipeline::{
|
|
FinancialValidationConfig, ModelArchitectureConfig, PerformanceConfig,
|
|
ProductionTrainingConfig, TrainingHyperparameters,
|
|
};
|
|
use ml_training_service::{
|
|
orchestrator::{JobStatus, TrainingJob},
|
|
validation_pipeline::{
|
|
PromotionDecision, ValidationConfig, ValidationMetrics, ValidationPipeline,
|
|
},
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
// ============================================================================
|
|
// Test 1: Validation Pipeline Creation
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_validation_pipeline_creation() {
|
|
let config = ValidationConfig {
|
|
holdout_data_path: "test_data/real/databento/ml_training".to_string(),
|
|
backtest_duration_days: 30,
|
|
min_sharpe_ratio: 1.5,
|
|
min_win_rate: 0.52,
|
|
max_drawdown: 0.15,
|
|
enable_promotion: true,
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config);
|
|
assert!(pipeline.is_ok(), "Pipeline creation should succeed");
|
|
|
|
let pipeline = pipeline.unwrap();
|
|
assert_eq!(pipeline.get_config().backtest_duration_days, 30);
|
|
assert_eq!(pipeline.get_config().min_sharpe_ratio, 1.5);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: Training Completion Trigger
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_validation_triggered_on_training_complete() {
|
|
let config = ValidationConfig::default();
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Simulate training job completion
|
|
let job_id = Uuid::new_v4();
|
|
let training_job = create_completed_training_job(job_id);
|
|
|
|
// Validation should trigger automatically
|
|
let result = pipeline.validate_on_completion(&training_job).await;
|
|
|
|
assert!(result.is_ok(), "Validation trigger should succeed");
|
|
let validation_result = result.unwrap();
|
|
assert_eq!(validation_result.job_id, job_id);
|
|
assert!(!validation_result.validation_id.is_empty());
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: Holdout Dataset Loading
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_holdout_dataset_loading() {
|
|
let config = ValidationConfig {
|
|
holdout_data_path:
|
|
"test_data/real/databento/ZN.FUT_ohlcv-1m_2024-01-02_to_2024-01-31.uncompressed.dbn"
|
|
.to_string(),
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Load holdout dataset (out-of-sample data)
|
|
let holdout_data = pipeline.load_holdout_dataset().await;
|
|
|
|
if let Err(ref e) = holdout_data {
|
|
eprintln!("Holdout data loading error: {:?}", e);
|
|
eprintln!("Error details: {}", e);
|
|
eprintln!("Error source: {:?}", e.source());
|
|
}
|
|
|
|
assert!(
|
|
holdout_data.is_ok(),
|
|
"Holdout data loading should succeed: {:?}",
|
|
holdout_data.as_ref().err()
|
|
);
|
|
let data = holdout_data.unwrap();
|
|
assert!(!data.is_empty(), "Holdout dataset should not be empty");
|
|
assert!(
|
|
data.len() >= 100,
|
|
"Holdout dataset should have at least 100 bars for 30-day backtest"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: Backtesting Integration
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_backtesting_integration() {
|
|
let config = ValidationConfig {
|
|
holdout_data_path:
|
|
"test_data/real/databento/ZN.FUT_ohlcv-1m_2024-01-02_to_2024-01-31.uncompressed.dbn"
|
|
.to_string(),
|
|
backtest_duration_days: 30,
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
let job_id = Uuid::new_v4();
|
|
let training_job = create_completed_training_job(job_id);
|
|
|
|
// Run backtest on holdout data
|
|
let backtest_result = pipeline
|
|
.run_backtest(&training_job, "validation_data_path")
|
|
.await;
|
|
|
|
assert!(
|
|
backtest_result.is_ok(),
|
|
"Backtesting integration should succeed"
|
|
);
|
|
let result = backtest_result.unwrap();
|
|
|
|
// Verify backtest executed and returned metrics
|
|
assert!(
|
|
result.sharpe_ratio.is_finite(),
|
|
"Sharpe ratio should be finite"
|
|
);
|
|
assert!(result.win_rate >= 0.0 && result.win_rate <= 1.0);
|
|
assert!(result.max_drawdown >= 0.0 && result.max_drawdown <= 1.0);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: Metrics Calculation
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_metrics_calculation() {
|
|
let config = ValidationConfig {
|
|
min_sharpe_ratio: 1.5,
|
|
min_win_rate: 0.52,
|
|
max_drawdown: 0.15,
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Mock backtest results
|
|
let mock_trades = vec![
|
|
// Winning trades
|
|
(100.0, 101.5), // +1.5% profit
|
|
(101.5, 103.0), // +1.5% profit
|
|
(103.0, 105.0), // +2.0% profit
|
|
// Losing trades
|
|
(105.0, 104.0), // -1.0% loss
|
|
(104.0, 105.5), // +1.5% profit
|
|
];
|
|
|
|
let metrics = pipeline.calculate_metrics(&mock_trades).await;
|
|
|
|
assert!(metrics.is_ok(), "Metrics calculation should succeed");
|
|
let result = metrics.unwrap();
|
|
|
|
assert_eq!(result.total_trades, 5);
|
|
assert!(result.win_rate > 0.5, "Win rate should be > 50%");
|
|
assert!(result.sharpe_ratio > 0.0, "Sharpe ratio should be positive");
|
|
assert!(
|
|
result.max_drawdown >= 0.0 && result.max_drawdown <= 1.0,
|
|
"Max drawdown should be between 0 and 1"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 6: Promotion Decision Logic (PASS)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_promotion_decision_pass() {
|
|
let config = ValidationConfig {
|
|
min_sharpe_ratio: 1.5,
|
|
min_win_rate: 0.52,
|
|
max_drawdown: 0.15,
|
|
enable_promotion: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Excellent metrics (should PASS)
|
|
let metrics = ValidationMetrics {
|
|
sharpe_ratio: 2.0, // Above threshold (1.5)
|
|
win_rate: 0.58, // Above threshold (0.52)
|
|
max_drawdown: 0.10, // Below threshold (0.15)
|
|
total_trades: 150,
|
|
avg_profit_per_trade: 0.015,
|
|
profit_factor: 2.5,
|
|
total_return: 0.45,
|
|
};
|
|
|
|
let decision = pipeline.make_promotion_decision(&metrics).await;
|
|
|
|
assert!(decision.is_ok(), "Promotion decision should succeed");
|
|
let result = decision.unwrap();
|
|
|
|
assert_eq!(result.decision, PromotionDecision::Promote);
|
|
assert!(
|
|
result.reason.contains("PASS"),
|
|
"Reason should indicate validation passed"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 7: Promotion Decision Logic (FAIL - Low Sharpe)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_promotion_decision_fail_low_sharpe() {
|
|
let config = ValidationConfig {
|
|
min_sharpe_ratio: 1.5,
|
|
min_win_rate: 0.52,
|
|
max_drawdown: 0.15,
|
|
enable_promotion: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Poor metrics (LOW SHARPE - should FAIL)
|
|
let metrics = ValidationMetrics {
|
|
sharpe_ratio: 0.8, // BELOW threshold (1.5) ❌
|
|
win_rate: 0.58, // Above threshold
|
|
max_drawdown: 0.10, // Below threshold
|
|
total_trades: 150,
|
|
avg_profit_per_trade: 0.005,
|
|
profit_factor: 1.2,
|
|
total_return: 0.15,
|
|
};
|
|
|
|
let decision = pipeline.make_promotion_decision(&metrics).await;
|
|
|
|
assert!(decision.is_ok(), "Promotion decision should succeed");
|
|
let result = decision.unwrap();
|
|
|
|
assert_eq!(result.decision, PromotionDecision::Reject);
|
|
assert!(
|
|
result.reason.contains("Sharpe") || result.reason.contains("sharpe"),
|
|
"Reason should mention Sharpe ratio failure"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 8: Promotion Decision Logic (FAIL - Low Win Rate)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_promotion_decision_fail_low_win_rate() {
|
|
let config = ValidationConfig {
|
|
min_sharpe_ratio: 1.5,
|
|
min_win_rate: 0.52,
|
|
max_drawdown: 0.15,
|
|
enable_promotion: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Poor metrics (LOW WIN RATE - should FAIL)
|
|
let metrics = ValidationMetrics {
|
|
sharpe_ratio: 2.0, // Above threshold
|
|
win_rate: 0.48, // BELOW threshold (0.52) ❌
|
|
max_drawdown: 0.10, // Below threshold
|
|
total_trades: 150,
|
|
avg_profit_per_trade: 0.015,
|
|
profit_factor: 1.8,
|
|
total_return: 0.35,
|
|
};
|
|
|
|
let decision = pipeline.make_promotion_decision(&metrics).await;
|
|
|
|
assert!(decision.is_ok(), "Promotion decision should succeed");
|
|
let result = decision.unwrap();
|
|
|
|
assert_eq!(result.decision, PromotionDecision::Reject);
|
|
assert!(
|
|
result.reason.contains("win rate") || result.reason.contains("Win rate"),
|
|
"Reason should mention win rate failure"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 9: Promotion Decision Logic (FAIL - High Drawdown)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_promotion_decision_fail_high_drawdown() {
|
|
let config = ValidationConfig {
|
|
min_sharpe_ratio: 1.5,
|
|
min_win_rate: 0.52,
|
|
max_drawdown: 0.15,
|
|
enable_promotion: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
|
|
// Poor metrics (HIGH DRAWDOWN - should FAIL)
|
|
let metrics = ValidationMetrics {
|
|
sharpe_ratio: 2.0, // Above threshold
|
|
win_rate: 0.58, // Above threshold
|
|
max_drawdown: 0.25, // ABOVE threshold (0.15) ❌
|
|
total_trades: 150,
|
|
avg_profit_per_trade: 0.015,
|
|
profit_factor: 2.0,
|
|
total_return: 0.40,
|
|
};
|
|
|
|
let decision = pipeline.make_promotion_decision(&metrics).await;
|
|
|
|
assert!(decision.is_ok(), "Promotion decision should succeed");
|
|
let result = decision.unwrap();
|
|
|
|
assert_eq!(result.decision, PromotionDecision::Reject);
|
|
assert!(
|
|
result.reason.contains("drawdown") || result.reason.contains("Drawdown"),
|
|
"Reason should mention drawdown failure"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 10: End-to-End Validation Flow
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_e2e_validation_flow() {
|
|
let config = ValidationConfig {
|
|
holdout_data_path:
|
|
"test_data/real/databento/ZN.FUT_ohlcv-1m_2024-01-02_to_2024-01-31.uncompressed.dbn"
|
|
.to_string(),
|
|
backtest_duration_days: 30,
|
|
min_sharpe_ratio: 1.0, // Relaxed for testing
|
|
min_win_rate: 0.50, // Relaxed for testing
|
|
max_drawdown: 0.20, // Relaxed for testing
|
|
enable_promotion: true,
|
|
};
|
|
|
|
let pipeline = ValidationPipeline::new(config).expect("Pipeline creation failed");
|
|
let job_id = Uuid::new_v4();
|
|
let training_job = create_completed_training_job(job_id);
|
|
|
|
// Complete validation flow:
|
|
// 1. Trigger validation
|
|
let validation_result = pipeline.validate_on_completion(&training_job).await;
|
|
|
|
if let Err(ref e) = validation_result {
|
|
eprintln!("Validation trigger error: {:?}", e);
|
|
}
|
|
|
|
assert!(
|
|
validation_result.is_ok(),
|
|
"Validation should trigger: {:?}",
|
|
validation_result.as_ref().err()
|
|
);
|
|
|
|
let result = validation_result.unwrap();
|
|
|
|
// 2. Verify validation executed
|
|
assert_eq!(result.job_id, job_id);
|
|
assert!(!result.validation_id.is_empty());
|
|
|
|
// 3. Check metrics were calculated
|
|
assert!(result.metrics.is_some(), "Metrics should be calculated");
|
|
let metrics = result.metrics.unwrap();
|
|
assert!(metrics.sharpe_ratio.is_finite());
|
|
assert!(metrics.total_trades > 0);
|
|
|
|
// 4. Verify promotion decision was made
|
|
assert!(result.promotion_decision.is_some());
|
|
let decision = result.promotion_decision.unwrap();
|
|
assert!(
|
|
matches!(
|
|
decision.decision,
|
|
PromotionDecision::Promote | PromotionDecision::Reject
|
|
),
|
|
"Decision should be Promote or Reject"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
/// Create a completed training job for testing
|
|
fn create_completed_training_job(job_id: Uuid) -> TrainingJob {
|
|
let config = 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(),
|
|
max_memory_bytes: 8 * 1024 * 1024 * 1024,
|
|
num_workers: 4,
|
|
gradient_accumulation_steps: 1,
|
|
},
|
|
};
|
|
|
|
let mut job = TrainingJob::new(
|
|
"DQN".to_string(),
|
|
config,
|
|
"Test training job for validation".to_string(),
|
|
HashMap::new(),
|
|
);
|
|
|
|
// Set job as completed with mock training results
|
|
job.id = job_id;
|
|
job.status = JobStatus::Completed;
|
|
job.started_at = Some(Utc::now() - chrono::Duration::hours(2));
|
|
job.completed_at = Some(Utc::now());
|
|
job.progress_percentage = 100.0;
|
|
job.current_epoch = 10;
|
|
job.total_epochs = 10;
|
|
job.model_artifact_path = Some(format!("models/{}.bin", job_id));
|
|
|
|
// Mock training metrics
|
|
job.metrics.insert("final_train_loss".to_string(), 0.015);
|
|
job.metrics.insert("final_val_loss".to_string(), 0.018);
|
|
job.metrics.insert("accuracy".to_string(), 0.85);
|
|
|
|
job
|
|
}
|