//! Regime Orchestrator //! //! Wires CUSUM structural break detection to regime state changes and database persistence. //! This is the central coordinator that: //! 1. Detects structural breaks using CUSUM //! 2. Classifies regimes (Trending, Ranging, Volatile, Transition) //! 3. Calculates confidence from ADX //! 4. Persists regime states to database //! 5. Updates transition matrix //! //! ## Architecture //! //! ```text //! CUSUM Breaks → Regime Classifiers → Database Persistence //! ↓ ↓ //! ADX Confidence Transition Matrix //! ``` //! //! ## Usage Example //! //! ```rust,no_run //! use ml::regime::orchestrator::RegimeOrchestrator; //! use sqlx::PgPool; //! //! # async fn example(pool: PgPool) -> Result<(), Box> { //! let mut orchestrator = RegimeOrchestrator::new(pool).await?; //! //! // Process market data //! let bars = vec![/* OHLCV bars */]; //! let regime_state = orchestrator.detect_and_persist("ES.FUT", &bars).await?; //! //! println!("Regime: {}, Confidence: {:.2}", regime_state.regime, regime_state.confidence); //! # Ok(()) //! # } //! ``` use crate::{ cusum::CUSUMDetector, ranging::RangingClassifier, trending::{TrendingClassifier, TrendingSignal}, volatile::{VolatileClassifier, VolatileSignal}, }; use crate::OHLCVBar; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::collections::HashMap; use thiserror::Error; /// Errors that can occur during regime orchestration #[derive(Debug, Error)] pub enum OrchestratorError { /// Database operation failed #[error("Database error: {0}")] Database(#[from] sqlx::Error), /// Insufficient data for regime detection #[error("Insufficient data: need at least {required} bars, got {actual}")] InsufficientData { required: usize, actual: usize }, /// Configuration error #[error("Configuration error: {0}")] Configuration(String), /// Regime detection failed #[error("Regime detection failed: {0}")] DetectionFailed(String), } /// Regime state output #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegimeState { /// Regime classification pub regime: String, /// Confidence score (0.0-1.0) pub confidence: f64, /// Timestamp of regime detection pub timestamp: DateTime, /// CUSUM positive sum pub cusum_s_plus: Option, /// CUSUM negative sum pub cusum_s_minus: Option, /// ADX value pub adx: Option, /// Regime stability pub stability: Option, } /// Regime Orchestrator - Central coordinator for regime detection /// /// Coordinates structural break detection, regime classification, and database persistence. /// Uses CUSUM to detect breaks, then classifies regimes using Trending, Ranging, and /// Volatile detectors. #[derive(Debug)] pub struct RegimeOrchestrator { /// CUSUM structural break detector cusum: CUSUMDetector, /// Trending regime classifier trending_classifier: TrendingClassifier, /// Ranging regime classifier ranging_classifier: RangingClassifier, /// Volatile regime classifier volatile_classifier: VolatileClassifier, /// Database connection pool db_pool: PgPool, /// Cached regime states per symbol cached_regimes: HashMap, /// Tracks the bar index at which the current regime started, per symbol. /// Used to compute transition duration_bars when a regime change occurs. regime_start_bars: HashMap, /// Tracks the cumulative bar count processed per symbol. /// Incremented each time detect_and_persist is called for a symbol. cumulative_bars: HashMap, /// Minimum bars required for detection min_bars: usize, } impl RegimeOrchestrator { /// Create a new regime orchestrator /// /// # Arguments /// /// * `pool` - PostgreSQL connection pool /// /// # Returns /// /// New orchestrator with default detector configurations /// /// # Errors /// /// Returns error if database pool creation fails pub async fn new(pool: PgPool) -> Result { let db_pool = pool; // Initialize detectors with default parameters let cusum = CUSUMDetector::new( 0.0, // target_mean 1.0, // target_std 0.5, // drift_allowance (k = 0.5σ) 5.0, // detection_threshold (h = 5σ) ); let trending_classifier = TrendingClassifier::new( 25.0, // ADX threshold 0.55, // Hurst threshold 50, // lookback period ); let ranging_classifier = RangingClassifier::new( 20, // Bollinger period 2.0, // Bollinger std 20.0, // ADX threshold ); let volatile_classifier = VolatileClassifier::new( 1.5, // Parkinson threshold multiplier 0.03, // Garman-Klass threshold 2.0, // ATR expansion multiplier 50, // lookback period ); Ok(Self { cusum, trending_classifier, ranging_classifier, volatile_classifier, db_pool, cached_regimes: HashMap::new(), regime_start_bars: HashMap::new(), cumulative_bars: HashMap::new(), min_bars: 20, // Minimum bars for statistical significance }) } /// Create orchestrator with custom detector configurations /// /// # Arguments /// /// * `pool` - PostgreSQL connection pool /// * `cusum_threshold` - CUSUM detection threshold (h parameter) /// * `adx_threshold` - ADX threshold for trending detection /// * `lookback_period` - Lookback period for regime classifiers /// /// # Errors /// /// Returns error if database pool creation fails pub async fn with_config( pool: PgPool, cusum_threshold: f64, adx_threshold: f64, lookback_period: usize, ) -> Result { let db_pool = pool; let cusum = CUSUMDetector::new(0.0, 1.0, 0.5, cusum_threshold); let trending_classifier = TrendingClassifier::new(adx_threshold, 0.55, lookback_period); let ranging_classifier = RangingClassifier::new(20, 2.0, adx_threshold); let volatile_classifier = VolatileClassifier::new(1.5, 0.03, 2.0, lookback_period); Ok(Self { cusum, trending_classifier, ranging_classifier, volatile_classifier, db_pool, cached_regimes: HashMap::new(), regime_start_bars: HashMap::new(), cumulative_bars: HashMap::new(), min_bars: 20, }) } /// Detect regime and persist to database /// /// # Algorithm /// /// 1. Run CUSUM to detect structural breaks /// 2. If break detected, query regime classifiers: /// - Trending (ADX + Hurst) /// - Ranging (Bollinger oscillation + variance ratio) /// - Volatile (Parkinson/Garman-Klass volatility) /// 3. Determine regime based on classifier signals /// 4. Calculate confidence from ADX (normalized to 0-1) /// 5. Persist to database (regime_states table) /// 6. Record transition (regime_transitions table) /// /// # Arguments /// /// * `symbol` - Trading symbol (e.g., "ES.FUT") /// * `bars` - OHLCV bars for analysis /// /// # Returns /// /// Current regime state with confidence /// /// # Errors /// /// Returns error if: /// - Insufficient data (< 20 bars) /// - Database persistence fails /// - Regime detection fails pub async fn detect_and_persist( &mut self, symbol: &str, bars: &[OHLCVBar], ) -> Result { // Validate input if bars.len() < self.min_bars { return Err(OrchestratorError::InsufficientData { required: self.min_bars, actual: bars.len(), }); } // Get cached regime (previous state) let prev_regime = self.cached_regimes.get(symbol).map(|r| r.regime.clone()); let prev_regime_for_transition = prev_regime.clone(); // Step 1: Run CUSUM on returns to detect structural breaks let mut break_detected = false; for i in 1..bars.len() { let log_return = (bars[i].close / bars[i - 1].close).ln(); if let Some(_break) = self.cusum.update(log_return) { break_detected = true; break; // Break on first detection } } // Always get CUSUM sums (even if no break) - for feature extraction let (cusum_s_plus, cusum_s_minus) = self.cusum.get_current_sums(); // Step 2: If break detected OR forced detection, classify regime let regime = if break_detected || prev_regime.is_none() { // Query regime classifiers (all use canonical OHLCVBar) let trending_signal = if let Some(&last_bar) = bars.last() { self.trending_classifier.classify(last_bar) } else { TrendingSignal::Ranging { adx: 0.0, hurst: 0.5, } }; let ranging_signal = if let Some(&last_bar) = bars.last() { self.ranging_classifier.classify(last_bar) } else { crate::ranging::RangingSignal::NotRanging }; let volatile_signal = if let Some(&last_bar) = bars.last() { self.volatile_classifier.classify(last_bar) } else { VolatileSignal::Low }; // Step 3: Determine regime based on priority: // 1. Volatile (highest priority during crisis) // 2. Trending (strong directional moves) // 3. Ranging (mean-reverting, sideways) // 4. Transition (default/ambiguous) match volatile_signal { VolatileSignal::Extreme => "Volatile".to_owned(), VolatileSignal::High => "Volatile".to_owned(), VolatileSignal::Low | VolatileSignal::Medium => { // Not highly volatile, check trending match trending_signal { TrendingSignal::StrongTrend { .. } => "Trending".to_owned(), TrendingSignal::WeakTrend { .. } => { // Weak trend, check if ranging match ranging_signal { crate::ranging::RangingSignal::StrongRanging | crate::ranging::RangingSignal::ModerateRanging => { "Ranging".to_owned() }, crate::ranging::RangingSignal::WeakRanging | crate::ranging::RangingSignal::NotRanging => { "Trending".to_owned() }, // Default to weak trend } }, TrendingSignal::Ranging { .. } => { // Check ranging classifier match ranging_signal { crate::ranging::RangingSignal::StrongRanging | crate::ranging::RangingSignal::ModerateRanging => { "Ranging".to_owned() }, crate::ranging::RangingSignal::WeakRanging | crate::ranging::RangingSignal::NotRanging => { "Normal".to_owned() }, // Default to normal } }, } }, } } else { // No break detected, return cached regime prev_regime.unwrap_or_else(|| "Normal".to_owned()) }; // Step 4: Calculate confidence from ADX (normalize to 0-1) let adx = self.trending_classifier.get_trend_strength(); let confidence = (adx / 100.0).clamp(0.0, 1.0); // ADX is 0-100, normalize to 0-1 // Step 5: Persist to database let timestamp = bars.last().ok_or(OrchestratorError::InsufficientData { required: 1, actual: 0, })?.timestamp; sqlx::query!( r#" INSERT INTO regime_states (symbol, regime, confidence, event_timestamp, cusum_s_plus, cusum_s_minus, adx, stability) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (symbol, event_timestamp) DO UPDATE SET regime = EXCLUDED.regime, confidence = EXCLUDED.confidence "#, symbol, regime, confidence, timestamp, Some(cusum_s_plus), Some(cusum_s_minus), Some(adx), None:: ).execute(&self.db_pool).await?; // Update cumulative bar counter for this symbol let current_bar_count = { let counter = self.cumulative_bars.entry(symbol.to_string()).or_insert(0); *counter += bars.len() as i64; *counter }; // Initialize regime_start_bars on first detection for this symbol if !self.regime_start_bars.contains_key(symbol) { self.regime_start_bars .insert(symbol.to_string(), current_bar_count); } // Step 6: Record transition if regime changed if let Some(prev) = prev_regime_for_transition { if prev != regime { // Calculate duration: bars since the last regime started let regime_start = self .regime_start_bars .get(symbol) .copied() .unwrap_or(0); let duration_bars = (current_bar_count - regime_start).max(1) as i32; // Record the new regime start bar self.regime_start_bars .insert(symbol.to_string(), current_bar_count); sqlx::query!( r#" INSERT INTO regime_transitions (symbol, event_timestamp, from_regime, to_regime, duration_bars, transition_probability, adx_at_transition, cusum_alert_triggered) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "#, symbol, timestamp, prev, regime, duration_bars, None::, // transition_probability (calculated separately) Some(adx), break_detected ) .execute(&self.db_pool) .await?; } } // Update cache let regime_state = RegimeState { regime: regime.clone(), confidence, timestamp, cusum_s_plus: Some(cusum_s_plus), cusum_s_minus: Some(cusum_s_minus), adx: Some(adx), stability: None, }; self.cached_regimes .insert(symbol.to_string(), regime_state.clone()); Ok(regime_state) } /// Get cached regime state for a symbol /// /// # Arguments /// /// * `symbol` - Trading symbol /// /// # Returns /// /// Cached regime state if available pub fn get_cached_regime(&self, symbol: &str) -> Option<&RegimeState> { self.cached_regimes.get(symbol) } /// Reset CUSUM detector (call after break detection) pub fn reset_cusum(&mut self) { self.cusum.reset(); } /// Get current CUSUM sums pub fn get_cusum_sums(&self) -> (f64, f64) { self.cusum.get_current_sums() } /// Get current ADX value pub fn get_adx(&self) -> f64 { self.trending_classifier.get_trend_strength() } /// Get database pool reference pub fn pool(&self) -> &PgPool { &self.db_pool } } #[cfg(test)] mod tests { use super::*; fn create_test_bars(count: usize, base_price: f64) -> Vec { let base_time = Utc::now(); (0..count) .map(|i| { let price = base_price + (i as f64 * 0.1); OHLCVBar { timestamp: base_time + chrono::Duration::seconds(i as i64 * 60), open: price, high: price + 0.5, low: price - 0.5, close: price + 0.2, volume: 1000.0, } }) .collect() } #[test] fn test_regime_state_serialization() { let state = RegimeState { regime: "Trending".to_owned(), confidence: 0.85, timestamp: Utc::now(), cusum_s_plus: Some(3.5), cusum_s_minus: Some(0.0), adx: Some(45.0), stability: Some(0.9), }; let json = serde_json::to_string(&state).unwrap(); let deserialized: RegimeState = serde_json::from_str(&json).unwrap(); assert_eq!(state.regime, deserialized.regime); assert_eq!(state.confidence, deserialized.confidence); } #[test] fn test_bar_conversion() { let bars = create_test_bars(5, 100.0); assert_eq!(bars.len(), 5); assert!(bars[0].close > 100.0); assert!(bars[4].close > bars[0].close); } #[test] fn test_insufficient_data_error() { let _bars = create_test_bars(10, 100.0); let error = OrchestratorError::InsufficientData { required: 20, actual: 10, }; let error_msg = error.to_string(); assert!(error_msg.contains("Insufficient data")); assert!(error_msg.contains("20")); assert!(error_msg.contains("10")); } }