Files
foxhunt/crates/ml-regime/src/orchestrator.rs
jgrusewski d313486dc2 refactor(ml): split monolith into 9 sub-crates + delete dead code
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>
2026-03-08 15:17:22 +01:00

535 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}
}