Files
foxhunt/services/trading_service/src/questdb_metrics.rs
jgrusewski e4870b17b9 fix: tune log levels across workspace — demote noisy warn to debug/trace
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>
2026-03-14 11:35:15 +01:00

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);
}
}