Files
foxhunt/crates/ml-regime-detection/src/feature_classifier.rs
jgrusewski 58f4f26113 refactor(ml): extract regime-detection, explainability, paper-trading
- ml-regime-detection (1.2K lines): feature_classifier, hmm modules.
  Depends on ml-core + ml-dqn (RegimeType). 23 tests passing.

- ml-explainability (329 lines): integrated_gradients module.
  Depends on ml-core + candle-core. 4 tests passing.

- ml-paper-trading (389 lines): broker, pnl_tracker modules.
  Depends on ml-ensemble (TradeAction, TradeSignal). 8 tests passing.

Total: 21 sub-crates extracted from ml monolith.
ml reduced from ~260K to ~90K lines (65% extracted).
All tests: 841 ml + 35 in new sub-crates = 876 passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:17:22 +01:00

266 lines
8.7 KiB
Rust

//! 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<f64>,
}
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<f64> {
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::<f64>() / 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"
);
}
}