//! Regime Detection Models for Market State Identification //! //! Implements regime detection algorithms to identify different market states //! and adapt ML models accordingly. //! //! ## Sub-modules //! //! - [`feature_classifier`] -- Real-time regime classification using ADX, Hurst exponent, //! and volatility z-score thresholds. //! - [`hmm`] -- Offline 3-state Gaussian Hidden Markov Model for regime transition estimation. #![deny(clippy::unwrap_used, clippy::expect_used)] #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] #![allow(dead_code)] #![allow(missing_docs)] #![allow(missing_debug_implementations)] #![allow(unused_crate_dependencies)] #![allow(clippy::float_arithmetic)] #![allow(clippy::partial_pub_fields)] #![allow(clippy::shadow_reuse)] #![allow(clippy::shadow_unrelated)] #![allow(clippy::shadow_same)] #![allow(clippy::doc_markdown)] #![allow(clippy::indexing_slicing)] #![allow(clippy::missing_const_for_fn)] #![allow(clippy::module_name_repetitions)] #![allow(clippy::integer_division)] #![allow(clippy::similar_names)] #![allow(clippy::too_many_lines)] #![allow(clippy::as_conversions)] #![allow(clippy::cast_precision_loss)] #![allow(clippy::cast_possible_truncation)] #![allow(clippy::default_numeric_fallback)] #![allow(clippy::arithmetic_side_effects)] pub mod feature_classifier; pub mod hmm; use serde::{Deserialize, Serialize}; use ml_core::MLError; use ml_dqn::RegimeType; pub use feature_classifier::{ClassifierConfig, FeatureClassifier}; pub use hmm::{HMMConfig, HiddenMarkovModel}; /// Configuration for regime detection #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegimeDetectionConfig { pub window_size: usize, pub min_regime_duration: usize, pub threshold: f64, } impl Default for RegimeDetectionConfig { fn default() -> Self { Self { window_size: 100, min_regime_duration: 10, threshold: 0.05, } } } /// Regime detection engine combining feature-based classification and HMM. /// /// The engine accumulates feature data via [`update_features`](Self::update_features) /// and uses a [`FeatureClassifier`] to classify the current regime from the most /// recent ADX, Hurst exponent, and volatility observations. /// /// Feature vector layout (expected indices): /// - Index 0: ADX (Average Directional Index) /// - Index 1: Hurst exponent /// - Index 2: Current volatility /// /// For legacy compatibility, arbitrary feature slices are also accepted and /// accumulated; the classifier only uses the first 3 elements when present. #[derive(Debug)] pub struct RegimeDetectionEngine { pub total_updates: u64, pub feature_data: Vec, config: RegimeDetectionConfig, classifier: FeatureClassifier, /// Most recent ADX value (updated via `update_features`) last_adx: f64, /// Most recent Hurst exponent (updated via `update_features`) last_hurst: f64, } impl RegimeDetectionEngine { /// Create a new regime detection engine. pub fn new(config: RegimeDetectionConfig) -> Result { let classifier_config = ClassifierConfig { vol_window: config.window_size, ..ClassifierConfig::default() }; let classifier = FeatureClassifier::new(classifier_config); Ok(Self { total_updates: 0, feature_data: Vec::new(), config, classifier, last_adx: 0.0, last_hurst: 0.5, // neutral default }) } /// Create engine with custom classifier configuration. pub fn with_classifier_config( config: RegimeDetectionConfig, classifier_config: ClassifierConfig, ) -> Result { let classifier = FeatureClassifier::new(classifier_config); Ok(Self { total_updates: 0, feature_data: Vec::new(), config, classifier, last_adx: 0.0, last_hurst: 0.5, }) } /// Update the engine with new feature observations. /// /// Expected feature layout: /// - `features[0]` = ADX /// - `features[1]` = Hurst exponent /// - `features[2]` = Current volatility /// /// If fewer elements are provided, only the available ones are used. pub fn update_features(&mut self, features: &[f64]) -> Result<(), MLError> { self.feature_data.extend_from_slice(features); self.total_updates += 1; // Cap feature_data to avoid unbounded growth in long-running sessions. // Keep the most recent `window_size * 3` entries (3 features per update). let max_len = self.config.window_size * 3; if self.feature_data.len() > max_len * 2 { let drain_to = self.feature_data.len() - max_len; self.feature_data.drain(..drain_to); } // Extract structured features when available if let Some(&adx) = features.first() { self.last_adx = adx; } if let Some(&hurst) = features.get(1) { self.last_hurst = hurst; } if let Some(&vol) = features.get(2) { self.classifier.update_volatility(vol); } Ok(()) } /// Detect the current market regime using the feature classifier. /// /// Returns a [`RegimeType`] based on accumulated ADX, Hurst exponent, /// and volatility data. pub fn detect_regime(&self) -> Result { Ok(self.classifier.classify(self.last_adx, self.last_hurst)) } /// Get a reference to the internal feature classifier. pub fn classifier(&self) -> &FeatureClassifier { &self.classifier } /// Get the engine configuration. pub fn config(&self) -> &RegimeDetectionConfig { &self.config } } #[cfg(test)] #[allow( clippy::assertions_on_result_states, clippy::float_equality_without_abs, clippy::unreachable )] mod tests { use super::*; #[tokio::test] async fn test_regime_detection_engine_creation() -> Result<(), Box> { let config = RegimeDetectionConfig::default(); let engine = RegimeDetectionEngine::new(config)?; assert_eq!(engine.total_updates, 0); assert!(engine.feature_data.is_empty()); Ok(()) } #[tokio::test] async fn test_feature_data_update() -> Result<(), Box> { let mut engine = RegimeDetectionEngine::new(RegimeDetectionConfig::default())?; let features = vec![0.1, 0.01]; let result = engine.update_features(&features); assert!(result.is_ok()); assert_eq!(engine.total_updates, 1); assert_eq!(engine.feature_data.len(), 2); Ok(()) } #[tokio::test] async fn test_regime_detection_default_ranging() -> Result<(), Box> { let engine = RegimeDetectionEngine::new(RegimeDetectionConfig::default())?; // No features -> ADX=0, Hurst=0.5, no vol -> Ranging fallback let regime = engine.detect_regime()?; assert_eq!(regime, RegimeType::Ranging); Ok(()) } #[tokio::test] async fn test_regime_detection_trending() -> Result<(), Box> { let mut engine = RegimeDetectionEngine::new(RegimeDetectionConfig::default())?; // Feed trending features: ADX=30, Hurst=0.65, vol=0.02 for _ in 0..20 { engine.update_features(&[30.0, 0.65, 0.02])?; } let regime = engine.detect_regime()?; assert_eq!(regime, RegimeType::Trending); Ok(()) } #[tokio::test] async fn test_regime_detection_volatile() -> Result<(), Box> { let mut engine = RegimeDetectionEngine::new(RegimeDetectionConfig::default())?; // Fill with low vol first for _ in 0..50 { engine.update_features(&[10.0, 0.50, 0.01])?; } // Spike volatility engine.update_features(&[10.0, 0.50, 0.50])?; let regime = engine.detect_regime()?; assert_eq!(regime, RegimeType::Volatile); Ok(()) } #[test] fn test_config_defaults() { let config = RegimeDetectionConfig::default(); assert_eq!(config.window_size, 100); assert_eq!(config.min_regime_duration, 10); assert_eq!(config.threshold, 0.05); } #[test] fn test_config_serialization() { let config = RegimeDetectionConfig::default(); let serialized = serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_owned()); let deserialized: RegimeDetectionConfig = serde_json::from_str(&serialized).unwrap_or_default(); assert_eq!(config.window_size, deserialized.window_size); assert_eq!(config.min_regime_duration, deserialized.min_regime_duration); assert!(config.threshold - deserialized.threshold < f64::EPSILON); } #[test] fn test_with_classifier_config() { let regime_config = RegimeDetectionConfig::default(); let classifier_config = ClassifierConfig { adx_trending_threshold: 30.0, hurst_trending_threshold: 0.60, ..ClassifierConfig::default() }; let engine = RegimeDetectionEngine::with_classifier_config(regime_config, classifier_config); assert!(engine.is_ok()); let engine = engine.unwrap_or_else(|_| { RegimeDetectionEngine::new(RegimeDetectionConfig::default()) .unwrap_or_else(|_| unreachable!()) }); assert_eq!(engine.classifier().config().adx_trending_threshold, 30.0); } }