2739 lines
104 KiB
Rust
2739 lines
104 KiB
Rust
//! 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<Utc>) -> Result<()> {
|
||
/// let bar = ml::features::extraction::OHLCVBar { ... };
|
||
/// self.inner.update(&bar)
|
||
/// }
|
||
///
|
||
/// fn extract_features(&mut self) -> Result<Vec<f64>> {
|
||
/// 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<Utc>) -> Result<()>;
|
||
|
||
/// Extract 225-dimensional feature vector from current state
|
||
fn extract_features(&mut self) -> Result<Vec<f64>>;
|
||
}
|
||
|
||
/// 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<f64>,
|
||
/// Prediction timestamp
|
||
pub timestamp: DateTime<Utc>,
|
||
/// 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<f64>,
|
||
/// 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<f64>,
|
||
/// Volume history buffer
|
||
volume_history: Vec<f64>,
|
||
/// High/low price history for oscillators (simulated from close price)
|
||
high_low_history: Vec<(f64, f64)>,
|
||
/// EMA-9 state
|
||
ema_9: Option<f64>,
|
||
/// EMA-21 state
|
||
ema_21: Option<f64>,
|
||
/// EMA-50 state
|
||
ema_50: Option<f64>,
|
||
/// On-Balance Volume (OBV) cumulative value
|
||
obv: f64,
|
||
/// OBV history for momentum calculation (last 10 periods)
|
||
obv_history: Vec<f64>,
|
||
/// Accumulation/Distribution Line cumulative value
|
||
ad_line: f64,
|
||
/// Volume MA fast (5-period) for volume oscillator
|
||
volume_ma_fast: Option<f64>,
|
||
/// Volume MA slow (20-period) for volume oscillator
|
||
volume_ma_slow: Option<f64>,
|
||
/// EMA-10 for EMA ratio
|
||
ema_10: Option<f64>,
|
||
/// 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<f64>,
|
||
/// RSI average loss (14-period EMA)
|
||
rsi_avg_loss: Option<f64>,
|
||
/// MACD EMA-12
|
||
macd_ema_12: Option<f64>,
|
||
/// MACD EMA-26
|
||
macd_ema_26: Option<f64>,
|
||
/// MACD Signal EMA-9
|
||
macd_signal: Option<f64>,
|
||
/// Stochastic %K history for %D calculation
|
||
stoch_k_history: Vec<f64>,
|
||
/// ADX (Average Directional Index) for trend strength
|
||
adx: Option<f64>,
|
||
/// +DI (Positive Directional Indicator)
|
||
plus_di: Option<f64>,
|
||
/// -DI (Negative Directional Indicator)
|
||
minus_di: Option<f64>,
|
||
/// Smoothed +DM (for incremental ADX calculation)
|
||
plus_dm_smooth: Option<f64>,
|
||
/// Smoothed -DM (for incremental ADX calculation)
|
||
minus_dm_smooth: Option<f64>,
|
||
/// ATR (Average True Range) for ADX calculation
|
||
atr: Option<f64>,
|
||
/// Rolling volatility history for percentile calculation
|
||
volatility_history: Vec<f64>,
|
||
/// Rolling volume history for percentile calculation (separate from main volume buffer)
|
||
volume_percentile_buffer: Vec<f64>,
|
||
/// Return history for autocorrelation calculation
|
||
returns_history: Vec<f64>,
|
||
/// Momentum ROC(5) history for acceleration calculation
|
||
momentum_roc_5_history: Vec<f64>,
|
||
/// Momentum ROC(10) history for acceleration calculation
|
||
momentum_roc_10_history: Vec<f64>,
|
||
/// Acceleration history for jerk calculation
|
||
acceleration_history: Vec<f64>,
|
||
/// Price highs for divergence detection (last 20 periods)
|
||
price_highs: Vec<f64>,
|
||
/// Momentum highs for divergence detection (last 20 periods)
|
||
momentum_highs: Vec<f64>,
|
||
/// Historical momentum values for regime classification (last 100 periods)
|
||
momentum_regime_history: Vec<f64>,
|
||
|
||
// 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<Utc>,
|
||
) -> Vec<f64> {
|
||
// 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::<f64>() / 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<f64> = 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::<f64>() / recent_returns.len() as f64;
|
||
let variance = recent_returns
|
||
.iter()
|
||
.map(|&r| (r - mean_return).powi(2))
|
||
.sum::<f64>()
|
||
/ 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::<f64>() / 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<f64> =
|
||
self.price_history.iter().rev().take(20).copied().collect();
|
||
|
||
let middle = recent_20_prices.iter().sum::<f64>() / 20.0;
|
||
|
||
// Calculate standard deviation (20-period)
|
||
let variance = recent_20_prices
|
||
.iter()
|
||
.map(|&p| (p - middle).powi(2))
|
||
.sum::<f64>()
|
||
/ 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<f64> = 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::<f64>() / 20.0;
|
||
|
||
// Calculate Mean Absolute Deviation
|
||
let mad: f64 = typical_prices
|
||
.iter()
|
||
.map(|&tp| (tp - tp_sma).abs())
|
||
.sum::<f64>()
|
||
/ 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<MLPrediction>;
|
||
|
||
/// 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<f64>,
|
||
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, CommonError> {
|
||
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<Self, CommonError> {
|
||
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, CommonError> {
|
||
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, CommonError> {
|
||
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, CommonError> {
|
||
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, CommonError> {
|
||
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, CommonError> {
|
||
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<MLPrediction> {
|
||
// 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<RwLock<HashMap<String, Box<dyn MLModelAdapter>>>>,
|
||
/// Feature extractor - WAVE 10: Pluggable 225-feature extractor (inject from ml crate)
|
||
feature_extractor_225: Option<Arc<RwLock<Box<dyn ProductionFeatureExtractor225>>>>,
|
||
/// Fallback legacy feature extractor (66 features + 159 zeros) - DEPRECATED
|
||
legacy_feature_extractor: Option<Arc<RwLock<MLFeatureExtractor>>>,
|
||
/// Model performance tracking
|
||
model_performance: Arc<RwLock<HashMap<String, MLModelPerformance>>>,
|
||
/// 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", &"<async_lock>")
|
||
.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<Self, CommonError> {
|
||
let mut models: HashMap<String, Box<dyn MLModelAdapter>> = 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<dyn ProductionFeatureExtractor225>,
|
||
min_confidence_threshold: f64,
|
||
) -> std::result::Result<Self, CommonError> {
|
||
let mut models: HashMap<String, Box<dyn MLModelAdapter>> = 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<Utc>,
|
||
) -> Result<Vec<MLPrediction>> {
|
||
// Extract features using production extractor (225 features) or legacy fallback
|
||
let features: Vec<f64> = 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::<f64>()
|
||
/ total_confidence;
|
||
|
||
let average_confidence: f64 =
|
||
predictions.iter().map(|p| p.confidence).sum::<f64>() / 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<String, MLModelPerformance> {
|
||
self.model_performance.read().await.clone()
|
||
}
|
||
|
||
/// Add a model to the strategy
|
||
pub async fn add_model(&self, model_id: String, model: Box<dyn MLModelAdapter>) {
|
||
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
|
||
);
|
||
}
|
||
}
|