Files
foxhunt/crates/ml/src/ensemble/gate_optimizer.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

455 lines
16 KiB
Rust

//! 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<ThresholdAdjustment>),
/// 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<Instant>,
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::<f64>()
/ low_disagree_buckets
.iter()
.map(|b| b.trade_count as f64)
.sum::<f64>();
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<GateBucketMetrics> {
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);
}
}