Extract 9 new sub-crates from the ml monolith to enable parallel compilation across the workspace: New crates (this commit): - ml-features (282 tests): feature engineering, 21 modules - ml-labeling (45 tests): triple barrier, meta-labeling, fractional diff - ml-ensemble (116 tests): ensemble coordination, voting, confidence - ml-hyperopt (47 tests): core PSO/TPE optimizer, parameter space - ml-checkpoint (41 tests): checkpoint persistence, compression, signing - ml-regime (68 tests): CUSUM, Bayesian changepoint, regime classification - ml-data-validation (67 tests): FDR correction, CPCV, data quality - ml-risk (33 tests): neural VaR, Kelly criterion, circuit breakers - ml-validation (43 tests): statistical validation, walk-forward, DSR Extended existing crates: - ml-dqn: added evaluation/ (backtesting engine, metrics, reports) and checkpoint implementation - ml-supervised: added checkpoint implementations - ml-core: added shared types needed by new sub-crates Pattern: each module in ml/ becomes a thin facade (pub use subcrate::*) with bridge modules staying in ml for cross-model adapter code. Dead code deleted (~7K lines): - 13 undeclared files in microstructure/ (never compiled) - 7 undeclared files + tests/ in risk/ (never compiled) - parquet_io, cache_service, cache_storage, minio_integration (unused) - extraction_wave_d_impl.rs (bare fn outside impl block) All 2,746 sub-crate tests + 951 ml tests pass. Full workspace builds clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
535 lines
18 KiB
Rust
535 lines
18 KiB
Rust
//! 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<dyn std::error::Error>> {
|
||
//! 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<Utc>,
|
||
/// CUSUM positive sum
|
||
pub cusum_s_plus: Option<f64>,
|
||
/// CUSUM negative sum
|
||
pub cusum_s_minus: Option<f64>,
|
||
/// ADX value
|
||
pub adx: Option<f64>,
|
||
/// Regime stability
|
||
pub stability: Option<f64>,
|
||
}
|
||
|
||
/// 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<String, RegimeState>,
|
||
|
||
/// 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<String, i64>,
|
||
|
||
/// Tracks the cumulative bar count processed per symbol.
|
||
/// Incremented each time detect_and_persist is called for a symbol.
|
||
cumulative_bars: HashMap<String, i64>,
|
||
|
||
/// 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<Self, OrchestratorError> {
|
||
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<Self, OrchestratorError> {
|
||
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<RegimeState, OrchestratorError> {
|
||
// 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::<f64>
|
||
).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::<f64>, // 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<OHLCVBar> {
|
||
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"));
|
||
}
|
||
}
|