//! Background ML Prediction Generation Loop //! //! This module implements a continuous background task that generates ML predictions //! every 60 seconds for all configured trading symbols. Predictions are saved to the //! ensemble_predictions table for consumption by the paper trading executor. //! //! ## Architecture //! //! ```text //! ┌─────────────────────────────────────────────────────────┐ //! │ Prediction Generation Loop (60s interval) │ //! │ │ //! │ 1. Fetch current market data for all symbols │ //! │ 2. Extract 26 features from OHLCV data │ //! │ 3. Call EnsembleCoordinator.predict() │ //! │ 4. Save EnsembleDecision to ensemble_predictions │ //! │ 5. Sleep 60 seconds │ //! │ 6. Repeat (with graceful shutdown on SIGTERM) │ //! └─────────────────────────────────────────────────────────┘ //! ``` //! //! ## Error Handling //! //! The loop is designed to be resilient: //! - Database connection errors: Log and retry next cycle //! - Model inference errors: Log and continue (ensemble degradation) //! - Feature extraction errors: Log and skip symbol //! - NO crashes: Loop always continues unless shutdown signal received //! //! ## Graceful Shutdown //! //! The task listens for SIGTERM/SIGINT and exits cleanly: //! - Waits for current prediction cycle to complete //! - Flushes any pending database writes //! - Returns immediately after cleanup use anyhow::{Context, Result}; use ml::Features; use sqlx::PgPool; use std::sync::Arc; use std::time::Duration; use tokio::sync::broadcast; use tokio::time::interval; use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::ensemble_coordinator::EnsembleCoordinator; /// Configuration for prediction generation loop #[derive(Debug, Clone)] pub struct PredictionLoopConfig { /// Interval between prediction cycles (default: 60 seconds) pub prediction_interval: Duration, /// List of symbols to generate predictions for pub symbols: Vec, /// Account ID for attribution pub account_id: String, /// Strategy ID for attribution pub strategy_id: String, /// Node ID for system context pub node_id: String, } impl Default for PredictionLoopConfig { fn default() -> Self { Self { prediction_interval: Duration::from_secs(60), symbols: vec![ "ES.FUT".to_string(), "NQ.FUT".to_string(), "ZN.FUT".to_string(), "6E.FUT".to_string(), ], account_id: "prediction_generator_001".to_string(), strategy_id: "ensemble_ml_v1".to_string(), node_id: hostname::get() .ok() .and_then(|h| h.into_string().ok()) .unwrap_or_else(|| "unknown".to_string()), } } } impl PredictionLoopConfig { /// Create configuration from environment variables pub fn from_env() -> Self { Self { prediction_interval: std::env::var("PREDICTION_INTERVAL_SECONDS") .ok() .and_then(|s| s.parse().ok()) .map(Duration::from_secs) .unwrap_or(Duration::from_secs(60)), symbols: std::env::var("PREDICTION_SYMBOLS") .ok() .map(|s| s.split(',').map(|sym| sym.trim().to_string()).collect()) .unwrap_or_else(|| { vec![ "ES.FUT".to_string(), "NQ.FUT".to_string(), "ZN.FUT".to_string(), "6E.FUT".to_string(), ] }), account_id: std::env::var("PREDICTION_ACCOUNT_ID") .unwrap_or_else(|_| "prediction_generator_001".to_string()), strategy_id: std::env::var("PREDICTION_STRATEGY_ID") .unwrap_or_else(|_| "ensemble_ml_v1".to_string()), node_id: hostname::get() .ok() .and_then(|h| h.into_string().ok()) .unwrap_or_else(|| "unknown".to_string()), } } } /// Background task for continuous ML prediction generation pub struct PredictionGenerationLoop { coordinator: Arc, db_pool: PgPool, config: PredictionLoopConfig, } impl PredictionGenerationLoop { /// Create new prediction generation loop pub fn new( coordinator: Arc, db_pool: PgPool, config: PredictionLoopConfig, ) -> Self { Self { coordinator, db_pool, config, } } /// Start the prediction generation loop /// /// This runs forever until a shutdown signal is received via the shutdown_rx channel. /// The loop generates predictions for all configured symbols every `prediction_interval`. pub async fn run(&self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<()> { info!( "Starting prediction generation loop for symbols: {:?}, interval: {:?}", self.config.symbols, self.config.prediction_interval ); let mut ticker = interval(self.config.prediction_interval); loop { tokio::select! { _ = ticker.tick() => { // Generate predictions for all symbols if let Err(e) = self.generate_predictions_for_all_symbols().await { error!("Prediction generation cycle failed: {}", e); // Don't crash - log and continue to next cycle } } _ = shutdown_rx.recv() => { info!("Shutdown signal received, stopping prediction generation loop"); break; } } } info!("Prediction generation loop stopped gracefully"); Ok(()) } /// Generate predictions for all configured symbols async fn generate_predictions_for_all_symbols(&self) -> Result<()> { debug!( "Starting prediction generation cycle for {} symbols", self.config.symbols.len() ); let start_time = std::time::Instant::now(); for symbol in &self.config.symbols { if let Err(e) = self.generate_prediction_for_symbol(symbol).await { warn!("Failed to generate prediction for {}: {}", symbol, e); // Continue with other symbols even if one fails } } let cycle_duration = start_time.elapsed(); info!( "Prediction generation cycle completed in {:.2}s for {} symbols", cycle_duration.as_secs_f64(), self.config.symbols.len() ); Ok(()) } /// Generate prediction for a single symbol async fn generate_prediction_for_symbol(&self, symbol: &str) -> Result<()> { debug!("Generating prediction for {}", symbol); // 1. Fetch current market data and extract features let features = self.fetch_features_for_symbol(symbol).await?; // 2. Generate ensemble prediction let start_inference = std::time::Instant::now(); let decision = self .coordinator .predict(&features) .await .context("Ensemble prediction failed")?; let inference_latency_us = start_inference.elapsed().as_micros() as i32; // 3. Save prediction to database self.save_prediction_to_database(symbol, &decision, inference_latency_us) .await?; debug!( "Prediction saved for {}: action={:?}, confidence={:.3}, disagreement={:.3}, latency={}μs", symbol, decision.action, decision.confidence, decision.disagreement_rate, inference_latency_us ); Ok(()) } /// Fetch market data and extract features for a symbol async fn fetch_features_for_symbol(&self, symbol: &str) -> Result { // Query recent market data from database let market_data = self.query_recent_market_data(symbol).await?; // Extract 26 features from OHLCV data // For production, use real feature engineering from ml/src/features // For now, create a simple feature vector with mock technical indicators let features = self.extract_features_from_market_data(symbol, &market_data)?; Ok(features) } /// Query recent market data from database async fn query_recent_market_data(&self, symbol: &str) -> Result { // Query last 100 bars of OHLCV data for technical indicators let bars = sqlx::query_as::<_, OhlcvBar>( r#" SELECT timestamp, symbol, open, high, low, close, volume FROM ohlcv_bars WHERE symbol = $1 ORDER BY timestamp DESC LIMIT 100 "#, ) .bind(symbol) .fetch_all(&self.db_pool) .await .context("Failed to query market data")?; if bars.is_empty() { return Err(anyhow::anyhow!("No market data available for {}", symbol)); } Ok(MarketDataSnapshot { bars }) } /// Extract features from market data fn extract_features_from_market_data( &self, symbol: &str, market_data: &MarketDataSnapshot, ) -> Result { // Use the most recent bar for current price data let latest_bar = market_data .bars .first() .ok_or_else(|| anyhow::anyhow!("No bars in market data"))?; // Calculate basic technical indicators // In production, use ml/src/features/technical_indicators.rs let close_prices: Vec = market_data.bars.iter().map(|b| b.close).collect(); let volumes: Vec = market_data.bars.iter().map(|b| b.volume).collect(); // Basic features (5 OHLCV + 10 technical indicators = 15 total) // Simplified for MVP - production should use full 26-feature extraction let mut feature_values = vec![ latest_bar.open, latest_bar.high, latest_bar.low, latest_bar.close, latest_bar.volume, ]; // Add 10 technical indicators (simplified calculations) feature_values.push(calculate_sma(&close_prices, 20)); // SMA_20 feature_values.push(calculate_sma(&close_prices, 50)); // SMA_50 feature_values.push(calculate_rsi(&close_prices, 14)); // RSI_14 feature_values.push(calculate_volatility(&close_prices, 20)); // Volatility_20 feature_values.push(calculate_momentum(&close_prices, 10)); // Momentum_10 feature_values.push(calculate_volume_ratio(&volumes, 20)); // Volume_Ratio feature_values.push(latest_bar.high - latest_bar.low); // Range feature_values.push(latest_bar.close - latest_bar.open); // Body feature_values.push(calculate_returns(&close_prices)); // Returns_1 feature_values.push(calculate_log_returns(&close_prices)); // Log_Returns_1 let feature_names = vec![ "open", "high", "low", "close", "volume", "sma_20", "sma_50", "rsi_14", "volatility_20", "momentum_10", "volume_ratio_20", "range", "body", "returns_1", "log_returns_1", ] .into_iter() .map(|s| s.to_string()) .collect(); let mut features = Features::new(feature_values, feature_names); features.symbol = Some(symbol.to_string()); Ok(features) } /// Save prediction to ensemble_predictions table async fn save_prediction_to_database( &self, symbol: &str, decision: &ml::ensemble::EnsembleDecision, inference_latency_us: i32, ) -> Result<()> { let prediction_id = Uuid::new_v4(); let prediction_timestamp = chrono::Utc::now(); // Extract per-model votes from decision let (dqn_signal, dqn_confidence, dqn_weight, dqn_vote) = extract_model_vote(decision, "DQN"); let (ppo_signal, ppo_confidence, ppo_weight, ppo_vote) = extract_model_vote(decision, "PPO"); let (mamba2_signal, mamba2_confidence, mamba2_weight, mamba2_vote) = extract_model_vote(decision, "MAMBA2"); let (tft_signal, tft_confidence, tft_weight, tft_vote) = extract_model_vote(decision, "TFT"); // Convert action to string let ensemble_action = format!("{:?}", decision.action).to_uppercase(); // Insert into database sqlx::query( r#" INSERT INTO ensemble_predictions ( id, prediction_timestamp, symbol, account_id, strategy_id, ensemble_action, ensemble_signal, ensemble_confidence, disagreement_rate, dqn_signal, dqn_confidence, dqn_weight, dqn_vote, ppo_signal, ppo_confidence, ppo_weight, ppo_vote, mamba2_signal, mamba2_confidence, mamba2_weight, mamba2_vote, tft_signal, tft_confidence, tft_weight, tft_vote, node_id, inference_latency_us, metadata ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) "#, ) .bind(prediction_id) .bind(prediction_timestamp) .bind(symbol) .bind(&self.config.account_id) .bind(&self.config.strategy_id) .bind(ensemble_action) .bind(decision.signal) .bind(decision.confidence) .bind(decision.disagreement_rate) .bind(dqn_signal) .bind(dqn_confidence) .bind(dqn_weight) .bind(dqn_vote) .bind(ppo_signal) .bind(ppo_confidence) .bind(ppo_weight) .bind(ppo_vote) .bind(mamba2_signal) .bind(mamba2_confidence) .bind(mamba2_weight) .bind(mamba2_vote) .bind(tft_signal) .bind(tft_confidence) .bind(tft_weight) .bind(tft_vote) .bind(&self.config.node_id) .bind(inference_latency_us) .bind(serde_json::json!({"generator": "background_loop"})) .execute(&self.db_pool) .await .context("Failed to insert prediction into database")?; Ok(()) } } /// Extract model vote from ensemble decision fn extract_model_vote( decision: &ml::ensemble::EnsembleDecision, model_id: &str, ) -> (Option, Option, Option, Option) { if let Some(vote) = decision.model_votes.get(model_id) { let action_str = format!("{:?}", vote.action(0.0)).to_uppercase(); // threshold=0.0 for basic classification ( Some(vote.signal), Some(vote.confidence), Some(vote.weight), Some(action_str), ) } else { (None, None, None, None) } } /// Market data snapshot for feature extraction #[derive(Debug, Clone)] struct MarketDataSnapshot { bars: Vec, } /// OHLCV bar from database #[derive(Debug, Clone, sqlx::FromRow)] struct OhlcvBar { #[allow(dead_code)] timestamp: chrono::DateTime, #[allow(dead_code)] symbol: String, open: f64, high: f64, low: f64, close: f64, volume: f64, } // ================================================================================================ // Technical Indicator Calculations (Simplified) // For production, use ml/src/features/technical_indicators.rs // ================================================================================================ fn calculate_sma(prices: &[f64], period: usize) -> f64 { if prices.len() < period { return prices.iter().sum::() / prices.len() as f64; } prices.iter().take(period).sum::() / period as f64 } fn calculate_rsi(prices: &[f64], period: usize) -> f64 { if prices.len() < period + 1 { return 50.0; // Neutral } let mut gains = 0.0; let mut losses = 0.0; for i in 0..period { let change = prices[i] - prices[i + 1]; if change > 0.0 { gains += change; } else { losses += change.abs(); } } let avg_gain = gains / period as f64; let avg_loss = losses / period as f64; if avg_loss == 0.0 { return 100.0; } let rs = avg_gain / avg_loss; 100.0 - (100.0 / (1.0 + rs)) } fn calculate_volatility(prices: &[f64], period: usize) -> f64 { if prices.len() < period { return 0.0; } let mean = calculate_sma(prices, period); let variance: f64 = prices .iter() .take(period) .map(|p| (p - mean).powi(2)) .sum::() / period as f64; variance.sqrt() } fn calculate_momentum(prices: &[f64], period: usize) -> f64 { if prices.len() <= period { return 0.0; } prices.first().copied().unwrap_or(0.0) - prices.get(period).copied().unwrap_or(0.0) } fn calculate_volume_ratio(volumes: &[f64], period: usize) -> f64 { if volumes.is_empty() { return 1.0; } let avg_volume = calculate_sma(volumes, period); if avg_volume == 0.0 { return 1.0; } volumes.first().copied().unwrap_or(0.0) / avg_volume } fn calculate_returns(prices: &[f64]) -> f64 { if prices.len() < 2 { return 0.0; } let price_0 = prices.first().copied().unwrap_or(0.0); let price_1 = prices.get(1).copied().unwrap_or(1.0); if price_1 == 0.0 { return 0.0; } (price_0 - price_1) / price_1 } fn calculate_log_returns(prices: &[f64]) -> f64 { if prices.len() < 2 { return 0.0; } let price_0 = prices.first().copied().unwrap_or(0.0); let price_1 = prices.get(1).copied().unwrap_or(1.0); if price_1 == 0.0 { return 0.0; } (price_0 / price_1).ln() } #[cfg(test)] mod tests { use super::*; #[test] fn test_config_default() { let config = PredictionLoopConfig::default(); assert_eq!(config.prediction_interval, Duration::from_secs(60)); assert_eq!(config.symbols.len(), 4); assert!(config.symbols.contains(&"ES.FUT".to_string())); } #[test] fn test_calculate_sma() { let prices = vec![10.0, 11.0, 12.0, 13.0, 14.0]; let sma = calculate_sma(&prices, 3); assert!((sma - 11.0).abs() < 0.01); // (10 + 11 + 12) / 3 = 11 } #[test] fn test_calculate_rsi() { let prices = vec![ 50.0, 51.0, 52.0, 51.5, 53.0, 52.0, 54.0, 53.5, 55.0, 54.5, 56.0, 55.5, 57.0, 56.5, 58.0, ]; let rsi = calculate_rsi(&prices, 14); assert!(rsi >= 0.0 && rsi <= 100.0); } #[test] fn test_calculate_returns() { let prices = vec![110.0, 100.0]; let returns = calculate_returns(&prices); assert!((returns - 0.1).abs() < 0.001); // 10% return } #[test] fn test_extract_model_vote() { use ml::ensemble::{EnsembleDecision, ModelVote, TradingAction}; use std::collections::HashMap; let mut model_votes = HashMap::new(); model_votes.insert( "DQN".to_string(), ModelVote::new("DQN".to_string(), 0.8, 0.9, 0.33), ); let decision = EnsembleDecision::new(TradingAction::Buy, 0.85, 0.75, 0.15, model_votes); let (signal, confidence, weight, vote) = extract_model_vote(&decision, "DQN"); assert_eq!(signal, Some(0.8)); assert_eq!(confidence, Some(0.9)); assert_eq!(weight, Some(0.33)); assert_eq!(vote, Some("BUY".to_string())); // Non-existent model let (signal, confidence, weight, vote) = extract_model_vote(&decision, "UNKNOWN"); assert_eq!(signal, None); assert_eq!(confidence, None); assert_eq!(weight, None); assert_eq!(vote, None); } }