- Delete MLFeatureExtractor (1,294 lines) and SimpleDQNAdapter (235 lines) - Delete 830 lines of inline tests for deleted types - Remove legacy_feature_extractor field from SharedMLStrategy - Replace new() and new_with_production_extractor() with new(extractor, models, threshold) - Single constructor accepts injected models via Vec<Box<dyn MLModelAdapter>> - Update all callers: backtesting_service, 2 integration tests, 2 trading_service tests - Fix doc comments referencing MLFeatureExtractor - Fix feature count test: real extractor produces 51 features, not 225 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
417 lines
14 KiB
Rust
417 lines
14 KiB
Rust
//! Shared ML Strategy for Foxhunt Trading System
|
|
//!
|
|
//! This module provides a unified ML strategy implementation that is used by both
|
|
//! trading service and backtesting service to ensure consistent ML predictions
|
|
//! across all services. This eliminates code duplication and ensures ONE SINGLE SYSTEM.
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! ```text
|
|
//! SharedMLStrategy
|
|
//! ├─ MLModelAdapter (abstraction over ml crate models)
|
|
//! ├─ ProductionFeatureExtractor225 (consistent 225-feature engineering)
|
|
//! ├─ EnsembleCoordinator (weighted voting)
|
|
//! └─ ModelPerformanceTracker (metrics)
|
|
//! ```
|
|
|
|
use anyhow::Result;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
|
|
/// Trait for pluggable 225-feature extraction
|
|
///
|
|
/// This trait allows applications to inject the production-grade feature extractor
|
|
/// from the `ml` crate without creating circular dependencies.
|
|
pub trait ProductionFeatureExtractor225: Send + Sync {
|
|
/// Update internal state with new market data
|
|
fn update(&mut self, price: f64, volume: f64, timestamp: DateTime<Utc>) -> Result<()>;
|
|
|
|
/// Extract 225-dimensional feature vector from current state
|
|
fn extract_features(&mut self) -> Result<Vec<f64>>;
|
|
}
|
|
|
|
/// ML prediction result
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MLPrediction {
|
|
/// Model identifier
|
|
pub model_id: String,
|
|
/// Prediction value (0.0-1.0)
|
|
pub prediction_value: f64,
|
|
/// Confidence score (0.0-1.0)
|
|
pub confidence: f64,
|
|
/// Features used for prediction
|
|
pub features: Vec<f64>,
|
|
/// Prediction timestamp
|
|
pub timestamp: DateTime<Utc>,
|
|
/// Inference latency in microseconds
|
|
pub inference_latency_us: u64,
|
|
}
|
|
|
|
/// ML model performance metrics
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct MLModelPerformance {
|
|
/// Model identifier
|
|
pub model_id: String,
|
|
/// Total predictions made
|
|
pub total_predictions: u64,
|
|
/// Correct predictions
|
|
pub correct_predictions: u64,
|
|
/// Average inference latency
|
|
pub avg_latency_us: f64,
|
|
/// Average confidence score
|
|
pub avg_confidence: f64,
|
|
/// Model accuracy percentage
|
|
pub accuracy_percentage: f64,
|
|
/// Returns generated
|
|
pub returns: Vec<f64>,
|
|
/// Sharpe ratio
|
|
pub sharpe_ratio: f64,
|
|
/// Maximum drawdown
|
|
pub max_drawdown: f64,
|
|
}
|
|
|
|
/// Trait for ML model adapters
|
|
pub trait MLModelAdapter: Send + Sync {
|
|
/// Get model prediction
|
|
fn predict(&self, features: &[f64]) -> Result<MLPrediction>;
|
|
|
|
/// Get model identifier
|
|
fn model_id(&self) -> &str;
|
|
|
|
/// Validate prediction against actual outcome
|
|
fn validate_prediction(&mut self, prediction: &MLPrediction, actual_outcome: bool);
|
|
}
|
|
|
|
/// Shared ML strategy implementation (ONE SINGLE SYSTEM)
|
|
pub struct SharedMLStrategy {
|
|
/// Available ML models
|
|
models: Arc<RwLock<HashMap<String, Box<dyn MLModelAdapter>>>>,
|
|
/// Feature extractor - pluggable 225-feature extractor (inject from ml crate)
|
|
feature_extractor_225: Option<Arc<RwLock<Box<dyn ProductionFeatureExtractor225>>>>,
|
|
/// Model performance tracking
|
|
model_performance: Arc<RwLock<HashMap<String, MLModelPerformance>>>,
|
|
/// Minimum confidence threshold
|
|
min_confidence_threshold: f64,
|
|
}
|
|
|
|
impl std::fmt::Debug for SharedMLStrategy {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("SharedMLStrategy")
|
|
.field("min_confidence_threshold", &self.min_confidence_threshold)
|
|
.field("model_count", &"<async_lock>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl SharedMLStrategy {
|
|
/// Create strategy with injected models and production feature extractor.
|
|
///
|
|
/// # Arguments
|
|
/// - `extractor`: Production-grade 225-feature extractor (inject from ml crate)
|
|
/// - `models`: Pre-built model adapters for ensemble prediction
|
|
/// - `min_confidence_threshold`: Minimum confidence for predictions
|
|
///
|
|
/// If `models` is empty, predictions will return an empty vec (graceful degradation).
|
|
pub fn new(
|
|
extractor: Box<dyn ProductionFeatureExtractor225>,
|
|
models: Vec<Box<dyn MLModelAdapter>>,
|
|
min_confidence_threshold: f64,
|
|
) -> Self {
|
|
let model_map: HashMap<String, Box<dyn MLModelAdapter>> = models
|
|
.into_iter()
|
|
.map(|m| (m.model_id().to_string(), m))
|
|
.collect();
|
|
|
|
Self {
|
|
models: Arc::new(RwLock::new(model_map)),
|
|
feature_extractor_225: Some(Arc::new(RwLock::new(extractor))),
|
|
model_performance: Arc::new(RwLock::new(HashMap::new())),
|
|
min_confidence_threshold,
|
|
}
|
|
}
|
|
|
|
/// Get ensemble prediction from all models
|
|
pub async fn get_ensemble_prediction(
|
|
&self,
|
|
price: f64,
|
|
volume: f64,
|
|
timestamp: DateTime<Utc>,
|
|
) -> Result<Vec<MLPrediction>> {
|
|
// Extract features using production 225-feature extractor
|
|
let features: Vec<f64> = match self.feature_extractor_225 {
|
|
Some(ref prod_extractor) => {
|
|
let mut extractor = prod_extractor.write().await;
|
|
extractor.update(price, volume, timestamp)?;
|
|
extractor.extract_features()?
|
|
}
|
|
None => {
|
|
anyhow::bail!("No feature extractor configured")
|
|
}
|
|
};
|
|
|
|
// Validate feature count
|
|
if features.len() != 225 {
|
|
tracing::error!(
|
|
"Feature extraction returned {} features, expected 225. \
|
|
Models will receive incorrect input!",
|
|
features.len()
|
|
);
|
|
}
|
|
|
|
let mut predictions = Vec::new();
|
|
|
|
// Get predictions from all models
|
|
let models = self.models.read().await;
|
|
for (model_id, model) in models.iter() {
|
|
match model.predict(&features) {
|
|
Ok(prediction) => {
|
|
if prediction.confidence >= self.min_confidence_threshold {
|
|
predictions.push(prediction);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Model {} failed to predict: {}", model_id, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(predictions)
|
|
}
|
|
|
|
/// Calculate weighted ensemble vote
|
|
pub fn calculate_ensemble_vote(&self, predictions: &[MLPrediction]) -> Option<(f64, f64)> {
|
|
if predictions.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let total_confidence: f64 = predictions.iter().map(|p| p.confidence).sum();
|
|
if total_confidence == 0.0 {
|
|
return None;
|
|
}
|
|
|
|
// Weighted average by confidence
|
|
let weighted_prediction: f64 = predictions
|
|
.iter()
|
|
.map(|p| p.prediction_value * p.confidence)
|
|
.sum::<f64>()
|
|
/ total_confidence;
|
|
|
|
let average_confidence: f64 =
|
|
predictions.iter().map(|p| p.confidence).sum::<f64>() / predictions.len() as f64;
|
|
|
|
Some((weighted_prediction, average_confidence))
|
|
}
|
|
|
|
/// Validate predictions against actual market outcomes
|
|
pub async fn validate_predictions(&self, predictions: &[MLPrediction], actual_return: f64) {
|
|
let actual_outcome = actual_return > 0.0; // Positive return = good outcome
|
|
|
|
let mut models = self.models.write().await;
|
|
let mut performance = self.model_performance.write().await;
|
|
|
|
for prediction in predictions {
|
|
if let Some(model) = models.get_mut(&prediction.model_id) {
|
|
model.validate_prediction(prediction, actual_outcome);
|
|
}
|
|
|
|
// Update performance tracking
|
|
let perf = performance
|
|
.entry(prediction.model_id.clone())
|
|
.or_insert_with(|| MLModelPerformance {
|
|
model_id: prediction.model_id.clone(),
|
|
..Default::default()
|
|
});
|
|
|
|
perf.total_predictions += 1;
|
|
|
|
let predicted_positive = prediction.prediction_value > 0.5;
|
|
if predicted_positive == actual_outcome {
|
|
perf.correct_predictions += 1;
|
|
}
|
|
|
|
perf.accuracy_percentage = if perf.total_predictions > 0 {
|
|
(perf.correct_predictions as f64 / perf.total_predictions as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Update average confidence
|
|
let total_samples = perf.total_predictions as f64;
|
|
perf.avg_confidence = (perf.avg_confidence * (total_samples - 1.0)
|
|
+ prediction.confidence)
|
|
/ total_samples;
|
|
|
|
// Update average latency
|
|
perf.avg_latency_us = (perf.avg_latency_us * (total_samples - 1.0)
|
|
+ prediction.inference_latency_us as f64)
|
|
/ total_samples;
|
|
}
|
|
}
|
|
|
|
/// Get performance summary for all models
|
|
pub async fn get_performance_summary(&self) -> HashMap<String, MLModelPerformance> {
|
|
self.model_performance.read().await.clone()
|
|
}
|
|
|
|
/// Add a model to the strategy
|
|
pub async fn add_model(&self, model_id: String, model: Box<dyn MLModelAdapter>) {
|
|
let mut models = self.models.write().await;
|
|
models.insert(model_id, model);
|
|
}
|
|
|
|
/// Get minimum confidence threshold
|
|
pub fn min_confidence_threshold(&self) -> f64 {
|
|
self.min_confidence_threshold
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Minimal mock adapter for unit tests
|
|
struct MockAdapter {
|
|
id: String,
|
|
}
|
|
|
|
impl MockAdapter {
|
|
fn new(id: &str) -> Self {
|
|
Self {
|
|
id: id.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MLModelAdapter for MockAdapter {
|
|
fn predict(&self, features: &[f64]) -> Result<MLPrediction> {
|
|
let sum: f64 = features.iter().sum::<f64>() / features.len().max(1) as f64;
|
|
let prediction_value = 1.0 / (1.0 + (-sum).exp());
|
|
let confidence = 0.5 + (prediction_value - 0.5).abs() * 0.8;
|
|
Ok(MLPrediction {
|
|
model_id: self.id.clone(),
|
|
prediction_value,
|
|
confidence,
|
|
features: features.to_vec(),
|
|
timestamp: Utc::now(),
|
|
inference_latency_us: 10,
|
|
})
|
|
}
|
|
|
|
fn model_id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
|
|
fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) {}
|
|
}
|
|
|
|
/// Minimal mock extractor for unit tests (returns 225 values)
|
|
struct MockExtractor;
|
|
|
|
impl ProductionFeatureExtractor225 for MockExtractor {
|
|
fn update(&mut self, _price: f64, _volume: f64, _timestamp: DateTime<Utc>) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
fn extract_features(&mut self) -> Result<Vec<f64>> {
|
|
Ok(vec![0.1; 225])
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_shared_ml_strategy_creation() {
|
|
let strategy = SharedMLStrategy::new(
|
|
Box::new(MockExtractor),
|
|
vec![Box::new(MockAdapter::new("m1"))],
|
|
0.6,
|
|
);
|
|
assert_eq!(strategy.min_confidence_threshold(), 0.6);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_prediction() {
|
|
let strategy = SharedMLStrategy::new(
|
|
Box::new(MockExtractor),
|
|
vec![Box::new(MockAdapter::new("m1"))],
|
|
0.0,
|
|
);
|
|
let predictions = strategy
|
|
.get_ensemble_prediction(100.0, 1000.0, Utc::now())
|
|
.await
|
|
.unwrap_or_default();
|
|
assert!(!predictions.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ensemble_vote() {
|
|
let strategy = SharedMLStrategy::new(
|
|
Box::new(MockExtractor),
|
|
vec![Box::new(MockAdapter::new("m1"))],
|
|
0.0,
|
|
);
|
|
let predictions = vec![
|
|
MLPrediction {
|
|
model_id: "model1".to_string(),
|
|
prediction_value: 0.8,
|
|
confidence: 0.9,
|
|
features: vec![],
|
|
timestamp: Utc::now(),
|
|
inference_latency_us: 50,
|
|
},
|
|
MLPrediction {
|
|
model_id: "model2".to_string(),
|
|
prediction_value: 0.6,
|
|
confidence: 0.7,
|
|
features: vec![],
|
|
timestamp: Utc::now(),
|
|
inference_latency_us: 60,
|
|
},
|
|
];
|
|
let (vote, confidence) = strategy
|
|
.calculate_ensemble_vote(&predictions)
|
|
.unwrap_or_default();
|
|
assert!((0.6..=0.8).contains(&vote));
|
|
assert!((0.7..=0.9).contains(&confidence));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_performance_tracking() {
|
|
let strategy = SharedMLStrategy::new(
|
|
Box::new(MockExtractor),
|
|
vec![Box::new(MockAdapter::new("test_model"))],
|
|
0.0,
|
|
);
|
|
let prediction = MLPrediction {
|
|
model_id: "test_model".to_string(),
|
|
prediction_value: 0.7,
|
|
confidence: 0.8,
|
|
features: vec![],
|
|
timestamp: Utc::now(),
|
|
inference_latency_us: 50,
|
|
};
|
|
strategy
|
|
.validate_predictions(std::slice::from_ref(&prediction), 0.05)
|
|
.await;
|
|
let performance = strategy.get_performance_summary().await;
|
|
let perf = performance.get("test_model").cloned().unwrap_or_default();
|
|
assert_eq!(perf.total_predictions, 1);
|
|
assert_eq!(perf.correct_predictions, 1);
|
|
assert_eq!(perf.accuracy_percentage, 100.0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_empty_models_graceful() {
|
|
let strategy =
|
|
SharedMLStrategy::new(Box::new(MockExtractor), vec![], 0.0);
|
|
let predictions = strategy
|
|
.get_ensemble_prediction(100.0, 1000.0, Utc::now())
|
|
.await
|
|
.unwrap_or_default();
|
|
assert!(
|
|
predictions.is_empty(),
|
|
"No models should produce no predictions"
|
|
);
|
|
}
|
|
}
|