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:
30
Cargo.lock
generated
30
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
30
crates/ml-explainability/Cargo.toml
Normal file
30
crates/ml-explainability/Cargo.toml
Normal 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
|
||||
@@ -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();
|
||||
9
crates/ml-explainability/src/lib.rs
Normal file
9
crates/ml-explainability/src/lib.rs
Normal 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;
|
||||
19
crates/ml-paper-trading/Cargo.toml
Normal file
19
crates/ml-paper-trading/Cargo.toml
Normal 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
|
||||
@@ -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)]
|
||||
22
crates/ml-paper-trading/src/lib.rs
Normal file
22
crates/ml-paper-trading/src/lib.rs
Normal 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;
|
||||
30
crates/ml-regime-detection/Cargo.toml
Normal file
30
crates/ml-regime-detection/Cargo.toml
Normal 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
|
||||
@@ -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,
|
||||
@@ -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
|
||||
291
crates/ml-regime-detection/src/lib.rs
Normal file
291
crates/ml-regime-detection/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user