Files
foxhunt/crates/ml/src/ensemble/model_adapter.rs
jgrusewski c0c44a5f17 feat(dqn): GPU-native regime classification with 42-dim feature vector
Expand FeatureVector from 40 to 42 dimensions by including ADX(14) at
index 40 and CUSUM direction at index 41 from the existing CPU feature
extraction pipeline. This eliminates proxy-based regime classification
and enables GPU-native regime detection via tensor narrow/comparison ops.

Key changes:
- extraction.rs: wire RegimeADXFeatures + RegimeCUSUMFeatures into
  extract_current_features_v2(), output 42 features per bar
- regime_conditional.rs: classify_regime_masks_gpu() creates per-regime
  mask tensors entirely on GPU (ADX > 0.25 = trending, |CUSUM| > 0.7 =
  volatile, else ranging). Zero CPU roundtrip in training hot path.
- trainer.rs/config.rs: state_dim 43→45 (no OFI), 51→53 (with OFI),
  aligned dims unchanged (48/56). GPU batch insertion for all 3 heads.
- CUDA header: MARKET_DIM 40→42
- walk_forward.rs: FEATURE_DIM 40→42
- 42 files updated, all [f64;40]→[f64;42] propagated across workspace

Test results: ml=874/0, ml-dqn=354/0, ml-features=282/0, ml-core=274/0
Real data GPU smoke tests: 7/7 passed (OHLCV + OFI + trade enrichment)
Hyperopt baseline RL: 2 trials completed on local RTX 3050 Ti

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:16:05 +01:00

129 lines
4.3 KiB
Rust

//! Bridge between ml model registry and common::ml_strategy::MLModelAdapter trait.
//!
//! 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.
use anyhow::Result;
use chrono::Utc;
use common::ml_strategy::{MLModelAdapter, MLPrediction, SharedMLStrategy};
use crate::features::production_adapter::ProductionFeatureExtractorAdapter;
/// Adapter that will delegate predict() to a loaded model checkpoint.
///
/// Currently returns neutral predictions with zero confidence (filtered out
/// by the confidence threshold) until checkpoint loading is production-ready.
#[derive(Debug)]
pub struct EnsembleModelAdapter {
model_id: String,
}
impl EnsembleModelAdapter {
pub fn new<S: Into<String>>(model_id: S) -> Self {
Self {
model_id: model_id.into(),
}
}
}
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;
Ok(MLPrediction {
model_id: self.model_id.clone(),
prediction_value: 0.5,
confidence: 0.0, // Zero confidence = filtered out by threshold
features: vec![],
timestamp: Utc::now(),
inference_latency_us: 0,
})
}
fn model_id(&self) -> &str {
&self.model_id
}
fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) {
// Performance tracking handled by SharedMLStrategy
}
}
/// Build a production-ready SharedMLStrategy with model adapters for each
/// model in the 10-model ensemble.
///
/// Returns a strategy with:
/// - ProductionFeatureExtractorAdapter (42 market features currently)
/// - One EnsembleModelAdapter per known model type
///
/// 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 {
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>)
.collect();
SharedMLStrategy::new(extractor, models, min_confidence_threshold)
}
#[cfg(test)]
mod tests {
use super::*;
#[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();
assert_eq!(prediction.model_id, "dqn");
assert_eq!(prediction.prediction_value, 0.5);
assert_eq!(prediction.confidence, 0.0);
}
#[test]
fn test_build_production_strategy() {
let strategy = build_production_strategy(0.6);
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);
let predictions = strategy
.get_ensemble_prediction(100.0, 1000.0, Utc::now())
.await
.unwrap();
// 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"
);
}
#[tokio::test]
async fn test_production_strategy_zero_threshold_keeps_all() {
let strategy = build_production_strategy(0.0);
let predictions = strategy
.get_ensemble_prediction(100.0, 1000.0, Utc::now())
.await
.unwrap();
// With threshold=0.0, all 10 neutral predictions pass through
assert_eq!(
predictions.len(),
10,
"Zero threshold should keep all 10 model predictions"
);
}
}