- 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>
266 lines
8.7 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|