//! Gate threshold optimizer for conviction gates //! //! Adjusts conviction gate thresholds based on win-rate per confidence bucket. //! Same safety rails pattern as the weight optimizer: bounded adjustments, //! cooldown, freeze/unfreeze. use super::conviction_gates::ConvictionGateConfig; use std::time::{Duration, Instant}; use tracing::{info, warn}; /// Maximum threshold change per optimization cycle const MAX_THRESHOLD_STEP: f64 = 0.03; /// Minimum allowed threshold value const MIN_THRESHOLD: f64 = 0.30; /// Maximum allowed threshold value const MAX_THRESHOLD: f64 = 0.90; /// Minimum trades per bucket before adjustment const MIN_BUCKET_TRADES: u64 = 50; /// Win-rate metrics per confidence bucket #[derive(Debug, Clone)] pub struct GateBucketMetrics { /// Confidence bucket lower bound (e.g. 0.60) pub confidence_lower: f64, /// Confidence bucket upper bound (e.g. 0.70) pub confidence_upper: f64, /// Win rate in this bucket (0.0-1.0) pub win_rate: f64, /// Number of trades in this bucket pub trade_count: u64, /// Average P&L per trade in this bucket pub avg_pnl: f64, } /// Gate optimizer configuration #[derive(Debug, Clone)] pub struct GateOptimizerConfig { pub max_step: f64, pub min_threshold: f64, pub max_threshold: f64, pub min_bucket_trades: u64, pub cooldown: Duration, /// Target win rate -- thresholds tighten if below, loosen if above pub target_win_rate: f64, } impl Default for GateOptimizerConfig { fn default() -> Self { Self { max_step: MAX_THRESHOLD_STEP, min_threshold: MIN_THRESHOLD, max_threshold: MAX_THRESHOLD, min_bucket_trades: MIN_BUCKET_TRADES, cooldown: Duration::from_secs(24 * 3600), target_win_rate: 0.55, } } } /// Proposed threshold change #[derive(Debug, Clone)] pub struct ThresholdAdjustment { pub field_name: String, pub old_value: f64, pub new_value: f64, pub reason: String, } /// Result of a gate optimization cycle #[derive(Debug, Clone)] pub enum GateOptimizationResult { /// Thresholds adjusted Adjusted(Vec), /// Not enough data InsufficientData { total_trades: u64, required: u64 }, /// Still in cooldown Cooldown { remaining: Duration }, /// Frozen by kill switch KillSwitchActive, } /// Gate threshold optimizer #[derive(Debug)] pub struct GateOptimizer { config: GateOptimizerConfig, last_adjustment: Option, frozen: bool, } impl GateOptimizer { pub fn new(config: GateOptimizerConfig) -> Self { Self { config, last_adjustment: None, frozen: false, } } /// Freeze all adjustments (kill switch) pub fn freeze(&mut self) { self.frozen = true; warn!("Gate optimizer frozen by kill switch"); } /// Unfreeze adjustments pub fn unfreeze(&mut self) { self.frozen = false; info!("Gate optimizer unfrozen"); } pub fn is_frozen(&self) -> bool { self.frozen } /// Run one optimization cycle /// /// Analyzes win-rate per confidence bucket and adjusts min_confidence threshold. /// If win rate near the current threshold is below target, threshold increases /// (more selective). If well above target, threshold decreases (more permissive). pub fn optimize( &mut self, gate_config: &ConvictionGateConfig, buckets: &[GateBucketMetrics], ) -> GateOptimizationResult { if self.frozen { return GateOptimizationResult::KillSwitchActive; } // Check cooldown if let Some(last) = self.last_adjustment { let elapsed = last.elapsed(); if elapsed < self.config.cooldown { return GateOptimizationResult::Cooldown { remaining: self.config.cooldown - elapsed, }; } } // Check minimum data let total_trades: u64 = buckets.iter().map(|b| b.trade_count).sum(); let min_required = self.config.min_bucket_trades * 3; // At least 3 buckets worth if total_trades < min_required { return GateOptimizationResult::InsufficientData { total_trades, required: min_required, }; } let mut adjustments = Vec::new(); // Analyze min_confidence threshold // Find the bucket containing the current threshold let threshold_bucket = buckets.iter().find(|b| { b.confidence_lower <= gate_config.min_confidence && gate_config.min_confidence < b.confidence_upper && b.trade_count >= self.config.min_bucket_trades }); if let Some(bucket) = threshold_bucket { let win_rate_delta = bucket.win_rate - self.config.target_win_rate; // If win rate is too low near threshold → tighten (increase threshold) // If win rate is high → loosen (decrease threshold) let direction = if win_rate_delta < -0.05 { // Win rate below target by > 5pp → tighten 1.0 } else if win_rate_delta > 0.10 { // Win rate above target by > 10pp → loosen -1.0 } else { 0.0 // In acceptable range }; if direction != 0.0 { let step = (win_rate_delta.abs() * 0.1) .min(self.config.max_step) .max(0.005); let new_confidence = (gate_config.min_confidence + direction * step) .clamp(self.config.min_threshold, self.config.max_threshold); if (new_confidence - gate_config.min_confidence).abs() > 1e-6 { adjustments.push(ThresholdAdjustment { field_name: "min_confidence".into(), old_value: gate_config.min_confidence, new_value: new_confidence, reason: format!( "bucket win_rate={:.3}, target={:.3}, delta={:.3}", bucket.win_rate, self.config.target_win_rate, win_rate_delta ), }); } } } // Analyze max_disagreement threshold // If overall win rate on low-disagreement trades is high, can loosen let low_disagree_buckets: Vec<&GateBucketMetrics> = buckets .iter() .filter(|b| b.trade_count >= self.config.min_bucket_trades) .collect(); if !low_disagree_buckets.is_empty() { let weighted_win_rate: f64 = low_disagree_buckets .iter() .map(|b| b.win_rate * b.trade_count as f64) .sum::() / low_disagree_buckets .iter() .map(|b| b.trade_count as f64) .sum::(); if weighted_win_rate < self.config.target_win_rate - 0.05 { // Poor overall performance → tighten disagreement (lower max) let new_max = (gate_config.max_disagreement - 0.01) .clamp(0.10, 0.60); if (new_max - gate_config.max_disagreement).abs() > 1e-6 { adjustments.push(ThresholdAdjustment { field_name: "max_disagreement".into(), old_value: gate_config.max_disagreement, new_value: new_max, reason: format!( "weighted win_rate={:.3} below target {:.3}", weighted_win_rate, self.config.target_win_rate ), }); } } } if !adjustments.is_empty() { self.last_adjustment = Some(Instant::now()); info!( "Gate optimizer adjusted {} thresholds", adjustments.len() ); } GateOptimizationResult::Adjusted(adjustments) } /// Apply adjustments to a ConvictionGateConfig (returns modified copy) pub fn apply(config: &ConvictionGateConfig, adjustments: &[ThresholdAdjustment]) -> ConvictionGateConfig { let mut new_config = config.clone(); for adj in adjustments { match adj.field_name.as_str() { "min_confidence" => new_config.min_confidence = adj.new_value, "max_disagreement" => new_config.max_disagreement = adj.new_value, "min_quorum" => new_config.min_quorum = adj.new_value, _ => {} } } new_config } } #[cfg(test)] mod tests { use super::*; fn make_buckets(data: &[(f64, f64, f64, u64)]) -> Vec { data.iter() .map(|(lower, upper, win_rate, trades)| GateBucketMetrics { confidence_lower: *lower, confidence_upper: *upper, win_rate: *win_rate, trade_count: *trades, avg_pnl: 0.0, }) .collect() } #[test] fn test_insufficient_data() { let mut opt = GateOptimizer::new(GateOptimizerConfig::default()); let config = ConvictionGateConfig::default(); let buckets = make_buckets(&[(0.50, 0.60, 0.55, 10), (0.60, 0.70, 0.60, 10)]); let result = opt.optimize(&config, &buckets); assert!(matches!( result, GateOptimizationResult::InsufficientData { .. } )); } #[test] fn test_cooldown_enforced() { let mut opt = GateOptimizer::new(GateOptimizerConfig::default()); let config = ConvictionGateConfig::default(); let buckets = make_buckets(&[ (0.50, 0.60, 0.45, 100), (0.60, 0.70, 0.40, 100), (0.70, 0.80, 0.55, 100), ]); let _ = opt.optimize(&config, &buckets); let result2 = opt.optimize(&config, &buckets); assert!(matches!(result2, GateOptimizationResult::Cooldown { .. })); } #[test] fn test_kill_switch() { let mut opt = GateOptimizer::new(GateOptimizerConfig::default()); opt.freeze(); let config = ConvictionGateConfig::default(); let buckets = make_buckets(&[(0.50, 0.60, 0.45, 200)]); let result = opt.optimize(&config, &buckets); assert!(matches!(result, GateOptimizationResult::KillSwitchActive)); } #[test] fn test_tightens_on_low_win_rate() { let mut config_opt = GateOptimizerConfig::default(); config_opt.cooldown = Duration::ZERO; let mut opt = GateOptimizer::new(config_opt); let config = ConvictionGateConfig::default(); // min_confidence = 0.60 // Win rate at threshold bucket is poor (0.40 < 0.55 target) let buckets = make_buckets(&[ (0.50, 0.60, 0.40, 100), (0.60, 0.70, 0.40, 100), // Threshold bucket (0.70, 0.80, 0.60, 100), ]); let result = opt.optimize(&config, &buckets); if let GateOptimizationResult::Adjusted(adjustments) = result { let confidence_adj = adjustments .iter() .find(|a| a.field_name == "min_confidence"); assert!( confidence_adj.is_some(), "Expected min_confidence adjustment" ); if let Some(adj) = confidence_adj { assert!( adj.new_value > adj.old_value, "Expected threshold to increase (tighten) on low win rate" ); } } } #[test] fn test_loosens_on_high_win_rate() { let mut config_opt = GateOptimizerConfig::default(); config_opt.cooldown = Duration::ZERO; let mut opt = GateOptimizer::new(config_opt); let config = ConvictionGateConfig::default(); // min_confidence = 0.60 // Win rate at threshold bucket is very good (0.75 >> 0.55 target) let buckets = make_buckets(&[ (0.50, 0.60, 0.70, 100), (0.60, 0.70, 0.75, 100), // Threshold bucket (0.70, 0.80, 0.80, 100), ]); let result = opt.optimize(&config, &buckets); if let GateOptimizationResult::Adjusted(adjustments) = result { let confidence_adj = adjustments .iter() .find(|a| a.field_name == "min_confidence"); if let Some(adj) = confidence_adj { assert!( adj.new_value < adj.old_value, "Expected threshold to decrease (loosen) on high win rate" ); } } } #[test] fn test_bounds_enforced() { let mut config_opt = GateOptimizerConfig::default(); config_opt.cooldown = Duration::ZERO; let mut opt = GateOptimizer::new(config_opt); // Config with threshold already near maximum let mut config = ConvictionGateConfig::default(); config.min_confidence = 0.89; // Very low win rate to force tightening let buckets = make_buckets(&[ (0.80, 0.90, 0.30, 100), (0.89, 0.95, 0.30, 100), // Threshold bucket (0.70, 0.80, 0.40, 100), ]); let result = opt.optimize(&config, &buckets); if let GateOptimizationResult::Adjusted(adjustments) = result { for adj in &adjustments { assert!( adj.new_value >= MIN_THRESHOLD, "Below minimum: {}", adj.new_value ); assert!( adj.new_value <= MAX_THRESHOLD, "Above maximum: {}", adj.new_value ); } } } #[test] fn test_no_change_in_acceptable_range() { let mut config_opt = GateOptimizerConfig::default(); config_opt.cooldown = Duration::ZERO; let mut opt = GateOptimizer::new(config_opt); let config = ConvictionGateConfig::default(); // Win rate is in acceptable range (target ± tolerance) let buckets = make_buckets(&[ (0.50, 0.60, 0.56, 100), (0.60, 0.70, 0.58, 100), // Just above target, within tolerance (0.70, 0.80, 0.60, 100), ]); let result = opt.optimize(&config, &buckets); if let GateOptimizationResult::Adjusted(adjustments) = result { let confidence_adj = adjustments .iter() .find(|a| a.field_name == "min_confidence"); assert!( confidence_adj.is_none(), "Expected no min_confidence adjustment when in acceptable range" ); } } #[test] fn test_apply_adjustments() { let config = ConvictionGateConfig::default(); let adjustments = vec![ ThresholdAdjustment { field_name: "min_confidence".into(), old_value: 0.60, new_value: 0.65, reason: "test".into(), }, ThresholdAdjustment { field_name: "max_disagreement".into(), old_value: 0.40, new_value: 0.35, reason: "test".into(), }, ]; let new_config = GateOptimizer::apply(&config, &adjustments); assert!((new_config.min_confidence - 0.65).abs() < 1e-10); assert!((new_config.max_disagreement - 0.35).abs() < 1e-10); // Unchanged fields preserved assert!((new_config.min_quorum - 0.60).abs() < 1e-10); } }