feat(ensemble): add 7-gate conviction system with evaluator and 15 tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-23 12:24:36 +01:00
parent df251a9d7e
commit 948b2d8992
2 changed files with 571 additions and 0 deletions

View File

@@ -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<TradingSession>,
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<GateEvaluation>,
}
/// 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
);
}
}

View File

@@ -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)]