//! Feature-based regime classifier using ADX, Hurst exponent, and volatility z-score. //! //! Classifies market regimes in real-time from observable features: //! - **Trending**: ADX > threshold AND Hurst > trending threshold (persistent trend) //! - **Volatile**: Volatility z-score > threshold (extreme volatility spike) //! - **Ranging**: Hurst < ranging threshold AND vol z-score < 0 (mean-reverting) //! - Fallback -> Ranging (most conservative default) use std::collections::VecDeque; use serde::{Deserialize, Serialize}; use ml_dqn::RegimeType; /// Configuration for the feature-based regime classifier. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClassifierConfig { /// ADX value above which the market is considered trending (default: 25.0) pub adx_trending_threshold: f64, /// Hurst exponent above which trend persistence is confirmed (default: 0.55) pub hurst_trending_threshold: f64, /// Hurst exponent below which mean-reversion is confirmed (default: 0.45) pub hurst_ranging_threshold: f64, /// Volatility z-score above which the market is classified as volatile (default: 2.0) pub vol_zscore_threshold: f64, /// Size of the rolling volatility window for z-score computation (default: 100) pub vol_window: usize, } impl Default for ClassifierConfig { fn default() -> Self { Self { adx_trending_threshold: 25.0, hurst_trending_threshold: 0.55, hurst_ranging_threshold: 0.45, vol_zscore_threshold: 2.0, vol_window: 100, } } } /// Real-time feature-based regime classifier. /// /// Maintains a rolling window of volatility observations for z-score computation /// and classifies the current market regime from ADX, Hurst exponent, and /// volatility features. #[derive(Debug, Clone)] pub struct FeatureClassifier { config: ClassifierConfig, vol_history: VecDeque, } impl FeatureClassifier { /// Create a new classifier with the given configuration. pub fn new(config: ClassifierConfig) -> Self { let vol_window = config.vol_window; Self { config, vol_history: VecDeque::with_capacity(vol_window), } } /// Push a new volatility observation into the rolling window. /// /// Older observations are evicted once the window is full. pub fn update_volatility(&mut self, vol: f64) { if self.vol_history.len() >= self.config.vol_window { self.vol_history.pop_front(); } self.vol_history.push_back(vol); } /// Compute the z-score of the latest volatility observation against the /// rolling window. Returns `None` if fewer than 2 observations exist. fn vol_zscore(&self) -> Option { if self.vol_history.len() < 2 { return None; } let n = self.vol_history.len() as f64; let sum: f64 = self.vol_history.iter().sum(); let mean = sum / n; let var: f64 = self.vol_history.iter().map(|v| (v - mean).powi(2)).sum::() / n; let std_dev = var.sqrt(); if std_dev < f64::EPSILON { return Some(0.0); } let latest = self.vol_history.back()?; Some((latest - mean) / std_dev) } /// Classify the current market regime. /// /// # Arguments /// /// * `adx` - Average Directional Index (0-100) /// * `hurst` - Hurst exponent (0.0 - 1.0). H > 0.5 = persistent, H < 0.5 = mean-reverting /// /// # Classification Rules /// /// 1. **Volatile**: vol z-score > `vol_zscore_threshold` (checked first -- extreme vol overrides) /// 2. **Trending**: ADX > `adx_trending_threshold` AND Hurst > `hurst_trending_threshold` /// 3. **Ranging**: Hurst < `hurst_ranging_threshold` AND vol z-score < 0 /// 4. Fallback: **Ranging** (most conservative) pub fn classify(&self, adx: f64, hurst: f64) -> RegimeType { let vol_z = self.vol_zscore().unwrap_or(0.0); // Volatile dominates -- extreme vol spikes override everything if vol_z > self.config.vol_zscore_threshold { return RegimeType::Volatile; } // Trending: strong directional movement + persistent autocorrelation if adx > self.config.adx_trending_threshold && hurst > self.config.hurst_trending_threshold { return RegimeType::Trending; } // Ranging: mean-reverting Hurst + below-average volatility if hurst < self.config.hurst_ranging_threshold && vol_z < 0.0 { return RegimeType::Ranging; } // Fallback: Ranging (most conservative default) RegimeType::Ranging } /// Get the current rolling volatility window length. pub fn vol_history_len(&self) -> usize { self.vol_history.len() } /// Get a reference to the classifier config. pub fn config(&self) -> &ClassifierConfig { &self.config } } #[cfg(test)] mod tests { use super::*; fn make_classifier(vol_window: usize) -> FeatureClassifier { FeatureClassifier::new(ClassifierConfig { vol_window, ..ClassifierConfig::default() }) } /// Fill the rolling window with a constant value so mean = val, std = 0. fn fill_constant(classifier: &mut FeatureClassifier, val: f64, n: usize) { for _ in 0..n { classifier.update_volatility(val); } } #[test] fn test_trending_regime() { let mut c = make_classifier(20); // Fill with moderate volatility so z-score stays near 0 fill_constant(&mut c, 0.02, 20); let regime = c.classify(30.0, 0.65); assert_eq!(regime, RegimeType::Trending, "ADX>25 + Hurst>0.55 = Trending"); } #[test] fn test_ranging_regime() { let mut c = make_classifier(20); // Fill with moderate vol, then push a below-average observation for i in 0..20 { c.update_volatility(0.02 + (i as f64) * 0.001); } // Push a low-vol observation to get negative z-score c.update_volatility(0.005); let regime = c.classify(15.0, 0.40); assert_eq!(regime, RegimeType::Ranging, "Hurst<0.45 + vol_z<0 = Ranging"); } #[test] fn test_volatile_regime() { let mut c = make_classifier(20); // Fill with low volatility fill_constant(&mut c, 0.01, 19); // Spike the last observation c.update_volatility(0.10); let regime = c.classify(10.0, 0.50); assert_eq!( regime, RegimeType::Volatile, "vol z-score > 2.0 = Volatile (overrides everything)" ); } #[test] fn test_fallback_to_ranging() { let mut c = make_classifier(20); fill_constant(&mut c, 0.02, 20); // ADX below trending but Hurst above ranging threshold: neither trending nor ranging let regime = c.classify(20.0, 0.50); assert_eq!(regime, RegimeType::Ranging, "No strong signal = fallback Ranging"); } #[test] fn test_volatile_overrides_trending() { let mut c = make_classifier(20); fill_constant(&mut c, 0.01, 19); c.update_volatility(0.10); // Even though ADX and Hurst say trending, vol spike should override let regime = c.classify(35.0, 0.70); assert_eq!( regime, RegimeType::Volatile, "Vol spike overrides trending classification" ); } #[test] fn test_empty_vol_history_fallback() { let c = make_classifier(20); // No volatility data -- z-score defaults to 0.0 let regime = c.classify(20.0, 0.50); assert_eq!(regime, RegimeType::Ranging, "Empty vol history = fallback Ranging"); } #[test] fn test_single_observation_fallback() { let mut c = make_classifier(20); c.update_volatility(0.02); // Only 1 observation -- z-score defaults to 0.0 (need >= 2) let regime = c.classify(30.0, 0.65); assert_eq!( regime, RegimeType::Trending, "Single vol observation (z-score=0) allows trending classification" ); } #[test] fn test_vol_window_eviction() { let mut c = make_classifier(5); for i in 0..10 { c.update_volatility(i as f64); } assert_eq!(c.vol_history_len(), 5, "Window should not grow beyond capacity"); } #[test] fn test_zscore_zero_variance() { let mut c = make_classifier(10); fill_constant(&mut c, 0.05, 10); // All identical values -> std_dev ~ 0 -> z-score = 0 let regime = c.classify(30.0, 0.60); assert_eq!( regime, RegimeType::Trending, "Zero-variance vol history: z-score=0, so trending if ADX+Hurst qualify" ); } }