Reduce log noise for non-critical operational paths: connection retries, expected fallbacks, graceful degradation, and optional feature absence. Keeps warn/error for genuine failures requiring attention. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
423 lines
16 KiB
Rust
423 lines
16 KiB
Rust
//! QuestDB-backed MetricsProvider for the feedback loop
|
|
//!
|
|
//! Queries QuestDB via PostgreSQL wire protocol (port 8812) to provide
|
|
//! rolling model metrics, confidence buckets, and ensemble Sharpe for
|
|
//! the autonomous weight/gate optimizers.
|
|
|
|
use crate::feedback_loop::MetricsProvider;
|
|
use ml::ensemble::conviction_gates::ConvictionGateConfig;
|
|
use ml::ensemble::gate_optimizer::GateBucketMetrics;
|
|
use ml::ensemble::weight_optimizer::ModelRollingMetrics;
|
|
use sqlx::postgres::PgPool;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use tokio::sync::RwLock;
|
|
use tracing::{debug, info};
|
|
|
|
/// QuestDB metrics provider that queries real time-series data.
|
|
///
|
|
/// Tables expected in QuestDB (created on first write via ILP):
|
|
/// - `model_predictions`: model_id, signal, confidence, timestamp
|
|
/// - `trade_outcomes`: model_id, realized_pnl, signal_alignment, timestamp
|
|
/// - `ensemble_metrics`: sharpe_7d, sharpe_30d, timestamp
|
|
pub struct QuestDBMetricsProvider {
|
|
pool: PgPool,
|
|
/// Cached gate config (updated from external source)
|
|
gate_config: Arc<RwLock<ConvictionGateConfig>>,
|
|
/// Cached weights (updated by the feedback loop itself)
|
|
current_weights: Arc<RwLock<HashMap<String, f64>>>,
|
|
}
|
|
|
|
impl QuestDBMetricsProvider {
|
|
pub async fn new(questdb_pg_url: &str) -> Result<Self, sqlx::Error> {
|
|
let pool = PgPool::connect(questdb_pg_url).await?;
|
|
|
|
Ok(Self {
|
|
pool,
|
|
gate_config: Arc::new(RwLock::new(ConvictionGateConfig::default())),
|
|
current_weights: Arc::new(RwLock::new(HashMap::new())),
|
|
})
|
|
}
|
|
|
|
/// Try to connect, returning None if QuestDB is unavailable
|
|
pub async fn try_new(questdb_pg_url: &str) -> Option<Self> {
|
|
match Self::new(questdb_pg_url).await {
|
|
Ok(provider) => Some(provider),
|
|
Err(e) => {
|
|
info!("QuestDB unavailable at {}: {} — feedback loop will use defaults", questdb_pg_url, e);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update the cached gate config (called when config changes)
|
|
pub async fn set_gate_config(&self, config: ConvictionGateConfig) {
|
|
*self.gate_config.write().await = config;
|
|
}
|
|
|
|
/// Update the cached weights (called after weight adjustments)
|
|
pub async fn set_weights(&self, weights: HashMap<String, f64>) {
|
|
*self.current_weights.write().await = weights;
|
|
}
|
|
|
|
/// Create the required tables if they don't exist.
|
|
/// QuestDB auto-creates tables on ILP write, but we create them
|
|
/// explicitly for querying so tests can verify structure.
|
|
pub async fn ensure_tables(&self) -> Result<(), sqlx::Error> {
|
|
// QuestDB uses CREATE TABLE IF NOT EXISTS with designated timestamp
|
|
sqlx::query(
|
|
"CREATE TABLE IF NOT EXISTS model_predictions (
|
|
model_id SYMBOL,
|
|
signal DOUBLE,
|
|
confidence DOUBLE,
|
|
prediction_accuracy DOUBLE,
|
|
win_rate DOUBLE,
|
|
timestamp TIMESTAMP
|
|
) TIMESTAMP(timestamp) PARTITION BY DAY;"
|
|
)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"CREATE TABLE IF NOT EXISTS trade_outcomes (
|
|
model_id SYMBOL,
|
|
realized_pnl DOUBLE,
|
|
signal_alignment DOUBLE,
|
|
confidence_bucket_lower DOUBLE,
|
|
confidence_bucket_upper DOUBLE,
|
|
timestamp TIMESTAMP
|
|
) TIMESTAMP(timestamp) PARTITION BY DAY;"
|
|
)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"CREATE TABLE IF NOT EXISTS ensemble_metrics (
|
|
sharpe_7d DOUBLE,
|
|
sharpe_30d DOUBLE,
|
|
total_pnl DOUBLE,
|
|
timestamp TIMESTAMP
|
|
) TIMESTAMP(timestamp) PARTITION BY DAY;"
|
|
)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Query 30-day rolling Sharpe per model
|
|
async fn query_model_sharpe(&self) -> HashMap<String, f64> {
|
|
let result: Result<Vec<(String, f64)>, _> = sqlx::query_as(
|
|
"SELECT model_id, coalesce(avg(signal) / (stddev(signal) + 1e-10), 0.0) as sharpe
|
|
FROM model_predictions
|
|
WHERE timestamp > dateadd('d', -30, now())
|
|
GROUP BY model_id"
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(rows) => rows.into_iter().collect(),
|
|
Err(e) => {
|
|
debug!("QuestDB model Sharpe query failed: {}", e);
|
|
HashMap::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query 30-day win rate per model
|
|
async fn query_model_win_rates(&self) -> HashMap<String, (f64, u64)> {
|
|
let result: Result<Vec<(String, f64, i64)>, _> = sqlx::query_as(
|
|
"SELECT model_id,
|
|
sum(CASE WHEN signal_alignment > 0 THEN 1.0 ELSE 0.0 END) / count(1) as win_rate,
|
|
count(*) as trade_count
|
|
FROM trade_outcomes
|
|
WHERE timestamp > dateadd('d', -30, now())
|
|
GROUP BY model_id"
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(rows) => rows
|
|
.into_iter()
|
|
.map(|(id, wr, cnt)| (id, (wr, cnt as u64)))
|
|
.collect(),
|
|
Err(e) => {
|
|
debug!("QuestDB win rate query failed: {}", e);
|
|
HashMap::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query confidence-bucketed win rates for gate optimization
|
|
async fn query_confidence_buckets(&self) -> Vec<GateBucketMetrics> {
|
|
// 5 buckets: [0.5-0.6), [0.6-0.7), [0.7-0.8), [0.8-0.9), [0.9-1.0]
|
|
let result: Result<Vec<(f64, f64, f64, i64, f64)>, _> = sqlx::query_as(
|
|
"SELECT confidence_bucket_lower,
|
|
confidence_bucket_upper,
|
|
sum(CASE WHEN signal_alignment > 0 THEN 1.0 ELSE 0.0 END) / count(1) as win_rate,
|
|
count(*) as trade_count,
|
|
avg(realized_pnl) as avg_pnl
|
|
FROM trade_outcomes
|
|
WHERE timestamp > dateadd('d', -30, now())
|
|
AND confidence_bucket_lower IS NOT NULL
|
|
GROUP BY confidence_bucket_lower, confidence_bucket_upper
|
|
ORDER BY confidence_bucket_lower"
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(rows) => rows
|
|
.into_iter()
|
|
.map(|(lower, upper, win_rate, count, avg_pnl)| GateBucketMetrics {
|
|
confidence_lower: lower,
|
|
confidence_upper: upper,
|
|
win_rate,
|
|
trade_count: count as u64,
|
|
avg_pnl,
|
|
})
|
|
.collect(),
|
|
Err(e) => {
|
|
debug!("QuestDB confidence bucket query failed: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query latest ensemble Sharpe (7-day)
|
|
async fn query_ensemble_sharpe(&self) -> f64 {
|
|
let result: Result<Option<(f64,)>, _> = sqlx::query_as(
|
|
"SELECT sharpe_7d FROM ensemble_metrics ORDER BY timestamp DESC LIMIT 1"
|
|
)
|
|
.fetch_optional(&self.pool)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Some((sharpe,))) => sharpe,
|
|
Ok(None) => 0.0,
|
|
Err(e) => {
|
|
debug!("QuestDB ensemble Sharpe query failed: {}", e);
|
|
0.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MetricsProvider for QuestDBMetricsProvider {
|
|
fn get_model_metrics(&self) -> Vec<ModelRollingMetrics> {
|
|
// block_in_place allows blocking inside a multi-threaded tokio runtime
|
|
// by moving other tasks off this thread. Safe because the feedback loop
|
|
// runs periodically (24h cycle) — not on the hot path.
|
|
let handle = tokio::runtime::Handle::current();
|
|
tokio::task::block_in_place(|| {
|
|
let sharpes = handle.block_on(self.query_model_sharpe());
|
|
let win_rates = handle.block_on(self.query_model_win_rates());
|
|
|
|
let mut metrics = Vec::new();
|
|
for (model_id, sharpe) in &sharpes {
|
|
let (win_rate, trade_count) = win_rates
|
|
.get(model_id)
|
|
.copied()
|
|
.unwrap_or((0.5, 0));
|
|
|
|
metrics.push(ModelRollingMetrics {
|
|
model_id: model_id.clone(),
|
|
sharpe_30d: *sharpe,
|
|
win_rate_30d: win_rate,
|
|
prediction_accuracy: win_rate, // Using win_rate as proxy
|
|
trade_count,
|
|
deployed_at: Instant::now() - std::time::Duration::from_secs(30 * 24 * 3600),
|
|
});
|
|
}
|
|
metrics
|
|
})
|
|
}
|
|
|
|
fn get_gate_buckets(&self) -> Vec<GateBucketMetrics> {
|
|
let handle = tokio::runtime::Handle::current();
|
|
tokio::task::block_in_place(|| handle.block_on(self.query_confidence_buckets()))
|
|
}
|
|
|
|
fn get_current_weights(&self) -> HashMap<String, f64> {
|
|
let handle = tokio::runtime::Handle::current();
|
|
tokio::task::block_in_place(|| {
|
|
handle.block_on(async { self.current_weights.read().await.clone() })
|
|
})
|
|
}
|
|
|
|
fn get_gate_config(&self) -> ConvictionGateConfig {
|
|
let handle = tokio::runtime::Handle::current();
|
|
tokio::task::block_in_place(|| {
|
|
handle.block_on(async { self.gate_config.read().await.clone() })
|
|
})
|
|
}
|
|
|
|
fn get_ensemble_sharpe_7d(&self) -> f64 {
|
|
let handle = tokio::runtime::Handle::current();
|
|
tokio::task::block_in_place(|| handle.block_on(self.query_ensemble_sharpe()))
|
|
}
|
|
|
|
fn get_model_correlations(&self) -> Vec<((String, String), f64)> {
|
|
// Correlation requires cross-model signal comparison
|
|
// For now, query is deferred until we have enough data
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Drop and recreate all test tables for clean isolation between runs.
|
|
/// QuestDB TRUNCATE via PG wire can be flaky; DROP + CREATE is reliable.
|
|
async fn reset_tables(pool: &PgPool) {
|
|
for table in &["model_predictions", "trade_outcomes", "ensemble_metrics"] {
|
|
let drop_q = format!("DROP TABLE IF EXISTS {table};");
|
|
let _ = sqlx::query(&drop_q).execute(pool).await;
|
|
}
|
|
}
|
|
|
|
/// Poll QuestDB until a table has at least `min_rows` rows (up to 5s).
|
|
async fn wait_for_rows(pool: &PgPool, table: &str, min_rows: i64) {
|
|
for _ in 0..50 {
|
|
let query = format!("SELECT count(*) FROM {table}");
|
|
let row: Option<(i64,)> = sqlx::query_as(&query)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
if row.map(|(c,)| c).unwrap_or(0) >= min_rows {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
/// Comprehensive integration test: connect, write, read, verify metrics.
|
|
/// Single test avoids parallel interference on shared QuestDB tables.
|
|
///
|
|
/// Run with: docker-compose up -d questdb
|
|
/// Then: SQLX_OFFLINE=true cargo test -p trading_service --lib -- questdb_metrics --include-ignored
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[ignore = "Requires QuestDB on localhost:8812"]
|
|
async fn test_questdb_provider_full_lifecycle() {
|
|
let provider = QuestDBMetricsProvider::new("postgresql://admin:quest@localhost:8812/qdb")
|
|
.await
|
|
.expect("QuestDB should be running");
|
|
|
|
// Drop + recreate for clean slate (prior test runs may have left data)
|
|
reset_tables(&provider.pool).await;
|
|
provider.ensure_tables().await.expect("Tables should be created");
|
|
|
|
// Phase 1: Empty tables — metrics should return defaults
|
|
let metrics = provider.get_model_metrics();
|
|
assert!(metrics.is_empty(), "Empty tables should yield no metrics");
|
|
|
|
let sharpe = provider.get_ensemble_sharpe_7d();
|
|
assert!((sharpe - 0.0).abs() < 1e-10, "Empty ensemble Sharpe should be 0.0");
|
|
|
|
// Phase 2: Write test data (multiple rows per model for valid stddev)
|
|
sqlx::query(
|
|
"INSERT INTO model_predictions(model_id, signal, confidence, prediction_accuracy, win_rate, timestamp)
|
|
VALUES ('dqn', 0.8, 0.7, 0.62, 0.58, systimestamp()),
|
|
('dqn', 0.6, 0.8, 0.65, 0.60, systimestamp()),
|
|
('ppo', 0.3, 0.6, 0.55, 0.52, systimestamp()),
|
|
('ppo', 0.5, 0.7, 0.58, 0.54, systimestamp())"
|
|
)
|
|
.execute(&provider.pool)
|
|
.await
|
|
.expect("Insert predictions should succeed");
|
|
|
|
sqlx::query(
|
|
"INSERT INTO trade_outcomes(model_id, realized_pnl, signal_alignment, confidence_bucket_lower, confidence_bucket_upper, timestamp)
|
|
VALUES ('dqn', 100.0, 1.0, 0.60, 0.70, systimestamp()),
|
|
('dqn', -50.0, -1.0, 0.60, 0.70, systimestamp()),
|
|
('ppo', 75.0, 1.0, 0.50, 0.60, systimestamp())"
|
|
)
|
|
.execute(&provider.pool)
|
|
.await
|
|
.expect("Insert outcomes should succeed");
|
|
|
|
sqlx::query(
|
|
"INSERT INTO ensemble_metrics(sharpe_7d, sharpe_30d, total_pnl, timestamp)
|
|
VALUES (1.5, 1.2, 5000.0, systimestamp())"
|
|
)
|
|
.execute(&provider.pool)
|
|
.await
|
|
.expect("Insert ensemble metrics should succeed");
|
|
|
|
// Wait for QuestDB WAL to commit all tables
|
|
wait_for_rows(&provider.pool, "model_predictions", 4).await;
|
|
wait_for_rows(&provider.pool, "trade_outcomes", 3).await;
|
|
wait_for_rows(&provider.pool, "ensemble_metrics", 1).await;
|
|
|
|
// Phase 3: Read back and verify
|
|
let metrics = provider.get_model_metrics();
|
|
assert!(!metrics.is_empty(), "Should have model metrics after insert");
|
|
assert!(metrics.len() >= 2, "Should have at least DQN and PPO");
|
|
|
|
let sharpe = provider.get_ensemble_sharpe_7d();
|
|
assert!((sharpe - 1.5).abs() < 0.1, "Sharpe should be ~1.5, got {sharpe}");
|
|
|
|
let buckets = provider.get_gate_buckets();
|
|
assert!(!buckets.is_empty(), "Should have confidence buckets");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[ignore = "Requires QuestDB on localhost:8812"]
|
|
async fn test_questdb_provider_try_new_succeeds() {
|
|
let provider = QuestDBMetricsProvider::try_new("postgresql://admin:quest@localhost:8812/qdb").await;
|
|
assert!(provider.is_some(), "Should connect to QuestDB");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn test_questdb_provider_try_new_fails_gracefully() {
|
|
// Connect to a non-existent QuestDB — should return None, not panic
|
|
let provider = QuestDBMetricsProvider::try_new("postgresql://admin:quest@localhost:19999/qdb").await;
|
|
assert!(provider.is_none(), "Should fail gracefully");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[ignore = "Requires QuestDB on localhost:8812"]
|
|
async fn test_feedback_loop_with_questdb() {
|
|
use crate::feedback_loop::{FeedbackLoop, FeedbackLoopConfig};
|
|
use ml::ensemble::gate_optimizer::GateOptimizerConfig;
|
|
use ml::ensemble::weight_optimizer::WeightOptimizerConfig;
|
|
use std::time::Duration;
|
|
|
|
let provider = QuestDBMetricsProvider::new("postgresql://admin:quest@localhost:8812/qdb")
|
|
.await
|
|
.expect("QuestDB should be running");
|
|
|
|
provider.ensure_tables().await.expect("Tables should be created");
|
|
|
|
// Set up some weights
|
|
let mut weights = HashMap::new();
|
|
weights.insert("dqn".to_string(), 0.5);
|
|
weights.insert("ppo".to_string(), 0.5);
|
|
provider.set_weights(weights).await;
|
|
|
|
let feedback = FeedbackLoop::with_optimizers(
|
|
FeedbackLoopConfig {
|
|
cycle_interval: Duration::from_millis(100),
|
|
..Default::default()
|
|
},
|
|
WeightOptimizerConfig {
|
|
cooldown: Duration::ZERO,
|
|
..Default::default()
|
|
},
|
|
GateOptimizerConfig {
|
|
cooldown: Duration::ZERO,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
// Run a cycle — with empty QuestDB should report InsufficientData
|
|
let result = feedback.run_cycle(&provider).await;
|
|
assert!(!result.kill_switch_activated);
|
|
}
|
|
}
|