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>
839 lines
27 KiB
Rust
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"));
|
|
}
|
|
}
|