diff --git a/ml/src/ensemble/conviction_gates.rs b/ml/src/ensemble/conviction_gates.rs new file mode 100644 index 000000000..3113b235c --- /dev/null +++ b/ml/src/ensemble/conviction_gates.rs @@ -0,0 +1,566 @@ +//! 7-Gate Conviction System +//! +//! Evaluates ensemble predictions through a series of quality gates before +//! allowing trade execution. Gates are evaluated in order; any failure +//! results in HOLD. + +use serde::{Deserialize, Serialize}; + +/// Trading session windows (Eastern Time) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TradingSession { + PreMarket, + Regular, + AfterHours, +} + +/// Configuration for the 7-gate conviction system +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConvictionGateConfig { + pub model_health_threshold: f64, + pub allowed_sessions: Vec, + pub min_confidence: f64, + pub max_disagreement: f64, + pub min_quorum: f64, + pub regime_tightening_factor: f64, + pub high_vol_threshold: f64, + pub conviction_scaling_enabled: bool, +} + +impl Default for ConvictionGateConfig { + fn default() -> Self { + Self { + model_health_threshold: 0.70, + allowed_sessions: vec![TradingSession::Regular], + min_confidence: 0.60, + max_disagreement: 0.40, + min_quorum: 0.60, + regime_tightening_factor: 0.80, + high_vol_threshold: 0.03, + conviction_scaling_enabled: true, + } + } +} + +/// Which gate rejected the decision (if any) +#[derive(Debug, Clone, PartialEq)] +pub enum GateRejection { + ModelHealth { + healthy_ratio: f64, + threshold: f64, + }, + TimeOfDay { + current_session: TradingSession, + }, + Confidence { + confidence: f64, + threshold: f64, + }, + Agreement { + disagreement: f64, + threshold: f64, + }, + Quorum { + quorum_ratio: f64, + threshold: f64, + }, + Regime { + adjusted_confidence: f64, + adjusted_threshold: f64, + }, +} + +/// Result of passing all conviction gates +#[derive(Debug, Clone)] +pub struct GatePassResult { + pub conviction_score: f64, + pub gate_details: Vec, +} + +/// Individual gate evaluation result +#[derive(Debug, Clone)] +pub struct GateEvaluation { + pub gate_name: String, + pub value: f64, + pub threshold: f64, + pub passed: bool, +} + +/// Outcome of evaluating all gates +#[derive(Debug, Clone)] +pub enum ConvictionGateOutcome { + Passed(GatePassResult), + Rejected(GateRejection), +} + +/// Input data for gate evaluation +#[derive(Debug, Clone)] +pub struct GateInput { + pub confidence: f64, + pub disagreement_rate: f64, + pub quorum_ratio: f64, + pub healthy_models: usize, + pub total_models: usize, + pub current_session: TradingSession, + pub regime_volatility: f64, +} + +/// Evaluates ensemble decisions through 7 conviction gates +#[derive(Debug, Clone)] +pub struct ConvictionGateEvaluator { + config: ConvictionGateConfig, +} + +impl ConvictionGateEvaluator { + pub fn new(config: ConvictionGateConfig) -> Self { + Self { config } + } + + pub fn config(&self) -> &ConvictionGateConfig { + &self.config + } + + pub fn config_mut(&mut self) -> &mut ConvictionGateConfig { + &mut self.config + } + + /// Evaluate all 7 gates in order. Returns on first rejection. + pub fn evaluate(&self, input: &GateInput) -> ConvictionGateOutcome { + let mut gate_details = Vec::with_capacity(7); + + // Gate 1: Model Health - healthy_ratio >= threshold + let healthy_ratio = if input.total_models == 0 { + 0.0 + } else { + input.healthy_models as f64 / input.total_models as f64 + }; + + let gate1_passed = healthy_ratio >= self.config.model_health_threshold; + gate_details.push(GateEvaluation { + gate_name: "model_health".to_string(), + value: healthy_ratio, + threshold: self.config.model_health_threshold, + passed: gate1_passed, + }); + if !gate1_passed { + return ConvictionGateOutcome::Rejected(GateRejection::ModelHealth { + healthy_ratio, + threshold: self.config.model_health_threshold, + }); + } + + // Gate 2: Time-of-Day - current_session in allowed_sessions + let gate2_passed = self.config.allowed_sessions.contains(&input.current_session); + gate_details.push(GateEvaluation { + gate_name: "time_of_day".to_string(), + value: if gate2_passed { 1.0 } else { 0.0 }, + threshold: 1.0, + passed: gate2_passed, + }); + if !gate2_passed { + return ConvictionGateOutcome::Rejected(GateRejection::TimeOfDay { + current_session: input.current_session, + }); + } + + // Gate 6 prep: If regime_volatility > high_vol_threshold, tighten thresholds + let high_vol = input.regime_volatility > self.config.high_vol_threshold; + let (adj_min_confidence, adj_max_disagreement, adj_min_quorum) = if high_vol { + let factor = self.config.regime_tightening_factor; + let adj_conf = (self.config.min_confidence / factor).min(0.95); + let adj_disagree = (self.config.max_disagreement * factor).max(0.05); + let adj_quorum = (self.config.min_quorum / factor).min(0.95); + (adj_conf, adj_disagree, adj_quorum) + } else { + ( + self.config.min_confidence, + self.config.max_disagreement, + self.config.min_quorum, + ) + }; + + // Gate 3: Confidence >= adj_min_confidence + let gate3_passed = input.confidence >= adj_min_confidence; + gate_details.push(GateEvaluation { + gate_name: "confidence".to_string(), + value: input.confidence, + threshold: adj_min_confidence, + passed: gate3_passed, + }); + if !gate3_passed { + return ConvictionGateOutcome::Rejected(GateRejection::Confidence { + confidence: input.confidence, + threshold: adj_min_confidence, + }); + } + + // Gate 4: Disagreement <= adj_max_disagreement + let gate4_passed = input.disagreement_rate <= adj_max_disagreement; + gate_details.push(GateEvaluation { + gate_name: "agreement".to_string(), + value: input.disagreement_rate, + threshold: adj_max_disagreement, + passed: gate4_passed, + }); + if !gate4_passed { + return ConvictionGateOutcome::Rejected(GateRejection::Agreement { + disagreement: input.disagreement_rate, + threshold: adj_max_disagreement, + }); + } + + // Gate 5: Quorum >= adj_min_quorum + let gate5_passed = input.quorum_ratio >= adj_min_quorum; + gate_details.push(GateEvaluation { + gate_name: "quorum".to_string(), + value: input.quorum_ratio, + threshold: adj_min_quorum, + passed: gate5_passed, + }); + if !gate5_passed { + return ConvictionGateOutcome::Rejected(GateRejection::Quorum { + quorum_ratio: input.quorum_ratio, + threshold: adj_min_quorum, + }); + } + + // Gate 6: Regime (already applied via threshold adjustments) + // Record the regime gate evaluation + let regime_value = if high_vol { + input.confidence + } else { + input.confidence + }; + let regime_threshold = adj_min_confidence; + gate_details.push(GateEvaluation { + gate_name: "regime".to_string(), + value: regime_value, + threshold: regime_threshold, + passed: true, // Already enforced via adjusted thresholds in gates 3-5 + }); + + // Gate 7: Conviction Sizing + let conviction_score = if self.config.conviction_scaling_enabled { + input.confidence + * (1.0 - input.disagreement_rate) + * input.quorum_ratio + * healthy_ratio + } else { + 1.0 + }; + gate_details.push(GateEvaluation { + gate_name: "conviction_sizing".to_string(), + value: conviction_score, + threshold: 0.0, // No minimum threshold for conviction sizing + passed: true, + }); + + ConvictionGateOutcome::Passed(GatePassResult { + conviction_score, + gate_details, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_passing_input() -> GateInput { + GateInput { + confidence: 0.80, + disagreement_rate: 0.10, + quorum_ratio: 0.80, + healthy_models: 8, + total_models: 10, + current_session: TradingSession::Regular, + regime_volatility: 0.01, + } + } + + #[test] + fn test_default_config_values() { + let config = ConvictionGateConfig::default(); + assert!((config.model_health_threshold - 0.70).abs() < f64::EPSILON); + assert_eq!(config.allowed_sessions, vec![TradingSession::Regular]); + assert!((config.min_confidence - 0.60).abs() < f64::EPSILON); + assert!((config.max_disagreement - 0.40).abs() < f64::EPSILON); + assert!((config.min_quorum - 0.60).abs() < f64::EPSILON); + assert!((config.regime_tightening_factor - 0.80).abs() < f64::EPSILON); + assert!((config.high_vol_threshold - 0.03).abs() < f64::EPSILON); + assert!(config.conviction_scaling_enabled); + } + + #[test] + fn test_gate_rejection_variants() { + let r1 = GateRejection::ModelHealth { + healthy_ratio: 0.5, + threshold: 0.7, + }; + let r2 = GateRejection::ModelHealth { + healthy_ratio: 0.5, + threshold: 0.7, + }; + assert_eq!(r1, r2); + + let r3 = GateRejection::TimeOfDay { + current_session: TradingSession::PreMarket, + }; + assert_ne!(r1, r3); + } + + #[test] + fn test_gate1_model_health_passes() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + healthy_models: 8, + total_models: 10, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + assert!(matches!(outcome, ConvictionGateOutcome::Passed(_))); + } + + #[test] + fn test_gate1_model_health_rejects() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + healthy_models: 5, + total_models: 10, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::ModelHealth { + healthy_ratio, + threshold, + }) = outcome + { + assert!((healthy_ratio - 0.5).abs() < f64::EPSILON); + assert!((threshold - 0.70).abs() < f64::EPSILON); + } else { + panic!("Expected ModelHealth rejection, got {:?}", outcome); + } + } + + #[test] + fn test_gate2_time_of_day_rejects_premarket() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + current_session: TradingSession::PreMarket, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::TimeOfDay { current_session }) = + outcome + { + assert_eq!(current_session, TradingSession::PreMarket); + } else { + panic!("Expected TimeOfDay rejection, got {:?}", outcome); + } + } + + #[test] + fn test_gate3_confidence_rejects_low() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + confidence: 0.45, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::Confidence { + confidence, + threshold, + }) = outcome + { + assert!((confidence - 0.45).abs() < f64::EPSILON); + assert!((threshold - 0.60).abs() < f64::EPSILON); + } else { + panic!("Expected Confidence rejection, got {:?}", outcome); + } + } + + #[test] + fn test_gate4_agreement_rejects_high_disagreement() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + disagreement_rate: 0.55, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::Agreement { + disagreement, + threshold, + }) = outcome + { + assert!((disagreement - 0.55).abs() < f64::EPSILON); + assert!((threshold - 0.40).abs() < f64::EPSILON); + } else { + panic!("Expected Agreement rejection, got {:?}", outcome); + } + } + + #[test] + fn test_gate5_quorum_rejects_low() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + quorum_ratio: 0.40, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::Quorum { + quorum_ratio, + threshold, + }) = outcome + { + assert!((quorum_ratio - 0.40).abs() < f64::EPSILON); + assert!((threshold - 0.60).abs() < f64::EPSILON); + } else { + panic!("Expected Quorum rejection, got {:?}", outcome); + } + } + + #[test] + fn test_conviction_score_calculation() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + confidence: 0.80, + disagreement_rate: 0.10, + quorum_ratio: 0.80, + healthy_models: 9, + total_models: 10, + current_session: TradingSession::Regular, + regime_volatility: 0.01, + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Passed(result) = outcome { + // score = 0.80 * (1.0 - 0.10) * 0.80 * (9/10) = 0.80 * 0.90 * 0.80 * 0.90 = 0.5184 + let expected = 0.80 * 0.90 * 0.80 * 0.90; + assert!( + (result.conviction_score - expected).abs() < 1e-10, + "Expected {}, got {}", + expected, + result.conviction_score + ); + } else { + panic!("Expected Passed outcome, got {:?}", outcome); + } + } + + #[test] + fn test_gate6_regime_tightens_in_high_vol() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + // With default config: min_confidence=0.60, tightening=0.80 + // High vol: adj_min_confidence = 0.60 / 0.80 = 0.75 + // 0.70 confidence < 0.75 threshold => rejected + let input = GateInput { + confidence: 0.70, + regime_volatility: 0.05, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::Confidence { + confidence, + threshold, + }) = outcome + { + assert!((confidence - 0.70).abs() < f64::EPSILON); + assert!((threshold - 0.75).abs() < f64::EPSILON); + } else { + panic!( + "Expected Confidence rejection due to regime tightening, got {:?}", + outcome + ); + } + } + + #[test] + fn test_gate6_regime_no_effect_in_low_vol() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + // Low vol: thresholds unchanged, min_confidence=0.60 + // 0.65 confidence >= 0.60 => passes + let input = GateInput { + confidence: 0.65, + regime_volatility: 0.01, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + assert!( + matches!(outcome, ConvictionGateOutcome::Passed(_)), + "Expected Passed in low vol, got {:?}", + outcome + ); + } + + #[test] + fn test_zero_models_rejects() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = GateInput { + healthy_models: 0, + total_models: 0, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Rejected(GateRejection::ModelHealth { + healthy_ratio, + threshold, + }) = outcome + { + assert!((healthy_ratio - 0.0).abs() < f64::EPSILON); + assert!((threshold - 0.70).abs() < f64::EPSILON); + } else { + panic!("Expected ModelHealth rejection for 0/0 models, got {:?}", outcome); + } + } + + #[test] + fn test_conviction_scaling_disabled() { + let mut config = ConvictionGateConfig::default(); + config.conviction_scaling_enabled = false; + let evaluator = ConvictionGateEvaluator::new(config); + let input = default_passing_input(); + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Passed(result) = outcome { + assert!( + (result.conviction_score - 1.0).abs() < f64::EPSILON, + "Expected 1.0 when scaling disabled, got {}", + result.conviction_score + ); + } else { + panic!("Expected Passed outcome, got {:?}", outcome); + } + } + + #[test] + fn test_gate_details_count() { + let evaluator = ConvictionGateEvaluator::new(ConvictionGateConfig::default()); + let input = default_passing_input(); + let outcome = evaluator.evaluate(&input); + if let ConvictionGateOutcome::Passed(result) = outcome { + assert_eq!( + result.gate_details.len(), + 7, + "Expected 7 gate evaluations, got {}", + result.gate_details.len() + ); + } else { + panic!("Expected Passed outcome, got {:?}", outcome); + } + } + + #[test] + fn test_after_hours_allowed_when_configured() { + let mut config = ConvictionGateConfig::default(); + config.allowed_sessions.push(TradingSession::AfterHours); + let evaluator = ConvictionGateEvaluator::new(config); + let input = GateInput { + current_session: TradingSession::AfterHours, + ..default_passing_input() + }; + let outcome = evaluator.evaluate(&input); + assert!( + matches!(outcome, ConvictionGateOutcome::Passed(_)), + "Expected Passed for AfterHours when configured, got {:?}", + outcome + ); + } +} diff --git a/ml/src/ensemble/mod.rs b/ml/src/ensemble/mod.rs index 24ab0c40e..50b023d78 100644 --- a/ml/src/ensemble/mod.rs +++ b/ml/src/ensemble/mod.rs @@ -21,6 +21,7 @@ pub mod inference_adapter; pub mod inference_ensemble; pub mod signal; pub mod adapters; +pub mod conviction_gates; // Re-export key types that are used across ensemble modules pub use ab_testing::{ @@ -48,6 +49,10 @@ pub use metrics::{ }; pub use training_integration::EnsembleTrainingIntegration; pub use inference_adapter::{EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta}; +pub use conviction_gates::{ + ConvictionGateConfig, ConvictionGateOutcome, GateEvaluation, GatePassResult, GateRejection, + TradingSession, +}; /// Errors that can occur in ensemble operations #[derive(Error, Debug)]