Files
foxhunt/testing/e2e/src/ml_pipeline.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

667 lines
21 KiB
Rust

//! ML Pipeline Testing Harness
//!
//! Provides comprehensive testing capabilities for ML models including
//! inference testing, feature extraction, ensemble predictions, and
//! model performance monitoring.
use anyhow::Result;
use common::types::MarketTick;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
/// ML model status information
#[derive(Debug, Clone)]
pub struct MLModelStatus {
pub mamba_available: bool,
pub dqn_available: bool,
pub ppo_available: bool,
pub tft_available: bool,
pub tlob_available: bool,
pub ensemble_available: bool,
}
impl MLModelStatus {
/// Check if any models are available
pub fn any_available(&self) -> bool {
self.mamba_available
|| self.dqn_available
|| self.ppo_available
|| self.tft_available
|| self.tlob_available
}
/// Get count of available models
pub fn available_count(&self) -> usize {
let mut count = 0;
if self.mamba_available {
count += 1;
}
if self.dqn_available {
count += 1;
}
if self.ppo_available {
count += 1;
}
if self.tft_available {
count += 1;
}
if self.tlob_available {
count += 1;
}
count
}
}
/// ML prediction result
#[derive(Debug, Clone)]
pub struct MLPrediction {
pub signal: f64, // Trading signal (-1.0 to 1.0)
pub confidence: f64, // Confidence (0.0 to 1.0)
pub model_name: String, // Name of the model that made the prediction
pub inference_time: Duration, // Time taken for inference
}
/// Ensemble prediction result
#[derive(Debug, Clone)]
pub struct EnsemblePrediction {
pub signal: f64, // Aggregated trading signal
pub confidence: f64, // Aggregated confidence
pub individual_predictions: Vec<MLPrediction>,
pub ensemble_method: String, // Method used for aggregation
pub total_inference_time: Duration,
pub prediction: PredictionType, // Discrete prediction type
pub signal_strength: f64, // Absolute signal strength
}
// Define PredictionType locally since tli crate is not available in e2e tests
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PredictionType {
Buy,
Sell,
Hold,
StrongBuy,
StrongSell,
}
impl std::fmt::Display for PredictionType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PredictionType::Buy => write!(f, "BUY"),
PredictionType::Sell => write!(f, "SELL"),
PredictionType::Hold => write!(f, "HOLD"),
PredictionType::StrongBuy => write!(f, "STRONG_BUY"),
PredictionType::StrongSell => write!(f, "STRONG_SELL"),
}
}
}
/// Model performance metrics
#[derive(Debug, Clone)]
pub struct ModelMetrics {
pub inference_count: u64,
pub avg_latency_ms: f64,
pub error_rate: f64,
pub last_prediction_time: chrono::DateTime<chrono::Utc>,
}
/// Feature vector for ML models
#[derive(Debug, Clone)]
pub struct FeatureVector {
pub features: Vec<f64>,
pub feature_names: Vec<String>,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub symbol: String,
}
/// ML Pipeline Testing Harness
#[derive(Debug)]
pub struct MLPipelineTestHarness {
model_status: MLModelStatus,
model_metrics: HashMap<String, ModelMetrics>,
feature_cache: HashMap<String, Vec<FeatureVector>>,
mock_mode: bool,
}
impl MLPipelineTestHarness {
/// Create a new ML pipeline test harness
pub async fn new() -> Result<Self> {
info!("🤖 Initializing ML pipeline test harness");
// Check if we're in mock mode (for CI/testing environments without GPU)
let mock_mode = std::env::var("ML_MOCK_MODE").unwrap_or_default() == "true";
if mock_mode {
info!("🎭 Running in mock mode - ML predictions will be simulated");
}
let model_status = Self::check_model_availability().await?;
info!("ML Models Status:");
info!(
" MAMBA: {}",
if model_status.mamba_available {
""
} else {
""
}
);
info!(
" DQN: {}",
if model_status.dqn_available {
""
} else {
""
}
);
info!(
" PPO: {}",
if model_status.ppo_available {
""
} else {
""
}
);
info!(
" TFT: {}",
if model_status.tft_available {
""
} else {
""
}
);
info!(
" TLOB: {}",
if model_status.tlob_available {
""
} else {
""
}
);
Ok(Self {
model_status,
model_metrics: HashMap::new(),
feature_cache: HashMap::new(),
mock_mode,
})
}
/// Check model health status
pub async fn check_models_health(&self) -> Result<MLModelStatus> {
Ok(self.model_status.clone())
}
/// Extract features from market data
pub async fn extract_features(
&mut self,
market_data: &[MarketTick],
) -> Result<Vec<FeatureVector>> {
debug!(
"🔧 Extracting features from {} market ticks",
market_data.len()
);
if market_data.is_empty() {
return Ok(Vec::new());
}
let mut features = Vec::new();
// Group by symbol for feature extraction
let mut symbol_data: HashMap<String, Vec<&MarketTick>> = HashMap::new();
for tick in market_data {
symbol_data
.entry(tick.symbol.to_string())
.or_default()
.push(tick);
}
for (symbol, ticks) in symbol_data {
let feature_vector = self.extract_features_for_symbol(&symbol, ticks).await?;
features.push(feature_vector);
}
// Cache features for later use
for feature in &features {
self.feature_cache
.entry(feature.symbol.clone())
.or_default()
.push(feature.clone());
}
debug!("✅ Extracted {} feature vectors", features.len());
Ok(features)
}
/// Extract features for a specific symbol
async fn extract_features_for_symbol(
&self,
symbol: &str,
ticks: Vec<&MarketTick>,
) -> Result<FeatureVector> {
if ticks.is_empty() {
return Err(anyhow::anyhow!("No ticks provided for feature extraction"));
}
// Sort by timestamp
let mut sorted_ticks = ticks;
sorted_ticks.sort_by_key(|t| t.timestamp);
let prices: Vec<f64> = sorted_ticks.iter().map(|t| t.price.as_f64()).collect();
let volumes: Vec<f64> = sorted_ticks.iter().map(|t| t.size.as_f64()).collect();
// Calculate technical indicators
let mut features = Vec::new();
let mut feature_names = Vec::new();
// Price-based features
if !prices.is_empty() {
let current_price = prices[prices.len() - 1];
let avg_price = prices.iter().sum::<f64>() / prices.len() as f64;
let price_std = Self::calculate_std(&prices);
features.push(current_price);
features.push(avg_price);
features.push(price_std);
features.push(current_price / avg_price - 1.0); // Price relative to average
feature_names.extend([
"current_price".to_string(),
"avg_price".to_string(),
"price_std".to_string(),
"price_rel_avg".to_string(),
]);
}
// Volume-based features
if !volumes.is_empty() {
let current_volume = volumes[volumes.len() - 1];
let avg_volume = volumes.iter().sum::<f64>() / volumes.len() as f64;
features.push(current_volume);
features.push(avg_volume);
features.push(current_volume / avg_volume.max(1.0)); // Volume relative to average
feature_names.extend([
"current_volume".to_string(),
"avg_volume".to_string(),
"volume_rel_avg".to_string(),
]);
}
// Returns-based features
if prices.len() >= 2 {
let returns: Vec<f64> = prices.windows(2).map(|w| (w[1] / w[0]) - 1.0).collect();
if !returns.is_empty() {
let last_return = returns[returns.len() - 1];
let avg_return = returns.iter().sum::<f64>() / returns.len() as f64;
let return_std = Self::calculate_std(&returns);
features.push(last_return);
features.push(avg_return);
features.push(return_std);
feature_names.extend([
"last_return".to_string(),
"avg_return".to_string(),
"return_std".to_string(),
]);
}
}
// Trend features
if prices.len() >= 5 {
let short_ma = prices[prices.len() - 5..].iter().sum::<f64>() / 5.0;
let long_ma = prices.iter().sum::<f64>() / prices.len() as f64;
let trend_strength = (short_ma / long_ma) - 1.0;
features.push(trend_strength);
feature_names.push("trend_strength".to_string());
}
Ok(FeatureVector {
features,
feature_names,
timestamp: chrono::Utc::now(),
symbol: symbol.to_string(),
})
}
/// Predict with MAMBA model
pub async fn predict_with_mamba(&mut self, features: &[FeatureVector]) -> Result<MLPrediction> {
self.predict_with_model("mamba", features).await
}
/// Predict with DQN model
pub async fn predict_with_dqn(&mut self, features: &[FeatureVector]) -> Result<MLPrediction> {
self.predict_with_model("dqn", features).await
}
/// Predict with TFT model
pub async fn predict_with_tft(&mut self, features: &[FeatureVector]) -> Result<MLPrediction> {
self.predict_with_model("tft", features).await
}
/// Predict with TLOB model
pub async fn predict_with_tlob(&mut self, features: &[FeatureVector]) -> Result<MLPrediction> {
self.predict_with_model("tlob", features).await
}
/// Generic model prediction
async fn predict_with_model(
&mut self,
model_name: &str,
features: &[FeatureVector],
) -> Result<MLPrediction> {
let start_time = Instant::now();
if features.is_empty() {
return Err(anyhow::anyhow!("No features provided for prediction"));
}
let prediction = if self.mock_mode {
self.mock_prediction(model_name, features).await?
} else {
self.real_prediction(model_name, features).await?
};
let inference_time = start_time.elapsed();
// Update model metrics
self.update_model_metrics(model_name, inference_time, true);
Ok(MLPrediction {
signal: prediction.0,
confidence: prediction.1,
model_name: model_name.to_string(),
inference_time,
})
}
/// Mock prediction for testing
async fn mock_prediction(
&self,
model_name: &str,
features: &[FeatureVector],
) -> Result<(f64, f64)> {
use rand::Rng;
let mut rng = rand::thread_rng();
// Simulate some processing time
tokio::time::sleep(Duration::from_millis(rng.gen_range(10..50))).await;
// Generate reasonable mock predictions based on features
let feature_sum: f64 = features.iter().flat_map(|f| &f.features).sum();
let normalized_sum = (feature_sum / 1000.0).tanh(); // Normalize to [-1, 1]
let signal = match model_name {
"mamba" => normalized_sum * 0.8 + rng.gen_range(-0.1..0.1),
"dqn" => normalized_sum * 0.6 + rng.gen_range(-0.2..0.2),
"tft" => normalized_sum * 0.9 + rng.gen_range(-0.05..0.05),
"tlob" => normalized_sum * 0.7 + rng.gen_range(-0.15..0.15),
_ => rng.gen_range(-0.5..0.5),
};
let confidence = rng.gen_range(0.6..0.95);
Ok((signal.clamp(-1.0, 1.0), confidence))
}
/// Real prediction (would integrate with actual ML models)
async fn real_prediction(
&self,
model_name: &str,
_features: &[FeatureVector],
) -> Result<(f64, f64)> {
// This would integrate with the actual ML models in the ml crate
warn!(
"Real ML prediction not implemented for {}, using mock",
model_name
);
// For now, fall back to mock prediction
self.mock_prediction(model_name, _features).await
}
/// Test ensemble prediction with raw feature array
pub async fn test_ensemble_prediction(
&mut self,
raw_features: Vec<f64>,
) -> Result<EnsemblePrediction> {
// Convert raw features to FeatureVector format
let feature_count = raw_features.len();
let feature_vector = FeatureVector {
features: raw_features,
feature_names: (0..feature_count)
.map(|i| format!("feature_{}", i))
.collect(),
timestamp: chrono::Utc::now(),
symbol: "TEST".to_string(),
};
self.predict_ensemble(&[feature_vector]).await
}
/// Ensemble prediction aggregating multiple models
pub async fn predict_ensemble(
&mut self,
features: &[FeatureVector],
) -> Result<EnsemblePrediction> {
let start_time = Instant::now();
let mut predictions = Vec::new();
// Get predictions from available models
if self.model_status.mamba_available {
match self.predict_with_mamba(features).await {
Ok(pred) => predictions.push(pred),
Err(e) => warn!("MAMBA prediction failed: {}", e),
}
}
if self.model_status.dqn_available {
match self.predict_with_dqn(features).await {
Ok(pred) => predictions.push(pred),
Err(e) => warn!("DQN prediction failed: {}", e),
}
}
if self.model_status.tft_available {
match self.predict_with_tft(features).await {
Ok(pred) => predictions.push(pred),
Err(e) => warn!("TFT prediction failed: {}", e),
}
}
if self.model_status.tlob_available {
match self.predict_with_tlob(features).await {
Ok(pred) => predictions.push(pred),
Err(e) => warn!("TLOB prediction failed: {}", e),
}
}
if predictions.is_empty() {
return Err(anyhow::anyhow!(
"No models available for ensemble prediction"
));
}
// Aggregate predictions using weighted average
let total_weight: f64 = predictions.iter().map(|p| p.confidence).sum();
let weighted_signal: f64 = predictions
.iter()
.map(|p| p.signal * p.confidence)
.sum::<f64>()
/ total_weight;
let avg_confidence: f64 =
predictions.iter().map(|p| p.confidence).sum::<f64>() / predictions.len() as f64;
let total_inference_time = start_time.elapsed();
// Convert signal to discrete prediction type
let prediction = if weighted_signal > 0.6 {
PredictionType::StrongBuy
} else if weighted_signal > 0.2 {
PredictionType::Buy
} else if weighted_signal < -0.6 {
PredictionType::StrongSell
} else if weighted_signal < -0.2 {
PredictionType::Sell
} else {
PredictionType::Hold
};
let signal_strength = weighted_signal.abs();
Ok(EnsemblePrediction {
signal: weighted_signal.clamp(-1.0, 1.0),
confidence: avg_confidence.clamp(0.0, 1.0),
individual_predictions: predictions,
ensemble_method: "weighted_average".to_string(),
total_inference_time,
prediction,
signal_strength,
})
}
/// Get model performance metrics
pub async fn get_model_metrics(&self) -> Result<HashMap<String, ModelMetrics>> {
Ok(self.model_metrics.clone())
}
/// Update model metrics
fn update_model_metrics(&mut self, model_name: &str, inference_time: Duration, success: bool) {
let metrics = self
.model_metrics
.entry(model_name.to_string())
.or_insert_with(|| ModelMetrics {
inference_count: 0,
avg_latency_ms: 0.0,
error_rate: 0.0,
last_prediction_time: chrono::Utc::now(),
});
metrics.inference_count += 1;
// Update average latency
let new_latency_ms = inference_time.as_millis() as f64;
if metrics.inference_count == 1 {
metrics.avg_latency_ms = new_latency_ms;
} else {
metrics.avg_latency_ms =
(metrics.avg_latency_ms * (metrics.inference_count - 1) as f64 + new_latency_ms)
/ metrics.inference_count as f64;
}
// Update error rate
if !success {
let error_count = (metrics.error_rate * (metrics.inference_count - 1) as f64) + 1.0;
metrics.error_rate = error_count / metrics.inference_count as f64;
} else {
let error_count = metrics.error_rate * (metrics.inference_count - 1) as f64;
metrics.error_rate = error_count / metrics.inference_count as f64;
}
metrics.last_prediction_time = chrono::Utc::now();
}
/// Disable a model for failover testing
pub async fn disable_model(&mut self, model_name: &str) -> Result<()> {
match model_name {
"mamba" => self.model_status.mamba_available = false,
"dqn" => self.model_status.dqn_available = false,
"ppo" => self.model_status.ppo_available = false,
"tft" => self.model_status.tft_available = false,
"tlob" => self.model_status.tlob_available = false,
_ => return Err(anyhow::anyhow!("Unknown model: {}", model_name)),
}
info!("🚫 Disabled model: {}", model_name);
Ok(())
}
/// Enable a model for failover testing
pub async fn enable_model(&mut self, model_name: &str) -> Result<()> {
match model_name {
"mamba" => self.model_status.mamba_available = true,
"dqn" => self.model_status.dqn_available = true,
"ppo" => self.model_status.ppo_available = true,
"tft" => self.model_status.tft_available = true,
"tlob" => self.model_status.tlob_available = true,
_ => return Err(anyhow::anyhow!("Unknown model: {}", model_name)),
}
info!("✅ Enabled model: {}", model_name);
Ok(())
}
/// Check model availability
async fn check_model_availability() -> Result<MLModelStatus> {
// In a real implementation, this would check for model files,
// GPU availability, etc. For testing, we'll assume models are available.
Ok(MLModelStatus {
mamba_available: true,
dqn_available: true,
ppo_available: true,
tft_available: true,
tlob_available: true,
ensemble_available: true,
})
}
/// Calculate standard deviation
fn calculate_std(values: &[f64]) -> f64 {
if values.len() < 2 {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance =
values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (values.len() - 1) as f64;
variance.sqrt()
}
}
/// Type alias for compatibility with workflow code
pub type MLTestPipeline = MLPipelineTestHarness;
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_ml_harness_creation() {
let harness = MLPipelineTestHarness::new().await;
assert!(harness.is_ok());
let harness = harness.unwrap();
assert!(harness.model_status.any_available());
}
#[test]
fn test_model_status() {
let status = MLModelStatus {
mamba_available: true,
dqn_available: true,
ppo_available: false,
tft_available: false,
tlob_available: false,
ensemble_available: true,
};
assert!(status.any_available());
assert_eq!(status.available_count(), 2);
}
#[test]
fn test_std_calculation() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let std = MLPipelineTestHarness::calculate_std(&values);
assert!((std - 1.581).abs() < 0.01); // Approximately sqrt(2.5)
}
}