//! Integration Test: Database Regime Persistence //! //! Verifies that regime_states, regime_transitions, and //! adaptive_strategy_metrics are properly populated during ML training operations. //! //! ## Dependencies //! - Requires PostgreSQL running with migration 045 applied //! - Uses real DatabasePool (no mocks) //! - Tests actual SQL queries used by Grafana dashboards use anyhow::Result; use chrono::{DateTime, Utc}; use common::database::DatabasePool; use common::regime_persistence::RegimePersistenceManager; use sqlx::{PgPool, Row}; /// Helper to create test database pool async fn setup_test_db() -> Result { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string() }); use common::database::{LocalDatabaseConfig, PerformanceConfig, PoolConfig}; let config = LocalDatabaseConfig { url: database_url, pool: PoolConfig { max_connections: 5, min_connections: 1, connect_timeout_ms: 10000, acquire_timeout_ms: 10000, max_lifetime_seconds: 3600, idle_timeout_seconds: 600, }, performance: PerformanceConfig { query_timeout_micros: 100_000, enable_prewarming: false, enable_prepared_statements: true, enable_slow_query_logging: false, slow_query_threshold_micros: 50_000, }, }; DatabasePool::new(config) .await .map_err(|e| anyhow::anyhow!("Failed to create pool: {}", e)) } /// Helper to get the underlying PgPool for raw SQL queries async fn get_pg_pool() -> Result { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { "postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string() }); Ok(PgPool::connect(&database_url).await?) } /// Helper to clear regime tables for clean testing async fn clear_regime_tables(pg_pool: &PgPool) -> Result<()> { sqlx::query("DELETE FROM adaptive_strategy_metrics") .execute(pg_pool) .await?; sqlx::query("DELETE FROM regime_transitions") .execute(pg_pool) .await?; sqlx::query("DELETE FROM regime_states") .execute(pg_pool) .await?; Ok(()) } fn generate_regime_features( cusum_mean: f64, cusum_std: f64, adx: f64, position_mult: f64, stop_mult: f64, ) -> [f64; 24] { [ cusum_mean, cusum_std, 0.5, -0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, adx, 0.0, 0.0, 0.0, 0.0, 0.7, 0.2, 0.1, 0.0, 0.0, position_mult, stop_mult, 0.0, 0.0, ] } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_regime_states_persisted_during_training() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let pool_clone = pool.clone(); let mut manager = RegimePersistenceManager::new(pool_clone); let symbols = ["ES.FUT", "NQ.FUT"]; let base_timestamp = Utc::now(); for (idx, symbol) in symbols.iter().enumerate() { let timestamp = base_timestamp + chrono::Duration::seconds(idx as i64 * 60); let features = generate_regime_features(0.5, 3.0, 35.0, 0.8, 3.5); manager .process_regime_features(symbol, &features, timestamp) .await?; } let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM regime_states") .fetch_one(&pg_pool) .await?; let state_count = row.0; assert!( state_count > 0, "No regime states persisted! Expected at least 2, got {}", state_count ); assert_eq!( state_count, 2, "Expected 2 regime states (ES.FUT + NQ.FUT), got {}", state_count ); let es_state = pool.get_latest_regime("ES.FUT").await?; assert_eq!(es_state.symbol, "ES.FUT"); assert_eq!(es_state.regime, "Volatile"); assert!(es_state.confidence > 0.0 && es_state.confidence <= 1.0); assert!(es_state.cusum_s_plus.is_some()); assert!(es_state.cusum_s_minus.is_some()); assert!(es_state.adx.is_some()); assert_eq!(es_state.adx.unwrap(), 35.0); let row: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM adaptive_strategy_metrics WHERE symbol = 'ES.FUT'", ) .fetch_one(&pg_pool) .await?; assert!(row.0 > 0, "No adaptive metrics persisted for ES.FUT!"); let metrics_row = sqlx::query( "SELECT position_multiplier, stop_loss_multiplier, regime \ FROM adaptive_strategy_metrics WHERE symbol = 'ES.FUT' \ ORDER BY event_timestamp DESC LIMIT 1", ) .fetch_one(&pg_pool) .await?; let position_multiplier: f64 = metrics_row.get("position_multiplier"); let stop_loss_multiplier: f64 = metrics_row.get("stop_loss_multiplier"); assert_eq!(position_multiplier, 0.8); assert_eq!(stop_loss_multiplier, 3.5); assert!( position_multiplier > 0.0 && position_multiplier <= 2.0, "Position multiplier out of range: {}", position_multiplier ); assert!( (1.0..=5.0).contains(&stop_loss_multiplier), "Stop-loss multiplier out of range: {}", stop_loss_multiplier ); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_regime_transitions_tracked() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let pool_clone = pool.clone(); let mut manager = RegimePersistenceManager::new(pool_clone); let symbol = "TRANSITION.TEST"; let base_timestamp = Utc::now(); for i in 0..3 { let timestamp = base_timestamp + chrono::Duration::seconds(i * 60); let features = generate_regime_features(0.5, 3.0, 35.0, 0.8, 3.5); manager .process_regime_features(symbol, &features, timestamp) .await?; } for i in 3..5 { let timestamp = base_timestamp + chrono::Duration::seconds(i * 60); let features = generate_regime_features(2.0, 1.0, 30.0, 1.2, 2.0); manager .process_regime_features(symbol, &features, timestamp) .await?; } let timestamp = base_timestamp + chrono::Duration::seconds(5 * 60); let features = generate_regime_features(0.2, 0.5, 15.0, 1.0, 1.5); manager .process_regime_features(symbol, &features, timestamp) .await?; let row: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM regime_transitions WHERE symbol = $1", ) .bind(symbol) .fetch_one(&pg_pool) .await?; let transition_count = row.0; assert!( transition_count >= 2, "Expected at least 2 transitions, got {}", transition_count ); let transitions = pool.get_regime_transitions(symbol, 10).await?; assert!(!transitions.is_empty(), "No transitions recorded!"); let first_transition = transitions .iter() .find(|t| t.from_regime == "Volatile" && t.to_regime == "Trending"); assert!( first_transition.is_some(), "Expected Volatile->Trending transition" ); let transition = first_transition.unwrap(); assert_eq!(transition.duration_bars, Some(3)); let matrix_rows = sqlx::query( "SELECT from_regime, to_regime, COUNT(*) as count \ FROM regime_transitions WHERE symbol = $1 \ GROUP BY from_regime, to_regime ORDER BY from_regime, to_regime", ) .bind(symbol) .fetch_all(&pg_pool) .await?; assert!(!matrix_rows.is_empty(), "No transition matrix data!"); assert!(matrix_rows.len() >= 2, "Expected at least 2 transition pairs"); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_grafana_can_query_regime_states() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let mut manager = RegimePersistenceManager::new(pool); let symbol = "ES.FUT"; let base_timestamp = Utc::now(); for i in 0..10 { let timestamp = base_timestamp + chrono::Duration::seconds(i * 60); let features = generate_regime_features(0.5, 3.0, 35.0, 0.8, 3.5); manager .process_regime_features(symbol, &features, timestamp) .await?; } let regime_distribution = sqlx::query( "SELECT symbol, regime, COUNT(*) as count, AVG(confidence) as avg_confidence \ FROM regime_states WHERE event_timestamp >= NOW() - INTERVAL '1 hour' \ GROUP BY symbol, regime ORDER BY symbol, regime", ) .fetch_all(&pg_pool) .await?; assert!(!regime_distribution.is_empty(), "Expected regime data"); for row in ®ime_distribution { let sym: &str = row.get("symbol"); let reg: &str = row.get("regime"); let cnt: i64 = row.get("count"); let avg: f64 = row.get("avg_confidence"); assert!(!sym.is_empty()); assert!(!reg.is_empty()); assert!(cnt > 0); assert!((0.0..=1.0).contains(&avg)); } let timeseries = sqlx::query( "SELECT event_timestamp, regime, confidence, adx \ FROM regime_states WHERE symbol = $1 \ ORDER BY event_timestamp DESC LIMIT 100", ) .bind(symbol) .fetch_all(&pg_pool) .await?; assert!(!timeseries.is_empty(), "Expected time-series data"); assert!(timeseries.len() <= 100, "Query limit not enforced"); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_regime_state_has_valid_timestamp() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let pool_clone = pool.clone(); let mut manager = RegimePersistenceManager::new(pool_clone); let symbol = "TIMESTAMP.TEST"; let timestamp = Utc::now(); let features = generate_regime_features(0.5, 3.0, 35.0, 0.8, 3.5); manager .process_regime_features(symbol, &features, timestamp) .await?; let state = pool.get_latest_regime(symbol).await?; let time_diff = (Utc::now() - state.event_timestamp).num_seconds().abs(); assert!( time_diff < 60, "Timestamp should be recent (within 60 seconds), got diff: {} seconds", time_diff ); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_confidence_scores_in_valid_range() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let pool_clone = pool.clone(); let mut manager = RegimePersistenceManager::new(pool_clone); let symbol = "CONFIDENCE.TEST"; let test_cases = [ (10.0, 0.2), (25.0, 0.5), (50.0, 1.0), (100.0, 1.0), ]; for (idx, (adx, expected_confidence)) in test_cases.iter().enumerate() { let timestamp = Utc::now() + chrono::Duration::seconds(idx as i64 * 60); let features = generate_regime_features(0.5, 3.0, *adx, 0.8, 3.5); manager .process_regime_features(symbol, &features, timestamp) .await?; let state = pool.get_latest_regime(symbol).await?; assert!( (0.0..=1.0).contains(&state.confidence), "Confidence out of range [0.0, 1.0]: {}", state.confidence ); assert!( (state.confidence - expected_confidence).abs() < 0.01, "Expected confidence {}, got {}", expected_confidence, state.confidence ); } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_adaptive_metrics_update_on_backtest() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let pool_clone = pool.clone(); let mut manager = RegimePersistenceManager::new(pool_clone); let symbol = "BACKTEST.TEST"; let regime = "Trending"; let timestamp = Utc::now(); let features = generate_regime_features(2.0, 1.0, 30.0, 1.2, 2.0); manager .process_regime_features(symbol, &features, timestamp) .await?; manager .update_trade_metrics(symbol, regime, timestamp, 1000, true) .await?; manager .update_trade_metrics(symbol, regime, timestamp, -500, false) .await?; manager .update_trade_metrics(symbol, regime, timestamp, 750, true) .await?; let performance = pool.get_regime_performance(Some(symbol), 24).await?; let trending_perf = performance .iter() .find(|p| p.regime == Some("Trending".to_string())) .expect("Expected Trending regime performance"); assert_eq!(trending_perf.total_trades, Some(3)); assert_eq!(trending_perf.win_rate, Some(2.0 / 3.0)); assert_eq!( trending_perf.total_pnl, Some(rust_decimal::Decimal::from(1250)) ); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_database_coverage_by_symbol() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let mut manager = RegimePersistenceManager::new(pool); let symbols = ["ES.FUT", "NQ.FUT", "6E.FUT", "ZN.FUT"]; for (idx, symbol) in symbols.iter().enumerate() { let timestamp = Utc::now() + chrono::Duration::seconds(idx as i64 * 60); let features = generate_regime_features(0.5, 3.0, 35.0, 0.8, 3.5); manager .process_regime_features(symbol, &features, timestamp) .await?; } let coverage = sqlx::query( "SELECT symbol, COUNT(*) as count FROM regime_states \ GROUP BY symbol ORDER BY symbol", ) .fetch_all(&pg_pool) .await?; assert_eq!(coverage.len(), 4, "Expected 4 symbols"); for (symbol, row) in symbols.iter().zip(coverage.iter()) { let row_symbol: &str = row.get("symbol"); let row_count: i64 = row.get("count"); assert_eq!(row_symbol, *symbol); assert!(row_count > 0); } Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_latest_adaptive_metrics_query() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let mut manager = RegimePersistenceManager::new(pool); let symbol = "METRICS.LATEST"; for i in 0..5 { let timestamp = Utc::now() + chrono::Duration::seconds(i * 60); let features = generate_regime_features(2.0, 1.0, 30.0, 1.2 + (i as f64 * 0.1), 2.0); manager .process_regime_features(symbol, &features, timestamp) .await?; } let latest_metrics = sqlx::query( "SELECT symbol, regime, position_multiplier, stop_loss_multiplier, event_timestamp \ FROM adaptive_strategy_metrics WHERE symbol = $1 \ ORDER BY event_timestamp DESC LIMIT 10", ) .bind(symbol) .fetch_all(&pg_pool) .await?; assert!(!latest_metrics.is_empty(), "Expected metrics"); assert!(latest_metrics.len() <= 10, "Query limit not enforced"); let mut prev_timestamp: Option> = None; for metric in &latest_metrics { let event_timestamp: DateTime = metric.get("event_timestamp"); if let Some(prev) = prev_timestamp { assert!( event_timestamp <= prev, "Metrics not ordered by timestamp DESC" ); } prev_timestamp = Some(event_timestamp); } let latest_pos_mult: f64 = latest_metrics[0].get("position_multiplier"); assert_eq!(latest_pos_mult, 1.6); Ok(()) } #[tokio::test] #[ignore = "Requires PostgreSQL with migration 045 applied"] async fn test_transition_probability_calculation() -> Result<()> { let pool = setup_test_db().await?; let pg_pool = get_pg_pool().await?; clear_regime_tables(&pg_pool).await?; let mut manager = RegimePersistenceManager::new(pool); let symbol = "PROB.TEST"; let base_timestamp = Utc::now(); let regime_sequence = [ (0.5, 3.0, 35.0), (2.0, 1.0, 30.0), (0.5, 3.0, 35.0), (2.0, 1.0, 30.0), (0.5, 3.0, 35.0), ]; for (i, (cusum_mean, cusum_std, adx)) in regime_sequence.iter().enumerate() { let timestamp = base_timestamp + chrono::Duration::seconds(i as i64 * 60); let features = generate_regime_features(*cusum_mean, *cusum_std, *adx, 1.0, 2.0); manager .process_regime_features(symbol, &features, timestamp) .await?; } let transition_matrix = sqlx::query( "SELECT from_regime, to_regime, transition_count, transition_probability \ FROM get_regime_transition_matrix($1, 24)", ) .bind(symbol) .fetch_all(&pg_pool) .await?; assert!( !transition_matrix.is_empty(), "No transition matrix data!" ); use std::collections::HashMap; let mut prob_sums: HashMap = HashMap::new(); for row in &transition_matrix { let from_regime: Option = row.get("from_regime"); let transition_probability: Option = row.get("transition_probability"); *prob_sums .entry(from_regime.unwrap_or_default()) .or_insert(0.0) += transition_probability.unwrap_or(0.0); } for (from_regime, sum) in prob_sums { assert!( (sum - 1.0).abs() < 0.01, "Transition probabilities from {} should sum to 1.0, got {}", from_regime, sum ); } Ok(()) }