From 58f4f2611347dc4ebad0fa511d36e3d28d070fdc Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 8 Mar 2026 14:20:16 +0100 Subject: [PATCH] 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 --- Cargo.lock | 30 ++ Cargo.toml | 6 + crates/ml-explainability/Cargo.toml | 30 ++ .../src}/integrated_gradients.rs | 10 +- crates/ml-explainability/src/lib.rs | 9 + crates/ml-paper-trading/Cargo.toml | 19 ++ .../src}/broker.rs | 2 +- crates/ml-paper-trading/src/lib.rs | 22 ++ .../src}/pnl_tracker.rs | 0 crates/ml-regime-detection/Cargo.toml | 30 ++ .../src}/feature_classifier.rs | 14 +- .../src}/hmm.rs | 4 +- crates/ml-regime-detection/src/lib.rs | 291 ++++++++++++++++++ crates/ml/Cargo.toml | 3 + crates/ml/src/explainability/mod.rs | 6 +- crates/ml/src/paper_trading/mod.rs | 6 +- crates/ml/src/regime_detection/mod.rs | 270 +--------------- 17 files changed, 468 insertions(+), 284 deletions(-) create mode 100644 crates/ml-explainability/Cargo.toml rename crates/{ml/src/explainability => ml-explainability/src}/integrated_gradients.rs (96%) create mode 100644 crates/ml-explainability/src/lib.rs create mode 100644 crates/ml-paper-trading/Cargo.toml rename crates/{ml/src/paper_trading => ml-paper-trading/src}/broker.rs (99%) create mode 100644 crates/ml-paper-trading/src/lib.rs rename crates/{ml/src/paper_trading => ml-paper-trading/src}/pnl_tracker.rs (100%) create mode 100644 crates/ml-regime-detection/Cargo.toml rename crates/{ml/src/regime_detection => ml-regime-detection/src}/feature_classifier.rs (95%) rename crates/{ml/src/regime_detection => ml-regime-detection/src}/hmm.rs (99%) create mode 100644 crates/ml-regime-detection/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index a92524cde..b4b9d6f72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f25db3156..5e712a9d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/ml-explainability/Cargo.toml b/crates/ml-explainability/Cargo.toml new file mode 100644 index 000000000..a7783e885 --- /dev/null +++ b/crates/ml-explainability/Cargo.toml @@ -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 diff --git a/crates/ml/src/explainability/integrated_gradients.rs b/crates/ml-explainability/src/integrated_gradients.rs similarity index 96% rename from crates/ml/src/explainability/integrated_gradients.rs rename to crates/ml-explainability/src/integrated_gradients.rs index bc359ec60..7c821f01d 100644 --- a/crates/ml/src/explainability/integrated_gradients.rs +++ b/crates/ml-explainability/src/integrated_gradients.rs @@ -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 { - // 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(); diff --git a/crates/ml-explainability/src/lib.rs b/crates/ml-explainability/src/lib.rs new file mode 100644 index 000000000..269f15a26 --- /dev/null +++ b/crates/ml-explainability/src/lib.rs @@ -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; diff --git a/crates/ml-paper-trading/Cargo.toml b/crates/ml-paper-trading/Cargo.toml new file mode 100644 index 000000000..0794ca3f7 --- /dev/null +++ b/crates/ml-paper-trading/Cargo.toml @@ -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 diff --git a/crates/ml/src/paper_trading/broker.rs b/crates/ml-paper-trading/src/broker.rs similarity index 99% rename from crates/ml/src/paper_trading/broker.rs rename to crates/ml-paper-trading/src/broker.rs index 141cba002..de0d7984a 100644 --- a/crates/ml/src/paper_trading/broker.rs +++ b/crates/ml-paper-trading/src/broker.rs @@ -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)] diff --git a/crates/ml-paper-trading/src/lib.rs b/crates/ml-paper-trading/src/lib.rs new file mode 100644 index 000000000..4ed2fd7c5 --- /dev/null +++ b/crates/ml-paper-trading/src/lib.rs @@ -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; diff --git a/crates/ml/src/paper_trading/pnl_tracker.rs b/crates/ml-paper-trading/src/pnl_tracker.rs similarity index 100% rename from crates/ml/src/paper_trading/pnl_tracker.rs rename to crates/ml-paper-trading/src/pnl_tracker.rs diff --git a/crates/ml-regime-detection/Cargo.toml b/crates/ml-regime-detection/Cargo.toml new file mode 100644 index 000000000..34c754b76 --- /dev/null +++ b/crates/ml-regime-detection/Cargo.toml @@ -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 diff --git a/crates/ml/src/regime_detection/feature_classifier.rs b/crates/ml-regime-detection/src/feature_classifier.rs similarity index 95% rename from crates/ml/src/regime_detection/feature_classifier.rs rename to crates/ml-regime-detection/src/feature_classifier.rs index e011ef751..eab9eaba5 100644 --- a/crates/ml/src/regime_detection/feature_classifier.rs +++ b/crates/ml-regime-detection/src/feature_classifier.rs @@ -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, diff --git a/crates/ml/src/regime_detection/hmm.rs b/crates/ml-regime-detection/src/hmm.rs similarity index 99% rename from crates/ml/src/regime_detection/hmm.rs rename to crates/ml-regime-detection/src/hmm.rs index 6edfee5a4..383054637 100644 --- a/crates/ml/src/regime_detection/hmm.rs +++ b/crates/ml-regime-detection/src/hmm.rs @@ -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::() / observations.len() as f64; let global_var: f64 = observations diff --git a/crates/ml-regime-detection/src/lib.rs b/crates/ml-regime-detection/src/lib.rs new file mode 100644 index 000000000..1a4dc8f25 --- /dev/null +++ b/crates/ml-regime-detection/src/lib.rs @@ -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, + 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)] +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); + } +} diff --git a/crates/ml/Cargo.toml b/crates/ml/Cargo.toml index ae3c2f247..402045467 100644 --- a/crates/ml/Cargo.toml +++ b/crates/ml/Cargo.toml @@ -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" } diff --git a/crates/ml/src/explainability/mod.rs b/crates/ml/src/explainability/mod.rs index d1043c7b4..90c42cb01 100644 --- a/crates/ml/src/explainability/mod.rs +++ b/crates/ml/src/explainability/mod.rs @@ -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::*; diff --git a/crates/ml/src/paper_trading/mod.rs b/crates/ml/src/paper_trading/mod.rs index 3b0f519f2..680550ea3 100644 --- a/crates/ml/src/paper_trading/mod.rs +++ b/crates/ml/src/paper_trading/mod.rs @@ -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; diff --git a/crates/ml/src/regime_detection/mod.rs b/crates/ml/src/regime_detection/mod.rs index 46ac0fb53..7bf15bad2 100644 --- a/crates/ml/src/regime_detection/mod.rs +++ b/crates/ml/src/regime_detection/mod.rs @@ -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, - 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)] -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); - } -} +// 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;