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>
This commit is contained in:
jgrusewski
2026-03-08 14:20:16 +01:00
parent 4676fe79e2
commit 58f4f26113
17 changed files with 468 additions and 284 deletions

30
Cargo.lock generated
View File

@@ -6269,12 +6269,15 @@ dependencies = [
"ml-data-validation",
"ml-dqn",
"ml-ensemble",
"ml-explainability",
"ml-features",
"ml-hyperopt",
"ml-labeling",
"ml-observability",
"ml-paper-trading",
"ml-ppo",
"ml-regime",
"ml-regime-detection",
"ml-risk",
"ml-security",
"ml-stress-testing",
@@ -6509,6 +6512,15 @@ dependencies = [
"uuid",
]
[[package]]
name = "ml-explainability"
version = "1.0.0"
dependencies = [
"candle-core",
"candle-nn",
"ml-core",
]
[[package]]
name = "ml-features"
version = "1.0.0"
@@ -6583,6 +6595,13 @@ dependencies = [
"tracing",
]
[[package]]
name = "ml-paper-trading"
version = "1.0.0"
dependencies = [
"ml-ensemble",
]
[[package]]
name = "ml-ppo"
version = "1.0.0"
@@ -6625,6 +6644,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "ml-regime-detection"
version = "1.0.0"
dependencies = [
"ml-core",
"ml-dqn",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "ml-risk"
version = "1.0.0"

View File

@@ -119,6 +119,7 @@ members = [
"crates/ml-features",
"crates/ml-labeling",
"crates/ml-regime",
"crates/ml-regime-detection",
"crates/ml-checkpoint",
"crates/ml-data-validation",
"crates/ml-validation",
@@ -129,6 +130,8 @@ members = [
"crates/ml-universe",
"crates/ml-observability",
"crates/ml-stress-testing",
"crates/ml-explainability",
"crates/ml-paper-trading",
"crates/data",
"crates/backtesting",
"crates/common",
@@ -412,6 +415,7 @@ ml-hyperopt = { path = "crates/ml-hyperopt", default-features = false }
ml-features = { path = "crates/ml-features" }
ml-labeling = { path = "crates/ml-labeling", default-features = false }
ml-regime = { path = "crates/ml-regime" }
ml-regime-detection = { path = "crates/ml-regime-detection" }
ml-checkpoint = { path = "crates/ml-checkpoint" }
ml-data-validation = { path = "crates/ml-data-validation" }
ml-validation = { path = "crates/ml-validation" }
@@ -422,6 +426,8 @@ ml-asset-selection = { path = "crates/ml-asset-selection" }
ml-universe = { path = "crates/ml-universe" }
ml-observability = { path = "crates/ml-observability" }
ml-stress-testing = { path = "crates/ml-stress-testing" }
ml-explainability = { path = "crates/ml-explainability", default-features = false }
ml-paper-trading = { path = "crates/ml-paper-trading" }
common = { path = "crates/common" }
storage = { path = "crates/storage" }
market-data = { path = "crates/market-data" }

View File

@@ -0,0 +1,30 @@
[package]
name = "ml-explainability"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
publish.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Model explainability (integrated gradients) for Foxhunt ML"
[features]
default = ["cuda"]
cuda = ["candle-core/cuda", "candle-core/cudnn", "candle-nn/cuda", "candle-nn/cudnn"]
[dependencies]
ml-core = { path = "../ml-core", default-features = false }
candle-core = { git = "https://github.com/huggingface/candle", rev = "671de1db" }
candle-nn = { git = "https://github.com/huggingface/candle", rev = "671de1db" }
[dev-dependencies]
candle-core = { git = "https://github.com/huggingface/candle", rev = "671de1db" }
candle-nn = { git = "https://github.com/huggingface/candle", rev = "671de1db" }
[lints]
workspace = true

View File

@@ -19,7 +19,7 @@ use std::collections::HashMap;
use candle_core::{backprop::GradStore, Tensor, Var};
use candle_nn::Module;
use crate::MLError;
use ml_core::MLError;
/// Integrated Gradients explainability method.
///
@@ -197,7 +197,7 @@ mod tests {
fn test_integrated_gradients_basic() {
let device = Device::Cpu;
let varmap = VarMap::new();
let vs = VarBuilder::from_varmap(&varmap, crate::dqn::mixed_precision::training_dtype(&device), &device);
let vs = VarBuilder::from_varmap(&varmap, ml_core::mixed_precision::training_dtype(&device), &device);
let model = TwoLayerNet::new(vs);
// Linear layers require 2D input: [batch, features]
@@ -234,7 +234,7 @@ mod tests {
impl Module for LinearNet {
fn forward(&self, xs: &Tensor) -> candle_core::Result<Tensor> {
// No ReLU purely linear, so IG completeness holds exactly.
// No ReLU -- purely linear, so IG completeness holds exactly.
let h = self.fc1.forward(xs)?;
self.fc2.forward(&h)
}
@@ -244,7 +244,7 @@ mod tests {
fn test_ig_completeness_axiom() {
let device = Device::Cpu;
let varmap = VarMap::new();
let vs = VarBuilder::from_varmap(&varmap, crate::dqn::mixed_precision::training_dtype(&device), &device);
let vs = VarBuilder::from_varmap(&varmap, ml_core::mixed_precision::training_dtype(&device), &device);
let model = LinearNet::new(vs);
let input = Tensor::new(&[[1.0_f32, -0.5, 0.3, 2.0]], &device).unwrap();
@@ -298,7 +298,7 @@ mod tests {
fn test_ig_dimension_mismatch() {
let device = Device::Cpu;
let varmap = VarMap::new();
let vs = VarBuilder::from_varmap(&varmap, crate::dqn::mixed_precision::training_dtype(&device), &device);
let vs = VarBuilder::from_varmap(&varmap, ml_core::mixed_precision::training_dtype(&device), &device);
let model = TwoLayerNet::new(vs);
let input = Tensor::new(&[[1.0_f32, -0.5, 0.3, 2.0]], &device).unwrap();

View File

@@ -0,0 +1,9 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
#![allow(clippy::float_arithmetic)]
//! Model explainability: feature importance via integrated gradients.
pub mod integrated_gradients;
pub use integrated_gradients::IntegratedGradients;

View File

@@ -0,0 +1,19 @@
[package]
name = "ml-paper-trading"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
publish.workspace = true
keywords.workspace = true
categories.workspace = true
[dependencies]
ml-ensemble.workspace = true
[lints]
workspace = true

View File

@@ -1,6 +1,6 @@
//! Simulated broker for paper trading.
use crate::ensemble::signal::{TradeAction, TradeSignal};
use ml_ensemble::signal::{TradeAction, TradeSignal};
/// A simulated fill from the paper broker.
#[derive(Debug, Clone)]

View File

@@ -0,0 +1,22 @@
#![deny(clippy::unwrap_used, clippy::expect_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
#![allow(dead_code)]
#![allow(missing_docs)]
#![allow(clippy::float_arithmetic)]
#![allow(clippy::as_conversions)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::default_numeric_fallback)]
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unused_self)]
//! Paper trading simulation -- simulated fills and P&L tracking.
pub mod broker;
pub mod pnl_tracker;

View File

@@ -0,0 +1,30 @@
[package]
name = "ml-regime-detection"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
publish.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Market regime detection engine with feature-based classification and HMM for Foxhunt ML"
[features]
default = []
[dependencies]
ml-core.workspace = true
ml-dqn.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
[lints]
workspace = true

View File

@@ -4,13 +4,13 @@
//! - **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)
//! - Fallback -> Ranging (most conservative default)
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use crate::dqn::RegimeType;
use ml_dqn::RegimeType;
/// Configuration for the feature-based regime classifier.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -99,14 +99,14 @@ impl FeatureClassifier {
///
/// # Classification Rules
///
/// 1. **Volatile**: vol z-score > `vol_zscore_threshold` (checked first extreme vol overrides)
/// 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
// Volatile dominates -- extreme vol spikes override everything
if vol_z > self.config.vol_zscore_threshold {
return RegimeType::Volatile;
}
@@ -223,7 +223,7 @@ mod tests {
#[test]
fn test_empty_vol_history_fallback() {
let c = make_classifier(20);
// No volatility data z-score defaults to 0.0
// 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");
}
@@ -232,7 +232,7 @@ mod tests {
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)
// Only 1 observation -- z-score defaults to 0.0 (need >= 2)
let regime = c.classify(30.0, 0.65);
assert_eq!(
regime,
@@ -254,7 +254,7 @@ mod tests {
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
// All identical values -> std_dev ~ 0 -> z-score = 0
let regime = c.classify(30.0, 0.60);
assert_eq!(
regime,

View File

@@ -10,7 +10,7 @@
use serde::{Deserialize, Serialize};
use crate::MLError;
use ml_core::MLError;
/// Configuration for the Hidden Markov Model.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -254,7 +254,7 @@ impl HiddenMarkovModel {
self.emission_vars[i] =
(var_sums.get(i).copied().unwrap_or(1.0) / count as f64).max(1e-8);
} else {
// Single or no points use global variance as fallback
// Single or no points -- use global variance as fallback
let global_mean: f64 =
observations.iter().sum::<f64>() / observations.len() as f64;
let global_var: f64 = observations

View File

@@ -0,0 +1,291 @@
//! 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<f64>,
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<Self, MLError> {
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<Self, MLError> {
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<RegimeType, MLError> {
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)]
mod tests {
use super::*;
#[tokio::test]
async fn test_regime_detection_engine_creation() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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);
}
}

View File

@@ -77,6 +77,7 @@ ml-hyperopt.workspace = true
ml-features.workspace = true
ml-labeling.workspace = true
ml-regime.workspace = true
ml-regime-detection.workspace = true
ml-checkpoint.workspace = true
ml-data-validation.workspace = true
ml-validation.workspace = true
@@ -87,6 +88,8 @@ ml-asset-selection.workspace = true
ml-universe.workspace = true
ml-observability.workspace = true
ml-stress-testing.workspace = true
ml-explainability.workspace = true
ml-paper-trading.workspace = true
config.workspace = true
common = { workspace = true, features = ["questdb"] }
risk = { path = "../risk" }

View File

@@ -1,5 +1,5 @@
//! Model explainability: feature importance via integrated gradients.
//!
//! Facade -- the implementation lives in the `ml-explainability` crate.
pub mod integrated_gradients;
pub use integrated_gradients::IntegratedGradients;
pub use ml_explainability::*;

View File

@@ -1,4 +1,4 @@
//! Paper trading simulation -- simulated fills and P&L tracking.
//! Paper trading simulation -- thin facade re-exporting from ml-paper-trading crate.
pub mod broker;
pub mod pnl_tracker;
pub use ml_paper_trading::broker;
pub use ml_paper_trading::pnl_tracker;

View File

@@ -1,267 +1,11 @@
//! 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.
//! This module re-exports the `ml-regime-detection` crate. All types and logic
//! now live in the standalone crate; this facade preserves the `ml::regime_detection`
//! import path for downstream consumers.
pub mod feature_classifier;
pub mod hmm;
pub use ml_regime_detection::*;
use serde::{Deserialize, Serialize};
use crate::dqn::RegimeType;
use crate::MLError;
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<f64>,
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<Self, MLError> {
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<Self, MLError> {
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<RegimeType, MLError> {
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)]
mod tests {
use super::*;
#[tokio::test]
async fn test_regime_detection_engine_creation() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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);
}
}
// Re-export sub-modules so `ml::regime_detection::feature_classifier` still works
pub use ml_regime_detection::feature_classifier;
pub use ml_regime_detection::hmm;