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>
303 lines
8.2 KiB
Rust
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
|
|
}
|
|
}
|