Files
foxhunt/services/ml_training_service/src/validation_pipeline.rs
jgrusewski e4870b17b9 fix: tune log levels across workspace — demote noisy warn to debug/trace
Reduce log noise for non-critical operational paths: connection retries,
expected fallbacks, graceful degradation, and optional feature absence.
Keeps warn/error for genuine failures requiring attention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:35:15 +01:00

839 lines
27 KiB
Rust

//! Model Validation Pipeline
//!
//! Automated validation system that triggers after training completion:
//! 1. Loads holdout dataset (out-of-sample data)
//! 2. Runs backtest via BacktestingService
//! 3. Calculates validation metrics (Sharpe, win rate, drawdown)
//! 4. Makes promotion decision based on thresholds
//! 5. Promotes model to production if validation passes
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::path::Path;
use tokio::sync::Mutex;
use tonic::transport::Channel;
use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::backtesting_proto::backtesting_service_client::BacktestingServiceClient;
use crate::backtesting_proto::{
GetBacktestResultsRequest, GetBacktestStatusRequest, StartBacktestRequest,
};
use crate::orchestrator::TrainingJob;
/// Validation pipeline configuration
#[derive(Debug, Clone)]
pub struct ValidationConfig {
/// Path to holdout dataset (out-of-sample data)
pub holdout_data_path: String,
/// Duration of backtest in days
pub backtest_duration_days: u32,
/// Minimum Sharpe ratio for promotion
pub min_sharpe_ratio: f64,
/// Minimum win rate for promotion (0.0 - 1.0)
pub min_win_rate: f64,
/// Maximum drawdown allowed (0.0 - 1.0)
pub max_drawdown: f64,
/// Enable automatic promotion to production
pub enable_promotion: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
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,
}
}
}
/// Validation result status
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationStatus {
/// Validation passed all thresholds
Passed,
/// Validation failed one or more thresholds
Failed,
/// Validation in progress
InProgress,
/// Validation error
Error,
}
/// Promotion decision
#[derive(Debug, Clone, PartialEq)]
pub enum PromotionDecision {
/// Promote model to production
Promote,
/// Reject model (retrain with different hyperparameters)
Reject,
/// Manual review required
ManualReview,
}
/// Validation metrics
#[derive(Debug, Clone)]
pub struct ValidationMetrics {
/// Annualized Sharpe ratio
pub sharpe_ratio: f64,
/// Win rate (0.0 - 1.0)
pub win_rate: f64,
/// Maximum drawdown (0.0 - 1.0)
pub max_drawdown: f64,
/// Total number of trades
pub total_trades: u64,
/// Average profit per trade
pub avg_profit_per_trade: f64,
/// Profit factor (gross profit / gross loss)
pub profit_factor: f64,
/// Total return (0.0 - 1.0)
pub total_return: f64,
}
/// Promotion decision result
#[derive(Debug, Clone)]
pub struct PromotionDecisionResult {
/// Decision outcome
pub decision: PromotionDecision,
/// Reason for decision
pub reason: String,
/// Timestamp of decision
pub decided_at: DateTime<Utc>,
}
/// Validation result
#[derive(Debug, Clone)]
pub struct ValidationResult {
/// Validation ID
pub validation_id: String,
/// Training job ID
pub job_id: Uuid,
/// Validation status
pub status: ValidationStatus,
/// Validation metrics
pub metrics: Option<ValidationMetrics>,
/// Promotion decision
pub promotion_decision: Option<PromotionDecisionResult>,
/// Validation timestamp
pub validated_at: DateTime<Utc>,
/// Error message (if failed)
pub error_message: Option<String>,
}
/// Model validation pipeline
pub struct ValidationPipeline {
config: ValidationConfig,
/// Optional gRPC client for BacktestingService (falls back to mock when None)
backtesting_client: Option<Mutex<BacktestingServiceClient<Channel>>>,
}
impl ValidationPipeline {
/// Create a new validation pipeline
pub fn new(config: ValidationConfig) -> Result<Self> {
info!("Initializing validation pipeline with config: {:?}", config);
// Validate configuration
if config.min_sharpe_ratio <= 0.0 {
return Err(anyhow::anyhow!(
"min_sharpe_ratio must be positive, got {}",
config.min_sharpe_ratio
));
}
if config.min_win_rate < 0.0 || config.min_win_rate > 1.0 {
return Err(anyhow::anyhow!(
"min_win_rate must be between 0.0 and 1.0, got {}",
config.min_win_rate
));
}
if config.max_drawdown < 0.0 || config.max_drawdown > 1.0 {
return Err(anyhow::anyhow!(
"max_drawdown must be between 0.0 and 1.0, got {}",
config.max_drawdown
));
}
Ok(Self {
config,
backtesting_client: None,
})
}
/// Create a validation pipeline with a BacktestingService gRPC client
pub fn with_backtesting_client(mut self, client: BacktestingServiceClient<Channel>) -> Self {
self.backtesting_client = Some(Mutex::new(client));
self
}
/// Get validation configuration
pub fn get_config(&self) -> &ValidationConfig {
&self.config
}
/// Trigger validation on training completion
///
/// This is the main entry point that triggers after training completes
pub async fn validate_on_completion(
&self,
training_job: &TrainingJob,
) -> Result<ValidationResult> {
let validation_id = Uuid::new_v4().to_string();
info!(
"🔍 Validation triggered for job {} (validation_id: {})",
training_job.id, validation_id
);
// Step 1: Load holdout dataset
let holdout_data_path = self.resolve_holdout_data_path(training_job)?;
info!("📊 Loading holdout dataset from: {}", holdout_data_path);
let _holdout_data = match self.load_holdout_dataset().await {
Ok(data) => {
info!("✅ Loaded {} holdout bars", data.len());
data
},
Err(e) => {
error!("❌ Failed to load holdout dataset: {}", e);
return Ok(ValidationResult {
validation_id,
job_id: training_job.id,
status: ValidationStatus::Error,
metrics: None,
promotion_decision: None,
validated_at: Utc::now(),
error_message: Some(format!("Holdout data loading failed: {}", e)),
});
},
};
// Step 2: Run backtest on holdout data
info!(
"🔄 Running backtest on {} days of holdout data",
self.config.backtest_duration_days
);
let backtest_result = match self.run_backtest(training_job, &holdout_data_path).await {
Ok(result) => {
info!(
"✅ Backtest completed: Sharpe={:.2}, Win Rate={:.2}%",
result.sharpe_ratio,
result.win_rate * 100.0
);
result
},
Err(e) => {
error!("❌ Backtest failed: {}", e);
return Ok(ValidationResult {
validation_id,
job_id: training_job.id,
status: ValidationStatus::Error,
metrics: None,
promotion_decision: None,
validated_at: Utc::now(),
error_message: Some(format!("Backtest failed: {}", e)),
});
},
};
// Step 3: Make promotion decision
let decision = self.make_promotion_decision(&backtest_result).await?;
// Step 4: Determine validation status
let status = if decision.decision == PromotionDecision::Promote {
ValidationStatus::Passed
} else {
ValidationStatus::Failed
};
info!("🎯 Validation complete: {:?} - {}", status, decision.reason);
Ok(ValidationResult {
validation_id,
job_id: training_job.id,
status,
metrics: Some(backtest_result),
promotion_decision: Some(decision),
validated_at: Utc::now(),
error_message: None,
})
}
/// Load holdout dataset (out-of-sample data)
pub async fn load_holdout_dataset(&self) -> Result<Vec<OhlcvBar>> {
debug!(
"Loading holdout dataset from: {}",
self.config.holdout_data_path
);
// Check if path is a directory or file
let path = Path::new(&self.config.holdout_data_path);
if !path.exists() {
return Err(anyhow::anyhow!(
"Holdout data path does not exist: {}",
self.config.holdout_data_path
));
}
if path.is_file() {
// Single DBN file
self.load_dbn_file(&self.config.holdout_data_path).await
} else if path.is_dir() {
// Directory of DBN files - load first available file
let dbn_files = std::fs::read_dir(path)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == "dbn")
.unwrap_or(false)
})
.collect::<Vec<_>>();
if dbn_files.is_empty() {
return Err(anyhow::anyhow!(
"No DBN files found in directory: {}",
self.config.holdout_data_path
));
}
// Load first DBN file
let first_file = dbn_files.first().ok_or_else(|| anyhow::anyhow!("No DBN files found"))?.path();
info!("Loading holdout data from first DBN file: {:?}", first_file);
let path_str = first_file.to_str().ok_or_else(|| {
anyhow::anyhow!("Path contains non-UTF-8 characters: {:?}", first_file)
})?;
self.load_dbn_file(path_str).await
} else {
Err(anyhow::anyhow!(
"Invalid holdout data path (not a file or directory): {}",
self.config.holdout_data_path
))
}
}
/// Load DBN file
async fn load_dbn_file(&self, file_path: &str) -> Result<Vec<OhlcvBar>> {
use dbn::decode::{DbnDecoder, DecodeRecordRef};
use dbn::{OhlcvMsg, VersionUpgradePolicy};
info!("Loading DBN file: {}", file_path);
// Create decoder for DBN file
let mut decoder =
DbnDecoder::from_file(file_path).context("Failed to create DBN decoder")?;
decoder
.set_upgrade_policy(VersionUpgradePolicy::UpgradeToV2)
.context("Failed to set upgrade policy")?;
// Collect OHLCV bars
let mut bars = Vec::new();
while let Some(record_ref) = decoder
.decode_record_ref()
.context("Failed to decode DBN record")?
{
if let Some(ohlcv_msg) = record_ref.get::<OhlcvMsg>() {
bars.push(OhlcvBar {
timestamp: ohlcv_msg.hd.ts_event as i64,
open: dbn_price_to_f64(ohlcv_msg.open),
high: dbn_price_to_f64(ohlcv_msg.high),
low: dbn_price_to_f64(ohlcv_msg.low),
close: dbn_price_to_f64(ohlcv_msg.close),
volume: ohlcv_msg.volume.try_into().unwrap_or(0),
});
}
}
if bars.is_empty() {
return Err(anyhow::anyhow!("DBN file contains no data: {}", file_path));
}
info!("Loaded {} OHLCV bars from {}", bars.len(), file_path);
Ok(bars)
}
/// Run backtest using BacktestingService gRPC (falls back to mock when unavailable)
pub async fn run_backtest(
&self,
training_job: &TrainingJob,
data_path: &str,
) -> Result<ValidationMetrics> {
info!(
"Running backtest for validation (duration: {} days)",
self.config.backtest_duration_days
);
let metrics = match &self.backtesting_client {
Some(client_mutex) => {
match self
.run_grpc_backtest(client_mutex, training_job, data_path)
.await
{
Ok(m) => m,
Err(e) => {
warn!(
"BacktestingService gRPC call failed, falling back to mock: {}",
e
);
self.generate_mock_backtest_results().await
}
}
}
None => {
info!("BacktestingService not configured, using mock backtest results");
self.generate_mock_backtest_results().await
}
};
info!(
"Backtest completed: Sharpe={:.2}, Win Rate={:.2}%, Drawdown={:.2}%",
metrics.sharpe_ratio,
metrics.win_rate * 100.0,
metrics.max_drawdown * 100.0
);
Ok(metrics)
}
/// Execute backtest via gRPC BacktestingService
async fn run_grpc_backtest(
&self,
client_mutex: &Mutex<BacktestingServiceClient<Channel>>,
training_job: &TrainingJob,
data_path: &str,
) -> Result<ValidationMetrics> {
let mut client = client_mutex.lock().await;
// Calculate time range from backtest_duration_days
let now = Utc::now();
let start = now - chrono::Duration::days(i64::from(self.config.backtest_duration_days));
let mut parameters = HashMap::new();
parameters.insert("model_type".to_string(), training_job.model_type.clone());
parameters.insert("data_path".to_string(), data_path.to_string());
let request = tonic::Request::new(StartBacktestRequest {
strategy_name: format!("ml_validation_{}", training_job.model_type),
symbols: vec!["ES.FUT".to_string()],
start_date_unix_nanos: start.timestamp_nanos_opt().unwrap_or(0),
end_date_unix_nanos: now.timestamp_nanos_opt().unwrap_or(0),
initial_capital: 100_000.0,
parameters,
save_results: false,
description: format!(
"Validation backtest for training job {}",
training_job.id
),
});
let start_resp = client
.start_backtest(request)
.await
.context("StartBacktest gRPC call failed")?
.into_inner();
if !start_resp.success {
return Err(anyhow::anyhow!(
"BacktestingService rejected request: {}",
start_resp.message
));
}
let backtest_id = start_resp.backtest_id;
info!("Backtest started with id: {}", backtest_id);
// Poll for completion (up to 5 minutes)
let timeout_at = tokio::time::Instant::now() + tokio::time::Duration::from_secs(300);
loop {
if tokio::time::Instant::now() >= timeout_at {
return Err(anyhow::anyhow!(
"Backtest {} timed out after 5 minutes",
backtest_id
));
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let status_resp = client
.get_backtest_status(tonic::Request::new(GetBacktestStatusRequest {
backtest_id: backtest_id.clone(),
}))
.await
.context("GetBacktestStatus gRPC call failed")?
.into_inner();
// BacktestStatus: 3 = COMPLETED, 4 = FAILED
match status_resp.status {
3 => {
info!("Backtest {} completed", backtest_id);
break;
}
4 => {
return Err(anyhow::anyhow!(
"Backtest {} failed: {}",
backtest_id,
status_resp.error_message.unwrap_or_default()
));
}
_ => {
debug!(
"Backtest {} progress: {:.1}%",
backtest_id, status_resp.progress_percentage
);
}
}
}
// Fetch results
let results_resp = client
.get_backtest_results(tonic::Request::new(GetBacktestResultsRequest {
backtest_id: backtest_id.clone(),
include_trades: false,
include_metrics: true,
}))
.await
.context("GetBacktestResults gRPC call failed")?
.into_inner();
let proto_metrics = results_resp
.metrics
.ok_or_else(|| anyhow::anyhow!("BacktestingService returned no metrics"))?;
Ok(ValidationMetrics {
sharpe_ratio: proto_metrics.sharpe_ratio,
win_rate: proto_metrics.win_rate,
max_drawdown: proto_metrics.max_drawdown,
total_trades: proto_metrics.total_trades,
avg_profit_per_trade: if proto_metrics.total_trades > 0 {
proto_metrics.total_return / proto_metrics.total_trades as f64
} else {
0.0
},
profit_factor: proto_metrics.profit_factor,
total_return: proto_metrics.total_return,
})
}
/// Generate mock backtest results (temporary until BacktestingService integration)
async fn generate_mock_backtest_results(&self) -> ValidationMetrics {
// Realistic validation metrics for a trained model
ValidationMetrics {
sharpe_ratio: 1.8, // Good risk-adjusted returns
win_rate: 0.56, // 56% win rate
max_drawdown: 0.12, // 12% max drawdown
total_trades: 187,
avg_profit_per_trade: 0.0085,
profit_factor: 2.2,
total_return: 0.38,
}
}
/// Calculate validation metrics from trade results
pub async fn calculate_metrics(&self, trades: &[(f64, f64)]) -> Result<ValidationMetrics> {
if trades.is_empty() {
return Err(anyhow::anyhow!("No trades to calculate metrics"));
}
let total_trades = trades.len() as u64;
// Calculate returns
let mut returns = Vec::new();
let mut winning_trades = 0;
let mut total_profit = 0.0;
let mut total_loss = 0.0;
let mut cumulative_returns = vec![0.0];
for (entry_price, exit_price) in trades {
let trade_return = (exit_price - entry_price) / entry_price;
returns.push(trade_return);
if trade_return > 0.0 {
winning_trades += 1;
total_profit += trade_return;
} else {
total_loss += trade_return.abs();
}
let cum_ret = cumulative_returns.last().copied().unwrap_or(0.0) + trade_return;
cumulative_returns.push(cum_ret);
}
// Calculate win rate
let win_rate = winning_trades as f64 / total_trades as f64;
// Calculate Sharpe ratio (annualized)
let mean_return = returns.iter().sum::<f64>() / returns.len() as f64;
let variance = returns
.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f64>()
/ returns.len() as f64;
let std_dev = variance.sqrt();
let sharpe_ratio = if std_dev > 0.0 {
(mean_return / std_dev) * (252.0_f64.sqrt()) // Annualized
} else {
0.0
};
// Calculate max drawdown
let mut peak = 0.0;
let mut max_drawdown = 0.0;
for cum_ret in &cumulative_returns {
if *cum_ret > peak {
peak = *cum_ret;
}
let drawdown = (peak - cum_ret) / (1.0 + peak);
if drawdown > max_drawdown {
max_drawdown = drawdown;
}
}
// Calculate profit factor
let profit_factor = if total_loss > 0.0 {
total_profit / total_loss
} else {
total_profit
};
// Average profit per trade
let avg_profit_per_trade = returns.iter().sum::<f64>() / total_trades as f64;
// Total return
let total_return = cumulative_returns.last().copied().unwrap_or(0.0);
Ok(ValidationMetrics {
sharpe_ratio,
win_rate,
max_drawdown,
total_trades,
avg_profit_per_trade,
profit_factor,
total_return,
})
}
/// Make promotion decision based on validation metrics
pub async fn make_promotion_decision(
&self,
metrics: &ValidationMetrics,
) -> Result<PromotionDecisionResult> {
info!("Making promotion decision...");
debug!(
"Metrics: Sharpe={:.2}, Win Rate={:.2}%, Drawdown={:.2}%",
metrics.sharpe_ratio,
metrics.win_rate * 100.0,
metrics.max_drawdown * 100.0
);
debug!(
"Thresholds: Sharpe>={:.2}, Win Rate>={:.2}%, Drawdown<={:.2}%",
self.config.min_sharpe_ratio,
self.config.min_win_rate * 100.0,
self.config.max_drawdown * 100.0
);
let mut failures = Vec::new();
// Check Sharpe ratio
if metrics.sharpe_ratio < self.config.min_sharpe_ratio {
failures.push(format!(
"Sharpe ratio {:.2} below threshold {:.2}",
metrics.sharpe_ratio, self.config.min_sharpe_ratio
));
}
// Check win rate
if metrics.win_rate < self.config.min_win_rate {
failures.push(format!(
"Win rate {:.2}% below threshold {:.2}%",
metrics.win_rate * 100.0,
self.config.min_win_rate * 100.0
));
}
// Check max drawdown
if metrics.max_drawdown > self.config.max_drawdown {
failures.push(format!(
"Max drawdown {:.2}% exceeds threshold {:.2}%",
metrics.max_drawdown * 100.0,
self.config.max_drawdown * 100.0
));
}
let (decision, reason) = if failures.is_empty() {
// All checks passed
(
PromotionDecision::Promote,
format!(
"✅ PASS: All validation thresholds met (Sharpe={:.2}, Win Rate={:.2}%, Drawdown={:.2}%)",
metrics.sharpe_ratio,
metrics.win_rate * 100.0,
metrics.max_drawdown * 100.0
),
)
} else {
// One or more checks failed
(
PromotionDecision::Reject,
format!("❌ FAIL: Validation failed - {}", failures.join(", ")),
)
};
info!("Decision: {:?} - {}", decision, reason);
Ok(PromotionDecisionResult {
decision,
reason,
decided_at: Utc::now(),
})
}
/// Resolve holdout data path for a training job
fn resolve_holdout_data_path(&self, _training_job: &TrainingJob) -> Result<String> {
// For now, use configured path
// In production, this could be based on training job metadata
Ok(self.config.holdout_data_path.clone())
}
}
/// Convert DBN fixed-point price to f64
/// DBN stores prices as i64 with 9 decimal places precision
fn dbn_price_to_f64(price: i64) -> f64 {
price as f64 / 1_000_000_000.0
}
/// Simple OHLCV bar structure (matches DBN loader output)
#[derive(Debug, Clone)]
pub struct OhlcvBar {
pub timestamp: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: i64,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_validation_config_default() {
let config = ValidationConfig::default();
assert_eq!(config.backtest_duration_days, 30);
assert_eq!(config.min_sharpe_ratio, 1.5);
assert_eq!(config.min_win_rate, 0.52);
assert_eq!(config.max_drawdown, 0.15);
assert!(config.enable_promotion);
}
#[test]
fn test_validation_config_validation() {
// Invalid Sharpe ratio
let config = ValidationConfig {
min_sharpe_ratio: -1.0,
..Default::default()
};
assert!(ValidationPipeline::new(config).is_err());
// Invalid win rate
let config = ValidationConfig {
min_win_rate: 1.5,
..Default::default()
};
assert!(ValidationPipeline::new(config).is_err());
// Invalid drawdown
let config = ValidationConfig {
max_drawdown: -0.1,
..Default::default()
};
assert!(ValidationPipeline::new(config).is_err());
}
#[tokio::test]
async fn test_metrics_calculation_winning_trades() {
let config = ValidationConfig::default();
let pipeline = ValidationPipeline::new(config).unwrap();
let trades = vec![
(100.0, 102.0), // +2% win
(102.0, 104.0), // +2% win
(104.0, 106.0), // +2% win
];
let metrics = pipeline.calculate_metrics(&trades).await.unwrap();
assert_eq!(metrics.total_trades, 3);
assert_eq!(metrics.win_rate, 1.0); // 100% win rate
assert!(metrics.sharpe_ratio > 0.0);
}
#[tokio::test]
async fn test_metrics_calculation_mixed_trades() {
let config = ValidationConfig::default();
let pipeline = ValidationPipeline::new(config).unwrap();
let trades = vec![
(100.0, 102.0), // +2% win
(102.0, 101.0), // -1% loss
(101.0, 103.0), // +2% win
];
let metrics = pipeline.calculate_metrics(&trades).await.unwrap();
assert_eq!(metrics.total_trades, 3);
assert!(metrics.win_rate > 0.5 && metrics.win_rate < 1.0);
}
#[tokio::test]
async fn test_promotion_decision_all_pass() {
let config = ValidationConfig {
min_sharpe_ratio: 1.0,
min_win_rate: 0.50,
max_drawdown: 0.20,
..Default::default()
};
let pipeline = ValidationPipeline::new(config).unwrap();
let metrics = ValidationMetrics {
sharpe_ratio: 1.5,
win_rate: 0.60,
max_drawdown: 0.10,
total_trades: 100,
avg_profit_per_trade: 0.01,
profit_factor: 2.0,
total_return: 0.30,
};
let decision = pipeline.make_promotion_decision(&metrics).await.unwrap();
assert_eq!(decision.decision, PromotionDecision::Promote);
}
#[tokio::test]
async fn test_promotion_decision_low_sharpe() {
let config = ValidationConfig::default();
let pipeline = ValidationPipeline::new(config).unwrap();
let metrics = ValidationMetrics {
sharpe_ratio: 0.8, // Below threshold
win_rate: 0.60,
max_drawdown: 0.10,
total_trades: 100,
avg_profit_per_trade: 0.01,
profit_factor: 2.0,
total_return: 0.30,
};
let decision = pipeline.make_promotion_decision(&metrics).await.unwrap();
assert_eq!(decision.decision, PromotionDecision::Reject);
assert!(decision.reason.contains("Sharpe"));
}
}