Files
foxhunt/ml/src/ensemble/adaptive_ml_integration.rs
jgrusewski 57bae2cb68 fix(ml): OOM hardening + battle-test KAN/xLSTM/Diffusion models
Replace 8 unbounded Vec accumulation patterns with bounded VecDeque
across ensemble, PPO, DQN, Mamba2, and data pipeline code to prevent
OOM on RTX 3050 Ti (4GB VRAM) during live trading and extended training.

Key OOM fixes:
- Ensemble price/volatility history: Vec → VecDeque with O(1) eviction
- Data pipeline: MAX_FEATURES=500K cap (~512MB) prevents unbounded loading
- DQN replay buffer: full-array shuffle → HashSet random sampling (8MB → 256B)
- PPO loss histories: bounded VecDeque (cap 1K), eliminated batch.clone()
- Mamba2 scan: pre-allocated Vecs, explicit drop() after Tensor::cat
- Mamba2 training history: capped at 100, Tensor::randn replaces Vec→Tensor
- Mamba2 SSM reset: 2 unwrap() violations replaced with proper error handling

Battle-testing (19 new integration tests):
- KAN: 5 tests (forward, 50-epoch training 89.9% loss reduction, checkpoint)
- xLSTM: 7 tests (2D+3D forward, 30-epoch training 82% reduction, checkpoint)
- Diffusion: 7 tests (2D+3D forward, 20-epoch pipeline, checkpoint, validation)

Bonus: fix pre-existing cache test failure (match .dbn.zst files, graceful skip)

All 2390 lib tests pass, 0 new clippy errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:55:39 +01:00

737 lines
24 KiB
Rust

