Files
foxhunt/services/trading_service/src/feedback_loop.rs
jgrusewski 69d435cb52 feat(trading): integrate QuestDB metrics provider for feedback loop
Replace stub metrics provider with real QuestDBMetricsProvider that
queries QuestDB via PostgreSQL wire protocol (port 8812) for rolling
model Sharpe, win rates, confidence buckets, and ensemble metrics.

- Add QuestDB service to docker-compose.yml (8.2.3, ports 9009/8812/9003)
- Create questdb_metrics.rs with 5 SQL queries and graceful fallback
- Wire QuestDBMetricsProvider into main.rs (replaces StubMetricsProvider)
- Fix unused Instant import in feedback_loop.rs (#[cfg(test)] scope)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:57:46 +01:00

530 lines
18 KiB
Rust

//! Autonomous Feedback Loop Orchestrator
//!
//! Runs as a background tokio task. Periodically:
//! 1. Queries rolling performance metrics (from QuestDB or in-memory)
//! 2. Runs weight optimizer → applies weight adjustments
//! 3. Runs gate optimizer → applies threshold adjustments
//! 4. Checks retraining triggers (Sharpe < threshold, accuracy < threshold)
//! 5. Monitors ensemble Sharpe for kill switch activation
use ml::ensemble::conviction_gates::ConvictionGateConfig;
use ml::ensemble::gate_optimizer::{
GateBucketMetrics, GateOptimizationResult, GateOptimizer, GateOptimizerConfig,
};
use ml::ensemble::weight_optimizer::{
ModelRollingMetrics, OptimizationResult, WeightOptimizer, WeightOptimizerConfig,
};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tracing::{error, info, warn};
/// Retraining trigger reasons
#[derive(Debug, Clone)]
pub enum RetrainingTrigger {
/// Sharpe ratio dropped below threshold
LowSharpe { model_id: String, sharpe: f64 },
/// Accuracy dropped below threshold
LowAccuracy { model_id: String, accuracy: f64 },
/// Models are too correlated
HighCorrelation { pair: (String, String), correlation: f64 },
}
/// Configuration for the feedback loop
#[derive(Debug, Clone)]
pub struct FeedbackLoopConfig {
/// How often to run the optimization cycle
pub cycle_interval: Duration,
/// Kill switch: if ensemble Sharpe drops below this, freeze all optimizers
pub kill_switch_threshold: f64,
/// Sharpe ratio threshold for retraining trigger
pub retrain_sharpe_threshold: f64,
/// Accuracy threshold for retraining trigger
pub retrain_accuracy_threshold: f64,
/// Correlation threshold for retraining trigger
pub retrain_correlation_threshold: f64,
}
impl Default for FeedbackLoopConfig {
fn default() -> Self {
Self {
cycle_interval: Duration::from_secs(24 * 3600), // 24h
kill_switch_threshold: -1.0,
retrain_sharpe_threshold: -0.5,
retrain_accuracy_threshold: 0.45,
retrain_correlation_threshold: 0.90,
}
}
}
/// Result of a single feedback loop cycle
#[derive(Debug, Clone)]
pub struct CycleResult {
/// Weight adjustments applied (if any)
pub weight_result: OptimizationResult,
/// Gate adjustments applied (if any)
pub gate_result: GateOptimizationResult,
/// Retraining triggers detected
pub retraining_triggers: Vec<RetrainingTrigger>,
/// Whether kill switch was activated this cycle
pub kill_switch_activated: bool,
}
/// Trait for providing rolling metrics to the feedback loop.
/// In production, this queries QuestDB. In tests, it's mocked.
pub trait MetricsProvider: Send + Sync {
/// Get rolling model-level metrics for weight optimization
fn get_model_metrics(&self) -> Vec<ModelRollingMetrics>;
/// Get confidence-bucketed metrics for gate optimization
fn get_gate_buckets(&self) -> Vec<GateBucketMetrics>;
/// Get current model weights
fn get_current_weights(&self) -> HashMap<String, f64>;
/// Get current conviction gate config
fn get_gate_config(&self) -> ConvictionGateConfig;
/// Get 7-day ensemble Sharpe ratio
fn get_ensemble_sharpe_7d(&self) -> f64;
/// Get model pairwise correlations (for retraining triggers)
fn get_model_correlations(&self) -> Vec<((String, String), f64)>;
}
/// Autonomous feedback loop orchestrator
pub struct FeedbackLoop {
weight_optimizer: Arc<Mutex<WeightOptimizer>>,
gate_optimizer: Arc<Mutex<GateOptimizer>>,
config: FeedbackLoopConfig,
}
impl FeedbackLoop {
pub fn new(config: FeedbackLoopConfig) -> Self {
Self {
weight_optimizer: Arc::new(Mutex::new(WeightOptimizer::new(
WeightOptimizerConfig::default(),
))),
gate_optimizer: Arc::new(Mutex::new(GateOptimizer::new(
GateOptimizerConfig::default(),
))),
config,
}
}
/// Create with custom optimizer configs
pub fn with_optimizers(
config: FeedbackLoopConfig,
weight_config: WeightOptimizerConfig,
gate_config: GateOptimizerConfig,
) -> Self {
Self {
weight_optimizer: Arc::new(Mutex::new(WeightOptimizer::new(weight_config))),
gate_optimizer: Arc::new(Mutex::new(GateOptimizer::new(gate_config))),
config,
}
}
/// Run one optimization cycle. Returns the results for observability.
pub async fn run_cycle(&self, metrics: &dyn MetricsProvider) -> CycleResult {
// 1. Check kill switch
let ensemble_sharpe = metrics.get_ensemble_sharpe_7d();
let kill_switch_activated = ensemble_sharpe < self.config.kill_switch_threshold;
if kill_switch_activated {
warn!(
ensemble_sharpe = ensemble_sharpe,
threshold = self.config.kill_switch_threshold,
"Kill switch activated — freezing all optimizers"
);
self.weight_optimizer.lock().await.freeze();
self.gate_optimizer.lock().await.freeze();
return CycleResult {
weight_result: OptimizationResult::KillSwitchActive,
gate_result: GateOptimizationResult::KillSwitchActive,
retraining_triggers: vec![],
kill_switch_activated: true,
};
}
// 2. Run weight optimization
let model_metrics = metrics.get_model_metrics();
let current_weights = metrics.get_current_weights();
let weight_result = self
.weight_optimizer
.lock()
.await
.optimize(&current_weights, &model_metrics);
if let OptimizationResult::Adjusted(ref adjustments) = weight_result {
info!(
count = adjustments.len(),
"Feedback loop: weight adjustments proposed"
);
}
// 3. Run gate optimization
let gate_config = metrics.get_gate_config();
let buckets = metrics.get_gate_buckets();
let gate_result = self
.gate_optimizer
.lock()
.await
.optimize(&gate_config, &buckets);
if let GateOptimizationResult::Adjusted(ref adjustments) = gate_result {
info!(
count = adjustments.len(),
"Feedback loop: gate adjustments proposed"
);
}
// 4. Check retraining triggers
let retraining_triggers = self.check_retraining_triggers(metrics);
if !retraining_triggers.is_empty() {
warn!(
count = retraining_triggers.len(),
"Feedback loop: retraining triggers detected"
);
}
CycleResult {
weight_result,
gate_result,
retraining_triggers,
kill_switch_activated: false,
}
}
/// Spawn the feedback loop as a background tokio task.
///
/// Returns a handle that can be used to abort the loop.
pub fn spawn(
self: Arc<Self>,
metrics: Arc<dyn MetricsProvider>,
) -> tokio::task::JoinHandle<()> {
let interval = self.config.cycle_interval;
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Skip the first immediate tick
ticker.tick().await;
loop {
ticker.tick().await;
info!("Feedback loop: starting optimization cycle");
let result = self.run_cycle(metrics.as_ref()).await;
if result.kill_switch_activated {
error!("Feedback loop: kill switch active, pausing until manual intervention");
// Sleep longer when kill switch is active to avoid log spam
tokio::time::sleep(Duration::from_secs(3600)).await;
}
}
})
}
/// Freeze both optimizers (manual kill switch)
pub async fn freeze(&self) {
self.weight_optimizer.lock().await.freeze();
self.gate_optimizer.lock().await.freeze();
}
/// Unfreeze both optimizers (requires human acknowledgment)
pub async fn unfreeze(&self) {
self.weight_optimizer.lock().await.unfreeze();
self.gate_optimizer.lock().await.unfreeze();
}
/// Check if either optimizer is frozen
pub async fn is_frozen(&self) -> bool {
self.weight_optimizer.lock().await.is_frozen()
|| self.gate_optimizer.lock().await.is_frozen()
}
fn check_retraining_triggers(&self, metrics: &dyn MetricsProvider) -> Vec<RetrainingTrigger> {
let mut triggers = Vec::new();
// Check per-model metrics
for m in &metrics.get_model_metrics() {
if m.sharpe_30d < self.config.retrain_sharpe_threshold {
triggers.push(RetrainingTrigger::LowSharpe {
model_id: m.model_id.clone(),
sharpe: m.sharpe_30d,
});
}
if m.prediction_accuracy < self.config.retrain_accuracy_threshold {
triggers.push(RetrainingTrigger::LowAccuracy {
model_id: m.model_id.clone(),
accuracy: m.prediction_accuracy,
});
}
}
// Check correlations
for ((a, b), corr) in &metrics.get_model_correlations() {
if *corr > self.config.retrain_correlation_threshold {
triggers.push(RetrainingTrigger::HighCorrelation {
pair: (a.clone(), b.clone()),
correlation: *corr,
});
}
}
triggers
}
}
impl std::fmt::Debug for FeedbackLoop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FeedbackLoop")
.field("config", &self.config)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
/// Mock metrics provider for testing
struct MockMetrics {
model_metrics: Vec<ModelRollingMetrics>,
gate_buckets: Vec<GateBucketMetrics>,
weights: HashMap<String, f64>,
gate_config: ConvictionGateConfig,
ensemble_sharpe: f64,
correlations: Vec<((String, String), f64)>,
}
impl MockMetrics {
fn healthy() -> Self {
Self {
model_metrics: vec![
ModelRollingMetrics {
model_id: "dqn".into(),
sharpe_30d: 1.5,
win_rate_30d: 0.58,
prediction_accuracy: 0.62,
trade_count: 200,
deployed_at: Instant::now() - Duration::from_secs(30 * 24 * 3600),
},
ModelRollingMetrics {
model_id: "ppo".into(),
sharpe_30d: 0.8,
win_rate_30d: 0.55,
prediction_accuracy: 0.58,
trade_count: 200,
deployed_at: Instant::now() - Duration::from_secs(30 * 24 * 3600),
},
],
gate_buckets: vec![
GateBucketMetrics {
confidence_lower: 0.50,
confidence_upper: 0.60,
win_rate: 0.56,
trade_count: 100,
avg_pnl: 0.002,
},
GateBucketMetrics {
confidence_lower: 0.60,
confidence_upper: 0.70,
win_rate: 0.58,
trade_count: 100,
avg_pnl: 0.003,
},
GateBucketMetrics {
confidence_lower: 0.70,
confidence_upper: 0.80,
win_rate: 0.62,
trade_count: 100,
avg_pnl: 0.005,
},
],
weights: {
let mut w = HashMap::new();
w.insert("dqn".into(), 0.5);
w.insert("ppo".into(), 0.5);
w
},
gate_config: ConvictionGateConfig::default(),
ensemble_sharpe: 1.2,
correlations: vec![],
}
}
}
impl MetricsProvider for MockMetrics {
fn get_model_metrics(&self) -> Vec<ModelRollingMetrics> {
self.model_metrics.clone()
}
fn get_gate_buckets(&self) -> Vec<GateBucketMetrics> {
self.gate_buckets.clone()
}
fn get_current_weights(&self) -> HashMap<String, f64> {
self.weights.clone()
}
fn get_gate_config(&self) -> ConvictionGateConfig {
self.gate_config.clone()
}
fn get_ensemble_sharpe_7d(&self) -> f64 {
self.ensemble_sharpe
}
fn get_model_correlations(&self) -> Vec<((String, String), f64)> {
self.correlations.clone()
}
}
#[tokio::test]
async fn test_healthy_cycle_adjusts_weights() {
let mut config = FeedbackLoopConfig::default();
config.cycle_interval = Duration::from_millis(100);
let feedback = FeedbackLoop::with_optimizers(
config,
WeightOptimizerConfig {
cooldown: Duration::ZERO,
..Default::default()
},
GateOptimizerConfig {
cooldown: Duration::ZERO,
..Default::default()
},
);
let metrics = MockMetrics::healthy();
let result = feedback.run_cycle(&metrics).await;
// Should produce weight adjustments (models have different Sharpe)
assert!(matches!(result.weight_result, OptimizationResult::Adjusted(_)));
assert!(!result.kill_switch_activated);
assert!(result.retraining_triggers.is_empty());
}
#[tokio::test]
async fn test_kill_switch_freezes_optimizers() {
let feedback = FeedbackLoop::new(FeedbackLoopConfig {
kill_switch_threshold: -1.0,
..Default::default()
});
let mut metrics = MockMetrics::healthy();
metrics.ensemble_sharpe = -2.0; // Below kill switch
let result = feedback.run_cycle(&metrics).await;
assert!(result.kill_switch_activated);
assert!(matches!(
result.weight_result,
OptimizationResult::KillSwitchActive
));
assert!(matches!(
result.gate_result,
GateOptimizationResult::KillSwitchActive
));
assert!(feedback.is_frozen().await);
}
#[tokio::test]
async fn test_manual_freeze_unfreeze() {
let feedback = FeedbackLoop::new(FeedbackLoopConfig::default());
assert!(!feedback.is_frozen().await);
feedback.freeze().await;
assert!(feedback.is_frozen().await);
let metrics = MockMetrics::healthy();
let result = feedback.run_cycle(&metrics).await;
assert!(matches!(
result.weight_result,
OptimizationResult::KillSwitchActive
));
feedback.unfreeze().await;
assert!(!feedback.is_frozen().await);
}
#[tokio::test]
async fn test_retraining_trigger_low_sharpe() {
let config = FeedbackLoopConfig {
retrain_sharpe_threshold: -0.5,
..Default::default()
};
let feedback = FeedbackLoop::new(config);
let mut metrics = MockMetrics::healthy();
metrics.model_metrics[1].sharpe_30d = -1.0; // PPO tanking
let result = feedback.run_cycle(&metrics).await;
assert_eq!(result.retraining_triggers.len(), 1);
if let RetrainingTrigger::LowSharpe { ref model_id, .. } =
result.retraining_triggers[0]
{
assert_eq!(model_id, "ppo");
} else {
panic!("Expected LowSharpe trigger");
}
}
#[tokio::test]
async fn test_retraining_trigger_low_accuracy() {
let config = FeedbackLoopConfig {
retrain_accuracy_threshold: 0.45,
..Default::default()
};
let feedback = FeedbackLoop::new(config);
let mut metrics = MockMetrics::healthy();
metrics.model_metrics[0].prediction_accuracy = 0.40; // DQN accuracy dropped
let result = feedback.run_cycle(&metrics).await;
let accuracy_triggers: Vec<_> = result
.retraining_triggers
.iter()
.filter(|t| matches!(t, RetrainingTrigger::LowAccuracy { .. }))
.collect();
assert_eq!(accuracy_triggers.len(), 1);
}
#[tokio::test]
async fn test_retraining_trigger_high_correlation() {
let config = FeedbackLoopConfig {
retrain_correlation_threshold: 0.90,
..Default::default()
};
let feedback = FeedbackLoop::new(config);
let mut metrics = MockMetrics::healthy();
metrics.correlations = vec![
(("dqn".into(), "ppo".into()), 0.95), // Too correlated
];
let result = feedback.run_cycle(&metrics).await;
let corr_triggers: Vec<_> = result
.retraining_triggers
.iter()
.filter(|t| matches!(t, RetrainingTrigger::HighCorrelation { .. }))
.collect();
assert_eq!(corr_triggers.len(), 1);
}
#[tokio::test]
async fn test_no_triggers_healthy_system() {
let feedback = FeedbackLoop::new(FeedbackLoopConfig::default());
let metrics = MockMetrics::healthy();
let result = feedback.run_cycle(&metrics).await;
assert!(result.retraining_triggers.is_empty());
assert!(!result.kill_switch_activated);
}
}