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>
455 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|