//! Shared ML Strategy for Foxhunt Trading System //! //! This module provides a unified ML strategy implementation that is used by both //! trading service and backtesting service to ensure consistent ML predictions //! across all services. This eliminates code duplication and ensures ONE SINGLE SYSTEM. //! //! # Architecture //! //! ```text //! SharedMLStrategy //! ├─ MLModelAdapter (abstraction over ml crate models) //! ├─ FeatureExtractor (consistent feature engineering) //! ├─ EnsembleCoordinator (weighted voting) //! └─ ModelPerformanceTracker (metrics) //! ``` use anyhow::Result; use chrono::{DateTime, Datelike, Timelike, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; // Import technical indicators from common::features use crate::features::{RSI, EMA, MACD, BollingerBands, ATR, ADX}; use crate::error::CommonError; /// WAVE 10: Trait for pluggable 225-feature extraction /// /// This trait allows applications to inject the production-grade feature extractor /// from the `ml` crate without creating circular dependencies. /// /// # Example /// ```rust,ignore /// use ml::features::extraction::FeatureExtractor as MLExtractor; /// use common::ml_strategy::ProductionFeatureExtractor225; /// /// struct ML225Extractor { /// inner: MLExtractor, /// } /// /// impl ProductionFeatureExtractor225 for ML225Extractor { /// fn update(&mut self, price: f64, volume: f64, timestamp: DateTime) -> Result<()> { /// let bar = ml::features::extraction::OHLCVBar { ... }; /// self.inner.update(&bar) /// } /// /// fn extract_features(&mut self) -> Result> { /// Ok(self.inner.extract_current_features()?.to_vec()) /// } /// } /// ``` pub trait ProductionFeatureExtractor225: Send + Sync { /// Update internal state with new market data fn update(&mut self, price: f64, volume: f64, timestamp: DateTime) -> Result<()>; /// Extract 225-dimensional feature vector from current state fn extract_features(&mut self) -> Result>; } /// ML prediction result #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MLPrediction { /// Model identifier pub model_id: String, /// Prediction value (0.0-1.0) pub prediction_value: f64, /// Confidence score (0.0-1.0) pub confidence: f64, /// Features used for prediction pub features: Vec, /// Prediction timestamp pub timestamp: DateTime, /// Inference latency in microseconds pub inference_latency_us: u64, } /// ML model performance metrics #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct MLModelPerformance { /// Model identifier pub model_id: String, /// Total predictions made pub total_predictions: u64, /// Correct predictions pub correct_predictions: u64, /// Average inference latency pub avg_latency_us: f64, /// Average confidence score pub avg_confidence: f64, /// Model accuracy percentage pub accuracy_percentage: f64, /// Returns generated pub returns: Vec, /// Sharpe ratio pub sharpe_ratio: f64, /// Maximum drawdown pub max_drawdown: f64, } /// Feature extraction for ML models #[derive(Debug, Clone)] #[allow(dead_code)] // Some fields are internal state for future use pub struct MLFeatureExtractor { /// Lookback window for features pub lookback_periods: usize, /// Expected feature count (26=Wave A, 30=Wave A+4 extra, 36=Wave B, 65=Wave C) expected_feature_count: usize, /// Price history buffer price_history: Vec, /// Volume history buffer volume_history: Vec, /// High/low price history for oscillators (simulated from close price) high_low_history: Vec<(f64, f64)>, /// EMA-9 state ema_9: Option, /// EMA-21 state ema_21: Option, /// EMA-50 state ema_50: Option, /// On-Balance Volume (OBV) cumulative value obv: f64, /// OBV history for momentum calculation (last 10 periods) obv_history: Vec, /// Accumulation/Distribution Line cumulative value ad_line: f64, /// Volume MA fast (5-period) for volume oscillator volume_ma_fast: Option, /// Volume MA slow (20-period) for volume oscillator volume_ma_slow: Option, /// EMA-10 for EMA ratio ema_10: Option, /// VWAP cumulative price*volume sum vwap_pv_sum: f64, /// VWAP cumulative volume sum vwap_volume_sum: f64, /// RSI average gain (14-period EMA) rsi_avg_gain: Option, /// RSI average loss (14-period EMA) rsi_avg_loss: Option, /// MACD EMA-12 macd_ema_12: Option, /// MACD EMA-26 macd_ema_26: Option, /// MACD Signal EMA-9 macd_signal: Option, /// Stochastic %K history for %D calculation stoch_k_history: Vec, /// ADX (Average Directional Index) for trend strength adx: Option, /// +DI (Positive Directional Indicator) plus_di: Option, /// -DI (Negative Directional Indicator) minus_di: Option, /// Smoothed +DM (for incremental ADX calculation) plus_dm_smooth: Option, /// Smoothed -DM (for incremental ADX calculation) minus_dm_smooth: Option, /// ATR (Average True Range) for ADX calculation atr: Option, /// Rolling volatility history for percentile calculation volatility_history: Vec, /// Rolling volume history for percentile calculation (separate from main volume buffer) volume_percentile_buffer: Vec, /// Return history for autocorrelation calculation returns_history: Vec, /// Momentum ROC(5) history for acceleration calculation momentum_roc_5_history: Vec, /// Momentum ROC(10) history for acceleration calculation momentum_roc_10_history: Vec, /// Acceleration history for jerk calculation acceleration_history: Vec, /// Price highs for divergence detection (last 20 periods) price_highs: Vec, /// Momentum highs for divergence detection (last 20 periods) momentum_highs: Vec, /// Historical momentum values for regime classification (last 100 periods) momentum_regime_history: Vec, // NEW: Technical indicators from common::features (Wave D support) /// RSI calculator (14-period) rsi_calculator: RSI, /// EMA fast (12-period) ema_fast_calculator: EMA, /// EMA slow (26-period) ema_slow_calculator: EMA, /// MACD calculator (12, 26, 9) macd_calculator: MACD, /// Bollinger Bands (20-period, 2.0 std) bollinger_calculator: BollingerBands, /// ATR calculator (14-period) atr_calculator: ATR, /// ADX calculator (14-period) adx_calculator: ADX, } impl MLFeatureExtractor { /// Create new feature extractor with 30 features (Wave A + 4 Wave C indicators) pub fn new(lookback_periods: usize) -> Self { Self::with_feature_count(lookback_periods, 30) // Default: 30 features } /// Create feature extractor with specific feature count /// /// Supported feature counts: /// - 26: Wave A baseline (technical indicators only, no Wave C features) /// - 30: Wave A + 4 Wave C indicators (current default) /// - 36: Wave B (alternative bars) /// - 65: Wave C (advanced features) pub fn with_feature_count(lookback_periods: usize, feature_count: usize) -> Self { Self { lookback_periods, expected_feature_count: feature_count, price_history: Vec::with_capacity(lookback_periods + 1), volume_history: Vec::with_capacity(lookback_periods + 1), high_low_history: Vec::with_capacity(lookback_periods + 1), ema_9: None, ema_21: None, ema_50: None, obv: 0.0, obv_history: Vec::with_capacity(10), ad_line: 0.0, volume_ma_fast: None, volume_ma_slow: None, ema_10: None, vwap_pv_sum: 0.0, vwap_volume_sum: 0.0, rsi_avg_gain: None, rsi_avg_loss: None, macd_ema_12: None, macd_ema_26: None, macd_signal: None, stoch_k_history: Vec::with_capacity(3), adx: None, plus_di: None, minus_di: None, plus_dm_smooth: None, minus_dm_smooth: None, atr: None, volatility_history: Vec::with_capacity(lookback_periods), volume_percentile_buffer: Vec::with_capacity(lookback_periods), returns_history: Vec::with_capacity(lookback_periods), momentum_roc_5_history: Vec::with_capacity(lookback_periods), momentum_roc_10_history: Vec::with_capacity(lookback_periods), acceleration_history: Vec::with_capacity(lookback_periods), price_highs: Vec::with_capacity(20), momentum_highs: Vec::with_capacity(20), momentum_regime_history: Vec::with_capacity(100), // Initialize technical indicators from common::features rsi_calculator: RSI::new(14), ema_fast_calculator: EMA::new(12), ema_slow_calculator: EMA::new(26), macd_calculator: MACD::new(12, 26, 9), bollinger_calculator: BollingerBands::new(20, 2.0), atr_calculator: ATR::new(14), adx_calculator: ADX::new(14), } } /// Convenience constructors for specific Wave configurations pub fn new_wave_a(lookback_periods: usize) -> Self { Self::with_feature_count(lookback_periods, 26) } pub fn new_wave_a_plus(lookback_periods: usize) -> Self { Self::with_feature_count(lookback_periods, 30) } pub fn new_wave_b(lookback_periods: usize) -> Self { Self::with_feature_count(lookback_periods, 36) } pub fn new_wave_c(lookback_periods: usize) -> Self { Self::with_feature_count(lookback_periods, 65) } /// Create new Wave D feature extractor with 225 features (201 Wave C + 24 Wave D) pub fn new_wave_d(lookback_periods: usize) -> Self { Self::with_feature_count(lookback_periods, 225) } /// Get expected feature count pub fn expected_feature_count(&self) -> usize { self.expected_feature_count } /// Extract features from market data pub fn extract_features( &mut self, price: f64, volume: f64, timestamp: DateTime, ) -> Vec { // Update price and volume history self.price_history.push(price); self.volume_history.push(volume); // Simulate high/low with 0.1% spread (typical intraday range) self.high_low_history.push((price * 1.001, price * 0.999)); // Keep only the required lookback periods if self.price_history.len() > self.lookback_periods { self.price_history.remove(0); } if self.volume_history.len() > self.lookback_periods { self.volume_history.remove(0); } if self.high_low_history.len() > self.lookback_periods { self.high_low_history.remove(0); } // Calculate EMAs with exponential smoothing // EMA_today = (Price_today * α) + (EMA_yesterday * (1 - α)) // α = 2 / (period + 1) let alpha_9 = 2.0 / (9.0 + 1.0); // α = 0.2 let alpha_21 = 2.0 / (21.0 + 1.0); // α ≈ 0.0909 let alpha_50 = 2.0 / (50.0 + 1.0); // α ≈ 0.0392 // Update EMA-9 self.ema_9 = Some(match self.ema_9 { Some(prev_ema) => price * alpha_9 + prev_ema * (1.0 - alpha_9), None => price, // Initialize with first price }); // Update EMA-21 self.ema_21 = Some(match self.ema_21 { Some(prev_ema) => price * alpha_21 + prev_ema * (1.0 - alpha_21), None => price, // Initialize with first price }); // Update EMA-50 self.ema_50 = Some(match self.ema_50 { Some(prev_ema) => price * alpha_50 + prev_ema * (1.0 - alpha_50), None => price, // Initialize with first price }); let ema_9_val = self.ema_9.unwrap_or(price); let ema_21_val = self.ema_21.unwrap_or(price); let ema_50_val = self.ema_50.unwrap_or(price); // Extract technical features let mut features = Vec::new(); if self.price_history.len() >= 2 { // Price momentum (returns) let current_price = self.price_history.last().copied().unwrap_or(0.0); let prev_price = self .price_history .get(self.price_history.len() - 2) .copied() .unwrap_or(current_price); let price_return = if prev_price != 0.0 { (current_price - prev_price) / prev_price } else { 0.0 }; features.push(price_return); // Short-term moving average if self.price_history.len() >= 5 { let short_ma: f64 = self.price_history.iter().rev().take(5).sum::() / 5.0; let ma_ratio = if short_ma != 0.0 { current_price / short_ma - 1.0 } else { 0.0 }; features.push(ma_ratio); } else { features.push(0.0); } // Price volatility (rolling standard deviation) if self.price_history.len() >= 10 { let recent_returns: Vec = self .price_history .windows(2) .rev() .take(9) .filter_map(|w| w.get(1).and_then(|&w1| w.first().map(|&w0| (w1 - w0) / w0))) .collect(); let mean_return = recent_returns.iter().sum::() / recent_returns.len() as f64; let variance = recent_returns .iter() .map(|&r| (r - mean_return).powi(2)) .sum::() / recent_returns.len() as f64; let volatility = variance.sqrt(); features.push(volatility); } else { features.push(0.0); } } else { features.extend_from_slice(&[0.0, 0.0, 0.0]); } // Volume features if self.volume_history.len() >= 2 { let current_volume = self.volume_history.last().copied().unwrap_or(0.0); let prev_volume = self .volume_history .get(self.volume_history.len() - 2) .copied() .unwrap_or(current_volume); let volume_ratio = if prev_volume != 0.0 { current_volume / prev_volume - 1.0 } else { 0.0 }; features.push(volume_ratio); // Volume moving average if self.volume_history.len() >= 5 { let volume_ma = self.volume_history.iter().rev().take(5).sum::() / 5.0; let volume_ma_ratio = if volume_ma != 0.0 { current_volume / volume_ma - 1.0 } else { 0.0 }; features.push(volume_ma_ratio); } else { features.push(0.0); } } else { features.extend_from_slice(&[0.0, 0.0]); } // Add time-based features let hour = timestamp.hour() as f64 / 24.0; // Normalized hour let day_of_week = timestamp.weekday().num_days_from_monday() as f64 / 6.0; // Normalized day features.push(hour); features.push(day_of_week); // Williams %R (14-period) // Formula: (Highest High - Close) / (Highest High - Lowest Low) * -100 // Range: -100 (oversold) to 0 (overbought) if self.high_low_history.len() >= 14 && self.price_history.len() >= 14 { let recent_high_lows: Vec<(f64, f64)> = self .high_low_history .iter() .rev() .take(14) .copied() .collect(); let highest_high = recent_high_lows .iter() .map(|(h, _)| h) .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); let lowest_low = recent_high_lows .iter() .map(|(_, l)| l) .fold(f64::INFINITY, |a, &b| a.min(b)); let current_price = self.price_history.last().copied().unwrap_or(0.0); let williams_r = if highest_high != lowest_low { ((highest_high - current_price) / (highest_high - lowest_low)) * -100.0 } else { -50.0 // Neutral value when range is zero }; // Normalize to [-1, 1]: Williams %R is in range [-100, 0] // Map -100 (oversold) to -1, 0 (overbought) to +1 let normalized_williams_r = (williams_r + 50.0) / 50.0; // Maps [-100, 0] to [-1, 1] features.push(normalized_williams_r.tanh()); } else { features.push(0.0); } // ROC - Rate of Change (12-period) // Formula: ((Current Price - Price n periods ago) / Price n periods ago) * 100 // Measures momentum magnitude if self.price_history.len() >= 13 { // Need 13 prices for 12-period ROC let current_price = self.price_history.last().copied().unwrap_or(0.0); let price_12_periods_ago = self .price_history .get(self.price_history.len() - 13) .copied() .unwrap_or(current_price); let roc = if price_12_periods_ago != 0.0 { ((current_price - price_12_periods_ago) / price_12_periods_ago) * 100.0 } else { 0.0 }; // ROC can range widely, normalize with tanh features.push((roc / 100.0).tanh()); // Divide by 100 to scale before tanh } else { features.push(0.0); } // Ultimate Oscillator (7, 14, 28 periods) // Multi-timeframe oscillator that reduces false signals // Formula: Weighted average of 3 buying pressure ratios (BP/TR) if self.price_history.len() >= 29 && self.high_low_history.len() >= 29 { // Calculate buying pressure and true range for each period let mut buying_pressures = Vec::new(); let mut true_ranges = Vec::new(); for i in 1..self.price_history.len() { let current_close = match self.price_history.get(i) { Some(&price) => price, None => continue, }; let prev_close = self.price_history.get(i - 1).copied().unwrap_or(current_close); let (current_high, current_low) = match self.high_low_history.get(i) { Some(&hl) => hl, None => continue, }; // Buying Pressure = Close - min(Low, Previous Close) let bp = current_close - current_low.min(prev_close); buying_pressures.push(bp); // True Range = max(High, Previous Close) - min(Low, Previous Close) let tr = current_high.max(prev_close) - current_low.min(prev_close); true_ranges.push(tr); } // Calculate averages for 7, 14, 28 periods let calculate_avg = |data: &[f64], periods: usize| -> f64 { if data.len() >= periods { let sum: f64 = data.iter().rev().take(periods).sum(); sum / periods as f64 } else { 0.0 } }; let bp_7 = calculate_avg(&buying_pressures, 7); let tr_7 = calculate_avg(&true_ranges, 7); let avg_7 = if tr_7 != 0.0 { bp_7 / tr_7 } else { 0.0 }; let bp_14 = calculate_avg(&buying_pressures, 14); let tr_14 = calculate_avg(&true_ranges, 14); let avg_14 = if tr_14 != 0.0 { bp_14 / tr_14 } else { 0.0 }; let bp_28 = calculate_avg(&buying_pressures, 28); let tr_28 = calculate_avg(&true_ranges, 28); let avg_28 = if tr_28 != 0.0 { bp_28 / tr_28 } else { 0.0 }; // Ultimate Oscillator formula with weights 4, 2, 1 (sum to 7) let ultimate_oscillator = ((avg_7 * 4.0) + (avg_14 * 2.0) + (avg_28 * 1.0)) / 7.0 * 100.0; // Ultimate Oscillator typically ranges from 0 to 100 // Normalize to [-1, 1]: map [0, 100] to [-1, 1] let normalized_uo = (ultimate_oscillator - 50.0) / 50.0; features.push(normalized_uo.tanh()); } else { features.push(0.0); } // Volume-based technical indicators // 1. On-Balance Volume (OBV) // OBV tracks cumulative volume flow: +volume on up days, -volume on down days if self.price_history.len() >= 2 { let current_price = self.price_history.last().copied().unwrap_or(0.0); let prev_price = self .price_history .get(self.price_history.len() - 2) .copied() .unwrap_or(current_price); let current_volume = self.volume_history.last().copied().unwrap_or(0.0); // Update OBV: add volume if price up, subtract if price down if current_price > prev_price { self.obv += current_volume; } else if current_price < prev_price { self.obv -= current_volume; } // If price unchanged, OBV unchanged // Normalize OBV using tanh (already handles large values well) let obv_normalized = (self.obv / 1_000_000.0).tanh(); // Scale for typical volume ranges features.push(obv_normalized); } else { features.push(0.0); } // 2. Money Flow Index (MFI) - 14 period // MFI is a momentum indicator using price and volume, ranges 0-100 // MFI = 100 - (100 / (1 + Money Flow Ratio)) // Money Flow Ratio = (14-period Positive Money Flow) / (14-period Negative Money Flow) if self.price_history.len() >= 15 && self.volume_history.len() >= 15 { let mut positive_mf = 0.0; let mut negative_mf = 0.0; // Calculate money flow over last 14 periods for i in 0..14 { let idx = self.price_history.len() - 15 + i; // -15 to include previous period for comparison if idx == 0 { continue; } let current_price = match self.price_history.get(idx) { Some(&price) => price, None => continue, }; let prev_price = match self.price_history.get(idx - 1) { Some(&price) => price, None => continue, }; let volume = match self.volume_history.get(idx) { Some(&v) => v, None => continue, }; // Typical Price = (High + Low + Close) / 3 // For OHLCV data we only have Close, so use Close as typical price let typical_price = current_price; let money_flow = typical_price * volume; if current_price > prev_price { positive_mf += money_flow; } else if current_price < prev_price { negative_mf += money_flow; } } let mfi = if negative_mf > 0.0 { let money_flow_ratio = positive_mf / negative_mf; 100.0 - (100.0 / (1.0 + money_flow_ratio)) } else if positive_mf > 0.0 { 100.0 // All positive flow } else { 50.0 // No flow (neutral) }; // Normalize MFI from [0, 100] to [-1, 1] let mfi_normalized = ((mfi / 50.0) - 1.0).tanh(); features.push(mfi_normalized); } else { features.push(0.0); } // 3. VWAP (Volume-Weighted Average Price) // VWAP = Cumulative(Price * Volume) / Cumulative(Volume) if !self.price_history.is_empty() && !self.volume_history.is_empty() { let current_price = self.price_history.last().copied().unwrap_or(0.0); let current_volume = self.volume_history.last().copied().unwrap_or(0.0); // Update cumulative values self.vwap_pv_sum += current_price * current_volume; self.vwap_volume_sum += current_volume; let vwap = if self.vwap_volume_sum > 0.0 { self.vwap_pv_sum / self.vwap_volume_sum } else { current_price }; // VWAP as price ratio: (current_price - VWAP) / VWAP let vwap_ratio = if vwap > 0.0 { (current_price - vwap) / vwap } else { 0.0 }; // Normalize using tanh let vwap_normalized = vwap_ratio.tanh(); features.push(vwap_normalized); } else { features.push(0.0); } // Add EMA features (normalized to [-1, 1]) // Normalize: (current_price / EMA - 1.0).tanh() let ema_9_norm = if ema_9_val != 0.0 { (price / ema_9_val - 1.0).tanh() } else { 0.0 }; let ema_21_norm = if ema_21_val != 0.0 { (price / ema_21_val - 1.0).tanh() } else { 0.0 }; let ema_50_norm = if ema_50_val != 0.0 { (price / ema_50_val - 1.0).tanh() } else { 0.0 }; // EMA cross signals let ema_9_21_cross = if ema_9_val > ema_21_val { 1.0 } else { -1.0 }; let ema_21_50_cross = if ema_21_val > ema_50_val { 1.0 } else { -1.0 }; features.extend_from_slice(&[ ema_9_norm, ema_21_norm, ema_50_norm, ema_9_21_cross, ema_21_50_cross, ]); // ADX (Average Directional Index) - 14-period // ADX measures trend strength (0-100), NOT direction // Formula: // 1. Calculate True Range (TR) = max(high - low, abs(high - prev_close), abs(low - prev_close)) // 2. Calculate +DM = max(0, high - prev_high), -DM = max(0, prev_low - low) // 3. Smooth TR, +DM, -DM using Wilder's smoothing (14-period EMA with α=1/14) // 4. Calculate +DI = (+DM_smooth / TR_smooth) * 100, -DI = (-DM_smooth / TR_smooth) * 100 // 5. Calculate DX = abs(+DI - -DI) / (+DI + -DI) * 100 // 6. ADX = Wilder's smoothing of DX over 14 periods // // Incremental update: O(1) using exponential smoothing if self.high_low_history.len() >= 2 && self.price_history.len() >= 2 { let current_idx = self.high_low_history.len() - 1; let prev_idx = current_idx - 1; let (current_high, current_low) = match self.high_low_history.get(current_idx) { Some(&hl) => hl, None => return features, // Safety: shouldn't happen after length check }; let (prev_high, prev_low) = match self.high_low_history.get(prev_idx) { Some(&hl) => hl, None => return features, }; let _current_close = match self.price_history.get(current_idx) { Some(&price) => price, None => return features, }; let prev_close = match self.price_history.get(prev_idx) { Some(&price) => price, None => return features, }; // 1. Calculate True Range (TR) let tr = (current_high - current_low) .max((current_high - prev_close).abs()) .max((current_low - prev_close).abs()); // 2. Calculate Directional Movement (+DM, -DM) let high_move = current_high - prev_high; let low_move = prev_low - current_low; let (plus_dm, minus_dm) = if high_move > low_move && high_move > 0.0 { (high_move, 0.0) // Upward movement dominates } else if low_move > high_move && low_move > 0.0 { (0.0, low_move) // Downward movement dominates } else { (0.0, 0.0) // No clear directional movement }; // 3. Smooth TR, +DM, -DM using Wilder's smoothing (α = 1/14) // Wilder's smoothing: Smoothed_today = (Smoothed_yesterday * 13 + Value_today) / 14 // This is equivalent to EMA with α = 1/14 let alpha_wilder = 1.0 / 14.0; // Update ATR (smoothed TR) self.atr = Some(match self.atr { Some(prev_atr) => prev_atr * (1.0 - alpha_wilder) + tr * alpha_wilder, None => tr, // Initialize with first TR }); // Smooth +DM using Wilder's smoothing self.plus_dm_smooth = Some(match self.plus_dm_smooth { Some(prev_smooth) => prev_smooth * (1.0 - alpha_wilder) + plus_dm * alpha_wilder, None => plus_dm, // Initialize with first +DM }); // Smooth -DM using Wilder's smoothing self.minus_dm_smooth = Some(match self.minus_dm_smooth { Some(prev_smooth) => prev_smooth * (1.0 - alpha_wilder) + minus_dm * alpha_wilder, None => minus_dm, // Initialize with first -DM }); let plus_dm_smooth_val = self.plus_dm_smooth.unwrap_or(0.0); let minus_dm_smooth_val = self.minus_dm_smooth.unwrap_or(0.0); // 4. Calculate +DI and -DI let atr_val = self.atr.unwrap_or(1.0); let plus_di_val = if atr_val > 0.0 { (plus_dm_smooth_val / atr_val) * 100.0 } else { 0.0 }; let minus_di_val = if atr_val > 0.0 { (minus_dm_smooth_val / atr_val) * 100.0 } else { 0.0 }; // Update +DI and -DI state self.plus_di = Some(plus_di_val); self.minus_di = Some(minus_di_val); // 5. Calculate DX (Directional Index) let di_sum = plus_di_val + minus_di_val; let dx = if di_sum > 0.0 { ((plus_di_val - minus_di_val).abs() / di_sum) * 100.0 } else { 0.0 }; // 6. Calculate ADX (smoothed DX using Wilder's smoothing) self.adx = Some(match self.adx { Some(prev_adx) => prev_adx * (1.0 - alpha_wilder) + dx * alpha_wilder, None => dx, // Initialize with first DX }); // Normalize ADX from [0, 100] to [0, 1] let adx_normalized = self.adx.unwrap_or(0.0) / 100.0; features.push(adx_normalized.clamp(0.0, 1.0)); } else { // Not enough data for ADX calculation features.push(0.0); } // Bollinger Bands Position (20-period, 2σ) // Formula: (price - middle) / (upper - lower) // where: // middle = SMA(20) // upper = middle + 2*std // lower = middle - 2*std // Range: naturally in [-1, 1] when price is within bands // can exceed when price is outside bands (normalized with clamp) // Position interpretation: // +1.0: at or above upper band (overbought) // 0.0: at middle band (neutral) // -1.0: at or below lower band (oversold) if self.price_history.len() >= 20 { // Calculate SMA(20) let recent_20_prices: Vec = self.price_history.iter().rev().take(20).copied().collect(); let middle = recent_20_prices.iter().sum::() / 20.0; // Calculate standard deviation (20-period) let variance = recent_20_prices .iter() .map(|&p| (p - middle).powi(2)) .sum::() / 20.0; let std_dev = variance.sqrt(); // Calculate Bollinger Bands let upper = middle + 2.0 * std_dev; let lower = middle - 2.0 * std_dev; // Calculate Bollinger Bands Position let current_price = self.price_history.last().copied().unwrap_or(middle); let bb_position = if upper != lower { // Normal case: bands have width (current_price - middle) / (upper - lower) } else { // Edge case: zero volatility (upper == lower) // Return 0.0 (neutral position at middle band) 0.0 }; // Normalize to [-1, 1] range using clamp // This handles cases where price is significantly outside bands features.push(bb_position.clamp(-1.0, 1.0)); } else { // Insufficient history for Bollinger Bands (need 20 periods) features.push(0.0); } // Stochastic Oscillator (%K and %D) - 14-period // %K measures where current price is relative to 14-period high/low range // %D is 3-period SMA of %K (signal line) // Formula: // %K = (Close - Low14) / (High14 - Low14) * 100 // %D = SMA(%K, 3) // // Incremental update: O(1) using sliding window for high/low extremes if self.high_low_history.len() >= 14 && self.price_history.len() >= 14 { // Get last 14 periods for high/low calculation let recent_high_lows: Vec<(f64, f64)> = self .high_low_history .iter() .rev() .take(14) .copied() .collect(); // Find highest high and lowest low in 14-period window let highest_high = recent_high_lows .iter() .map(|(h, _)| h) .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); let lowest_low = recent_high_lows .iter() .map(|(_, l)| l) .fold(f64::INFINITY, |a, &b| a.min(b)); let current_close = self.price_history.last().copied().unwrap_or(0.0); // Calculate %K let stoch_k = if highest_high != lowest_low { ((current_close - lowest_low) / (highest_high - lowest_low)) * 100.0 } else { // Edge case: no range (flat prices) // Return 50.0 (middle of range) to avoid division by zero 50.0 }; // Normalize %K from [0, 100] to [0, 1] let stoch_k_normalized = (stoch_k / 100.0).clamp(0.0, 1.0); // Store %K value for %D calculation (3-period SMA) self.stoch_k_history.push(stoch_k_normalized); if self.stoch_k_history.len() > 3 { self.stoch_k_history.remove(0); } // Calculate %D (3-period SMA of %K) let stoch_d = if self.stoch_k_history.len() >= 3 { let sum: f64 = self.stoch_k_history.iter().sum(); sum / self.stoch_k_history.len() as f64 } else { // Insufficient history for %D, return %K as approximation stoch_k_normalized }; features.push(stoch_k_normalized); features.push(stoch_d.clamp(0.0, 1.0)); } else { // Insufficient data for Stochastic calculation // Return neutral values (0.5 = middle of range) features.push(0.5); features.push(0.5); } // CCI (Commodity Channel Index) - 20-period momentum oscillator // Formula: CCI = (Typical Price - SMA20) / (0.015 * Mean Absolute Deviation) // Typical Price = (High + Low + Close) / 3 // Mean Absolute Deviation = avg(abs(TP - SMA20)) over 20 periods // // CCI interpretation: // > +100: Overbought (price above normal deviation range) // < -100: Oversold (price below normal deviation range) // [-100, +100]: Normal range // // Normalization: (CCI / 200).tanh() → [-1, 1] range // This preserves sign while capping extreme values if self.price_history.len() >= 20 && self.high_low_history.len() >= 20 { // Calculate Typical Price for last 20 periods let mut typical_prices: Vec = Vec::with_capacity(20); for i in 0..20 { let idx = self.price_history.len().saturating_sub(20).saturating_add(i); let close = match self.price_history.get(idx) { Some(&price) => price, None => continue, }; let (high, low) = match self.high_low_history.get(idx) { Some(&hl) => hl, None => continue, }; let typical_price = (high + low + close) / 3.0; typical_prices.push(typical_price); } // Calculate SMA of Typical Price (20-period) let tp_sma: f64 = typical_prices.iter().sum::() / 20.0; // Calculate Mean Absolute Deviation let mad: f64 = typical_prices .iter() .map(|&tp| (tp - tp_sma).abs()) .sum::() / 20.0; // Get current typical price let current_close = self.price_history.last().copied().unwrap_or(0.0); let (current_high, current_low) = self .high_low_history .last() .copied() .unwrap_or((current_close, current_close)); let current_tp = (current_high + current_low + current_close) / 3.0; // Calculate CCI let cci = if mad > 0.0 { // Standard CCI formula (current_tp - tp_sma) / (0.015 * mad) } else { // Edge case: zero mean deviation (all prices identical) // Return 0.0 (neutral value) 0.0 }; // Normalize CCI using tanh // Divide by 200 to scale: ±100 → ±0.5, ±200 → ±1.0 // tanh provides smooth sigmoid-like normalization let cci_normalized = (cci / 200.0).tanh(); features.push(cci_normalized); } else { // Insufficient data for CCI calculation (need 20 periods) // Return 0.0 (neutral value) features.push(0.0); } // RSI (Relative Strength Index) - 14-period momentum oscillator // Formula: RSI = 100 - (100 / (1 + RS)), where RS = avg_gain / avg_loss // Uses Wilder's smoothing for exponential moving average if self.price_history.len() >= 2 { let current_close = self.price_history.last().copied().unwrap_or(0.0); let prev_close = self.price_history.get(self.price_history.len() - 2).copied().unwrap_or(current_close); // Calculate price change let change = current_close - prev_close; let gain = if change > 0.0 { change } else { 0.0 }; let loss = if change < 0.0 { -change } else { 0.0 }; // Update RSI exponential moving averages using Wilder's smoothing // First 14 periods: simple average, then EMA with alpha = 1/14 match (self.rsi_avg_gain, self.rsi_avg_loss) { (Some(prev_gain), Some(prev_loss)) => { // Wilder's smoothing: new_avg = (prev_avg * 13 + current_value) / 14 self.rsi_avg_gain = Some((prev_gain * 13.0 + gain) / 14.0); self.rsi_avg_loss = Some((prev_loss * 13.0 + loss) / 14.0); }, _ => { // Initialize with first values (insufficient history for EMA) self.rsi_avg_gain = Some(gain); self.rsi_avg_loss = Some(loss); }, } // Calculate RSI let rsi = if let (Some(avg_gain), Some(avg_loss)) = (self.rsi_avg_gain, self.rsi_avg_loss) { if avg_loss > 0.0 { // Standard RSI formula let rs = avg_gain / avg_loss; 100.0 - (100.0 / (1.0 + rs)) } else if avg_gain > 0.0 { // Only gains (no losses) -> RSI = 100 (overbought extreme) 100.0 } else { // No gains and no losses -> RSI = 50 (neutral) 50.0 } } else { // Insufficient data -> default to neutral 50.0 }; // Normalize RSI from [0, 100] to [0, 1] features.push((rsi / 100.0).clamp(0.0, 1.0)); } else { // No previous close price -> default to neutral (0.5) features.push(0.5); } // MACD (Moving Average Convergence Divergence) - Agent A2 // Formula: // MACD Line = EMA(12) - EMA(26) // Signal Line = EMA(9) of MACD Line // Normalization: (MACD / price).tanh() to get [-1, 1] range let alpha_12 = 2.0 / (12.0 + 1.0); // α = 0.1538 let alpha_26 = 2.0 / (26.0 + 1.0); // α = 0.0741 let alpha_9 = 2.0 / (9.0 + 1.0); // α = 0.2 // Update EMA-12 for MACD self.macd_ema_12 = Some(match self.macd_ema_12 { Some(prev_ema) => price * alpha_12 + prev_ema * (1.0 - alpha_12), None => price, }); // Update EMA-26 for MACD self.macd_ema_26 = Some(match self.macd_ema_26 { Some(prev_ema) => price * alpha_26 + prev_ema * (1.0 - alpha_26), None => price, }); let ema_12 = self.macd_ema_12.unwrap_or(price); let ema_26 = self.macd_ema_26.unwrap_or(price); let macd_line = ema_12 - ema_26; // Update MACD Signal (EMA-9 of MACD line) self.macd_signal = Some(match self.macd_signal { Some(prev_signal) => macd_line * alpha_9 + prev_signal * (1.0 - alpha_9), None => macd_line, }); let macd_signal_val = self.macd_signal.unwrap_or(macd_line); // Normalize to [-1, 1] range let macd_normalized = if price != 0.0 { (macd_line / price).tanh() } else { 0.0 }; let macd_signal_normalized = if price != 0.0 { (macd_signal_val / price).tanh() } else { 0.0 }; features.push(macd_normalized); features.push(macd_signal_normalized); // ======================================== // WAVE C: Additional Volume & EMA Indicators (4 features) // ======================================== // 1. OBV Momentum (10-period ROC) // OBV basic accumulation already exists (lines 395-412) // Now add momentum feature: (OBV_current - OBV_10_ago) / abs(OBV_10_ago) self.obv_history.push(self.obv); if self.obv_history.len() > 10 { self.obv_history.remove(0); } let obv_momentum = if self.obv_history.len() >= 10 { let obv_10_ago = self.obv_history.first().copied().unwrap_or(self.obv); if obv_10_ago.abs() > 0.0 { ((self.obv - obv_10_ago) / obv_10_ago.abs()).tanh() } else { 0.0 } } else { 0.0 }; features.push(obv_momentum); // 2. Volume Oscillator: (vol_ma_fast - vol_ma_slow) / vol_ma_slow // Fast MA: 5-period, Slow MA: 20-period let alpha_vol_fast = 2.0 / (5.0 + 1.0); // α = 0.333 let alpha_vol_slow = 2.0 / (20.0 + 1.0); // α = 0.095 let current_volume = self.volume_history.last().copied().unwrap_or(0.0); // Update volume MAs self.volume_ma_fast = Some(match self.volume_ma_fast { Some(prev_ma) => current_volume * alpha_vol_fast + prev_ma * (1.0 - alpha_vol_fast), None => current_volume, }); self.volume_ma_slow = Some(match self.volume_ma_slow { Some(prev_ma) => current_volume * alpha_vol_slow + prev_ma * (1.0 - alpha_vol_slow), None => current_volume, }); let vol_ma_fast_val = self.volume_ma_fast.unwrap_or(current_volume); let vol_ma_slow_val = self.volume_ma_slow.unwrap_or(current_volume); let volume_oscillator = if vol_ma_slow_val > 0.0 { ((vol_ma_fast_val - vol_ma_slow_val) / vol_ma_slow_val).tanh() } else { 0.0 }; features.push(volume_oscillator); // 3. A/D Line (Accumulation/Distribution Line) // Formula: A/D = Σ [((Close - Low) - (High - Close)) / (High - Low) * Volume] // Measures money flow: positive when close near high (accumulation) if !self.high_low_history.is_empty() && !self.price_history.is_empty() { let current_close = self.price_history.last().copied().unwrap_or(0.0); let (current_high, current_low) = self .high_low_history .last() .copied() .unwrap_or((current_close, current_close)); let volume_val = self.volume_history.last().copied().unwrap_or(0.0); let money_flow_multiplier = if current_high != current_low { ((current_close - current_low) - (current_high - current_close)) / (current_high - current_low) } else { 0.0 // No range, neutral }; let money_flow_volume = money_flow_multiplier * volume_val; self.ad_line += money_flow_volume; // Normalize A/D Line with tanh let ad_normalized = (self.ad_line / 1_000_000.0).tanh(); features.push(ad_normalized); } else { features.push(0.0); } // 4. EMA Ratio: EMA(10) / EMA(50) - trend strength indicator // Update EMA-10 let alpha_10 = 2.0 / (10.0 + 1.0); // α = 0.1818 self.ema_10 = Some(match self.ema_10 { Some(prev_ema) => price * alpha_10 + prev_ema * (1.0 - alpha_10), None => price, }); let ema_10_val = self.ema_10.unwrap_or(price); let ema_ratio = if ema_50_val > 0.0 { ((ema_10_val / ema_50_val) - 1.0).tanh() } else { 0.0 }; features.push(ema_ratio); // ======================================== // Total Features: 26 (Wave A) + 4 (Wave C) = 30 features // ======================================== // Wave A (26): // 0-6: Original 7 features // 7-9: Oscillators (Williams %R, ROC, Ultimate Oscillator) // 10-12: Volume indicators (OBV, MFI, VWAP) // 13-17: EMA features // 18: ADX // 19: Bollinger Bands Position // 20-21: Stochastic %K/%D // 22: CCI // 23: RSI // 24-25: MACD + Signal // // Wave C (4): // 26: OBV Momentum (10-period ROC) // 27: Volume Oscillator (5/20-period) // 28: A/D Line (Accumulation/Distribution) // 29: EMA Ratio (EMA-10 / EMA-50) // All features are already normalized in their respective calculations // No additional normalization needed (fixes double-tanh bug from Wave A) // ======================================== // Wave D: Expand to 225 features if configured // ======================================== if self.expected_feature_count == 225 { // Current features: 30 // Need to add: 195 more features (30 → 225) // Update technical indicators from common::features and add their outputs // These replace/supplement the manual implementations above let high = price * 1.001; // Simulated high let low = price * 0.999; // Simulated low // Features 30-35: Advanced RSI metrics (6 features) let rsi_value = self.rsi_calculator.update(price); features.push(rsi_value / 100.0); // Normalize to [0, 1] features.push((rsi_value / 100.0 - 0.5) * 2.0); // Center around 0 features.push(if rsi_value > 70.0 { 1.0 } else { 0.0 }); // Overbought flag features.push(if rsi_value < 30.0 { 1.0 } else { 0.0 }); // Oversold flag features.push((rsi_value - 50.0).abs() / 50.0); // Distance from neutral features.push((rsi_value / 100.0).powi(2)); // RSI squared (momentum emphasis) // Features 36-41: EMA-based features (6 features) let ema_fast = self.ema_fast_calculator.update(price); let ema_slow = self.ema_slow_calculator.update(price); features.push((price / ema_fast - 1.0).tanh()); // Price vs EMA fast features.push((price / ema_slow - 1.0).tanh()); // Price vs EMA slow features.push((ema_fast / ema_slow - 1.0).tanh()); // EMA crossover features.push(if ema_fast > ema_slow { 1.0 } else { 0.0 }); // Bull/bear flag features.push(((ema_fast - ema_slow) / ema_slow).abs()); // EMA divergence magnitude features.push((ema_fast - ema_slow).signum()); // EMA trend direction // Features 42-47: MACD metrics (6 features) let (macd_line, macd_signal, macd_histogram) = self.macd_calculator.update(price); features.push(macd_line.tanh()); // MACD line (normalized) features.push(macd_signal.tanh()); // MACD signal (normalized) features.push(macd_histogram.tanh()); // MACD histogram (normalized) features.push(if macd_line > macd_signal { 1.0 } else { 0.0 }); // Bull/bear signal features.push((macd_histogram.abs() / (macd_line.abs() + 1e-8)).tanh()); // Histogram strength features.push(macd_histogram.signum()); // Histogram direction // Features 48-53: Bollinger Bands metrics (6 features) let (bb_upper, bb_middle, bb_lower) = self.bollinger_calculator.update(price); let bb_width = bb_upper - bb_lower; let bb_position = if bb_width > 0.0 { (price - bb_lower) / bb_width } else { 0.5 }; features.push(bb_position.clamp(0.0, 1.0)); // Position in band [0, 1] features.push((price / bb_middle - 1.0).tanh()); // Price vs middle band features.push((bb_width / bb_middle).tanh()); // Band width (volatility) features.push(if price > bb_upper { 1.0 } else { 0.0 }); // Above upper band features.push(if price < bb_lower { 1.0 } else { 0.0 }); // Below lower band features.push(((bb_upper - bb_lower) / bb_middle * 100.0).tanh()); // %B indicator // Features 54-59: ATR metrics (6 features) let atr_value = self.atr_calculator.update(high, low, price); features.push((atr_value / price).tanh()); // ATR as % of price features.push((atr_value / price * 100.0).min(10.0) / 10.0); // ATR% clamped [0, 1] features.push(if atr_value > 0.0 { 1.0 } else { 0.0 }); // ATR active flag features.push((atr_value / (price * 0.01)).tanh()); // ATR in tick units features.push((atr_value.ln() + 5.0) / 10.0); // Log ATR (normalized) features.push((atr_value / (price + atr_value)).clamp(0.0, 1.0)); // ATR ratio // Features 60-65: ADX metrics (6 features) let (adx_value, plus_di, minus_di) = self.adx_calculator.update(high, low, price); features.push(adx_value / 100.0); // ADX [0, 1] features.push(plus_di / 100.0); // +DI [0, 1] features.push(minus_di / 100.0); // -DI [0, 1] features.push((plus_di - minus_di).abs() / 100.0); // DI spread features.push(if plus_di > minus_di { 1.0 } else { 0.0 }); // Bullish DI features.push((adx_value / 100.0 * (plus_di - minus_di).signum()).tanh()); // Directional strength // Features 66-224: Placeholder for Wave C advanced features (159 features) // TODO: Implement full 225-feature extraction // These features require complex logic from the ml crate: // - Fractional differentiation features (162 features, indices 39-200) // - Wave D regime detection features (24 features, indices 201-224): // * CUSUM statistics (10 features, 201-210) // * ADX & Directional (5 features, 211-215) // * Transition probabilities (5 features, 216-220) // * Adaptive strategy metrics (4 features, 221-224) // // For production use with 225 features, use ml::features::extraction::extract_ml_features() // which has the full implementation without circular dependencies. // // Current implementation provides 66 features (30 original + 36 new technical indicators) // Padding remaining 159 features with zeros for dimensional compatibility. features.resize(225, 0.0); } features } } /// Trait for ML model adapters pub trait MLModelAdapter: Send + Sync { /// Get model prediction fn predict(&self, features: &[f64]) -> Result; /// Get model identifier fn model_id(&self) -> &str; /// Validate prediction against actual outcome fn validate_prediction(&mut self, prediction: &MLPrediction, actual_outcome: bool); } /// Simple DQN model adapter (for backtesting/simulation) #[derive(Debug)] pub struct SimpleDQNAdapter { model_id: String, weights: Vec, expected_feature_count: usize, predictions_made: u64, correct_predictions: u64, } impl SimpleDQNAdapter { /// Create new DQN adapter with 30 features (Wave A + 4 Wave C indicators) /// /// # Errors /// Returns `CommonError::Validation` if weight construction fails. pub fn new(model_id: String) -> std::result::Result { Self::with_feature_count(model_id, 30) } /// Create DQN adapter with specific feature count /// /// Supported feature counts: /// - 26: Wave A baseline /// - 30: Wave A + 4 Wave C indicators (default) /// - 36: Wave B (alternative bars) /// - 65: Wave C (advanced features) /// - 225: Wave D (201 Wave C + 24 Wave D regime features) /// /// # Errors /// Returns `CommonError::Validation` if `feature_count` is not one of the supported values. pub fn with_feature_count(model_id: String, feature_count: usize) -> std::result::Result { let weights = match feature_count { 26 => { // Wave A: 26 features (baseline technical indicators) // Feature breakdown: // 0-6: Original 7 features (price_return, short_ma, volatility, volume_ratio, volume_ma_ratio, hour, day_of_week) // 7-9: Oscillators (williams_r, roc, ultimate_oscillator) // 10-12: Volume indicators (obv, mfi, vwap) // 13-17: EMA features (ema_9_norm, ema_21_norm, ema_50_norm, ema_9_21_cross, ema_21_50_cross) // 18: ADX (trend strength) // 19: Bollinger Bands Position (volatility/mean reversion) // 20: Stochastic %K (momentum oscillator) // 21: Stochastic %D (signal line) // 22: CCI (commodity momentum) // 23: RSI (relative strength) // 24: MACD (trend convergence) // 25: MACD Signal (signal line) vec![ // Original 7 features (indices 0-6) 0.1, -0.05, 0.2, 0.15, -0.1, 0.08, 0.03, // Oscillators (indices 7-9) 0.12, 0.09, 0.11, // Williams %R, ROC, Ultimate Oscillator // Volume indicators (indices 10-12) 0.07, 0.06, 0.05, // OBV, MFI, VWAP // EMA features (indices 13-17) 0.13, 0.14, 0.10, 0.18, -0.15, // EMA norms + crosses // Wave A indicators (indices 18-25) 0.11, // ADX (18) - trend strength indicator 0.16, // Bollinger Bands Position (19) - volatility/mean reversion -0.14, // Stochastic %K (20) - momentum (contrarian signal) 0.08, // Stochastic %D (21) - signal line confirmation 0.09, // CCI (22) - commodity momentum indicator 0.12, // RSI (23) - relative strength 0.10, // MACD (24) - trend following indicator 0.07, // MACD Signal (25) - signal line confirmation ] }, 30 => { // Wave A + 4 Wave C indicators (default configuration) // Indices 0-25: Wave A features (26 total) // Indices 26-29: Wave C features (4 total: OBV momentum, Volume oscillator, A/D Line, EMA ratio) vec![ // Original 7 features (indices 0-6) 0.1, -0.05, 0.2, 0.15, -0.1, 0.08, 0.03, // Oscillators (indices 7-9) 0.12, 0.09, 0.11, // Williams %R, ROC, Ultimate Oscillator // Volume indicators (indices 10-12) 0.07, 0.06, 0.05, // OBV, MFI, VWAP // EMA features (indices 13-17) 0.13, 0.14, 0.10, 0.18, -0.15, // EMA norms + crosses // Wave A indicators (indices 18-25) 0.11, // ADX (18) - trend strength indicator 0.16, // Bollinger Bands Position (19) - volatility/mean reversion -0.14, // Stochastic %K (20) - momentum (contrarian signal) 0.08, // Stochastic %D (21) - signal line confirmation 0.09, // CCI (22) - commodity momentum indicator 0.12, // RSI (23) - relative strength 0.10, // MACD (24) - trend following indicator 0.07, // MACD Signal (25) - signal line confirmation // Wave C indicators (indices 26-29) 0.13, // OBV Momentum (26) - volume flow momentum 0.11, // Volume Oscillator (27) - volume trend strength 0.09, // A/D Line (28) - accumulation/distribution 0.15, // EMA Ratio (29) - multi-timeframe trend strength ] }, 36 => { // Wave B: 36 features (Wave A + alternative bars) // Use uniform weights for alternative bar features (indices 26-35) let mut w = vec![ // Original 7 features (indices 0-6) 0.1, -0.05, 0.2, 0.15, -0.1, 0.08, 0.03, // Oscillators (indices 7-9) 0.12, 0.09, 0.11, // Volume indicators (indices 10-12) 0.07, 0.06, 0.05, // EMA features (indices 13-17) 0.13, 0.14, 0.10, 0.18, -0.15, // Wave A indicators (indices 18-25) 0.11, 0.16, -0.14, 0.08, 0.09, 0.12, 0.10, 0.07, ]; // Add 10 alternative bar features with uniform weights let uniform_weight = 1.0 / 36.0; w.extend(vec![uniform_weight; 10]); w }, 65 => { // Wave C: 65+ features (advanced features) // Use uniform weights for all features vec![1.0 / 65.0; 65] }, 225 => { // Wave D: 225 features (201 Wave C + 24 Wave D regime features) // Use uniform weights for all features vec![1.0 / 225.0; 225] }, _ => return Err(CommonError::validation(format!( "Unsupported feature count: {}. Supported: 26, 30, 36, 65, 225", feature_count ))), }; if weights.len() != feature_count { return Err(CommonError::validation(format!( "SimpleDQNAdapter weight count {} must match feature_count {}", weights.len(), feature_count ))); } Ok(Self { model_id, weights, expected_feature_count: feature_count, predictions_made: 0, correct_predictions: 0, }) } /// Wave A configuration: 26 features (baseline technical indicators) /// /// # Errors /// Returns `CommonError::Validation` if weight construction fails. pub fn new_wave_a(model_id: String) -> std::result::Result { Self::with_feature_count(model_id, 26) } /// Wave A+ configuration: 30 features (Wave A + 4 Wave C indicators) /// /// # Errors /// Returns `CommonError::Validation` if weight construction fails. pub fn new_wave_a_plus(model_id: String) -> std::result::Result { Self::with_feature_count(model_id, 30) } /// Wave B configuration: 36 features (Wave A + alternative bars) /// /// # Errors /// Returns `CommonError::Validation` if weight construction fails. pub fn new_wave_b(model_id: String) -> std::result::Result { Self::with_feature_count(model_id, 36) } /// Wave C configuration: 65+ features (advanced features) /// /// # Errors /// Returns `CommonError::Validation` if weight construction fails. pub fn new_wave_c(model_id: String) -> std::result::Result { Self::with_feature_count(model_id, 65) } /// Wave D configuration: 225 features (201 Wave C + 24 Wave D regime features) /// /// # Errors /// Returns `CommonError::Validation` if weight construction fails. pub fn new_wave_d(model_id: String) -> std::result::Result { Self::with_feature_count(model_id, 225) } /// Get expected feature count for this adapter pub fn expected_feature_count(&self) -> usize { self.expected_feature_count } } impl MLModelAdapter for SimpleDQNAdapter { fn predict(&self, features: &[f64]) -> Result { // Dynamic feature validation using expected_feature_count if features.len() != self.expected_feature_count { return Err(anyhow::anyhow!( "Feature dimension mismatch: got {}, expected {}", features.len(), self.expected_feature_count )); } // Simple linear combination with sigmoid activation let linear_output: f64 = features .iter() .zip(self.weights.iter()) .map(|(f, w)| f * w) .sum(); let prediction_value = 1.0 / (1.0 + (-linear_output).exp()); // Sigmoid activation // Calculate confidence based on distance from 0.5 let confidence = 0.5 + (prediction_value - 0.5).abs() * 0.8; Ok(MLPrediction { model_id: self.model_id.clone(), prediction_value, confidence, features: features.to_vec(), timestamp: Utc::now(), inference_latency_us: 50, // Simulated latency }) } fn model_id(&self) -> &str { &self.model_id } fn validate_prediction(&mut self, prediction: &MLPrediction, actual_outcome: bool) { self.predictions_made += 1; // Simple validation: if prediction > 0.5 and outcome is positive, it's correct let predicted_positive = prediction.prediction_value > 0.5; if predicted_positive == actual_outcome { self.correct_predictions += 1; } } } /// Shared ML strategy implementation (ONE SINGLE SYSTEM) pub struct SharedMLStrategy { /// Available ML models models: Arc>>>, /// Feature extractor - WAVE 10: Pluggable 225-feature extractor (inject from ml crate) feature_extractor_225: Option>>>, /// Fallback legacy feature extractor (66 features + 159 zeros) - DEPRECATED legacy_feature_extractor: Option>>, /// Model performance tracking model_performance: Arc>>, /// Minimum confidence threshold min_confidence_threshold: f64, } impl std::fmt::Debug for SharedMLStrategy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SharedMLStrategy") .field("min_confidence_threshold", &self.min_confidence_threshold) .field("model_count", &"") .finish() } } impl SharedMLStrategy { /// Create new shared ML strategy with LEGACY feature extraction (66 features + 159 zeros) /// /// # DEPRECATED: Use `new_with_production_extractor()` instead /// /// This constructor creates a strategy with the legacy 66-feature extractor that pads /// with 159 zeros. This is ONLY for backward compatibility. Production code should use /// `new_with_production_extractor()` and inject the ml::features::extraction extractor. /// # Errors /// Returns `CommonError::Validation` if model adapter construction fails. pub fn new(lookback_periods: usize, min_confidence_threshold: f64) -> std::result::Result { let mut models: HashMap> = HashMap::new(); // Add default Wave D models (225 features) models.insert( "dqn_v1".to_string(), Box::new(SimpleDQNAdapter::new_wave_d("dqn_v1".to_string())?), ); Ok(Self { models: Arc::new(RwLock::new(models)), feature_extractor_225: None, legacy_feature_extractor: Some(Arc::new(RwLock::new( MLFeatureExtractor::new_wave_d(lookback_periods), ))), model_performance: Arc::new(RwLock::new(HashMap::new())), min_confidence_threshold, }) } /// Create new shared ML strategy with production-grade 225-feature extraction /// /// # WAVE 10 FIX: Now uses ml::features::extraction for all 225 features /// - No more 66 features + 159 zeros padding /// - Wave D features (201-224) are fully operational /// - Training-production feature parity achieved /// /// # Arguments /// - `extractor`: Production-grade 225-feature extractor (inject from ml crate) /// - `min_confidence_threshold`: Minimum confidence for predictions /// /// # Example /// ```rust,ignore /// use ml::features::extraction::FeatureExtractor; /// use common::ml_strategy::SharedMLStrategy; /// /// // Wrap ml extractor /// struct ML225Wrapper(FeatureExtractor); /// impl ProductionFeatureExtractor225 for ML225Wrapper { /* implement trait */ } /// /// let extractor = Box::new(ML225Wrapper(FeatureExtractor::new())); /// let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.7); /// ``` /// # Errors /// Returns `CommonError::Validation` if model adapter construction fails. pub fn new_with_production_extractor( extractor: Box, min_confidence_threshold: f64, ) -> std::result::Result { let mut models: HashMap> = HashMap::new(); // Add default Wave D models (225 features) models.insert( "dqn_v1".to_string(), Box::new(SimpleDQNAdapter::new_wave_d("dqn_v1".to_string())?), ); Ok(Self { models: Arc::new(RwLock::new(models)), feature_extractor_225: Some(Arc::new(RwLock::new(extractor))), legacy_feature_extractor: None, model_performance: Arc::new(RwLock::new(HashMap::new())), min_confidence_threshold, }) } /// Get ensemble prediction from all models /// /// # WAVE 10 FIX: Uses production extractor (225 features) if available, otherwise legacy (66+159 zeros) pub async fn get_ensemble_prediction( &self, price: f64, volume: f64, timestamp: DateTime, ) -> Result> { // Extract features using production extractor (225 features) or legacy fallback let features: Vec = if let Some(ref prod_extractor) = self.feature_extractor_225 { // WAVE 10 PRODUCTION PATH: Use injected 225-feature extractor from ml crate let mut extractor = prod_extractor.write().await; extractor.update(price, volume, timestamp)?; extractor.extract_features()? } else if let Some(ref legacy_extractor) = self.legacy_feature_extractor { // LEGACY FALLBACK: 66 features + 159 zeros (DEPRECATED) tracing::warn!( "Using DEPRECATED legacy feature extractor (66 features + 159 zeros). \ Wave D features (201-224) will be ALL ZEROS. \ Use SharedMLStrategy::new_with_production_extractor() for production." ); let mut extractor = legacy_extractor.write().await; extractor.extract_features(price, volume, timestamp) } else { anyhow::bail!("No feature extractor configured (neither production nor legacy)") }; // Validate feature count if features.len() != 225 { tracing::error!( "Feature extraction returned {} features, expected 225. \ Models will receive incorrect input!", features.len() ); } let mut predictions = Vec::new(); // Get predictions from all models let models = self.models.read().await; for (model_id, model) in models.iter() { match model.predict(&features) { Ok(prediction) => { if prediction.confidence >= self.min_confidence_threshold { predictions.push(prediction); } }, Err(e) => { tracing::warn!("Model {} failed to predict: {}", model_id, e); }, } } Ok(predictions) } /// Calculate weighted ensemble vote pub fn calculate_ensemble_vote(&self, predictions: &[MLPrediction]) -> Option<(f64, f64)> { if predictions.is_empty() { return None; } let total_confidence: f64 = predictions.iter().map(|p| p.confidence).sum(); if total_confidence == 0.0 { return None; } // Weighted average by confidence let weighted_prediction: f64 = predictions .iter() .map(|p| p.prediction_value * p.confidence) .sum::() / total_confidence; let average_confidence: f64 = predictions.iter().map(|p| p.confidence).sum::() / predictions.len() as f64; Some((weighted_prediction, average_confidence)) } /// Validate predictions against actual market outcomes pub async fn validate_predictions(&self, predictions: &[MLPrediction], actual_return: f64) { let actual_outcome = actual_return > 0.0; // Positive return = good outcome let mut models = self.models.write().await; let mut performance = self.model_performance.write().await; for prediction in predictions { if let Some(model) = models.get_mut(&prediction.model_id) { model.validate_prediction(prediction, actual_outcome); } // Update performance tracking let perf = performance .entry(prediction.model_id.clone()) .or_insert_with(|| MLModelPerformance { model_id: prediction.model_id.clone(), ..Default::default() }); perf.total_predictions += 1; let predicted_positive = prediction.prediction_value > 0.5; if predicted_positive == actual_outcome { perf.correct_predictions += 1; } perf.accuracy_percentage = if perf.total_predictions > 0 { (perf.correct_predictions as f64 / perf.total_predictions as f64) * 100.0 } else { 0.0 }; // Update average confidence let total_samples = perf.total_predictions as f64; perf.avg_confidence = (perf.avg_confidence * (total_samples - 1.0) + prediction.confidence) / total_samples; // Update average latency perf.avg_latency_us = (perf.avg_latency_us * (total_samples - 1.0) + prediction.inference_latency_us as f64) / total_samples; } } /// Get performance summary for all models pub async fn get_performance_summary(&self) -> HashMap { self.model_performance.read().await.clone() } /// Add a model to the strategy pub async fn add_model(&self, model_id: String, model: Box) { let mut models = self.models.write().await; models.insert(model_id, model); } /// Get minimum confidence threshold pub fn min_confidence_threshold(&self) -> f64 { self.min_confidence_threshold } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_shared_ml_strategy_creation() -> Result<()> { let strategy = SharedMLStrategy::new(20, 0.6)?; assert_eq!(strategy.min_confidence_threshold(), 0.6); Ok(()) } #[tokio::test] async fn test_ensemble_prediction() -> Result<()> { let strategy = SharedMLStrategy::new(20, 0.0)?; let predictions = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await .unwrap_or_default(); // Should have at least one model prediction assert!(!predictions.is_empty()); Ok(()) } #[tokio::test] async fn test_ensemble_vote() -> Result<()> { let strategy = SharedMLStrategy::new(20, 0.0)?; let predictions = vec![ MLPrediction { model_id: "model1".to_string(), prediction_value: 0.8, confidence: 0.9, features: vec![], timestamp: Utc::now(), inference_latency_us: 50, }, MLPrediction { model_id: "model2".to_string(), prediction_value: 0.6, confidence: 0.7, features: vec![], timestamp: Utc::now(), inference_latency_us: 60, }, ]; let (vote, confidence) = strategy .calculate_ensemble_vote(&predictions) .unwrap_or_default(); // Weighted average should be between 0.6 and 0.8 assert!((0.6..=0.8).contains(&vote)); assert!((0.7..=0.9).contains(&confidence)); Ok(()) } #[tokio::test] async fn test_performance_tracking() -> Result<()> { let strategy = SharedMLStrategy::new(20, 0.0)?; let prediction = MLPrediction { model_id: "test_model".to_string(), prediction_value: 0.7, confidence: 0.8, features: vec![], timestamp: Utc::now(), inference_latency_us: 50, }; // Validate with positive outcome strategy .validate_predictions(std::slice::from_ref(&prediction), 0.05) .await; let performance = strategy.get_performance_summary().await; let model_perf = performance.get("test_model").cloned(); assert!(model_perf.is_some()); let perf = model_perf.unwrap_or_default(); assert_eq!(perf.total_predictions, 1); assert_eq!(perf.correct_predictions, 1); assert_eq!(perf.accuracy_percentage, 100.0); Ok(()) } #[test] fn test_oscillator_features_count() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Build up sufficient data for all features for i in 0..30 { let price = 100.0 + (i as f64 * 0.5); let volume = 1000.0; let features = extractor.extract_features(price, volume, timestamp); // After 29 periods, all features should be calculated if i >= 28 { // Features breakdown (Wave A + Wave C): // Wave A (26 features): // - Original 7: price_return, short_ma, volatility, volume_ratio, volume_ma_ratio, hour, day_of_week // - Oscillators 3: williams_r, roc, ultimate_oscillator // - Volume 3: obv, mfi, vwap // - EMA 5: ema_9_norm, ema_21_norm, ema_50_norm, ema_9_21_cross, ema_21_50_cross // - Indicators 8: adx, bb_position, stoch_k, stoch_d, cci, rsi, macd, macd_signal // Wave C (4 features): // - obv_momentum, volume_oscillator, ad_line, ema_ratio // Total: 30 features assert_eq!( features.len(), 30, "Should have 30 features (Wave A: 26 + Wave C: 4) at iteration {}", i ); // Verify all features are in [-1, 1] range for (idx, &feature) in features.iter().enumerate() { assert!( (-1.0..=1.0).contains(&feature), "Feature {} at index {} out of range [-1, 1]", feature, idx ); } } } } #[test] fn test_williams_r_oversold_overbought() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create oversold condition: sharp downtrend for i in 0..30 { let price = 100.0 - (i as f64 * 2.0); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features_oversold = extractor.extract_features(30.0, 1000.0, timestamp); #[allow(clippy::indexing_slicing)] // Test code with known feature count let williams_r_oversold = features_oversold[7]; // Williams %R is at index 7 (after 7 base features) // Williams %R should indicate oversold (negative value) assert!( williams_r_oversold < -0.3, "Williams %R should indicate oversold, got {}", williams_r_oversold ); // Create overbought condition: sharp uptrend let mut extractor2 = MLFeatureExtractor::new(30); for i in 0..30 { let price = 50.0 + (i as f64 * 2.0); let volume = 1000.0; extractor2.extract_features(price, volume, timestamp); } let features_overbought = extractor2.extract_features(170.0, 1000.0, timestamp); #[allow(clippy::indexing_slicing)] // Test code with known feature count let williams_r_overbought = features_overbought[7]; // Williams %R should indicate overbought (positive value) assert!( williams_r_overbought > 0.3, "Williams %R should indicate overbought, got {}", williams_r_overbought ); } #[test] fn test_roc_momentum_detection() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create flat market for _ in 0..15 { extractor.extract_features(100.0, 1000.0, timestamp); } // Then strong upward momentum for i in 0..15 { let price = 100.0 + (i as f64 * 3.0); extractor.extract_features(price, 1000.0, timestamp); } let features = extractor.extract_features(145.0, 1000.0, timestamp); #[allow(clippy::indexing_slicing)] // Test code with known feature count let roc = features[8]; // ROC is at index 8 (after 7 base features + williams_r) // ROC should be strongly positive assert!( roc > 0.25, "ROC should indicate strong positive momentum, got {}", roc ); // Test negative momentum let mut extractor2 = MLFeatureExtractor::new(30); for _ in 0..15 { extractor2.extract_features(100.0, 1000.0, timestamp); } for i in 0..15 { let price = 100.0 - (i as f64 * 2.0); extractor2.extract_features(price, 1000.0, timestamp); } let features2 = extractor2.extract_features(70.0, 1000.0, timestamp); #[allow(clippy::indexing_slicing)] // Test code with known feature count let roc2 = features2[8]; // ROC should be negative assert!( roc2 < -0.15, "ROC should indicate negative momentum, got {}", roc2 ); } #[test] fn test_ultimate_oscillator_multi_timeframe() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create volatile price action for i in 0..30 { let price = 100.0 + ((i as f64 * 3.0).sin() * 15.0); let volume = 1000.0; let features = extractor.extract_features(price, volume, timestamp); if i >= 28 { #[allow(clippy::indexing_slicing)] // Test code with known feature count let uo = features[9]; // Ultimate Oscillator is at index 9 // Ultimate Oscillator should remain in valid range assert!( (-1.0..=1.0).contains(&uo), "Ultimate Oscillator out of range: {}", uo ); } } } #[test] fn test_oscillators_normalized_range() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Test with extreme price movements for i in 0..30 { let price = if i < 15 { 50.0 + (i as f64 * 5.0) // Sharp rise } else { 125.0 - ((i - 15) as f64 * 3.0) // Sharp fall }; let volume = 500.0 + (i as f64 * 50.0); let features = extractor.extract_features(price, volume, timestamp); if i >= 28 { #[allow(clippy::indexing_slicing)] // Test code with known feature count let williams_r = features[7]; #[allow(clippy::indexing_slicing)] // Test code with known feature count let roc = features[8]; #[allow(clippy::indexing_slicing)] // Test code with known feature count let uo = features[9]; // All oscillators should be normalized to [-1, 1] assert!( (-1.0..=1.0).contains(&williams_r), "Williams %R out of range: {}", williams_r ); assert!((-1.0..=1.0).contains(&roc), "ROC out of range: {}", roc); assert!( (-1.0..=1.0).contains(&uo), "Ultimate Oscillator out of range: {}", uo ); } } } // ======================================== // WAVE C: Tests for New Volume & EMA Indicators (12 tests) // ======================================== #[test] fn test_obv_momentum_calculation() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Build up OBV with clear trend for i in 0..15 { let price = if i < 10 { 100.0 + (i as f64) // Rising prices -> positive OBV } else { 109.0 - (i as f64 - 10.0) // Falling prices -> negative OBV }; let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(105.0, 1000.0, timestamp); let obv_momentum = features[26]; // OBV momentum is at index 26 // After trend reversal, OBV momentum should reflect the change assert!( obv_momentum.abs() <= 1.0, "OBV momentum should be normalized to [-1, 1], got {}", obv_momentum ); } #[test] fn test_obv_momentum_positive_trend() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Consistent uptrend for i in 0..20 { let price = 100.0 + (i as f64 * 2.0); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(142.0, 1000.0, timestamp); let obv_momentum = features[26]; // OBV momentum should be positive in strong uptrend assert!( obv_momentum > 0.0, "OBV momentum should be positive in uptrend, got {}", obv_momentum ); } #[test] fn test_volume_oscillator_calculation() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Build volume pattern: low volume then high volume spike for i in 0..15 { let price = 100.0 + (i as f64 * 0.5); let volume = if i < 10 { 500.0 } else { 2000.0 }; // Volume spike extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(108.0, 2000.0, timestamp); let volume_oscillator = features[27]; // Volume oscillator is at index 27 // Volume oscillator should detect the spike assert!( volume_oscillator.abs() <= 1.0, "Volume oscillator should be normalized to [-1, 1], got {}", volume_oscillator ); // Volume spike should create positive oscillator assert!( volume_oscillator > 0.0, "Volume oscillator should be positive during volume spike, got {}", volume_oscillator ); } #[test] fn test_volume_oscillator_fast_vs_slow() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Establish baseline volume for _ in 0..10 { let price = 100.0; let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } // Gradually increase volume for i in 0..10 { let price = 100.0; let volume = 1000.0 + (i as f64 * 100.0); extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(100.0, 2000.0, timestamp); let volume_oscillator = features[27]; // Fast MA should be above slow MA -> positive oscillator assert!( volume_oscillator > 0.0, "Volume oscillator should be positive when fast MA > slow MA, got {}", volume_oscillator ); } #[test] fn test_ad_line_accumulation() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create accumulation pattern: close near high for i in 0..20 { let price = 100.0 + (i as f64 * 0.5); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(110.0, 1000.0, timestamp); let ad_line = features[28]; // A/D Line is at index 28 // A/D Line should be normalized assert!( ad_line.abs() <= 1.0, "A/D Line should be normalized to [-1, 1], got {}", ad_line ); // Accumulation pattern should create positive A/D Line assert!( ad_line > -0.5, "A/D Line should reflect accumulation, got {}", ad_line ); } #[test] fn test_ad_line_distribution() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create distribution pattern: price falls, close near low // Simulate by having price drop consistently (close will be near low) for i in 0..20 { let price = 110.0 - (i as f64 * 0.5); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(100.0, 1000.0, timestamp); let ad_line = features[28]; // Distribution pattern should create negative or neutral A/D Line assert!( ad_line < 0.5, "A/D Line should reflect distribution, got {}", ad_line ); } #[test] fn test_ema_ratio_uptrend() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create strong uptrend for i in 0..60 { let price = 100.0 + (i as f64 * 1.0); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(160.0, 1000.0, timestamp); let ema_ratio = features[29]; // EMA ratio is at index 29 // In strong uptrend, short EMA should be above long EMA // EMA(10) > EMA(50) -> ratio > 0 assert!( ema_ratio > 0.0, "EMA ratio should be positive in uptrend (EMA-10 > EMA-50), got {}", ema_ratio ); assert!( ema_ratio.abs() <= 1.0, "EMA ratio should be normalized to [-1, 1], got {}", ema_ratio ); } #[test] fn test_ema_ratio_downtrend() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Establish high price for _ in 0..30 { extractor.extract_features(160.0, 1000.0, timestamp); } // Create downtrend for i in 0..30 { let price = 160.0 - (i as f64 * 1.0); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } let features = extractor.extract_features(130.0, 1000.0, timestamp); let ema_ratio = features[29]; // In downtrend, short EMA should be below long EMA // EMA(10) < EMA(50) -> ratio < 0 assert!( ema_ratio < 0.0, "EMA ratio should be negative in downtrend (EMA-10 < EMA-50), got {}", ema_ratio ); } #[test] fn test_wave_c_features_range_validation() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Create diverse market conditions for i in 0..50 { let price = 100.0 + ((i as f64 * 3.0).sin() * 20.0); // Oscillating price let volume = 500.0 + ((i as f64 * 2.0).cos() * 300.0).abs(); // Oscillating volume let features = extractor.extract_features(price, volume, timestamp); if i >= 30 { // Wave C features: indices 26-29 let obv_momentum = features[26]; let volume_oscillator = features[27]; let ad_line = features[28]; let ema_ratio = features[29]; // All Wave C features should be in valid range assert!( obv_momentum.abs() <= 1.0 && obv_momentum.is_finite(), "OBV momentum out of range at iteration {}: {}", i, obv_momentum ); assert!( volume_oscillator.abs() <= 1.0 && volume_oscillator.is_finite(), "Volume oscillator out of range at iteration {}: {}", i, volume_oscillator ); assert!( ad_line.abs() <= 1.0 && ad_line.is_finite(), "A/D Line out of range at iteration {}: {}", i, ad_line ); assert!( ema_ratio.abs() <= 1.0 && ema_ratio.is_finite(), "EMA ratio out of range at iteration {}: {}", i, ema_ratio ); } } } #[test] fn test_wave_c_features_with_zero_volume() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Test edge case: zero volume for _ in 0..20 { let features = extractor.extract_features(100.0, 0.0, timestamp); // Wave C features should handle zero volume gracefully if features.len() >= 30 { let obv_momentum = features[26]; let volume_oscillator = features[27]; let ad_line = features[28]; let ema_ratio = features[29]; assert!(obv_momentum.is_finite()); assert!(volume_oscillator.is_finite()); assert!(ad_line.is_finite()); assert!(ema_ratio.is_finite()); } } } #[test] fn test_wave_c_features_with_flat_price() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Test edge case: constant price for _ in 0..30 { let features = extractor.extract_features(100.0, 1000.0, timestamp); if features.len() >= 30 { let obv_momentum = features[26]; let _volume_oscillator = features[27]; // Not used in these assertions let _ad_line = features[28]; // Not used in these assertions let ema_ratio = features[29]; // All features should be neutral or near-zero for flat price assert!( obv_momentum.abs() <= 0.1, "OBV momentum should be near zero for flat price" ); assert!( ema_ratio.abs() <= 0.1, "EMA ratio should be near zero for flat price" ); } } } #[test] fn test_wave_a_and_c_integration() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Test that Wave A and Wave C features work together for i in 0..50 { let price = 100.0 + (i as f64 * 0.5); let volume = 1000.0 + (i as f64 * 10.0); let features = extractor.extract_features(price, volume, timestamp); if i >= 30 { // Validate all 30 features are present assert_eq!(features.len(), 30, "Should have exactly 30 features"); // Validate Wave A features (indices 0-25) for (idx, feature) in features.iter().enumerate().take(26) { assert!( feature.is_finite(), "Wave A feature {} is not finite at iteration {}", idx, i ); } // Validate Wave C features (indices 26-29) for (idx, feature) in features.iter().enumerate().take(30).skip(26) { assert!( feature.is_finite(), "Wave C feature {} is not finite at iteration {}", idx, i ); assert!( features[idx].abs() <= 1.0, "Wave C feature {} out of range [-1, 1] at iteration {}: {}", idx, i, features[idx] ); } } } } #[test] fn test_wave_c_performance_benchmark() { use std::time::Instant; let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Warmup period for i in 0..30 { let price = 100.0 + (i as f64 * 0.5); let volume = 1000.0; extractor.extract_features(price, volume, timestamp); } // Benchmark feature extraction (1000 iterations) let start = Instant::now(); for i in 0..1000 { let price = 100.0 + ((i as f64 * 0.1).sin() * 10.0); let volume = 1000.0 + ((i as f64 * 0.05).cos() * 200.0); extractor.extract_features(price, volume, timestamp); } let duration = start.elapsed(); let avg_latency_us = duration.as_micros() / 1000; println!("Wave C Performance:"); println!(" Total iterations: 1000"); println!(" Total time: {:?}", duration); println!(" Average latency per bar: {}μs", avg_latency_us); // Performance target: <100μs per feature extraction (30 features) assert!( avg_latency_us < 100, "Feature extraction too slow: {}μs (target: <100μs)", avg_latency_us ); } // ======================================== // END WAVE C TESTS // ======================================== // ======================================== // DYNAMIC FEATURE SUPPORT TESTS (Agent D5) // ======================================== #[test] fn test_dynamic_feature_support_wave_a() -> std::result::Result<(), CommonError> { // Wave A: 26 features let adapter = SimpleDQNAdapter::new_wave_a("wave_a_model".to_string())?; assert_eq!(adapter.expected_feature_count(), 26); // Test prediction with correct feature count let features = vec![0.5; 26]; let result = adapter.predict(&features); assert!( result.is_ok(), "Wave A prediction should succeed with 26 features" ); // Test prediction with incorrect feature count let wrong_features = vec![0.5; 30]; let result = adapter.predict(&wrong_features); assert!( result.is_err(), "Wave A prediction should fail with 30 features" ); assert!(result .unwrap_err() .to_string() .contains("Feature dimension mismatch")); Ok(()) } #[test] fn test_dynamic_feature_support_wave_a_plus() -> std::result::Result<(), CommonError> { // Wave A+: 30 features (default) let adapter = SimpleDQNAdapter::new("wave_a_plus_model".to_string())?; assert_eq!(adapter.expected_feature_count(), 30); let adapter_plus = SimpleDQNAdapter::new_wave_a_plus("wave_a_plus_model".to_string())?; assert_eq!(adapter_plus.expected_feature_count(), 30); // Test prediction with correct feature count let features = vec![0.5; 30]; let result = adapter.predict(&features); assert!( result.is_ok(), "Wave A+ prediction should succeed with 30 features" ); Ok(()) } #[test] fn test_dynamic_feature_support_wave_b() -> std::result::Result<(), CommonError> { // Wave B: 36 features let adapter = SimpleDQNAdapter::new_wave_b("wave_b_model".to_string())?; assert_eq!(adapter.expected_feature_count(), 36); // Test prediction with correct feature count let features = vec![0.5; 36]; let result = adapter.predict(&features); assert!( result.is_ok(), "Wave B prediction should succeed with 36 features" ); // Test prediction with incorrect feature count let wrong_features = vec![0.5; 26]; let result = adapter.predict(&wrong_features); assert!( result.is_err(), "Wave B prediction should fail with 26 features" ); Ok(()) } #[test] fn test_dynamic_feature_support_wave_c() -> std::result::Result<(), CommonError> { // Wave C: 65 features let adapter = SimpleDQNAdapter::new_wave_c("wave_c_model".to_string())?; assert_eq!(adapter.expected_feature_count(), 65); // Test prediction with correct feature count let features = vec![0.5; 65]; let result = adapter.predict(&features); assert!( result.is_ok(), "Wave C prediction should succeed with 65 features" ); // Test prediction with incorrect feature count let wrong_features = vec![0.5; 30]; let result = adapter.predict(&wrong_features); assert!( result.is_err(), "Wave C prediction should fail with 30 features" ); Ok(()) } #[test] fn test_ml_feature_extractor_wave_configurations() { // Test Wave A configuration let extractor_a = MLFeatureExtractor::new_wave_a(20); assert_eq!(extractor_a.expected_feature_count(), 26); // Test Wave A+ configuration let extractor_a_plus = MLFeatureExtractor::new_wave_a_plus(20); assert_eq!(extractor_a_plus.expected_feature_count(), 30); // Test Wave B configuration let extractor_b = MLFeatureExtractor::new_wave_b(20); assert_eq!(extractor_b.expected_feature_count(), 36); // Test Wave C configuration let extractor_c = MLFeatureExtractor::new_wave_c(20); assert_eq!(extractor_c.expected_feature_count(), 65); // Test Wave D configuration (NEW) let extractor_d = MLFeatureExtractor::new_wave_d(20); assert_eq!(extractor_d.expected_feature_count(), 225); // Verify Wave D actually extracts 225 features let mut extractor_d_test = MLFeatureExtractor::new_wave_d(20); let features = extractor_d_test.extract_features( 100.0, 1000.0, chrono::Utc::now(), ); assert_eq!(features.len(), 225, "Wave D should extract exactly 225 features"); // Test default (should be Wave A+) let extractor_default = MLFeatureExtractor::new(20); assert_eq!(extractor_default.expected_feature_count(), 30); } #[test] fn test_with_feature_count_custom() -> std::result::Result<(), CommonError> { // Test custom feature count using with_feature_count let adapter_26 = SimpleDQNAdapter::with_feature_count("custom_26".to_string(), 26)?; assert_eq!(adapter_26.expected_feature_count(), 26); let adapter_30 = SimpleDQNAdapter::with_feature_count("custom_30".to_string(), 30)?; assert_eq!(adapter_30.expected_feature_count(), 30); let adapter_36 = SimpleDQNAdapter::with_feature_count("custom_36".to_string(), 36)?; assert_eq!(adapter_36.expected_feature_count(), 36); let adapter_65 = SimpleDQNAdapter::with_feature_count("custom_65".to_string(), 65)?; assert_eq!(adapter_65.expected_feature_count(), 65); Ok(()) } #[test] fn test_unsupported_feature_count() { // Should return Err with unsupported feature count let result = SimpleDQNAdapter::with_feature_count("invalid".to_string(), 42); assert!(result.is_err(), "Expected error for unsupported feature count 42"); if let Err(e) = result { let err_msg = e.to_string(); assert!( err_msg.contains("Unsupported feature count"), "Error message should mention unsupported feature count, got: {err_msg}" ); } } #[test] fn test_backward_compatibility() -> std::result::Result<(), CommonError> { // Existing code using SimpleDQNAdapter::new() should still work with 30 features let adapter = SimpleDQNAdapter::new("backward_compat".to_string())?; assert_eq!(adapter.expected_feature_count(), 30); let features = vec![0.5; 30]; let result = adapter.predict(&features); assert!( result.is_ok(), "Backward compatibility: should work with 30 features" ); Ok(()) } // ======================================== // END DYNAMIC FEATURE SUPPORT TESTS // ======================================== #[test] fn test_oscillators_complement_existing_features() { let mut extractor = MLFeatureExtractor::new(30); let timestamp = Utc::now(); // Build market data with a clear trend reversal // Phase 1: Uptrend (15 periods) for i in 0..15 { let price = 100.0 + (i as f64 * 2.0); extractor.extract_features(price, 1000.0, timestamp); } // Phase 2: Downtrend (15 periods) for i in 0..15 { let price = 130.0 - (i as f64 * 1.5); extractor.extract_features(price, 1000.0, timestamp); } let features = extractor.extract_features(107.5, 1000.0, timestamp); let williams_r = features[7]; let roc = features[8]; let uo = features[9]; // After trend reversal, oscillators should show different sensitivities // This tests that they provide complementary signals assert!( williams_r.abs() <= 1.0 && roc.abs() <= 1.0 && uo.abs() <= 1.0, "All oscillators should be in valid range after trend reversal" ); // ROC should be negative (12-period lookback sees the downtrend) assert!( roc < 0.0, "ROC should detect downward momentum, got {}", roc ); } }