fix(ensemble): wire EnsembleModelAdapter::predict to real inference

The adapter's `predict` returned
`{ prediction_value: 0.5, confidence: 0.0 }` as a "neutral" stub.
Downstream ensemble filtering dropped any 0-confidence prediction,
so the adapter silently contributed nothing -- a stub that survived
only because the caller discarded it.

Now: `EnsembleModelAdapter` holds an
`Arc<dyn ModelInferenceAdapter>` and dispatches `predict` to the
wrapped per-model GPU adapter (`DqnInferenceAdapter`,
`PpoInferenceAdapter`, `TftInferenceAdapter`, `Mamba2InferenceAdapter`,
`LiquidInferenceAdapter`, `KanInferenceAdapter`,
`XlstmInferenceAdapter`, `TggnInferenceAdapter`,
`TlobInferenceAdapter`, `DiffusionInferenceAdapter`). The inner
adapter already runs a full forward pass on its model and returns a
normalized `(direction, confidence)` pair; the bridge maps
`direction in [-1, 1]` -> `prediction_value in [0, 1]` via
`(direction + 1) / 2` to match `MLPrediction`'s bullish-probability
contract (> 0.5 = bullish), clamps confidence to [0, 1], and
propagates `metadata.latency_us` as the inference latency.

The bridge returns `Err` (never a faked 0-confidence success) when
the inner model reports `is_ready() == false`, the inner `predict`
fails, the output is non-finite, or the feature slice is empty.

`build_production_strategy` no longer fabricates ten zero-confidence
ghost adapters. It now accepts
`Vec<(String, Arc<dyn ModelInferenceAdapter>)>` -- the caller owns
real model construction (checkpoint loading, device selection). The
only current caller (backtesting service `MLPoweredStrategy::new`)
passes an empty vec; that yields an empty ensemble, which is an
honest "no models loaded" signal rather than ten stubs that exist
only to be filtered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-23 09:24:25 +02:00
parent d9fee6ef8d
commit 9b27428de9
2 changed files with 349 additions and 61 deletions

View File

@@ -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<dyn ModelInferenceAdapter>` (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<dyn ModelInferenceAdapter>`, 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<dyn ModelInferenceAdapter>,
}
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<S: Into<String>>(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<S: Into<String>>(model_id: S, inner: Arc<dyn ModelInferenceAdapter>) -> Self {
Self {
model_id: model_id.into(),
inner,
}
}
}
impl MLModelAdapter for EnsembleModelAdapter {
fn predict(&self, features: &[f64]) -> Result<MLPrediction> {
// 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<dyn ModelInferenceAdapter>)>,
) -> SharedMLStrategy {
let extractor = Box::new(ProductionFeatureExtractorAdapter::new());
let model_ids = [
"dqn", "ppo", "tft", "mamba2", "tggn", "tlob", "liquid", "kan", "xlstm", "diffusion",
];
let models: Vec<Box<dyn MLModelAdapter>> = model_ids
.iter()
.map(|&id| Box::new(EnsembleModelAdapter::new(id)) as Box<dyn MLModelAdapter>)
let models: Vec<Box<dyn MLModelAdapter>> = adapters
.into_iter()
.map(|(id, inner)| {
Box::new(EnsembleModelAdapter::new(id, inner)) as Box<dyn MLModelAdapter>
})
.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<EnsemblePrediction> {
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<dyn ModelInferenceAdapter> =
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<dyn ModelInferenceAdapter> =
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<dyn ModelInferenceAdapter> =
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<dyn ModelInferenceAdapter> =
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<dyn ModelInferenceAdapter> =
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<dyn ModelInferenceAdapter> =
Arc::new(StubInferenceAdapter::bullish("DQN"));
let adapter = EnsembleModelAdapter::new("dqn", inner);
let features: Vec<f64> = 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<dyn ModelInferenceAdapter> =
Arc::new(StubInferenceAdapter::bullish("DQN"));
let ppo: Arc<dyn ModelInferenceAdapter> =
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<dyn ModelInferenceAdapter> = 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());
}
}

View File

@@ -115,9 +115,23 @@ impl MLPoweredStrategy {
/// # Errors
/// Returns error if ML model adapter construction fails.
pub fn new(name: String, _lookback_periods: usize) -> Result<Self> {
// 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();