Files
foxhunt/ml/src/ensemble/model.rs
jgrusewski bdf5b690b7 cleanup(ml): remove 31 disabled imports and commented-out module blocks
Removes dead code across 28 files:
- 31 commented-out "DISABLED" import lines (mostly safe_operations, error_handling)
- Commented-out module declarations in lib.rs (deployment, model_loader_integration, tests)
- Commented-out re-exports in lib.rs (training_pipeline, deployment::ModelVersion)
- Commented-out adaptive strategy modules in regime/mod.rs

All are in git history if ever needed. Net -74 lines removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:23:41 +01:00

560 lines
16 KiB
Rust

//! Ensemble Model Management for Trading Signal Aggregation
//!
//! Provides a unified interface for managing multiple ML models, aggregating their
//! signals, and maintaining performance tracking for ultra-low latency trading.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use crossbeam::atomic::AtomicCell;
use serde::{Deserialize, Serialize};
use super::aggregator::ModelSignal;
use crate::{HealthStatus, MLError};
use super::*;
use common::trading::MarketRegime;
/// Configuration for ensemble models
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnsembleConfig {
pub min_models: usize,
pub max_models: usize,
pub signal_timeout_ms: u64,
pub aggregation_method: AggregationMethod,
pub confidence_threshold: f32,
}
impl Default for EnsembleConfig {
fn default() -> Self {
Self {
min_models: 2,
max_models: 10,
signal_timeout_ms: 1000,
aggregation_method: AggregationMethod::WeightedAverage,
confidence_threshold: 0.5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AggregationMethod {
WeightedAverage,
MajorityVote,
AdaptiveWeighted,
}
// Use ModelSignal and SignalMetadata from aggregator.rs
/// Ensemble signal result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnsembleSignal {
pub value: f32,
pub confidence: f32,
pub contributing_models: usize,
pub timestamp: u64,
pub regime: MarketRegime,
}
/// Health status information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthInfo {
pub status: HealthStatus,
pub total_models: usize,
pub active_models: usize,
pub last_signal_time: Option<u64>,
pub error_rate: f32,
}
/// Ensemble metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnsembleMetrics {
pub total_models: usize,
pub active_models: usize,
pub current_regime: MarketRegime,
pub total_signals: u64,
pub successful_aggregations: u64,
pub average_latency_us: f32,
}
/// Model information
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ModelInfo {
model_id: String,
model_type: String,
version: String,
features: Vec<String>,
expected_latency_us: u64,
last_signal_time: Option<u64>,
signal_count: u64,
error_count: u64,
}
/// Main ensemble model struct
#[derive(Debug)]
pub struct EnsembleModel {
config: EnsembleConfig,
models: Arc<RwLock<HashMap<String, ModelInfo>>>,
signals: Arc<RwLock<Vec<ModelSignal>>>,
current_regime: Arc<AtomicCell<MarketRegime>>,
metrics: Arc<RwLock<EnsembleMetrics>>,
}
impl EnsembleModel {
/// Create a new ensemble model
///
/// # Errors
///
/// Returns `MLError` if:
/// - Configuration validation fails
/// - Internal data structure initialization fails
/// - Memory allocation fails
pub fn new(config: EnsembleConfig) -> Result<Self, MLError> {
Ok(Self {
config,
models: Arc::new(RwLock::new(HashMap::new())),
signals: Arc::new(RwLock::new(Vec::new())),
current_regime: Arc::new(AtomicCell::new(MarketRegime::Normal)),
metrics: Arc::new(RwLock::new(EnsembleMetrics {
total_models: 0,
active_models: 0,
current_regime: MarketRegime::Normal,
total_signals: 0,
successful_aggregations: 0,
average_latency_us: 0.0,
})),
})
}
/// Register a new model
pub fn register_model(
&self,
model_id: &str,
model_type: &str,
version: &str,
features: Vec<String>,
expected_latency_us: u64,
) -> Result<(), MLError> {
let mut models = self
.models
.write()
.map_err(|e| MLError::LockError(e.to_string()))?;
let model_info = ModelInfo {
model_id: model_id.to_string(),
model_type: model_type.to_string(),
version: version.to_string(),
features,
expected_latency_us,
last_signal_time: None,
signal_count: 0,
error_count: 0,
};
models.insert(model_id.to_string(), model_info);
// Update metrics
let mut metrics = self
.metrics
.write()
.map_err(|e| MLError::LockError(e.to_string()))?;
metrics.total_models = models.len();
metrics.active_models = models.len();
Ok(())
}
/// Unregister a model
///
/// # Errors
///
/// Returns `MLError` if:
/// - Model ID not found in registry
/// - Lock acquisition fails
/// - Metrics update fails
/// - Model removal fails
pub fn unregister_model(&self, model_id: &str) -> Result<(), MLError> {
let mut models = self
.models
.write()
.map_err(|e| MLError::LockError(e.to_string()))?;
if models.remove(model_id).is_none() {
return Err(MLError::ModelNotFound(model_id.to_string()));
}
// Update metrics
let mut metrics = self
.metrics
.write()
.map_err(|e| MLError::LockError(e.to_string()))?;
metrics.total_models = models.len();
metrics.active_models = models.len();
Ok(())
}
/// Submit a signal from a model
///
/// # Errors
///
/// Returns `MLError` if:
/// - Lock acquisition fails
/// - Model ID not found
/// - Signal validation fails
/// - Timestamp is invalid
/// - Signal storage fails
pub fn submit_signal(&self, signal: ModelSignal) -> Result<(), MLError> {
let mut signals = self
.signals
.write()
.map_err(|e| MLError::LockError(e.to_string()))?;
// Update model info
{
let mut models = self
.models
.write()
.map_err(|e| MLError::LockError(e.to_string()))?;
if let Some(model_info) = models.get_mut(&signal.model_id) {
model_info.last_signal_time = Some(signal.timestamp);
model_info.signal_count += 1;
}
}
signals.push(signal);
// Clean old signals
let current_time = current_timestamp();
signals.retain(|s| current_time - s.timestamp < self.config.signal_timeout_ms);
Ok(())
}
/// Aggregate signals from all models
///
/// # Errors
///
/// Returns `MLError` if:
/// - Lock acquisition fails
/// - Insufficient models have submitted signals
/// - Signal aggregation calculation fails
/// - Confidence calculation fails
/// - No valid signals available
/// - Consensus cannot be reached
/// - Regime detection fails
pub fn aggregate_signals(&self) -> Result<EnsembleSignal, MLError> {
let signals = self
.signals
.read()
.map_err(|e| MLError::LockError(e.to_string()))?;
let _models = self
.models
.read()
.map_err(|e| MLError::LockError(e.to_string()))?;
let current_time = current_timestamp();
let recent_signals: Vec<_> = signals
.iter()
.filter(|s| current_time - s.timestamp < self.config.signal_timeout_ms)
.collect();
if recent_signals.len() < self.config.min_models {
return Err(MLError::InsufficientData(format!(
"Need at least {} models, got {}",
self.config.min_models,
recent_signals.len()
)));
}
let (aggregated_value, aggregated_confidence) = match self.config.aggregation_method {
AggregationMethod::WeightedAverage => {
self.weighted_average_aggregation(&recent_signals)
},
AggregationMethod::MajorityVote => self.majority_vote_aggregation(&recent_signals),
AggregationMethod::AdaptiveWeighted => {
self.adaptive_weighted_aggregation(&recent_signals)
},
};
Ok(EnsembleSignal {
value: aggregated_value,
confidence: aggregated_confidence,
contributing_models: recent_signals.len(),
timestamp: current_time,
regime: self.current_regime.load(),
})
}
fn weighted_average_aggregation(&self, signals: &[&ModelSignal]) -> (f32, f32) {
let total_weight: f32 = signals.iter().map(|s| s.confidence).sum();
if total_weight == 0.0 {
return (0.0, 0.0);
}
let weighted_value: f32 =
signals.iter().map(|s| s.signal * s.confidence).sum::<f32>() / total_weight;
let average_confidence: f32 = total_weight / signals.len() as f32;
(weighted_value as f32, average_confidence as f32)
}
fn majority_vote_aggregation(&self, signals: &[&ModelSignal]) -> (f32, f32) {
// Simple majority vote for binary signals
let positive_count = signals.iter().filter(|s| s.signal > 0.0).count();
let total_count = signals.len();
let majority_value = if positive_count * 2 > total_count {
1.0_f32
} else {
-1.0_f32
};
let confidence =
(positive_count.max(total_count - positive_count) as f32) / (total_count as f32);
(majority_value, confidence)
}
fn adaptive_weighted_aggregation(&self, signals: &[&ModelSignal]) -> (f32, f32) {
// Use recent performance to adjust weights
self.weighted_average_aggregation(signals) // Simplified - same as weighted for now
}
/// Update market regime
pub fn update_market_regime(&self, regime: MarketRegime) {
self.current_regime.store(regime);
// Update metrics
if let Ok(mut metrics) = self.metrics.write() {
metrics.current_regime = regime;
}
}
/// Get list of registered models
pub fn list_models(&self) -> Vec<String> {
if let Ok(models) = self.models.read() {
models.keys().cloned().collect()
} else {
Vec::new()
}
}
/// Health check
pub fn health_check(&self) -> HealthInfo {
let models = self.models.read().ok();
let signals = self.signals.read().ok();
let total_models = models.as_ref().map(|m| m.len()).unwrap_or(0);
let active_models = total_models; // Simplified
let last_signal_time = signals
.as_ref()
.and_then(|s| s.last().map(|sig| sig.timestamp));
let status = if total_models >= self.config.min_models {
HealthStatus::Healthy
} else if total_models > 0 {
HealthStatus::Degraded
} else {
HealthStatus::Unhealthy
};
HealthInfo {
status,
total_models,
active_models,
last_signal_time,
error_rate: 0.0, // Simplified
}
}
/// Get current metrics
pub fn get_metrics(&self) -> Result<EnsembleMetrics, MLError> {
let metrics = self
.metrics
.read()
.map_err(|e| MLError::LockError(e.to_string()))?;
Ok(metrics.clone())
}
}
/// Helper function to get current timestamp
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
/*
// DISABLED: Tests require proper ensemble API implementation
// Fix after completing ensemble model infrastructure
#[cfg(test)]
mod tests {
use super::*;
fn create_test_ensemble() -> EnsembleModel {
let config = EnsembleConfig::default();
EnsembleModel::new(config)?
}
fn create_test_signal(model_id: &str, signal: f32, confidence: f32) -> ModelSignal {
ModelSignal {
model_id: model_id.to_string(),
signal,
confidence,
timestamp: current_timestamp(),
metadata: SignalMetadata {
model_version: "1.0".to_string(),
features_used: vec!["test_feature".to_string()],
prediction_horizon: 50,
},
}
}
#[test]
fn test_ensemble_creation() {
let ensemble = create_test_ensemble();
let metrics = ensemble.get_metrics()?;
assert_eq!(metrics.total_models, 0);
assert_eq!(metrics.active_models, 0);
}
#[test]
fn test_model_registration() {
let ensemble = create_test_ensemble();
let result = ensemble.register_model(
"test_model",
"momentum",
"1.0",
vec!["price".to_string(), "volume".to_string()],
100,
);
assert!(result.is_ok());
assert_eq!(ensemble.list_models().len(), 1);
assert_eq!(ensemble.list_models()[0], "test_model");
}
#[test]
fn test_signal_submission_and_aggregation() {
let ensemble = create_test_ensemble();
// Register models
ensemble.register_model("model1", "momentum", "1.0", vec!["price".to_string()], 50)?;
ensemble.register_model(
"model2",
"mean_reversion",
"1.0",
vec!["price".to_string()],
60,
)?;
// Submit signals
let signal1 = create_test_signal("model1", 0.8, 0.9);
let signal2 = create_test_signal("model2", 0.6, 0.8);
ensemble.submit_signal(signal1)?;
ensemble.submit_signal(signal2)?;
// Aggregate signals
let result = ensemble.aggregate_signals();
assert!(result.is_ok());
let ensemble_signal = result?;
assert!(ensemble_signal.value > 0.0);
assert!(ensemble_signal.confidence > 0.0);
assert_eq!(ensemble_signal.contributing_models, 2);
}
#[test]
fn test_insufficient_models() {
let mut config = EnsembleConfig::default();
config.min_models = 3;
let ensemble = EnsembleModel::new(config)?;
// Register only 2 models
ensemble.register_model("model1", "momentum", "1.0", vec!["price".to_string()], 50)?;
ensemble.register_model(
"model2",
"mean_reversion",
"1.0",
vec!["price".to_string()],
60,
)?;
let signal1 = create_test_signal("model1", 0.8, 0.9);
let signal2 = create_test_signal("model2", 0.6, 0.8);
ensemble.submit_signal(signal1)?;
ensemble.submit_signal(signal2)?;
// Should fail due to insufficient models
let result = ensemble.aggregate_signals();
assert!(result.is_err());
}
#[test]
fn test_health_check() {
let ensemble = create_test_ensemble();
// Initially should be critical (no models)
let health = ensemble.health_check();
assert_eq!(health.status, HealthStatus::Critical);
// Register enough models
ensemble.register_model("model1", "momentum", "1.0", vec!["price".to_string()], 50)?;
ensemble.register_model(
"model2",
"mean_reversion",
"1.0",
vec!["price".to_string()],
60,
)?;
ensemble.register_model("model3", "momentum", "1.0", vec!["volume".to_string()], 70)?;
let health = ensemble.health_check();
assert_eq!(health.status, HealthStatus::Healthy);
assert_eq!(health.total_models, 3);
}
#[test]
fn test_model_unregistration() {
let ensemble = create_test_ensemble();
ensemble.register_model("model1", "momentum", "1.0", vec!["price".to_string()], 50)?;
assert_eq!(ensemble.list_models().len(), 1);
ensemble.unregister_model("model1")?;
assert_eq!(ensemble.list_models().len(), 0);
// Should fail to unregister non-existent model
let result = ensemble.unregister_model("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_market_regime_update() {
let ensemble = create_test_ensemble();
ensemble.register_model("momentum", "momentum", "1.0", vec!["price".to_string()], 50)?;
ensemble.register_model(
"mean_rev",
"mean_reversion",
"1.0",
vec!["price".to_string()],
60,
)?;
// Update regime and check that it's reflected in metrics
ensemble.update_market_regime(MarketRegime::Trending);
let metrics = ensemble.get_metrics()?;
assert_eq!(metrics.current_regime, MarketRegime::Trending);
}
}
*/