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>
530 lines
18 KiB
Rust
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(¤t_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);
|
|
}
|
|
}
|