Files
foxhunt/crates/ml-ensemble/src/model.rs
jgrusewski d313486dc2 refactor(ml): split monolith into 9 sub-crates + delete dead code
Extract 9 new sub-crates from the ml monolith to enable parallel
compilation across the workspace:

New crates (this commit):
- ml-features (282 tests): feature engineering, 21 modules
- ml-labeling (45 tests): triple barrier, meta-labeling, fractional diff
- ml-ensemble (116 tests): ensemble coordination, voting, confidence
- ml-hyperopt (47 tests): core PSO/TPE optimizer, parameter space
- ml-checkpoint (41 tests): checkpoint persistence, compression, signing
- ml-regime (68 tests): CUSUM, Bayesian changepoint, regime classification
- ml-data-validation (67 tests): FDR correction, CPCV, data quality
- ml-risk (33 tests): neural VaR, Kelly criterion, circuit breakers
- ml-validation (43 tests): statistical validation, walk-forward, DSR

Extended existing crates:
- ml-dqn: added evaluation/ (backtesting engine, metrics, reports)
  and checkpoint implementation
- ml-supervised: added checkpoint implementations
- ml-core: added shared types needed by new sub-crates

Pattern: each module in ml/ becomes a thin facade (pub use subcrate::*)
with bridge modules staying in ml for cross-model adapter code.

Dead code deleted (~7K lines):
- 13 undeclared files in microstructure/ (never compiled)
- 7 undeclared files + tests/ in risk/ (never compiled)
- parquet_io, cache_service, cache_storage, minio_integration (unused)
- extraction_wave_d_impl.rs (bare fn outside impl block)

All 2,746 sub-crate tests + 951 ml tests pass.
Full workspace builds clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:17:22 +01:00

394 lines
12 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 crate::aggregator::ModelSignal;
use crate::{HealthStatus, MLError};
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().map_err(|e| {
tracing::error!("Models RwLock poisoned in health_check: {}", e);
e
}).ok();
let signals = self.signals.read().map_err(|e| {
tracing::error!("Signals RwLock poisoned in health_check: {}", e);
e
}).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
}