//! Adaptive ML Ensemble Integration
//!
//! Combines 6-model ML ensemble with adaptive trading strategy for regime-aware trading.
//! This module bridges the ML ensemble coordinator with market regime detection to
//! dynamically adjust model weights based on current market conditions.
use crate::ensemble::EnsembleDecision;
use crate::{MLError, MLResult, ModelPrediction};
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
use super::coordinator_extended::{EnsembleConfig, ExtendedEnsembleCoordinator};
/// Market regime types for adaptive weighting
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MarketRegime {
/// Normal market conditions with typical volatility and volume
Normal,
/// Strong directional movement with clear trends
Trending,
/// Bull market - upward trending with moderate volatility
Bull,
/// Bear market - downward trending with moderate volatility
Bear,
/// Sideways market - low volatility, range-bound
Sideways,
/// High volatility - significant price swings
HighVolatility,
/// Crisis conditions with extreme volatility and risk
Crisis,
/// Unknown/transitioning regime
Unknown,
}
/// Adaptive ML Ensemble combining ensemble coordinator with regime detection
#[derive(Debug)]
pub struct AdaptiveMLEnsemble {
/// Extended ensemble coordinator (6 models)
coordinator: Arc<ExtendedEnsembleCoordinator>,
/// Current market regime
current_regime: Arc<RwLock<MarketRegime>>,
/// Regime detection parameters
regime_config: RegimeConfig,
/// Price history for regime detection (VecDeque for O(1) front removal)
price_history: Arc<RwLock<VecDeque<PricePoint>>>,
/// Volatility history for regime detection (VecDeque for O(1) front removal)
volatility_history: Arc<RwLock<VecDeque<f64>>>,
/// Performance metrics
metrics: Arc<RwLock<AdaptiveMetrics>>,
}
/// Configuration for regime detection
#[derive(Debug, Clone)]
pub struct RegimeConfig {
/// Lookback window for trend detection (bars)
pub trend_lookback: usize,
/// Volatility window for regime classification (bars)
pub volatility_window: usize,
/// Bull/Bear threshold (% change)
pub trend_threshold: f64,
/// High volatility threshold (multiple of average)
pub volatility_threshold: f64,
/// Minimum data points for regime detection
pub min_data_points: usize,
}
impl Default for RegimeConfig {
fn default() -> Self {
Self {
trend_lookback: 20,
volatility_window: 20,
trend_threshold: 0.02, // 2% trend
volatility_threshold: 1.5, // 1.5x average volatility
min_data_points: 20,
}
}
}
/// Price point for regime detection
#[derive(Debug, Clone)]
pub struct PricePoint {
pub timestamp: u64,
pub price: f64,
pub volume: f64,
}
/// Performance metrics for adaptive ensemble
#[derive(Debug, Clone)]
pub struct AdaptiveMetrics {
/// Total predictions made
pub total_predictions: u64,
/// Predictions per regime
pub predictions_per_regime: HashMap<MarketRegime, u64>,
/// Sharpe ratio per regime
pub sharpe_per_regime: HashMap<MarketRegime, f64>,
/// Cumulative returns
pub cumulative_return: f64,
/// Maximum drawdown
pub max_drawdown: f64,
/// Win rate
pub win_rate: f64,
/// Regime transitions
pub regime_transitions: u64,
}
impl Default for AdaptiveMetrics {
fn default() -> Self {
Self {
total_predictions: 0,
predictions_per_regime: HashMap::new(),
sharpe_per_regime: HashMap::new(),
cumulative_return: 0.0,
max_drawdown: 0.0,
win_rate: 0.0,
regime_transitions: 0,
}
}
}
impl AdaptiveMLEnsemble {
/// Create new adaptive ML ensemble
pub fn new(regime_config: Option<RegimeConfig>) -> Self {
let ensemble_config = EnsembleConfig {
adaptive_weighting: true,
min_correlation_threshold: 0.7,
diversity_adjustment_factor: 0.2,
performance_window_size: 1000,
min_weight: 0.05,
max_weight: 0.50,
};
let coordinator = Arc::new(ExtendedEnsembleCoordinator::new(ensemble_config));
Self {
coordinator,
current_regime: Arc::new(RwLock::new(MarketRegime::Unknown)),
regime_config: regime_config.unwrap_or_default(),
price_history: Arc::new(RwLock::new(VecDeque::new())),
volatility_history: Arc::new(RwLock::new(VecDeque::new())),
metrics: Arc::new(RwLock::new(AdaptiveMetrics::default())),
}
}
/// Register all 6 models with initial weights
pub async fn register_models(&self) -> MLResult<()> {
// Register all 6 models with equal initial weights
self.coordinator
.register_model("DQN".to_string(), 0.167)
.await?;
self.coordinator
.register_model("PPO".to_string(), 0.167)
.await?;
self.coordinator
.register_model("TFT".to_string(), 0.167)
.await?;
self.coordinator
.register_model("MAMBA-2".to_string(), 0.166)
.await?;
self.coordinator
.register_model("Liquid".to_string(), 0.166)
.await?;
self.coordinator
.register_model("TLOB".to_string(), 0.167)
.await?;
info!("Registered 6 models in adaptive ensemble");
Ok(())
}
/// Update market regime based on price data
pub async fn update_regime(&self, price: f64, volume: f64) -> MLResult<MarketRegime> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Add to history
{
let mut history = self.price_history.write().await;
history.push_back(PricePoint {
timestamp,
price,
volume,
});
// Keep only required history — O(1) pop_front via VecDeque
let max_history = self
.regime_config
.trend_lookback
.max(self.regime_config.volatility_window)
* 2;
while history.len() > max_history {
history.pop_front();
}
}
// Detect regime
let new_regime = self.detect_regime().await?;
// Update current regime if changed
{
let mut current = self.current_regime.write().await;
if *current != new_regime {
info!("Regime transition: {:?} -> {:?}", *current, new_regime);
*current = new_regime;
// Update metrics
let mut metrics = self.metrics.write().await;
metrics.regime_transitions += 1;
}
}
Ok(new_regime)
}
/// Detect current market regime from price history
async fn detect_regime(&self) -> MLResult<MarketRegime> {
let history = self.price_history.read().await;
if history.len() < self.regime_config.min_data_points {
return Ok(MarketRegime::Unknown);
}
// Calculate trend
let lookback = self.regime_config.trend_lookback.min(history.len());
let prices: Vec<f64> = history
.iter()
.rev()
.take(lookback)
.map(|p| p.price)
.collect();
let first_price = prices.last().copied().unwrap_or(0.0);
let last_price = prices.first().copied().unwrap_or(0.0);
let trend = if first_price > 0.0 {
(last_price - first_price) / first_price
} else {
0.0
};
// Calculate volatility
let returns: Vec<f64> = prices.windows(2).map(|w| (w[0] - w[1]) / w[1]).collect();
let volatility = if !returns.is_empty() {
let mean: f64 = returns.iter().sum::<f64>() / returns.len() as f64;
let variance: f64 =
returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64;
variance.sqrt()
} else {
0.0
};
// Update volatility history
{
let mut vol_history = self.volatility_history.write().await;
vol_history.push_back(volatility);
if vol_history.len() > self.regime_config.volatility_window {
vol_history.pop_front();
}
}
// Calculate average volatility
let vol_history = self.volatility_history.read().await;
let avg_volatility = if !vol_history.is_empty() {
vol_history.iter().sum::<f64>() / vol_history.len() as f64
} else {
volatility
};
// Classify regime
let regime = if volatility > avg_volatility * self.regime_config.volatility_threshold {
MarketRegime::HighVolatility
} else if trend > self.regime_config.trend_threshold {
MarketRegime::Bull
} else if trend < -self.regime_config.trend_threshold {
MarketRegime::Bear
} else {
MarketRegime::Sideways
};
debug!(
"Regime detection: trend={:.4}, volatility={:.4}, avg_vol={:.4}, regime={:?}",
trend, volatility, avg_volatility, regime
);
Ok(regime)
}
/// Make prediction with regime-adaptive model weighting
pub async fn predict(&self, predictions: Vec<ModelPrediction>) -> MLResult<EnsembleDecision> {
if predictions.is_empty() {
return Err(MLError::ValidationError {
message: "No predictions provided".to_string(),
});
}
// Adjust weights based on current regime
let regime = *self.current_regime.read().await;
self.apply_regime_weights(regime).await?;
// Get ensemble decision
let decision = self.coordinator.predict(predictions).await?;
// Update metrics
{
let mut metrics = self.metrics.write().await;
metrics.total_predictions += 1;
*metrics.predictions_per_regime.entry(regime).or_insert(0) += 1;
}
Ok(decision)
}
/// Apply regime-conditional model weights
async fn apply_regime_weights(&self, regime: MarketRegime) -> MLResult<()> {
// Define regime-specific weights
let weights: HashMap<String, f64> = match regime {
MarketRegime::Bull => {
// Bull market: Weight trend-following models higher (DQN, PPO)
[
("DQN".to_string(), 0.30), // Trend follower
("PPO".to_string(), 0.25), // Reinforcement learning
("TFT".to_string(), 0.15), // Time-series forecasting
("MAMBA-2".to_string(), 0.15), // State-space model
("Liquid".to_string(), 0.10), // Adaptive time constants
("TLOB".to_string(), 0.05), // Order book (less relevant)
]
.iter()
.cloned()
.collect()
},
MarketRegime::Bear => {
// Bear market: Weight risk-aware models higher (PPO, TFT)
[
("PPO".to_string(), 0.30), // Risk-aware RL
("TFT".to_string(), 0.25), // Forecasting
("DQN".to_string(), 0.15), // Q-learning
("MAMBA-2".to_string(), 0.15), // State-space
("Liquid".to_string(), 0.10), // Adaptive
("TLOB".to_string(), 0.05), // Order book
]
.iter()
.cloned()
.collect()
},
MarketRegime::Sideways => {
// Sideways: Equal weights, focus on mean reversion
[
("TLOB".to_string(), 0.25), // Order book microstructure
("Liquid".to_string(), 0.20), // Adaptive dynamics
("TFT".to_string(), 0.20), // Pattern recognition
("MAMBA-2".to_string(), 0.15), // State transitions
("DQN".to_string(), 0.10), // Reduced trend
("PPO".to_string(), 0.10), // Reduced trend
]
.iter()
.cloned()
.collect()
},
MarketRegime::HighVolatility => {
// High volatility: Weight robust models higher
[
("PPO".to_string(), 0.35), // Robust RL
("MAMBA-2".to_string(), 0.25), // State-space handles chaos
("TFT".to_string(), 0.20), // Forecasting
("Liquid".to_string(), 0.10), // Adaptive
("DQN".to_string(), 0.05), // Reduce Q-learning
("TLOB".to_string(), 0.05), // Order book noise
]
.iter()
.cloned()
.collect()
},
MarketRegime::Normal | MarketRegime::Trending => {
// Normal/Trending: Balanced weights with slight trend bias
[
("DQN".to_string(), 0.20),
("PPO".to_string(), 0.20),
("TFT".to_string(), 0.20),
("MAMBA-2".to_string(), 0.20),
("Liquid".to_string(), 0.10),
("TLOB".to_string(), 0.10),
]
.iter()
.cloned()
.collect()
},
MarketRegime::Crisis => {
// Crisis: Maximum risk aversion, weight PPO heavily
[
("PPO".to_string(), 0.50), // Maximum risk control
("MAMBA-2".to_string(), 0.20), // State transitions
("TFT".to_string(), 0.15), // Forecasting
("Liquid".to_string(), 0.10), // Adaptive
("DQN".to_string(), 0.03), // Minimal risk-taking
("TLOB".to_string(), 0.02), // Minimal exposure
]
.iter()
.cloned()
.collect()
},
MarketRegime::Unknown => {
// Unknown: Equal weights
[
("DQN".to_string(), 0.167),
("PPO".to_string(), 0.167),
("TFT".to_string(), 0.167),
("MAMBA-2".to_string(), 0.166),
("Liquid".to_string(), 0.166),
("TLOB".to_string(), 0.167),
]
.iter()
.cloned()
.collect()
},
};
// Apply weights to coordinator
for (model_id, weight) in weights {
self.coordinator.register_model(model_id, weight).await?;
}
debug!("Applied regime-specific weights for {:?}", regime);
Ok(())
}
/// Calculate volatility-adjusted position size using Kelly Criterion
pub async fn calculate_position_size(
&self,
signal: f64,
confidence: f64,
account_equity: f64,
_current_volatility: f64,
) -> f64 {
// Kelly Criterion: f = (bp - q) / b
// where b = odds, p = win probability, q = 1 - p
// Estimate win probability from confidence (0.5 to 0.8 range)
let win_prob = 0.5 + (confidence * 0.3);
let lose_prob = 1.0 - win_prob;
// Estimate odds from signal strength (1:1 to 3:1)
let odds = 1.0 + (signal.abs() * 2.0);
// Kelly fraction
let kelly_fraction = ((odds * win_prob) - lose_prob) / odds;
// Apply fractional Kelly (25% of full Kelly for safety)
let fractional_kelly = kelly_fraction * 0.25;
// Adjust for volatility (reduce position in high volatility)
let regime = *self.current_regime.read().await;
let volatility_adjustment = match regime {
MarketRegime::HighVolatility => 0.5, // 50% reduction
MarketRegime::Crisis => 0.3, // 70% reduction (max risk control)
MarketRegime::Bull | MarketRegime::Bear => 0.8, // 20% reduction
MarketRegime::Sideways => 1.0, // No reduction
MarketRegime::Normal | MarketRegime::Trending => 0.9, // 10% reduction
MarketRegime::Unknown => 0.7, // 30% reduction
};
// Calculate position size
let position_fraction = fractional_kelly.max(0.0).min(0.25) * volatility_adjustment;
let position_size = account_equity * position_fraction;
debug!(
"Position sizing: signal={:.3}, confidence={:.3}, kelly={:.3}, adj={:.3}, size=${:.2}",
signal, confidence, fractional_kelly, volatility_adjustment, position_size
);
position_size
}
/// Record outcome for performance tracking
pub async fn record_outcome(&self, model_id: &str, return_value: f64) -> MLResult<()> {
self.coordinator
.record_outcome(model_id, return_value)
.await?;
// Update metrics
{
let mut metrics = self.metrics.write().await;
// Calculate win rate before incrementing total_predictions
let total_before = metrics.total_predictions;
if total_before > 0 {
if return_value > 0.0 {
let wins = (metrics.win_rate * total_before as f64) + 1.0;
metrics.win_rate = wins / (total_before + 1) as f64;
} else {
let wins = metrics.win_rate * total_before as f64;
metrics.win_rate = wins / (total_before + 1) as f64;
}
} else {
// First outcome
metrics.win_rate = if return_value > 0.0 { 1.0 } else { 0.0 };
}
metrics.total_predictions += 1;
metrics.cumulative_return += return_value;
// Update drawdown
if return_value < 0.0 && return_value.abs() > metrics.max_drawdown {
metrics.max_drawdown = return_value.abs();
}
}
Ok(())
}
/// Get current metrics
pub async fn get_metrics(&self) -> AdaptiveMetrics {
self.metrics.read().await.clone()
}
/// Get current regime
pub async fn get_regime(&self) -> MarketRegime {
*self.current_regime.read().await
}
/// Get diversity metrics
pub async fn get_diversity_metrics(&self) -> super::coordinator_extended::DiversityMetrics {
self.coordinator.get_diversity_metrics().await
}
/// Get performance attribution
pub async fn get_performance_attribution(
&self,
) -> super::coordinator_extended::PerformanceAttribution {
self.coordinator.get_performance_attribution().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_adaptive_ensemble_creation() {
let ensemble = AdaptiveMLEnsemble::new(None);
ensemble.register_models().await.unwrap();
assert_eq!(ensemble.coordinator.model_count().await, 6);
}
#[tokio::test]
async fn test_regime_detection_bull() {
let ensemble = AdaptiveMLEnsemble::new(None);
// Simulate bull market (rising prices)
for i in 0..30 {
let price = 100.0 + (i as f64);
ensemble.update_regime(price, 1000.0).await.unwrap();
}
let regime = ensemble.get_regime().await;
assert_eq!(regime, MarketRegime::Bull);
}
#[tokio::test]
async fn test_regime_detection_bear() {
let ensemble = AdaptiveMLEnsemble::new(None);
// Simulate bear market (falling prices)
for i in 0..30 {
let price = 100.0 - (i as f64);
ensemble.update_regime(price, 1000.0).await.unwrap();
}
let regime = ensemble.get_regime().await;
assert_eq!(regime, MarketRegime::Bear);
}
#[tokio::test]
async fn test_regime_detection_sideways() {
let ensemble = AdaptiveMLEnsemble::new(None);
// Simulate sideways market (oscillating prices)
for i in 0..30 {
let price = 100.0 + ((i % 2) as f64 * 0.1);
ensemble.update_regime(price, 1000.0).await.unwrap();
}
let regime = ensemble.get_regime().await;
assert_eq!(regime, MarketRegime::Sideways);
}
#[tokio::test]
async fn test_regime_adaptive_weights() {
let ensemble = AdaptiveMLEnsemble::new(None);
ensemble.register_models().await.unwrap();
// Set bull regime
{
let mut regime = ensemble.current_regime.write().await;
*regime = MarketRegime::Bull;
}
// Apply regime weights
ensemble
.apply_regime_weights(MarketRegime::Bull)
.await
.unwrap();
let weights = ensemble.coordinator.get_weights().await;
// DQN should have higher weight in bull market
assert!(weights.get("DQN").copied().unwrap_or(0.0) > 0.25);
assert!(weights.get("PPO").copied().unwrap_or(0.0) > 0.20);
}
#[tokio::test]
async fn test_position_sizing_kelly() {
let ensemble = AdaptiveMLEnsemble::new(None);
let position = ensemble
.calculate_position_size(
0.7, // Strong signal
0.8, // High confidence
100000.0, // $100k account
0.02, // 2% volatility
)
.await;
// Position should be positive and reasonable (< 25% of equity)
assert!(position > 0.0);
assert!(position < 25000.0); // Max 25% of equity
}
#[tokio::test]
async fn test_volatility_adjusted_position_sizing() {
let ensemble = AdaptiveMLEnsemble::new(None);
// Set high volatility regime
{
let mut regime = ensemble.current_regime.write().await;
*regime = MarketRegime::HighVolatility;
}
let position = ensemble
.calculate_position_size(
0.7, 0.8, 100000.0, 0.05, // 5% volatility (high)
)
.await;
// Position should be reduced due to high volatility
assert!(position < 15000.0); // Should be less than normal
}
#[tokio::test]
async fn test_ensemble_prediction_with_regime() {
let ensemble = AdaptiveMLEnsemble::new(None);
ensemble.register_models().await.unwrap();
// Set regime
ensemble.update_regime(100.0, 1000.0).await.unwrap();
// Create predictions
let predictions = vec![
ModelPrediction::new("DQN".to_string(), 0.5, 0.8),
ModelPrediction::new("PPO".to_string(), 0.6, 0.85),
ModelPrediction::new("TFT".to_string(), 0.4, 0.75),
ModelPrediction::new("MAMBA-2".to_string(), 0.55, 0.8),
ModelPrediction::new("Liquid".to_string(), 0.45, 0.7),
ModelPrediction::new("TLOB".to_string(), 0.3, 0.65),
];
let decision = ensemble.predict(predictions).await.unwrap();
assert!(decision.confidence > 0.0);
assert!(decision.signal >= -1.0 && decision.signal <= 1.0);
assert_eq!(decision.model_count(), 6);
}
#[tokio::test]
async fn test_metrics_tracking() {
let ensemble = AdaptiveMLEnsemble::new(None);
ensemble.register_models().await.unwrap();
// Record some outcomes
ensemble.record_outcome("DQN", 0.02).await.unwrap();
ensemble.record_outcome("PPO", 0.01).await.unwrap();
ensemble.record_outcome("TFT", -0.01).await.unwrap();
let metrics = ensemble.get_metrics().await;
assert_eq!(metrics.total_predictions, 3);
assert!(metrics.cumulative_return > 0.0);
assert!(metrics.win_rate > 0.0);
}
#[tokio::test]
async fn test_regime_transitions() {
let ensemble = AdaptiveMLEnsemble::new(None);
// Start with bull market
for i in 0..30 {
ensemble
.update_regime(100.0 + i as f64, 1000.0)
.await
.unwrap();
}
assert_eq!(ensemble.get_regime().await, MarketRegime::Bull);
// Transition to bear market
for i in 0..30 {
ensemble
.update_regime(130.0 - i as f64, 1000.0)
.await
.unwrap();
}
assert_eq!(ensemble.get_regime().await, MarketRegime::Bear);
let metrics = ensemble.get_metrics().await;
assert!(metrics.regime_transitions >= 1);
}
}