Files
foxhunt/crates/ml-ensemble/src/decision.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

303 lines
8.2 KiB
Rust

//! Ensemble decision types for production trading
//!
//! This module defines the core types for ensemble model decisions,
//! including aggregated predictions, confidence scores, and metadata
//! for tracking model contributions.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Action to take based on ensemble decision
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TradingAction {
/// Buy signal (long position)
Buy,
/// Sell signal (short position)
Sell,
/// Hold current position (no action)
Hold,
}
impl TradingAction {
/// Convert from signal value (-1.0 to 1.0)
pub fn from_signal(signal: f64, threshold: f64) -> Self {
if signal > threshold {
Self::Buy
} else if signal < -threshold {
Self::Sell
} else {
Self::Hold
}
}
/// Convert to signal value
pub fn to_signal(&self) -> f64 {
match self {
Self::Buy => 1.0,
Self::Sell => -1.0,
Self::Hold => 0.0,
}
}
}
/// Ensemble decision with aggregated model predictions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnsembleDecision {
/// Trading action (Buy/Sell/Hold)
pub action: TradingAction,
/// Ensemble confidence (0.0-1.0)
pub confidence: f64,
/// Raw signal value (-1.0 to 1.0, bearish to bullish)
pub signal: f64,
/// Disagreement rate (0.0-1.0, % models disagree)
pub disagreement_rate: f64,
/// Per-model contributions
pub model_votes: HashMap<String, ModelVote>,
/// Timestamp of decision (microseconds since epoch)
pub timestamp: u64,
/// Symbol for this decision
pub symbol: Option<String>,
/// Additional metadata
pub metadata: HashMap<String, serde_json::Value>,
}
impl EnsembleDecision {
/// Create new ensemble decision
pub fn new(
action: TradingAction,
confidence: f64,
signal: f64,
disagreement_rate: f64,
model_votes: HashMap<String, ModelVote>,
) -> Self {
Self {
action,
confidence,
signal,
disagreement_rate,
model_votes,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_micros() as u64,
symbol: None,
metadata: HashMap::new(),
}
}
/// Set symbol for this decision
pub fn with_symbol(mut self, symbol: String) -> Self {
self.symbol = Some(symbol);
self
}
/// Add metadata
pub fn with_metadata(mut self, key: String, value: serde_json::Value) -> Self {
self.metadata.insert(key, value);
self
}
/// Check if high disagreement (> 50% models disagree)
pub fn is_high_disagreement(&self) -> bool {
self.disagreement_rate > 0.5
}
/// Get number of models that voted
pub fn model_count(&self) -> usize {
self.model_votes.len()
}
}
/// Individual model vote in ensemble
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelVote {
/// Model identifier
pub model_id: String,
/// Model's signal (-1.0 to 1.0)
pub signal: f64,
/// Model's confidence (0.0 to 1.0)
pub confidence: f64,
/// Weight applied to this model's vote
pub weight: f64,
/// Model type
pub model_type: String,
}
impl ModelVote {
/// Create new model vote
pub fn new(model_id: String, signal: f64, confidence: f64, weight: f64) -> Self {
Self {
model_id,
signal,
confidence,
weight,
model_type: "unknown".to_owned(),
}
}
/// Set model type
pub fn with_model_type(mut self, model_type: String) -> Self {
self.model_type = model_type;
self
}
/// Get trading action from signal
pub fn action(&self, threshold: f64) -> TradingAction {
TradingAction::from_signal(self.signal, threshold)
}
}
/// Model weight configuration for ensemble
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelWeight {
/// Model identifier
pub model_id: String,
/// Static weight (base contribution)
pub static_weight: f64,
/// Dynamic weight (performance-adjusted)
pub dynamic_weight: f64,
/// Recent performance metrics
pub performance_metrics: PerformanceMetrics,
}
impl ModelWeight {
/// Create new model weight
pub fn new(model_id: String, static_weight: f64) -> Self {
Self {
model_id,
static_weight,
dynamic_weight: static_weight,
performance_metrics: PerformanceMetrics::default(),
}
}
/// Get effective weight (static * dynamic adjustment)
pub fn effective_weight(&self) -> f64 {
self.static_weight * self.dynamic_weight
}
/// Update dynamic weight based on recent performance
pub fn update_dynamic_weight(&mut self) {
// Simple performance-based adjustment
// Sharpe factor: target Sharpe ratio of 1.0 as baseline
let sharpe_factor = (self.performance_metrics.sharpe_ratio / 1.0)
.min(1.5)
.max(0.5);
// Accuracy factor: target accuracy of 0.55 as baseline
let accuracy_factor = (self.performance_metrics.accuracy / 0.55).min(1.5).max(0.5);
// Average the two factors (no division by 2 since we want higher weights)
self.dynamic_weight = (sharpe_factor + accuracy_factor) / 2.0;
}
}
/// Performance metrics for a model
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceMetrics {
/// Recent accuracy (0.0 to 1.0)
pub accuracy: f64,
/// Recent Sharpe ratio
pub sharpe_ratio: f64,
/// Win rate (0.0 to 1.0)
pub win_rate: f64,
/// Number of predictions
pub prediction_count: u64,
/// Average latency in microseconds
pub avg_latency_us: u64,
}
impl Default for PerformanceMetrics {
fn default() -> Self {
Self {
accuracy: 0.5,
sharpe_ratio: 1.0,
win_rate: 0.5,
prediction_count: 0,
avg_latency_us: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trading_action_from_signal() {
assert_eq!(TradingAction::from_signal(0.6, 0.5), TradingAction::Buy);
assert_eq!(TradingAction::from_signal(-0.6, 0.5), TradingAction::Sell);
assert_eq!(TradingAction::from_signal(0.3, 0.5), TradingAction::Hold);
}
#[test]
fn test_ensemble_decision_creation() {
let mut votes = HashMap::new();
votes.insert(
"DQN".to_owned(),
ModelVote::new("DQN".to_owned(), 0.8, 0.9, 0.33),
);
votes.insert(
"PPO".to_owned(),
ModelVote::new("PPO".to_owned(), 0.7, 0.85, 0.33),
);
votes.insert(
"TFT".to_owned(),
ModelVote::new("TFT".to_owned(), 0.6, 0.8, 0.34),
);
let decision = EnsembleDecision::new(TradingAction::Buy, 0.85, 0.7, 0.1, votes);
assert_eq!(decision.action, TradingAction::Buy);
assert_eq!(decision.confidence, 0.85);
assert_eq!(decision.signal, 0.7);
assert_eq!(decision.disagreement_rate, 0.1);
assert_eq!(decision.model_count(), 3);
assert!(!decision.is_high_disagreement());
}
#[test]
fn test_high_disagreement_detection() {
let votes = HashMap::new();
let decision = EnsembleDecision::new(TradingAction::Hold, 0.3, 0.0, 0.6, votes);
assert!(decision.is_high_disagreement());
}
#[test]
fn test_model_weight_adjustment() {
let mut weight = ModelWeight::new("DQN".to_owned(), 0.33);
// High performance scenario
weight.performance_metrics.sharpe_ratio = 2.0;
weight.performance_metrics.accuracy = 0.6;
weight.update_dynamic_weight();
assert!(weight.dynamic_weight > 0.8); // Should increase weight
// Low performance scenario
weight.performance_metrics.sharpe_ratio = 0.5;
weight.performance_metrics.accuracy = 0.4;
weight.update_dynamic_weight();
assert!(weight.dynamic_weight < 0.7); // Should decrease weight
}
}