diff --git a/crates/ml/src/ensemble/model_adapter.rs b/crates/ml/src/ensemble/model_adapter.rs index d120f6b84..eb6b3f0e2 100644 --- a/crates/ml/src/ensemble/model_adapter.rs +++ b/crates/ml/src/ensemble/model_adapter.rs @@ -1,44 +1,115 @@ -//! Bridge between ml model registry and common::ml_strategy::MLModelAdapter trait. +//! Bridge between `ml::ensemble::ModelInferenceAdapter` (per-model GPU inference) +//! and `common::ml_strategy::MLModelAdapter` (trait consumed by `SharedMLStrategy`). //! -//! EnsembleModelAdapter wraps a model ID and implements MLModelAdapter so it can -//! be injected into SharedMLStrategy. When real checkpoint loading is wired, this -//! adapter will delegate to the loaded model for inference. +//! `EnsembleModelAdapter` is a thin adapter: it holds a polymorphic +//! `Arc` (the real, GPU-resident per-model adapter — +//! `DqnInferenceAdapter`, `PpoInferenceAdapter`, `TftInferenceAdapter`, ...) and +//! forwards `predict` to it. The inner adapter already runs a full forward pass +//! on its wrapped model and returns a normalized `(direction, confidence)` pair; +//! this bridge remaps `direction ∈ [-1, 1]` to `prediction_value ∈ [0, 1]` so the +//! result fits `MLPrediction`'s bullish-probability contract (values > 0.5 = +//! bullish, < 0.5 = bearish; consumed by `SharedMLStrategy::validate_predictions`). -use anyhow::Result; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; use chrono::Utc; use common::ml_strategy::{MLModelAdapter, MLPrediction, SharedMLStrategy}; + +use crate::ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}; use crate::features::production_adapter::ProductionFeatureExtractorAdapter; -/// Adapter that will delegate predict() to a loaded model checkpoint. +/// Adapter that forwards `MLModelAdapter::predict` to a wrapped +/// `ModelInferenceAdapter` running real GPU inference on a specific model +/// (DQN, PPO, TFT, Mamba2, Liquid, KAN, xLSTM, TGGN, TLOB, Diffusion, ...). /// -/// Currently returns neutral predictions with zero confidence (filtered out -/// by the confidence threshold) until checkpoint loading is production-ready. -#[derive(Debug)] +/// The wrapped adapter is held behind `Arc`, giving +/// `EnsembleModelAdapter` polymorphic dispatch across every model type that +/// implements the inference trait — the bridge itself knows nothing about +/// individual model architectures. pub struct EnsembleModelAdapter { model_id: String, + inner: Arc, +} + +impl std::fmt::Debug for EnsembleModelAdapter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnsembleModelAdapter") + .field("model_id", &self.model_id) + .field("inner_model_name", &self.inner.model_name()) + .field("inner_ready", &self.inner.is_ready()) + .finish() + } } impl EnsembleModelAdapter { - pub fn new>(model_id: S) -> Self { + /// Create a bridge wrapping a real per-model inference adapter. + /// + /// `model_id` is the public ensemble identifier exposed via + /// `MLModelAdapter::model_id` (e.g. "dqn", "ppo", "tft"); `inner` is the + /// actual GPU-resident adapter that will produce predictions. + pub fn new>(model_id: S, inner: Arc) -> Self { Self { model_id: model_id.into(), + inner, } } } impl MLModelAdapter for EnsembleModelAdapter { fn predict(&self, features: &[f64]) -> Result { - // TODO: Wire to real model inference via checkpoint loading when - // the model registry is production-ready. For now, return neutral - // prediction so the ensemble pipeline is fully wired end-to-end. - let _ = features; + if !self.inner.is_ready() { + return Err(anyhow!( + "EnsembleModelAdapter: inner model '{}' (id={}) is not ready for inference", + self.inner.model_name(), + self.model_id + )); + } + + if features.is_empty() { + return Err(anyhow!( + "EnsembleModelAdapter: empty feature vector for model '{}'", + self.model_id + )); + } + + let fv = FeatureVector { + values: features.to_vec(), + timestamp: Utc::now().timestamp_micros(), + }; + + let raw = self.inner.predict(&fv).map_err(|e| { + anyhow!( + "EnsembleModelAdapter: inference failed for model '{}': {e}", + self.model_id + ) + })?; + + if !raw.direction.is_finite() || !raw.confidence.is_finite() { + return Err(anyhow!( + "EnsembleModelAdapter: non-finite prediction from model '{}' (direction={}, confidence={})", + self.model_id, + raw.direction, + raw.confidence, + )); + } + + // Map direction ∈ [-1, 1] -> prediction_value ∈ [0, 1]: + // direction = -1 -> 0.0 (strong bearish) + // direction = 0 -> 0.5 (neutral) + // direction = +1 -> 1.0 (strong bullish) + // This matches SharedMLStrategy's bullish-probability contract + // (predicted_positive = prediction_value > 0.5). + let prediction_value = ((raw.direction + 1.0) * 0.5).clamp(0.0, 1.0); + let confidence = raw.confidence.clamp(0.0, 1.0); + Ok(MLPrediction { model_id: self.model_id.clone(), - prediction_value: 0.5, - confidence: 0.0, // Zero confidence = filtered out by threshold - features: vec![], + prediction_value, + confidence, + features: features.to_vec(), timestamp: Utc::now(), - inference_latency_us: 0, + inference_latency_us: raw.metadata.latency_us, }) } @@ -47,28 +118,36 @@ impl MLModelAdapter for EnsembleModelAdapter { } fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) { - // Performance tracking handled by SharedMLStrategy + // Performance tracking is handled by SharedMLStrategy. + // The wrapped ModelInferenceAdapter trait does not expose a mutable + // validation hook; model-side performance feedback, when required, + // is driven by the training pipeline, not the ensemble read path. } } -/// Build a production-ready SharedMLStrategy with model adapters for each -/// model in the 10-model ensemble. +/// Build a production `SharedMLStrategy` using the 225-feature extractor and +/// one `EnsembleModelAdapter` per injected inference adapter. /// -/// Returns a strategy with: -/// - ProductionFeatureExtractorAdapter (42 market features currently) -/// - One EnsembleModelAdapter per known model type +/// The caller owns model construction (checkpoint loading, device selection, +/// config) and passes the concrete `ModelInferenceAdapter` implementations — +/// `DqnInferenceAdapter::from_checkpoint(...)`, etc. — paired with the public +/// ensemble id each model should advertise. /// -/// When no checkpoints are loaded, models return neutral predictions with -/// zero confidence, which get filtered out by the confidence threshold. -pub fn build_production_strategy(min_confidence_threshold: f64) -> SharedMLStrategy { +/// Passing an empty `adapters` vec produces a strategy with zero models; that +/// is an honest reflection of "no models are loaded", not a stub — +/// `SharedMLStrategy::get_ensemble_prediction` will return an empty prediction +/// list rather than fabricate neutral outputs. +pub fn build_production_strategy( + min_confidence_threshold: f64, + adapters: Vec<(String, Arc)>, +) -> SharedMLStrategy { let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let model_ids = [ - "dqn", "ppo", "tft", "mamba2", "tggn", "tlob", "liquid", "kan", "xlstm", "diffusion", - ]; - let models: Vec> = model_ids - .iter() - .map(|&id| Box::new(EnsembleModelAdapter::new(id)) as Box) + let models: Vec> = adapters + .into_iter() + .map(|(id, inner)| { + Box::new(EnsembleModelAdapter::new(id, inner)) as Box + }) .collect(); SharedMLStrategy::new(extractor, models, min_confidence_threshold) @@ -77,52 +156,247 @@ pub fn build_production_strategy(min_confidence_threshold: f64) -> SharedMLStrat #[cfg(test)] mod tests { use super::*; + use crate::ensemble::inference_adapter::{EnsemblePrediction, PredictionMeta}; + use crate::MLResult; - #[test] - fn test_ensemble_model_adapter_neutral_prediction() { - let adapter = EnsembleModelAdapter::new("dqn"); - let features = vec![0.1; 51]; - let prediction = adapter.predict(&features).unwrap(); + /// Minimal non-GPU `ModelInferenceAdapter` used to exercise the bridge + /// semantics deterministically without spinning up CUDA. + struct StubInferenceAdapter { + name: String, + direction: f64, + confidence: f64, + ready: bool, + should_fail: bool, + } - assert_eq!(prediction.model_id, "dqn"); - assert_eq!(prediction.prediction_value, 0.5); - assert_eq!(prediction.confidence, 0.0); + impl StubInferenceAdapter { + fn bullish(name: &str) -> Self { + Self { + name: name.to_string(), + direction: 0.6, + confidence: 0.8, + ready: true, + should_fail: false, + } + } + + fn bearish(name: &str) -> Self { + Self { + name: name.to_string(), + direction: -0.4, + confidence: 0.7, + ready: true, + should_fail: false, + } + } + + fn not_ready(name: &str) -> Self { + Self { + name: name.to_string(), + direction: 0.0, + confidence: 0.0, + ready: false, + should_fail: false, + } + } + + fn failing(name: &str) -> Self { + Self { + name: name.to_string(), + direction: 0.0, + confidence: 0.0, + ready: true, + should_fail: true, + } + } + + fn non_finite(name: &str) -> Self { + Self { + name: name.to_string(), + direction: f64::NAN, + confidence: 0.5, + ready: true, + should_fail: false, + } + } + } + + impl ModelInferenceAdapter for StubInferenceAdapter { + fn model_name(&self) -> &str { + &self.name + } + + fn predict(&self, _features: &FeatureVector) -> MLResult { + if self.should_fail { + return Err(crate::MLError::InferenceError( + "stub forced failure".to_owned(), + )); + } + Ok(EnsemblePrediction { + model_name: self.name.clone(), + direction: self.direction, + confidence: self.confidence, + metadata: PredictionMeta { + latency_us: 42, + quantiles: None, + attention_weights: None, + q_values: None, + }, + }) + } + + fn is_ready(&self) -> bool { + self.ready + } } #[test] - fn test_build_production_strategy() { - let strategy = build_production_strategy(0.6); + fn predict_forwards_bullish_inner_prediction() { + let inner: Arc = + Arc::new(StubInferenceAdapter::bullish("DQN")); + let adapter = EnsembleModelAdapter::new("dqn", inner); + let features = vec![0.1_f64; 42]; + + let pred = adapter.predict(&features).expect("predict should succeed"); + + assert_eq!(pred.model_id, "dqn"); + // direction=0.6 -> prediction_value=(0.6+1)/2 = 0.8 + assert!((pred.prediction_value - 0.8).abs() < 1e-9); + assert!((pred.confidence - 0.8).abs() < 1e-9); + assert!(pred.prediction_value > 0.5, "bullish inner -> >0.5 bridge output"); + assert_eq!(pred.inference_latency_us, 42); + } + + #[test] + fn predict_forwards_bearish_inner_prediction() { + let inner: Arc = + Arc::new(StubInferenceAdapter::bearish("PPO")); + let adapter = EnsembleModelAdapter::new("ppo", inner); + let features = vec![0.1_f64; 42]; + + let pred = adapter.predict(&features).expect("predict should succeed"); + + // direction=-0.4 -> prediction_value=(-0.4+1)/2 = 0.3 + assert!((pred.prediction_value - 0.3).abs() < 1e-9); + assert!(pred.prediction_value < 0.5, "bearish inner -> <0.5 bridge output"); + } + + #[test] + fn predict_returns_err_when_inner_not_ready() { + let inner: Arc = + Arc::new(StubInferenceAdapter::not_ready("TFT")); + let adapter = EnsembleModelAdapter::new("tft", inner); + let features = vec![0.1_f64; 42]; + + let err = adapter + .predict(&features) + .expect_err("not-ready model must return Err, not confidence=0 success"); + let msg = err.to_string(); + assert!(msg.contains("not ready"), "err message should mention readiness: {msg}"); + } + + #[test] + fn predict_returns_err_when_inner_fails() { + let inner: Arc = + Arc::new(StubInferenceAdapter::failing("Mamba2")); + let adapter = EnsembleModelAdapter::new("mamba2", inner); + let features = vec![0.1_f64; 42]; + + let err = adapter + .predict(&features) + .expect_err("inner predict failure must propagate"); + assert!(err.to_string().contains("inference failed")); + } + + #[test] + fn predict_returns_err_on_non_finite_output() { + let inner: Arc = + Arc::new(StubInferenceAdapter::non_finite("Liquid")); + let adapter = EnsembleModelAdapter::new("liquid", inner); + let features = vec![0.1_f64; 42]; + + let err = adapter + .predict(&features) + .expect_err("NaN direction must return Err, not fake confidence=0"); + assert!(err.to_string().contains("non-finite")); + } + + #[test] + fn predict_returns_err_on_empty_features() { + let inner: Arc = + Arc::new(StubInferenceAdapter::bullish("DQN")); + let adapter = EnsembleModelAdapter::new("dqn", inner); + let features: Vec = Vec::new(); + + let err = adapter + .predict(&features) + .expect_err("empty features must be rejected"); + assert!(err.to_string().contains("empty feature vector")); + } + + #[test] + fn build_production_strategy_with_empty_adapters_has_no_models() { + let strategy = build_production_strategy(0.6, Vec::new()); assert_eq!(strategy.min_confidence_threshold(), 0.6); } #[tokio::test] - async fn test_production_strategy_filters_neutral_predictions() { - let strategy = build_production_strategy(0.5); + async fn build_production_strategy_forwards_real_predictions() { + let dqn: Arc = + Arc::new(StubInferenceAdapter::bullish("DQN")); + let ppo: Arc = + Arc::new(StubInferenceAdapter::bearish("PPO")); + + let strategy = build_production_strategy( + 0.5, // threshold below stub confidences (0.7, 0.8) + vec![ + ("dqn".to_string(), dqn), + ("ppo".to_string(), ppo), + ], + ); + let predictions = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await - .unwrap(); + .expect("ensemble prediction should succeed"); - // All 10 adapters return confidence=0.0, so threshold=0.5 filters them all - assert!( - predictions.is_empty(), - "Neutral predictions (confidence=0.0) should be filtered by threshold=0.5" + // With real (non-zero) confidences, both adapters should clear the + // threshold -- the previous stub behaviour dropped them all. + assert_eq!( + predictions.len(), + 2, + "expected 2 real predictions (DQN + PPO); got {} — the stub \ + ghost would have produced 0", + predictions.len(), ); + for p in &predictions { + assert!(p.confidence > 0.0, "real inner confidence must be > 0"); + assert!(p.prediction_value >= 0.0 && p.prediction_value <= 1.0); + } } #[tokio::test] - async fn test_production_strategy_zero_threshold_keeps_all() { - let strategy = build_production_strategy(0.0); + async fn build_production_strategy_filters_low_confidence_honestly() { + let low_conf: Arc = Arc::new(StubInferenceAdapter { + name: "KAN".to_string(), + direction: 0.1, + confidence: 0.2, // below threshold + ready: true, + should_fail: false, + }); + + let strategy = build_production_strategy( + 0.5, + vec![("kan".to_string(), low_conf)], + ); + let predictions = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await - .unwrap(); + .expect("ensemble prediction should succeed"); - // With threshold=0.0, all 10 neutral predictions pass through - assert_eq!( - predictions.len(), - 10, - "Zero threshold should keep all 10 model predictions" - ); + // Filtered by genuine threshold comparison (0.2 < 0.5), not by a + // fake 0.0 baked in by the adapter. + assert!(predictions.is_empty()); } } diff --git a/services/backtesting_service/src/ml_strategy_engine.rs b/services/backtesting_service/src/ml_strategy_engine.rs index 1813508dd..6d7e0415b 100644 --- a/services/backtesting_service/src/ml_strategy_engine.rs +++ b/services/backtesting_service/src/ml_strategy_engine.rs @@ -115,9 +115,23 @@ impl MLPoweredStrategy { /// # Errors /// Returns error if ML model adapter construction fails. pub fn new(name: String, _lookback_periods: usize) -> Result { - // Use shared ML strategy (ONE SINGLE SYSTEM) with ProductionFeatureExtractorAdapter (225 features) + // Use shared ML strategy (ONE SINGLE SYSTEM) with ProductionFeatureExtractorAdapter (225 features). + // + // `build_production_strategy` now takes a Vec of real per-model inference + // adapters. The backtesting service has no checkpoint-loader wired at this + // layer, so we pass an empty vec: the resulting `SharedMLStrategy` honestly + // reports "no models loaded" (empty prediction list) instead of the prior + // ghost behaviour where 10 stub adapters all returned confidence=0.0 and + // were silently filtered by the threshold. + // + // When the backtesting service grows a model-loader, it should build the + // appropriate `DqnInferenceAdapter::from_checkpoint(..)` / `PpoInferenceAdapter::new(..)` + // / etc. adapters and pass them in here with their ensemble ids. let min_confidence_threshold = 0.6; - let strategy = Arc::new(ml::ensemble::build_production_strategy(min_confidence_threshold)); + let strategy = Arc::new(ml::ensemble::build_production_strategy( + min_confidence_threshold, + Vec::new(), + )); // Initialize UnifiedFeatureExtractor (256 features) let feature_config = FeatureExtractionConfig::default();