diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 36bdce00e..227472ebc 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -80,8 +80,4 @@ tempfile = "3.8" [features] default = ["database"] database = ["sqlx"] -questdb = ["questdb-rs"] - -[[bench]] -name = "ml_strategy_bench" -harness = false \ No newline at end of file +questdb = ["questdb-rs"] \ No newline at end of file diff --git a/crates/common/benches/ml_strategy_bench.rs b/crates/common/benches/ml_strategy_bench.rs deleted file mode 100644 index 68f1f8647..000000000 --- a/crates/common/benches/ml_strategy_bench.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! Performance Benchmarks for 25-Feature ML Strategy System -//! -//! Agent A13 - Comprehensive latency and memory profiling for: -//! - Individual technical indicators (RSI, MACD, BB, ATR, Stochastic, ADX, CCI) -//! - Full 25-feature extraction end-to-end -//! - Memory usage analysis -//! -//! ## Targets -//! - Individual indicators: <5μs per update -//! - Full 25-feature extraction: <100μs per bar -//! - Memory: <500 bytes per symbol state -//! -//! ## Run Benchmarks -//! ```bash -//! cargo bench -p common --bench ml_strategy_bench -//! ``` - -use chrono::Utc; -use common::ml_strategy::MLFeatureExtractor; -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use std::time::Duration; - -// ============================================================================ -// Test Data Generator -// ============================================================================ - -/// Generate realistic market data for benchmarking -fn generate_market_data(num_bars: usize, seed: u64) -> Vec<(f64, f64)> { - use std::f64::consts::PI; - - let mut rng = fastrand::Rng::with_seed(seed); - let mut data = Vec::with_capacity(num_bars); - let mut price = 100.0; - - for i in 0..num_bars { - // Combine trend, cycle, and noise - let trend = (i as f64 * 0.01) % 10.0 - 5.0; - let cycle = (i as f64 * 0.1 * PI).sin() * 2.0; - let noise = (rng.f64() - 0.5) * 0.5; - - price += trend * 0.01 + cycle * 0.05 + noise; - price = price.clamp(50.0, 150.0); - - let volume = 10000.0 + (i as f64 * 0.5 * PI).sin().abs() * 5000.0 + rng.f64() * 2000.0; - - data.push((price, volume)); - } - - data -} - -// ============================================================================ -// Individual Indicator Benchmarks -// ============================================================================ - -/// Benchmark RSI (14-period) incremental update -fn bench_rsi_update(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_rsi"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 42); - - // Warm up extractor with 20 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(20) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 20; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features[23]); // RSI is at index 23 - }); - }); - - group.finish(); -} - -/// Benchmark MACD incremental update (EMA-12, EMA-26, Signal-9) -fn bench_macd_update(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_macd"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 43); - - // Warm up extractor - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(26) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 26; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features[24]); // MACD line - black_box(features[25]); // MACD signal - }); - }); - - group.finish(); -} - -/// Benchmark Bollinger Bands (20-period SMA + 2σ) -fn bench_bollinger_bands(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_bollinger_bands"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 44); - - // Warm up with 20 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(20) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 20; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features[19]); // BB position at index 19 - }); - }); - - group.finish(); -} - -/// Benchmark Stochastic Oscillator (%K and %D) -fn bench_stochastic(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_stochastic"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 45); - - // Warm up with 14 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(14) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 14; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features[20]); // Stochastic %K - black_box(features[21]); // Stochastic %D - }); - }); - - group.finish(); -} - -/// Benchmark ADX (Average Directional Index, 14-period) -fn bench_adx(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_adx"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 46); - - // Warm up with 14 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(14) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 14; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features[18]); // ADX at index 18 - }); - }); - - group.finish(); -} - -/// Benchmark CCI (Commodity Channel Index, 20-period) -fn bench_cci(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_cci"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 47); - - // Warm up with 20 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(20) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 20; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features[22]); // CCI at index 22 - }); - }); - - group.finish(); -} - -/// Benchmark ATR (Average True Range) - part of ADX calculation -fn bench_atr(c: &mut Criterion) { - let mut group = c.benchmark_group("indicator_atr"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 48); - - // Warm up with 14 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(14) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_update", |b| { - let mut ext = extractor.clone(); - let mut idx = 14; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - // ATR is internal state, accessed via ADX feature - black_box(features[18]); // ADX uses ATR internally - }); - }); - - group.finish(); -} - -// ============================================================================ -// End-to-End Feature Extraction Benchmarks -// ============================================================================ - -/// Benchmark full 25-feature extraction (cold start) -fn bench_full_extraction_cold(c: &mut Criterion) { - let mut group = c.benchmark_group("full_extraction_cold"); - group.measurement_time(Duration::from_secs(10)); - - let data = generate_market_data(30, 50); - - group.bench_function("30_bars_cold_start", |b| { - let timestamp = Utc::now(); - - b.iter(|| { - let mut extractor = MLFeatureExtractor::new(30); - - for (price, volume) in &data { - let features = - extractor.extract_features(black_box(*price), black_box(*volume), timestamp); - black_box(features); - } - }); - }); - - group.finish(); -} - -/// Benchmark full 25-feature extraction (warm state, single update) -fn bench_full_extraction_warm(c: &mut Criterion) { - let mut group = c.benchmark_group("full_extraction_warm"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(1000, 51); - - // Warm up extractor with 30 bars - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(30) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("single_bar_warm", |b| { - let mut ext = extractor.clone(); - let mut idx = 30; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features); - }); - }); - - group.finish(); -} - -/// Benchmark throughput: bars processed per second -fn bench_extraction_throughput(c: &mut Criterion) { - let mut group = c.benchmark_group("extraction_throughput"); - group.measurement_time(Duration::from_secs(10)); - - for batch_size in [10, 100, 1000] { - let data = generate_market_data(batch_size, 52); - - group.bench_with_input( - BenchmarkId::from_parameter(batch_size), - &batch_size, - |b, _| { - let timestamp = Utc::now(); - - b.iter(|| { - let mut extractor = MLFeatureExtractor::new(30); - - for (price, volume) in &data { - let features = extractor.extract_features( - black_box(*price), - black_box(*volume), - timestamp, - ); - black_box(features); - } - }); - }, - ); - } - - group.finish(); -} - -/// Benchmark feature extraction with different lookback windows -fn bench_lookback_impact(c: &mut Criterion) { - let mut group = c.benchmark_group("lookback_window_impact"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(100, 53); - - for lookback in [20, 30, 50, 100] { - group.bench_with_input( - BenchmarkId::from_parameter(lookback), - &lookback, - |b, &lb| { - let timestamp = Utc::now(); - - b.iter(|| { - let mut extractor = MLFeatureExtractor::new(lb); - - // Process all bars - for (price, volume) in &data { - let features = extractor.extract_features( - black_box(*price), - black_box(*volume), - timestamp, - ); - black_box(features); - } - }); - }, - ); - } - - group.finish(); -} - -// ============================================================================ -// Memory Benchmarks -// ============================================================================ - -/// Memory usage analysis for MLFeatureExtractor -fn bench_memory_usage(c: &mut Criterion) { - let mut group = c.benchmark_group("memory_usage"); - group.measurement_time(Duration::from_secs(3)); - - group.bench_function("extractor_size", |b| { - b.iter(|| { - let extractor = MLFeatureExtractor::new(black_box(30)); - black_box(std::mem::size_of_val(&extractor)); - }); - }); - - // Measure memory after warmup - group.bench_function("extractor_warm_size", |b| { - let data = generate_market_data(30, 54); - let timestamp = Utc::now(); - - b.iter(|| { - let mut extractor = MLFeatureExtractor::new(30); - - // Fill with data - for (price, volume) in &data { - extractor.extract_features(*price, *volume, timestamp); - } - - black_box(std::mem::size_of_val(&extractor)); - }); - }); - - group.finish(); -} - -// ============================================================================ -// Latency Distribution Analysis -// ============================================================================ - -/// Measure P50/P95/P99 latencies for feature extraction -fn bench_latency_distribution(c: &mut Criterion) { - let mut group = c.benchmark_group("latency_distribution"); - group.measurement_time(Duration::from_secs(10)); - group.sample_size(1000); // Increase sample size for better percentile accuracy - - let data = generate_market_data(1000, 55); - - // Warm up extractor - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - for (price, volume) in data.iter().take(30) { - extractor.extract_features(*price, *volume, timestamp); - } - - group.bench_function("p50_p95_p99_latency", |b| { - let mut ext = extractor.clone(); - let mut idx = 30; - - b.iter(|| { - let (price, volume) = data[idx % data.len()]; - let features = ext.extract_features(black_box(price), black_box(volume), timestamp); - idx += 1; - black_box(features); - }); - }); - - group.finish(); -} - -// ============================================================================ -// Comparative Benchmarks -// ============================================================================ - -/// Compare feature extraction with/without oscillators -fn bench_oscillator_overhead(c: &mut Criterion) { - let mut group = c.benchmark_group("oscillator_overhead"); - group.measurement_time(Duration::from_secs(5)); - - let data = generate_market_data(100, 56); - let timestamp = Utc::now(); - - // Benchmark: Extract only first 7 base features (price, volume, time) - group.bench_function("base_features_7", |b| { - b.iter(|| { - let mut extractor = MLFeatureExtractor::new(30); - - for (price, volume) in &data { - let features = - extractor.extract_features(black_box(*price), black_box(*volume), timestamp); - // Access only base features - black_box(&features[0..7]); - } - }); - }); - - // Benchmark: Full 26-feature extraction (7 base + 3 oscillators + 3 volume + 5 EMA + 8 new) - group.bench_function("full_features_26", |b| { - b.iter(|| { - let mut extractor = MLFeatureExtractor::new(30); - - for (price, volume) in &data { - let features = - extractor.extract_features(black_box(*price), black_box(*volume), timestamp); - black_box(features); - } - }); - }); - - group.finish(); -} - -// ============================================================================ -// Criterion Configuration -// ============================================================================ - -criterion_group!( - benches, - // Individual indicators - bench_rsi_update, - bench_macd_update, - bench_bollinger_bands, - bench_stochastic, - bench_adx, - bench_cci, - bench_atr, - // End-to-end extraction - bench_full_extraction_cold, - bench_full_extraction_warm, - bench_extraction_throughput, - bench_lookback_impact, - // Memory analysis - bench_memory_usage, - // Latency distribution - bench_latency_distribution, - // Comparative analysis - bench_oscillator_overhead, -); - -criterion_main!(benches); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ec4eb724f..d8a5a4a42 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -78,8 +78,8 @@ pub mod trading; // Re-export shared ML strategy types pub use ml_strategy::{ - MLFeatureExtractor, MLModelAdapter, MLModelPerformance, MLPrediction, - SharedMLStrategy, SimpleDQNAdapter, + MLModelAdapter, MLModelPerformance, MLPrediction, + SharedMLStrategy, }; // Re-export canonical financial types (aliased to avoid collision with old types:: re-exports) diff --git a/crates/common/src/metrics/server.rs b/crates/common/src/metrics/server.rs index f9d79bc4b..8e47f29b1 100644 --- a/crates/common/src/metrics/server.rs +++ b/crates/common/src/metrics/server.rs @@ -4,7 +4,7 @@ //! existing HTTP stack. Training binaries and CLI tools that don't have an //! HTTP server use this instead. -use std::io::{BufRead, BufReader, Write as IoWrite}; +use std::io::{BufRead, BufReader, Read as IoRead, Write as IoWrite}; use std::net::TcpListener; use super::gather_metrics; @@ -14,6 +14,11 @@ use super::gather_metrics; /// Responds to `GET /metrics` with the global registry output in Prometheus /// text exposition format. Any other request gets a 404. The thread is /// detached and dies with the process. +/// +/// Safety hardening: +/// - 5-second read timeout prevents slow-loris connections +/// - 8KB request line limit prevents memory abuse +/// - Correct Content-Type charset for Prometheus scrapers pub fn start_metrics_server(port: u16) { std::thread::spawn(move || { let addr = format!("0.0.0.0:{port}"); @@ -33,21 +38,25 @@ pub fn start_metrics_server(port: u16) { continue; }; - // Read the HTTP request line (e.g. "GET /metrics HTTP/1.1") - let mut reader = BufReader::new(&stream); + // 5-second read timeout prevents slow-loris connections + _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5))); + + // Read the HTTP request line, limited to 8KB to prevent memory abuse let mut request_line = String::new(); - if reader.read_line(&mut request_line).is_err() { + if BufReader::new((&stream).take(8192)) + .read_line(&mut request_line) + .is_err() + { continue; } let is_metrics = request_line.starts_with("GET /metrics"); - drop(reader); if is_metrics { let body = gather_metrics(); let response = format!( "HTTP/1.1 200 OK\r\n\ - Content-Type: text/plain; version=0.0.4\r\n\ + Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n\ Content-Length: {}\r\n\r\n\ {}", body.len(), diff --git a/crates/common/src/ml_strategy.rs b/crates/common/src/ml_strategy.rs index 5e27bec72..5adf1c760 100644 --- a/crates/common/src/ml_strategy.rs +++ b/crates/common/src/ml_strategy.rs @@ -9,47 +9,22 @@ //! ```text //! SharedMLStrategy //! ├─ MLModelAdapter (abstraction over ml crate models) -//! ├─ FeatureExtractor (consistent feature engineering) +//! ├─ ProductionFeatureExtractor225 (consistent 225-feature engineering) //! ├─ EnsembleCoordinator (weighted voting) //! └─ ModelPerformanceTracker (metrics) //! ``` use anyhow::Result; -use chrono::{DateTime, Datelike, Timelike, Utc}; +use chrono::{DateTime, 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 +/// Trait for pluggable 225-feature extraction /// /// This trait allows applications to inject the production-grade feature extractor /// from the `ml` crate without creating circular dependencies. -/// -/// # Example -/// ```rust,ignore -/// use ml::features::extraction::FeatureExtractor as MLExtractor; -/// use common::ml_strategy::ProductionFeatureExtractor225; -/// -/// struct ML225Extractor { -/// inner: MLExtractor, -/// } -/// -/// impl ProductionFeatureExtractor225 for ML225Extractor { -/// fn update(&mut self, price: f64, volume: f64, timestamp: DateTime) -> Result<()> { -/// let bar = ml::features::extraction::OHLCVBar { ... }; -/// self.inner.update(&bar) -/// } -/// -/// fn extract_features(&mut self) -> Result> { -/// Ok(self.inner.extract_current_features()?.to_vec()) -/// } -/// } -/// ``` pub trait ProductionFeatureExtractor225: Send + Sync { /// Update internal state with new market data fn update(&mut self, price: f64, volume: f64, timestamp: DateTime) -> Result<()>; @@ -98,1201 +73,6 @@ pub struct MLModelPerformance { pub max_drawdown: f64, } -/// Feature extraction for ML models -#[derive(Debug, Clone)] -pub struct MLFeatureExtractor { - /// Lookback window for features - pub lookback_periods: usize, - /// Expected feature count (26=Wave A, 30=Wave A+4 extra, 36=Wave B, 65=Wave C) - expected_feature_count: usize, - /// Price history buffer - price_history: Vec, - /// Volume history buffer - volume_history: Vec, - /// High/low price history for oscillators (simulated from close price) - high_low_history: Vec<(f64, f64)>, - /// EMA-9 state - ema_9: Option, - /// EMA-21 state - ema_21: Option, - /// EMA-50 state - ema_50: Option, - /// On-Balance Volume (OBV) cumulative value - obv: f64, - /// OBV history for momentum calculation (last 10 periods) - obv_history: Vec, - /// Accumulation/Distribution Line cumulative value - ad_line: f64, - /// Volume MA fast (5-period) for volume oscillator - volume_ma_fast: Option, - /// Volume MA slow (20-period) for volume oscillator - volume_ma_slow: Option, - /// EMA-10 for EMA ratio - ema_10: Option, - /// VWAP cumulative price*volume sum - vwap_pv_sum: f64, - /// VWAP cumulative volume sum - vwap_volume_sum: f64, - /// RSI average gain (14-period EMA) - rsi_avg_gain: Option, - /// RSI average loss (14-period EMA) - rsi_avg_loss: Option, - /// MACD EMA-12 - macd_ema_12: Option, - /// MACD EMA-26 - macd_ema_26: Option, - /// MACD Signal EMA-9 - macd_signal: Option, - /// Stochastic %K history for %D calculation - stoch_k_history: Vec, - /// ADX (Average Directional Index) for trend strength - adx: Option, - /// +DI (Positive Directional Indicator) - plus_di: Option, - /// -DI (Negative Directional Indicator) - minus_di: Option, - /// Smoothed +DM (for incremental ADX calculation) - plus_dm_smooth: Option, - /// Smoothed -DM (for incremental ADX calculation) - minus_dm_smooth: Option, - /// ATR (Average True Range) for ADX calculation - atr: Option, - - // 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, - - // Initialize technical indicators from common::features - rsi_calculator: RSI::new(14), - ema_fast_calculator: EMA::new(12), - ema_slow_calculator: EMA::new(26), - macd_calculator: MACD::new(12, 26, 9), - bollinger_calculator: BollingerBands::new(20, 2.0), - atr_calculator: ATR::new(14), - adx_calculator: ADX::new(14), - } - } - - /// Convenience constructors for specific Wave configurations - pub fn new_wave_a(lookback_periods: usize) -> Self { - Self::with_feature_count(lookback_periods, 26) - } - - pub fn new_wave_a_plus(lookback_periods: usize) -> Self { - Self::with_feature_count(lookback_periods, 30) - } - - pub fn new_wave_b(lookback_periods: usize) -> Self { - Self::with_feature_count(lookback_periods, 36) - } - - pub fn new_wave_c(lookback_periods: usize) -> Self { - Self::with_feature_count(lookback_periods, 65) - } - - /// Create new Wave D feature extractor with 225 features (201 Wave C + 24 Wave D) - pub fn new_wave_d(lookback_periods: usize) -> Self { - Self::with_feature_count(lookback_periods, 225) - } - - /// Get expected feature count - pub fn expected_feature_count(&self) -> usize { - self.expected_feature_count - } - - /// Extract features from market data - pub fn extract_features( - &mut self, - price: f64, - volume: f64, - timestamp: DateTime, - ) -> Vec { - // Update price and volume history - self.price_history.push(price); - self.volume_history.push(volume); - - // Simulate high/low with 0.1% spread (typical intraday range) - self.high_low_history.push((price * 1.001, price * 0.999)); - - // Keep only the required lookback periods - if self.price_history.len() > self.lookback_periods { - self.price_history.remove(0); - } - if self.volume_history.len() > self.lookback_periods { - self.volume_history.remove(0); - } - if self.high_low_history.len() > self.lookback_periods { - self.high_low_history.remove(0); - } - - // Calculate EMAs with exponential smoothing - // EMA_today = (Price_today * α) + (EMA_yesterday * (1 - α)) - // α = 2 / (period + 1) - - let alpha_9 = 2.0 / (9.0 + 1.0); // α = 0.2 - let alpha_21 = 2.0 / (21.0 + 1.0); // α ≈ 0.0909 - let alpha_50 = 2.0 / (50.0 + 1.0); // α ≈ 0.0392 - - // Update EMA-9 - self.ema_9 = Some(match self.ema_9 { - Some(prev_ema) => price * alpha_9 + prev_ema * (1.0 - alpha_9), - None => price, // Initialize with first price - }); - - // Update EMA-21 - self.ema_21 = Some(match self.ema_21 { - Some(prev_ema) => price * alpha_21 + prev_ema * (1.0 - alpha_21), - None => price, // Initialize with first price - }); - - // Update EMA-50 - self.ema_50 = Some(match self.ema_50 { - Some(prev_ema) => price * alpha_50 + prev_ema * (1.0 - alpha_50), - None => price, // Initialize with first price - }); - - let ema_9_val = self.ema_9.unwrap_or(price); - let ema_21_val = self.ema_21.unwrap_or(price); - let ema_50_val = self.ema_50.unwrap_or(price); - - // Extract technical features - let mut features = Vec::new(); - - if self.price_history.len() >= 2 { - // Price momentum (returns) - let current_price = self.price_history.last().copied().unwrap_or(0.0); - let prev_price = self - .price_history - .get(self.price_history.len() - 2) - .copied() - .unwrap_or(current_price); - let price_return = if prev_price != 0.0 { - (current_price - prev_price) / prev_price - } else { - 0.0 - }; - features.push(price_return); - - // Short-term moving average - if self.price_history.len() >= 5 { - let short_ma: f64 = self.price_history.iter().rev().take(5).sum::() / 5.0; - let ma_ratio = if short_ma != 0.0 { - current_price / short_ma - 1.0 - } else { - 0.0 - }; - features.push(ma_ratio); - } else { - features.push(0.0); - } - - // Price volatility (rolling standard deviation) - if self.price_history.len() >= 10 { - let recent_returns: Vec = self - .price_history - .windows(2) - .rev() - .take(9) - .filter_map(|w| w.get(1).and_then(|&w1| w.first().map(|&w0| (w1 - w0) / w0))) - .collect(); - - let mean_return = recent_returns.iter().sum::() / recent_returns.len() as f64; - let variance = recent_returns - .iter() - .map(|&r| (r - mean_return).powi(2)) - .sum::() - / recent_returns.len() as f64; - let volatility = variance.sqrt(); - features.push(volatility); - } else { - features.push(0.0); - } - } else { - features.extend_from_slice(&[0.0, 0.0, 0.0]); - } - - // Volume features - if self.volume_history.len() >= 2 { - let current_volume = self.volume_history.last().copied().unwrap_or(0.0); - let prev_volume = self - .volume_history - .get(self.volume_history.len() - 2) - .copied() - .unwrap_or(current_volume); - let volume_ratio = if prev_volume != 0.0 { - current_volume / prev_volume - 1.0 - } else { - 0.0 - }; - features.push(volume_ratio); - - // Volume moving average - if self.volume_history.len() >= 5 { - let volume_ma = self.volume_history.iter().rev().take(5).sum::() / 5.0; - let volume_ma_ratio = if volume_ma != 0.0 { - current_volume / volume_ma - 1.0 - } else { - 0.0 - }; - features.push(volume_ma_ratio); - } else { - features.push(0.0); - } - } else { - features.extend_from_slice(&[0.0, 0.0]); - } - - // Add time-based features - let hour = timestamp.hour() as f64 / 24.0; // Normalized hour - let day_of_week = timestamp.weekday().num_days_from_monday() as f64 / 6.0; // Normalized day - features.push(hour); - features.push(day_of_week); - - // Williams %R (14-period) - // Formula: (Highest High - Close) / (Highest High - Lowest Low) * -100 - // Range: -100 (oversold) to 0 (overbought) - if self.high_low_history.len() >= 14 && self.price_history.len() >= 14 { - let recent_high_lows: Vec<(f64, f64)> = self - .high_low_history - .iter() - .rev() - .take(14) - .copied() - .collect(); - let highest_high = recent_high_lows - .iter() - .map(|(h, _)| h) - .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); - let lowest_low = recent_high_lows - .iter() - .map(|(_, l)| l) - .fold(f64::INFINITY, |a, &b| a.min(b)); - - let current_price = self.price_history.last().copied().unwrap_or(0.0); - let williams_r = if highest_high != lowest_low { - ((highest_high - current_price) / (highest_high - lowest_low)) * -100.0 - } else { - -50.0 // Neutral value when range is zero - }; - - // Normalize to [-1, 1]: Williams %R is in range [-100, 0] - // Map -100 (oversold) to -1, 0 (overbought) to +1 - let normalized_williams_r = (williams_r + 50.0) / 50.0; // Maps [-100, 0] to [-1, 1] - features.push(normalized_williams_r.tanh()); - } else { - features.push(0.0); - } - - // ROC - Rate of Change (12-period) - // Formula: ((Current Price - Price n periods ago) / Price n periods ago) * 100 - // Measures momentum magnitude - if self.price_history.len() >= 13 { - // Need 13 prices for 12-period ROC - let current_price = self.price_history.last().copied().unwrap_or(0.0); - let price_12_periods_ago = self - .price_history - .get(self.price_history.len() - 13) - .copied() - .unwrap_or(current_price); - - let roc = if price_12_periods_ago != 0.0 { - ((current_price - price_12_periods_ago) / price_12_periods_ago) * 100.0 - } else { - 0.0 - }; - - // ROC can range widely, normalize with tanh - features.push((roc / 100.0).tanh()); // Divide by 100 to scale before tanh - } else { - features.push(0.0); - } - - // Ultimate Oscillator (7, 14, 28 periods) - // Multi-timeframe oscillator that reduces false signals - // Formula: Weighted average of 3 buying pressure ratios (BP/TR) - if self.price_history.len() >= 29 && self.high_low_history.len() >= 29 { - // Calculate buying pressure and true range for each period - let mut buying_pressures = Vec::new(); - let mut true_ranges = Vec::new(); - - for i in 1..self.price_history.len() { - let current_close = match self.price_history.get(i) { - Some(&price) => price, - None => continue, - }; - let prev_close = self.price_history.get(i - 1).copied().unwrap_or(current_close); - let (current_high, current_low) = match self.high_low_history.get(i) { - Some(&hl) => hl, - None => continue, - }; - - // Buying Pressure = Close - min(Low, Previous Close) - let bp = current_close - current_low.min(prev_close); - buying_pressures.push(bp); - - // True Range = max(High, Previous Close) - min(Low, Previous Close) - let tr = current_high.max(prev_close) - current_low.min(prev_close); - true_ranges.push(tr); - } - - // Calculate averages for 7, 14, 28 periods - let calculate_avg = |data: &[f64], periods: usize| -> f64 { - if data.len() >= periods { - let sum: f64 = data.iter().rev().take(periods).sum(); - sum / periods as f64 - } else { - 0.0 - } - }; - - let bp_7 = calculate_avg(&buying_pressures, 7); - let tr_7 = calculate_avg(&true_ranges, 7); - let avg_7 = if tr_7 != 0.0 { bp_7 / tr_7 } else { 0.0 }; - - let bp_14 = calculate_avg(&buying_pressures, 14); - let tr_14 = calculate_avg(&true_ranges, 14); - let avg_14 = if tr_14 != 0.0 { bp_14 / tr_14 } else { 0.0 }; - - let bp_28 = calculate_avg(&buying_pressures, 28); - let tr_28 = calculate_avg(&true_ranges, 28); - let avg_28 = if tr_28 != 0.0 { bp_28 / tr_28 } else { 0.0 }; - - // Ultimate Oscillator formula with weights 4, 2, 1 (sum to 7) - let ultimate_oscillator = - ((avg_7 * 4.0) + (avg_14 * 2.0) + (avg_28 * 1.0)) / 7.0 * 100.0; - - // Ultimate Oscillator typically ranges from 0 to 100 - // Normalize to [-1, 1]: map [0, 100] to [-1, 1] - let normalized_uo = (ultimate_oscillator - 50.0) / 50.0; - features.push(normalized_uo.tanh()); - } else { - features.push(0.0); - } - - // Volume-based technical indicators - - // 1. On-Balance Volume (OBV) - // OBV tracks cumulative volume flow: +volume on up days, -volume on down days - if self.price_history.len() >= 2 { - let current_price = self.price_history.last().copied().unwrap_or(0.0); - let prev_price = self - .price_history - .get(self.price_history.len() - 2) - .copied() - .unwrap_or(current_price); - let current_volume = self.volume_history.last().copied().unwrap_or(0.0); - - // Update OBV: add volume if price up, subtract if price down - if current_price > prev_price { - self.obv += current_volume; - } else if current_price < prev_price { - self.obv -= current_volume; - } - // If price unchanged, OBV unchanged - - // Normalize OBV using tanh (already handles large values well) - let obv_normalized = (self.obv / 1_000_000.0).tanh(); // Scale for typical volume ranges - features.push(obv_normalized); - } else { - features.push(0.0); - } - - // 2. Money Flow Index (MFI) - 14 period - // MFI is a momentum indicator using price and volume, ranges 0-100 - // MFI = 100 - (100 / (1 + Money Flow Ratio)) - // Money Flow Ratio = (14-period Positive Money Flow) / (14-period Negative Money Flow) - if self.price_history.len() >= 15 && self.volume_history.len() >= 15 { - let mut positive_mf = 0.0; - let mut negative_mf = 0.0; - - // Calculate money flow over last 14 periods - for i in 0..14 { - let idx = self.price_history.len() - 15 + i; // -15 to include previous period for comparison - if idx == 0 { - continue; - } - - let current_price = match self.price_history.get(idx) { - Some(&price) => price, - None => continue, - }; - let prev_price = match self.price_history.get(idx - 1) { - Some(&price) => price, - None => continue, - }; - let volume = match self.volume_history.get(idx) { - Some(&v) => v, - None => continue, - }; - - // Typical Price = (High + Low + Close) / 3 - // For OHLCV data we only have Close, so use Close as typical price - let typical_price = current_price; - let money_flow = typical_price * volume; - - if current_price > prev_price { - positive_mf += money_flow; - } else if current_price < prev_price { - negative_mf += money_flow; - } - } - - let mfi = if negative_mf > 0.0 { - let money_flow_ratio = positive_mf / negative_mf; - 100.0 - (100.0 / (1.0 + money_flow_ratio)) - } else if positive_mf > 0.0 { - 100.0 // All positive flow - } else { - 50.0 // No flow (neutral) - }; - - // Normalize MFI from [0, 100] to [-1, 1] - let mfi_normalized = ((mfi / 50.0) - 1.0).tanh(); - features.push(mfi_normalized); - } else { - features.push(0.0); - } - - // 3. VWAP (Volume-Weighted Average Price) - // VWAP = Cumulative(Price * Volume) / Cumulative(Volume) - if !self.price_history.is_empty() && !self.volume_history.is_empty() { - let current_price = self.price_history.last().copied().unwrap_or(0.0); - let current_volume = self.volume_history.last().copied().unwrap_or(0.0); - - // Update cumulative values - self.vwap_pv_sum += current_price * current_volume; - self.vwap_volume_sum += current_volume; - - let vwap = if self.vwap_volume_sum > 0.0 { - self.vwap_pv_sum / self.vwap_volume_sum - } else { - current_price - }; - - // VWAP as price ratio: (current_price - VWAP) / VWAP - let vwap_ratio = if vwap > 0.0 { - (current_price - vwap) / vwap - } else { - 0.0 - }; - - // Normalize using tanh - let vwap_normalized = vwap_ratio.tanh(); - features.push(vwap_normalized); - } else { - features.push(0.0); - } - - // Add EMA features (normalized to [-1, 1]) - // Normalize: (current_price / EMA - 1.0).tanh() - let ema_9_norm = if ema_9_val != 0.0 { - (price / ema_9_val - 1.0).tanh() - } else { - 0.0 - }; - let ema_21_norm = if ema_21_val != 0.0 { - (price / ema_21_val - 1.0).tanh() - } else { - 0.0 - }; - let ema_50_norm = if ema_50_val != 0.0 { - (price / ema_50_val - 1.0).tanh() - } else { - 0.0 - }; - - // EMA cross signals - let ema_9_21_cross = if ema_9_val > ema_21_val { 1.0 } else { -1.0 }; - let ema_21_50_cross = if ema_21_val > ema_50_val { 1.0 } else { -1.0 }; - - features.extend_from_slice(&[ - ema_9_norm, - ema_21_norm, - ema_50_norm, - ema_9_21_cross, - ema_21_50_cross, - ]); - - // ADX (Average Directional Index) - 14-period - // ADX measures trend strength (0-100), NOT direction - // Formula: - // 1. Calculate True Range (TR) = max(high - low, abs(high - prev_close), abs(low - prev_close)) - // 2. Calculate +DM = max(0, high - prev_high), -DM = max(0, prev_low - low) - // 3. Smooth TR, +DM, -DM using Wilder's smoothing (14-period EMA with α=1/14) - // 4. Calculate +DI = (+DM_smooth / TR_smooth) * 100, -DI = (-DM_smooth / TR_smooth) * 100 - // 5. Calculate DX = abs(+DI - -DI) / (+DI + -DI) * 100 - // 6. ADX = Wilder's smoothing of DX over 14 periods - // - // Incremental update: O(1) using exponential smoothing - if self.high_low_history.len() >= 2 && self.price_history.len() >= 2 { - let current_idx = self.high_low_history.len() - 1; - let prev_idx = current_idx - 1; - - let (current_high, current_low) = match self.high_low_history.get(current_idx) { - Some(&hl) => hl, - None => return features, // Safety: shouldn't happen after length check - }; - let (prev_high, prev_low) = match self.high_low_history.get(prev_idx) { - Some(&hl) => hl, - None => return features, - }; - let _current_close = match self.price_history.get(current_idx) { - Some(&price) => price, - None => return features, - }; - let prev_close = match self.price_history.get(prev_idx) { - Some(&price) => price, - None => return features, - }; - - // 1. Calculate True Range (TR) - let tr = (current_high - current_low) - .max((current_high - prev_close).abs()) - .max((current_low - prev_close).abs()); - - // 2. Calculate Directional Movement (+DM, -DM) - let high_move = current_high - prev_high; - let low_move = prev_low - current_low; - - let (plus_dm, minus_dm) = if high_move > low_move && high_move > 0.0 { - (high_move, 0.0) // Upward movement dominates - } else if low_move > high_move && low_move > 0.0 { - (0.0, low_move) // Downward movement dominates - } else { - (0.0, 0.0) // No clear directional movement - }; - - // 3. Smooth TR, +DM, -DM using Wilder's smoothing (α = 1/14) - // Wilder's smoothing: Smoothed_today = (Smoothed_yesterday * 13 + Value_today) / 14 - // This is equivalent to EMA with α = 1/14 - let alpha_wilder = 1.0 / 14.0; - - // Update ATR (smoothed TR) - self.atr = Some(match self.atr { - Some(prev_atr) => prev_atr * (1.0 - alpha_wilder) + tr * alpha_wilder, - None => tr, // Initialize with first TR - }); - - // Smooth +DM using Wilder's smoothing - self.plus_dm_smooth = Some(match self.plus_dm_smooth { - Some(prev_smooth) => prev_smooth * (1.0 - alpha_wilder) + plus_dm * alpha_wilder, - None => plus_dm, // Initialize with first +DM - }); - - // Smooth -DM using Wilder's smoothing - self.minus_dm_smooth = Some(match self.minus_dm_smooth { - Some(prev_smooth) => prev_smooth * (1.0 - alpha_wilder) + minus_dm * alpha_wilder, - None => minus_dm, // Initialize with first -DM - }); - - let plus_dm_smooth_val = self.plus_dm_smooth.unwrap_or(0.0); - let minus_dm_smooth_val = self.minus_dm_smooth.unwrap_or(0.0); - - // 4. Calculate +DI and -DI - let atr_val = self.atr.unwrap_or(1.0); - let plus_di_val = if atr_val > 0.0 { - (plus_dm_smooth_val / atr_val) * 100.0 - } else { - 0.0 - }; - let minus_di_val = if atr_val > 0.0 { - (minus_dm_smooth_val / atr_val) * 100.0 - } else { - 0.0 - }; - - // Update +DI and -DI state - self.plus_di = Some(plus_di_val); - self.minus_di = Some(minus_di_val); - - // 5. Calculate DX (Directional Index) - let di_sum = plus_di_val + minus_di_val; - let dx = if di_sum > 0.0 { - ((plus_di_val - minus_di_val).abs() / di_sum) * 100.0 - } else { - 0.0 - }; - - // 6. Calculate ADX (smoothed DX using Wilder's smoothing) - self.adx = Some(match self.adx { - Some(prev_adx) => prev_adx * (1.0 - alpha_wilder) + dx * alpha_wilder, - None => dx, // Initialize with first DX - }); - - // Normalize ADX from [0, 100] to [0, 1] - let adx_normalized = self.adx.unwrap_or(0.0) / 100.0; - features.push(adx_normalized.clamp(0.0, 1.0)); - } else { - // Not enough data for ADX calculation - features.push(0.0); - } - - // Bollinger Bands Position (20-period, 2σ) - // Formula: (price - middle) / (upper - lower) - // where: - // middle = SMA(20) - // upper = middle + 2*std - // lower = middle - 2*std - // Range: naturally in [-1, 1] when price is within bands - // can exceed when price is outside bands (normalized with clamp) - // Position interpretation: - // +1.0: at or above upper band (overbought) - // 0.0: at middle band (neutral) - // -1.0: at or below lower band (oversold) - if self.price_history.len() >= 20 { - // Calculate SMA(20) - let recent_20_prices: Vec = - self.price_history.iter().rev().take(20).copied().collect(); - - let middle = recent_20_prices.iter().sum::() / 20.0; - - // Calculate standard deviation (20-period) - let variance = recent_20_prices - .iter() - .map(|&p| (p - middle).powi(2)) - .sum::() - / 20.0; - let std_dev = variance.sqrt(); - - // Calculate Bollinger Bands - let upper = middle + 2.0 * std_dev; - let lower = middle - 2.0 * std_dev; - - // Calculate Bollinger Bands Position - let current_price = self.price_history.last().copied().unwrap_or(middle); - - let bb_position = if upper != lower { - // Normal case: bands have width - (current_price - middle) / (upper - lower) - } else { - // Edge case: zero volatility (upper == lower) - // Return 0.0 (neutral position at middle band) - 0.0 - }; - - // Normalize to [-1, 1] range using clamp - // This handles cases where price is significantly outside bands - features.push(bb_position.clamp(-1.0, 1.0)); - } else { - // Insufficient history for Bollinger Bands (need 20 periods) - features.push(0.0); - } - - // Stochastic Oscillator (%K and %D) - 14-period - // %K measures where current price is relative to 14-period high/low range - // %D is 3-period SMA of %K (signal line) - // Formula: - // %K = (Close - Low14) / (High14 - Low14) * 100 - // %D = SMA(%K, 3) - // - // Incremental update: O(1) using sliding window for high/low extremes - if self.high_low_history.len() >= 14 && self.price_history.len() >= 14 { - // Get last 14 periods for high/low calculation - let recent_high_lows: Vec<(f64, f64)> = self - .high_low_history - .iter() - .rev() - .take(14) - .copied() - .collect(); - - // Find highest high and lowest low in 14-period window - let highest_high = recent_high_lows - .iter() - .map(|(h, _)| h) - .fold(f64::NEG_INFINITY, |a, &b| a.max(b)); - let lowest_low = recent_high_lows - .iter() - .map(|(_, l)| l) - .fold(f64::INFINITY, |a, &b| a.min(b)); - - let current_close = self.price_history.last().copied().unwrap_or(0.0); - - // Calculate %K - let stoch_k = if highest_high != lowest_low { - ((current_close - lowest_low) / (highest_high - lowest_low)) * 100.0 - } else { - // Edge case: no range (flat prices) - // Return 50.0 (middle of range) to avoid division by zero - 50.0 - }; - - // Normalize %K from [0, 100] to [0, 1] - let stoch_k_normalized = (stoch_k / 100.0).clamp(0.0, 1.0); - - // Store %K value for %D calculation (3-period SMA) - self.stoch_k_history.push(stoch_k_normalized); - if self.stoch_k_history.len() > 3 { - self.stoch_k_history.remove(0); - } - - // Calculate %D (3-period SMA of %K) - let stoch_d = if self.stoch_k_history.len() >= 3 { - let sum: f64 = self.stoch_k_history.iter().sum(); - sum / self.stoch_k_history.len() as f64 - } else { - // Insufficient history for %D, return %K as approximation - stoch_k_normalized - }; - - features.push(stoch_k_normalized); - features.push(stoch_d.clamp(0.0, 1.0)); - } else { - // Insufficient data for Stochastic calculation - // Return neutral values (0.5 = middle of range) - features.push(0.5); - features.push(0.5); - } - - // CCI (Commodity Channel Index) - 20-period momentum oscillator - // Formula: CCI = (Typical Price - SMA20) / (0.015 * Mean Absolute Deviation) - // Typical Price = (High + Low + Close) / 3 - // Mean Absolute Deviation = avg(abs(TP - SMA20)) over 20 periods - // - // CCI interpretation: - // > +100: Overbought (price above normal deviation range) - // < -100: Oversold (price below normal deviation range) - // [-100, +100]: Normal range - // - // Normalization: (CCI / 200).tanh() → [-1, 1] range - // This preserves sign while capping extreme values - if self.price_history.len() >= 20 && self.high_low_history.len() >= 20 { - // Calculate Typical Price for last 20 periods - let mut typical_prices: Vec = Vec::with_capacity(20); - - for i in 0..20 { - let idx = self.price_history.len().saturating_sub(20).saturating_add(i); - let close = match self.price_history.get(idx) { - Some(&price) => price, - None => continue, - }; - let (high, low) = match self.high_low_history.get(idx) { - Some(&hl) => hl, - None => continue, - }; - let typical_price = (high + low + close) / 3.0; - typical_prices.push(typical_price); - } - - // Calculate SMA of Typical Price (20-period) - let tp_sma: f64 = typical_prices.iter().sum::() / 20.0; - - // Calculate Mean Absolute Deviation - let mad: f64 = typical_prices - .iter() - .map(|&tp| (tp - tp_sma).abs()) - .sum::() - / 20.0; - - // Get current typical price - let current_close = self.price_history.last().copied().unwrap_or(0.0); - let (current_high, current_low) = self - .high_low_history - .last() - .copied() - .unwrap_or((current_close, current_close)); - let current_tp = (current_high + current_low + current_close) / 3.0; - - // Calculate CCI - let cci = if mad > 0.0 { - // Standard CCI formula - (current_tp - tp_sma) / (0.015 * mad) - } else { - // Edge case: zero mean deviation (all prices identical) - // Return 0.0 (neutral value) - 0.0 - }; - - // Normalize CCI using tanh - // Divide by 200 to scale: ±100 → ±0.5, ±200 → ±1.0 - // tanh provides smooth sigmoid-like normalization - let cci_normalized = (cci / 200.0).tanh(); - - features.push(cci_normalized); - } else { - // Insufficient data for CCI calculation (need 20 periods) - // Return 0.0 (neutral value) - features.push(0.0); - } - - // RSI (Relative Strength Index) - 14-period momentum oscillator - // Formula: RSI = 100 - (100 / (1 + RS)), where RS = avg_gain / avg_loss - // Uses Wilder's smoothing for exponential moving average - if self.price_history.len() >= 2 { - let current_close = self.price_history.last().copied().unwrap_or(0.0); - let prev_close = self.price_history.get(self.price_history.len() - 2).copied().unwrap_or(current_close); - - // Calculate price change - let change = current_close - prev_close; - let gain = if change > 0.0 { change } else { 0.0 }; - let loss = if change < 0.0 { -change } else { 0.0 }; - - // Update RSI exponential moving averages using Wilder's smoothing - // First 14 periods: simple average, then EMA with alpha = 1/14 - match (self.rsi_avg_gain, self.rsi_avg_loss) { - (Some(prev_gain), Some(prev_loss)) => { - // Wilder's smoothing: new_avg = (prev_avg * 13 + current_value) / 14 - self.rsi_avg_gain = Some((prev_gain * 13.0 + gain) / 14.0); - self.rsi_avg_loss = Some((prev_loss * 13.0 + loss) / 14.0); - }, - _ => { - // Initialize with first values (insufficient history for EMA) - self.rsi_avg_gain = Some(gain); - self.rsi_avg_loss = Some(loss); - }, - } - - // Calculate RSI - let rsi = - if let (Some(avg_gain), Some(avg_loss)) = (self.rsi_avg_gain, self.rsi_avg_loss) { - if avg_loss > 0.0 { - // Standard RSI formula - let rs = avg_gain / avg_loss; - 100.0 - (100.0 / (1.0 + rs)) - } else if avg_gain > 0.0 { - // Only gains (no losses) -> RSI = 100 (overbought extreme) - 100.0 - } else { - // No gains and no losses -> RSI = 50 (neutral) - 50.0 - } - } else { - // Insufficient data -> default to neutral - 50.0 - }; - - // Normalize RSI from [0, 100] to [0, 1] - features.push((rsi / 100.0).clamp(0.0, 1.0)); - } else { - // No previous close price -> default to neutral (0.5) - features.push(0.5); - } - - // MACD (Moving Average Convergence Divergence) - Agent A2 - // Formula: - // MACD Line = EMA(12) - EMA(26) - // Signal Line = EMA(9) of MACD Line - // Normalization: (MACD / price).tanh() to get [-1, 1] range - let alpha_12 = 2.0 / (12.0 + 1.0); // α = 0.1538 - let alpha_26 = 2.0 / (26.0 + 1.0); // α = 0.0741 - let alpha_9 = 2.0 / (9.0 + 1.0); // α = 0.2 - - // Update EMA-12 for MACD - self.macd_ema_12 = Some(match self.macd_ema_12 { - Some(prev_ema) => price * alpha_12 + prev_ema * (1.0 - alpha_12), - None => price, - }); - - // Update EMA-26 for MACD - self.macd_ema_26 = Some(match self.macd_ema_26 { - Some(prev_ema) => price * alpha_26 + prev_ema * (1.0 - alpha_26), - None => price, - }); - - let ema_12 = self.macd_ema_12.unwrap_or(price); - let ema_26 = self.macd_ema_26.unwrap_or(price); - let macd_line = ema_12 - ema_26; - - // Update MACD Signal (EMA-9 of MACD line) - self.macd_signal = Some(match self.macd_signal { - Some(prev_signal) => macd_line * alpha_9 + prev_signal * (1.0 - alpha_9), - None => macd_line, - }); - - let macd_signal_val = self.macd_signal.unwrap_or(macd_line); - - // Normalize to [-1, 1] range - let macd_normalized = if price != 0.0 { - (macd_line / price).tanh() - } else { - 0.0 - }; - - let macd_signal_normalized = if price != 0.0 { - (macd_signal_val / price).tanh() - } else { - 0.0 - }; - - features.push(macd_normalized); - features.push(macd_signal_normalized); - - // ======================================== - // WAVE C: Additional Volume & EMA Indicators (4 features) - // ======================================== - - // 1. OBV Momentum (10-period ROC) - // OBV basic accumulation already exists (lines 395-412) - // Now add momentum feature: (OBV_current - OBV_10_ago) / abs(OBV_10_ago) - self.obv_history.push(self.obv); - if self.obv_history.len() > 10 { - self.obv_history.remove(0); - } - - let obv_momentum = if self.obv_history.len() >= 10 { - let obv_10_ago = self.obv_history.first().copied().unwrap_or(self.obv); - if obv_10_ago.abs() > 0.0 { - ((self.obv - obv_10_ago) / obv_10_ago.abs()).tanh() - } else { - 0.0 - } - } else { - 0.0 - }; - features.push(obv_momentum); - - // 2. Volume Oscillator: (vol_ma_fast - vol_ma_slow) / vol_ma_slow - // Fast MA: 5-period, Slow MA: 20-period - let alpha_vol_fast = 2.0 / (5.0 + 1.0); // α = 0.333 - let alpha_vol_slow = 2.0 / (20.0 + 1.0); // α = 0.095 - - let current_volume = self.volume_history.last().copied().unwrap_or(0.0); - - // Update volume MAs - self.volume_ma_fast = Some(match self.volume_ma_fast { - Some(prev_ma) => current_volume * alpha_vol_fast + prev_ma * (1.0 - alpha_vol_fast), - None => current_volume, - }); - - self.volume_ma_slow = Some(match self.volume_ma_slow { - Some(prev_ma) => current_volume * alpha_vol_slow + prev_ma * (1.0 - alpha_vol_slow), - None => current_volume, - }); - - let vol_ma_fast_val = self.volume_ma_fast.unwrap_or(current_volume); - let vol_ma_slow_val = self.volume_ma_slow.unwrap_or(current_volume); - - let volume_oscillator = if vol_ma_slow_val > 0.0 { - ((vol_ma_fast_val - vol_ma_slow_val) / vol_ma_slow_val).tanh() - } else { - 0.0 - }; - features.push(volume_oscillator); - - // 3. A/D Line (Accumulation/Distribution Line) - // Formula: A/D = Σ [((Close - Low) - (High - Close)) / (High - Low) * Volume] - // Measures money flow: positive when close near high (accumulation) - if !self.high_low_history.is_empty() && !self.price_history.is_empty() { - let current_close = self.price_history.last().copied().unwrap_or(0.0); - let (current_high, current_low) = self - .high_low_history - .last() - .copied() - .unwrap_or((current_close, current_close)); - let volume_val = self.volume_history.last().copied().unwrap_or(0.0); - - let money_flow_multiplier = if current_high != current_low { - ((current_close - current_low) - (current_high - current_close)) - / (current_high - current_low) - } else { - 0.0 // No range, neutral - }; - - let money_flow_volume = money_flow_multiplier * volume_val; - self.ad_line += money_flow_volume; - - // Normalize A/D Line with tanh - let ad_normalized = (self.ad_line / 1_000_000.0).tanh(); - features.push(ad_normalized); - } else { - features.push(0.0); - } - - // 4. EMA Ratio: EMA(10) / EMA(50) - trend strength indicator - // Update EMA-10 - let alpha_10 = 2.0 / (10.0 + 1.0); // α = 0.1818 - self.ema_10 = Some(match self.ema_10 { - Some(prev_ema) => price * alpha_10 + prev_ema * (1.0 - alpha_10), - None => price, - }); - - let ema_10_val = self.ema_10.unwrap_or(price); - - let ema_ratio = if ema_50_val > 0.0 { - ((ema_10_val / ema_50_val) - 1.0).tanh() - } else { - 0.0 - }; - features.push(ema_ratio); - - // ======================================== - // Total Features: 26 (Wave A) + 4 (Wave C) = 30 features - // ======================================== - // Wave A (26): - // 0-6: Original 7 features - // 7-9: Oscillators (Williams %R, ROC, Ultimate Oscillator) - // 10-12: Volume indicators (OBV, MFI, VWAP) - // 13-17: EMA features - // 18: ADX - // 19: Bollinger Bands Position - // 20-21: Stochastic %K/%D - // 22: CCI - // 23: RSI - // 24-25: MACD + Signal - // - // Wave C (4): - // 26: OBV Momentum (10-period ROC) - // 27: Volume Oscillator (5/20-period) - // 28: A/D Line (Accumulation/Distribution) - // 29: EMA Ratio (EMA-10 / EMA-50) - - // All features are already normalized in their respective calculations - // No additional normalization needed (fixes double-tanh bug from Wave A) - - // ======================================== - // Wave D: Expand to 225 features if configured - // ======================================== - if self.expected_feature_count == 225 { - // Current features: 30 - // Need to add: 195 more features (30 → 225) - - // Update technical indicators from common::features and add their outputs - // These replace/supplement the manual implementations above - - let high = price * 1.001; // Simulated high - let low = price * 0.999; // Simulated low - - // Features 30-35: Advanced RSI metrics (6 features) - let rsi_value = self.rsi_calculator.update(price); - features.push(rsi_value / 100.0); // Normalize to [0, 1] - features.push((rsi_value / 100.0 - 0.5) * 2.0); // Center around 0 - features.push(if rsi_value > 70.0 { 1.0 } else { 0.0 }); // Overbought flag - features.push(if rsi_value < 30.0 { 1.0 } else { 0.0 }); // Oversold flag - features.push((rsi_value - 50.0).abs() / 50.0); // Distance from neutral - features.push((rsi_value / 100.0).powi(2)); // RSI squared (momentum emphasis) - - // Features 36-41: EMA-based features (6 features) - let ema_fast = self.ema_fast_calculator.update(price); - let ema_slow = self.ema_slow_calculator.update(price); - features.push((price / ema_fast - 1.0).tanh()); // Price vs EMA fast - features.push((price / ema_slow - 1.0).tanh()); // Price vs EMA slow - features.push((ema_fast / ema_slow - 1.0).tanh()); // EMA crossover - features.push(if ema_fast > ema_slow { 1.0 } else { 0.0 }); // Bull/bear flag - features.push(((ema_fast - ema_slow) / ema_slow).abs()); // EMA divergence magnitude - features.push((ema_fast - ema_slow).signum()); // EMA trend direction - - // Features 42-47: MACD metrics (6 features) - let (macd_line, macd_signal, macd_histogram) = self.macd_calculator.update(price); - features.push(macd_line.tanh()); // MACD line (normalized) - features.push(macd_signal.tanh()); // MACD signal (normalized) - features.push(macd_histogram.tanh()); // MACD histogram (normalized) - features.push(if macd_line > macd_signal { 1.0 } else { 0.0 }); // Bull/bear signal - features.push((macd_histogram.abs() / (macd_line.abs() + 1e-8)).tanh()); // Histogram strength - features.push(macd_histogram.signum()); // Histogram direction - - // Features 48-53: Bollinger Bands metrics (6 features) - let (bb_upper, bb_middle, bb_lower) = self.bollinger_calculator.update(price); - let bb_width = bb_upper - bb_lower; - let bb_position = if bb_width > 0.0 { - (price - bb_lower) / bb_width - } else { - 0.5 - }; - features.push(bb_position.clamp(0.0, 1.0)); // Position in band [0, 1] - features.push((price / bb_middle - 1.0).tanh()); // Price vs middle band - features.push((bb_width / bb_middle).tanh()); // Band width (volatility) - features.push(if price > bb_upper { 1.0 } else { 0.0 }); // Above upper band - features.push(if price < bb_lower { 1.0 } else { 0.0 }); // Below lower band - features.push(((bb_upper - bb_lower) / bb_middle * 100.0).tanh()); // %B indicator - - // Features 54-59: ATR metrics (6 features) - let atr_value = self.atr_calculator.update(high, low, price); - features.push((atr_value / price).tanh()); // ATR as % of price - features.push((atr_value / price * 100.0).min(10.0) / 10.0); // ATR% clamped [0, 1] - features.push(if atr_value > 0.0 { 1.0 } else { 0.0 }); // ATR active flag - features.push((atr_value / (price * 0.01)).tanh()); // ATR in tick units - features.push((atr_value.ln() + 5.0) / 10.0); // Log ATR (normalized) - features.push((atr_value / (price + atr_value)).clamp(0.0, 1.0)); // ATR ratio - - // Features 60-65: ADX metrics (6 features) - let (adx_value, plus_di, minus_di) = self.adx_calculator.update(high, low, price); - features.push(adx_value / 100.0); // ADX [0, 1] - features.push(plus_di / 100.0); // +DI [0, 1] - features.push(minus_di / 100.0); // -DI [0, 1] - features.push((plus_di - minus_di).abs() / 100.0); // DI spread - features.push(if plus_di > minus_di { 1.0 } else { 0.0 }); // Bullish DI - features.push((adx_value / 100.0 * (plus_di - minus_di).signum()).tanh()); // Directional strength - - // Features 66-224: Placeholder for Wave C advanced features (159 features) - // TODO: Implement full 225-feature extraction - // These features require complex logic from the ml crate: - // - Fractional differentiation features (162 features, indices 39-200) - // - Wave D regime detection features (24 features, indices 201-224): - // * CUSUM statistics (10 features, 201-210) - // * ADX & Directional (5 features, 211-215) - // * Transition probabilities (5 features, 216-220) - // * Adaptive strategy metrics (4 features, 221-224) - // - // For production use with 225 features, use ml::features::extraction::extract_ml_features() - // which has the full implementation without circular dependencies. - // - // Current implementation provides 66 features (30 original + 36 new technical indicators) - // Padding remaining 159 features with zeros for dimensional compatibility. - - features.resize(225, 0.0); - } - - features - } -} - /// Trait for ML model adapters pub trait MLModelAdapter: Send + Sync { /// Get model prediction @@ -1305,251 +85,12 @@ pub trait MLModelAdapter: Send + Sync { fn validate_prediction(&mut self, prediction: &MLPrediction, actual_outcome: bool); } -/// Simple DQN model adapter (for backtesting/simulation) -#[derive(Debug)] -pub struct SimpleDQNAdapter { - model_id: String, - weights: Vec, - expected_feature_count: usize, - predictions_made: u64, - correct_predictions: u64, -} - -impl SimpleDQNAdapter { - /// Create new DQN adapter with 30 features (Wave A + 4 Wave C indicators) - /// - /// # Errors - /// Returns `CommonError::Validation` if weight construction fails. - pub fn new(model_id: String) -> std::result::Result { - Self::with_feature_count(model_id, 30) - } - - /// Create DQN adapter with specific feature count - /// - /// Supported feature counts: - /// - 26: Wave A baseline - /// - 30: Wave A + 4 Wave C indicators (default) - /// - 36: Wave B (alternative bars) - /// - 65: Wave C (advanced features) - /// - 225: Wave D (201 Wave C + 24 Wave D regime features) - /// - /// # Errors - /// Returns `CommonError::Validation` if `feature_count` is not one of the supported values. - pub fn with_feature_count(model_id: String, feature_count: usize) -> std::result::Result { - let weights = match feature_count { - 26 => { - // Wave A: 26 features (baseline technical indicators) - // Feature breakdown: - // 0-6: Original 7 features (price_return, short_ma, volatility, volume_ratio, volume_ma_ratio, hour, day_of_week) - // 7-9: Oscillators (williams_r, roc, ultimate_oscillator) - // 10-12: Volume indicators (obv, mfi, vwap) - // 13-17: EMA features (ema_9_norm, ema_21_norm, ema_50_norm, ema_9_21_cross, ema_21_50_cross) - // 18: ADX (trend strength) - // 19: Bollinger Bands Position (volatility/mean reversion) - // 20: Stochastic %K (momentum oscillator) - // 21: Stochastic %D (signal line) - // 22: CCI (commodity momentum) - // 23: RSI (relative strength) - // 24: MACD (trend convergence) - // 25: MACD Signal (signal line) - vec![ - // Original 7 features (indices 0-6) - 0.1, -0.05, 0.2, 0.15, -0.1, 0.08, 0.03, // Oscillators (indices 7-9) - 0.12, 0.09, 0.11, // Williams %R, ROC, Ultimate Oscillator - // Volume indicators (indices 10-12) - 0.07, 0.06, 0.05, // OBV, MFI, VWAP - // EMA features (indices 13-17) - 0.13, 0.14, 0.10, 0.18, -0.15, // EMA norms + crosses - // Wave A indicators (indices 18-25) - 0.11, // ADX (18) - trend strength indicator - 0.16, // Bollinger Bands Position (19) - volatility/mean reversion - -0.14, // Stochastic %K (20) - momentum (contrarian signal) - 0.08, // Stochastic %D (21) - signal line confirmation - 0.09, // CCI (22) - commodity momentum indicator - 0.12, // RSI (23) - relative strength - 0.10, // MACD (24) - trend following indicator - 0.07, // MACD Signal (25) - signal line confirmation - ] - }, - 30 => { - // Wave A + 4 Wave C indicators (default configuration) - // Indices 0-25: Wave A features (26 total) - // Indices 26-29: Wave C features (4 total: OBV momentum, Volume oscillator, A/D Line, EMA ratio) - vec![ - // Original 7 features (indices 0-6) - 0.1, -0.05, 0.2, 0.15, -0.1, 0.08, 0.03, // Oscillators (indices 7-9) - 0.12, 0.09, 0.11, // Williams %R, ROC, Ultimate Oscillator - // Volume indicators (indices 10-12) - 0.07, 0.06, 0.05, // OBV, MFI, VWAP - // EMA features (indices 13-17) - 0.13, 0.14, 0.10, 0.18, -0.15, // EMA norms + crosses - // Wave A indicators (indices 18-25) - 0.11, // ADX (18) - trend strength indicator - 0.16, // Bollinger Bands Position (19) - volatility/mean reversion - -0.14, // Stochastic %K (20) - momentum (contrarian signal) - 0.08, // Stochastic %D (21) - signal line confirmation - 0.09, // CCI (22) - commodity momentum indicator - 0.12, // RSI (23) - relative strength - 0.10, // MACD (24) - trend following indicator - 0.07, // MACD Signal (25) - signal line confirmation - // Wave C indicators (indices 26-29) - 0.13, // OBV Momentum (26) - volume flow momentum - 0.11, // Volume Oscillator (27) - volume trend strength - 0.09, // A/D Line (28) - accumulation/distribution - 0.15, // EMA Ratio (29) - multi-timeframe trend strength - ] - }, - 36 => { - // Wave B: 36 features (Wave A + alternative bars) - // Use uniform weights for alternative bar features (indices 26-35) - let mut w = vec![ - // Original 7 features (indices 0-6) - 0.1, -0.05, 0.2, 0.15, -0.1, 0.08, 0.03, // Oscillators (indices 7-9) - 0.12, 0.09, 0.11, // Volume indicators (indices 10-12) - 0.07, 0.06, 0.05, // EMA features (indices 13-17) - 0.13, 0.14, 0.10, 0.18, -0.15, // Wave A indicators (indices 18-25) - 0.11, 0.16, -0.14, 0.08, 0.09, 0.12, 0.10, 0.07, - ]; - // Add 10 alternative bar features with uniform weights - let uniform_weight = 1.0 / 36.0; - w.extend(vec![uniform_weight; 10]); - w - }, - 65 => { - // Wave C: 65+ features (advanced features) - // Use uniform weights for all features - vec![1.0 / 65.0; 65] - }, - 225 => { - // Wave D: 225 features (201 Wave C + 24 Wave D regime features) - // Use uniform weights for all features - vec![1.0 / 225.0; 225] - }, - _ => return Err(CommonError::validation(format!( - "Unsupported feature count: {}. Supported: 26, 30, 36, 65, 225", - feature_count - ))), - }; - - if weights.len() != feature_count { - return Err(CommonError::validation(format!( - "SimpleDQNAdapter weight count {} must match feature_count {}", - weights.len(), - feature_count - ))); - } - - Ok(Self { - model_id, - weights, - expected_feature_count: feature_count, - predictions_made: 0, - correct_predictions: 0, - }) - } - - /// Wave A configuration: 26 features (baseline technical indicators) - /// - /// # Errors - /// Returns `CommonError::Validation` if weight construction fails. - pub fn new_wave_a(model_id: String) -> std::result::Result { - Self::with_feature_count(model_id, 26) - } - - /// Wave A+ configuration: 30 features (Wave A + 4 Wave C indicators) - /// - /// # Errors - /// Returns `CommonError::Validation` if weight construction fails. - pub fn new_wave_a_plus(model_id: String) -> std::result::Result { - Self::with_feature_count(model_id, 30) - } - - /// Wave B configuration: 36 features (Wave A + alternative bars) - /// - /// # Errors - /// Returns `CommonError::Validation` if weight construction fails. - pub fn new_wave_b(model_id: String) -> std::result::Result { - Self::with_feature_count(model_id, 36) - } - - /// Wave C configuration: 65+ features (advanced features) - /// - /// # Errors - /// Returns `CommonError::Validation` if weight construction fails. - pub fn new_wave_c(model_id: String) -> std::result::Result { - Self::with_feature_count(model_id, 65) - } - - /// Wave D configuration: 225 features (201 Wave C + 24 Wave D regime features) - /// - /// # Errors - /// Returns `CommonError::Validation` if weight construction fails. - pub fn new_wave_d(model_id: String) -> std::result::Result { - Self::with_feature_count(model_id, 225) - } - - /// Get expected feature count for this adapter - pub fn expected_feature_count(&self) -> usize { - self.expected_feature_count - } -} - -impl MLModelAdapter for SimpleDQNAdapter { - fn predict(&self, features: &[f64]) -> Result { - // Dynamic feature validation using expected_feature_count - if features.len() != self.expected_feature_count { - return Err(anyhow::anyhow!( - "Feature dimension mismatch: got {}, expected {}", - features.len(), - self.expected_feature_count - )); - } - - // Simple linear combination with sigmoid activation - let linear_output: f64 = features - .iter() - .zip(self.weights.iter()) - .map(|(f, w)| f * w) - .sum(); - - let prediction_value = 1.0 / (1.0 + (-linear_output).exp()); // Sigmoid activation - - // Calculate confidence based on distance from 0.5 - let confidence = 0.5 + (prediction_value - 0.5).abs() * 0.8; - - Ok(MLPrediction { - model_id: self.model_id.clone(), - prediction_value, - confidence, - features: features.to_vec(), - timestamp: Utc::now(), - inference_latency_us: 50, // Simulated latency - }) - } - - fn model_id(&self) -> &str { - &self.model_id - } - - fn validate_prediction(&mut self, prediction: &MLPrediction, actual_outcome: bool) { - self.predictions_made += 1; - - // Simple validation: if prediction > 0.5 and outcome is positive, it's correct - let predicted_positive = prediction.prediction_value > 0.5; - if predicted_positive == actual_outcome { - self.correct_predictions += 1; - } - } -} - /// Shared ML strategy implementation (ONE SINGLE SYSTEM) pub struct SharedMLStrategy { /// Available ML models models: Arc>>>, - /// Feature extractor - WAVE 10: Pluggable 225-feature extractor (inject from ml crate) + /// Feature extractor - pluggable 225-feature extractor (inject from ml crate) feature_extractor_225: Option>>>, - /// Fallback legacy feature extractor (66 features + 159 zeros) - DEPRECATED - legacy_feature_extractor: Option>>, /// Model performance tracking model_performance: Arc>>, /// Minimum confidence threshold @@ -1566,107 +107,49 @@ impl std::fmt::Debug for SharedMLStrategy { } impl SharedMLStrategy { - /// Create new shared ML strategy with LEGACY feature extraction (66 features + 159 zeros) - /// - /// # DEPRECATED: Use `new_with_production_extractor()` instead - /// - /// This constructor creates a strategy with the legacy 66-feature extractor that pads - /// with 159 zeros. This is ONLY for backward compatibility. Production code should use - /// `new_with_production_extractor()` and inject the ml::features::extraction extractor. - /// # Errors - /// Returns `CommonError::Validation` if model adapter construction fails. - pub fn new(lookback_periods: usize, min_confidence_threshold: f64) -> std::result::Result { - let mut models: HashMap> = HashMap::new(); - - // Add default Wave D models (225 features) - models.insert( - "dqn_v1".to_string(), - Box::new(SimpleDQNAdapter::new_wave_d("dqn_v1".to_string())?), - ); - - Ok(Self { - models: Arc::new(RwLock::new(models)), - feature_extractor_225: None, - legacy_feature_extractor: Some(Arc::new(RwLock::new( - MLFeatureExtractor::new_wave_d(lookback_periods), - ))), - model_performance: Arc::new(RwLock::new(HashMap::new())), - min_confidence_threshold, - }) - } - - /// Create new shared ML strategy with production-grade 225-feature extraction - /// - /// # WAVE 10 FIX: Now uses ml::features::extraction for all 225 features - /// - No more 66 features + 159 zeros padding - /// - Wave D features (201-224) are fully operational - /// - Training-production feature parity achieved + /// Create strategy with injected models and production feature extractor. /// /// # Arguments /// - `extractor`: Production-grade 225-feature extractor (inject from ml crate) + /// - `models`: Pre-built model adapters for ensemble prediction /// - `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( + /// If `models` is empty, predictions will return an empty vec (graceful degradation). + pub fn new( extractor: Box, + models: Vec>, min_confidence_threshold: f64, - ) -> std::result::Result { - let mut models: HashMap> = HashMap::new(); + ) -> Self { + let model_map: HashMap> = models + .into_iter() + .map(|m| (m.model_id().to_string(), m)) + .collect(); - // 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)), + Self { + models: Arc::new(RwLock::new(model_map)), feature_extractor_225: Some(Arc::new(RwLock::new(extractor))), - legacy_feature_extractor: None, model_performance: Arc::new(RwLock::new(HashMap::new())), min_confidence_threshold, - }) + } } /// Get ensemble prediction from all models - /// - /// # WAVE 10 FIX: Uses production extractor (225 features) if available, otherwise legacy (66+159 zeros) pub async fn get_ensemble_prediction( &self, price: f64, volume: f64, timestamp: DateTime, ) -> Result> { - // Extract features using production extractor (225 features) or legacy fallback - let features: Vec = if let Some(ref prod_extractor) = self.feature_extractor_225 { - // WAVE 10 PRODUCTION PATH: Use injected 225-feature extractor from ml crate - let mut extractor = prod_extractor.write().await; - extractor.update(price, volume, timestamp)?; - extractor.extract_features()? - } else if let Some(ref legacy_extractor) = self.legacy_feature_extractor { - // LEGACY FALLBACK: 66 features + 159 zeros (DEPRECATED) - tracing::warn!( - "Using DEPRECATED legacy feature extractor (66 features + 159 zeros). \ - Wave D features (201-224) will be ALL ZEROS. \ - Use SharedMLStrategy::new_with_production_extractor() for production." - ); - let mut extractor = legacy_extractor.write().await; - extractor.extract_features(price, volume, timestamp) - } else { - anyhow::bail!("No feature extractor configured (neither production nor legacy)") + // Extract features using production 225-feature extractor + let features: Vec = match self.feature_extractor_225 { + Some(ref prod_extractor) => { + let mut extractor = prod_extractor.write().await; + extractor.update(price, volume, timestamp)?; + extractor.extract_features()? + } + None => { + anyhow::bail!("No feature extractor configured") + } }; // Validate feature count @@ -1688,10 +171,10 @@ impl SharedMLStrategy { if prediction.confidence >= self.min_confidence_threshold { predictions.push(prediction); } - }, + } Err(e) => { tracing::warn!("Model {} failed to predict: {}", model_id, e); - }, + } } } @@ -1789,31 +272,84 @@ impl SharedMLStrategy { 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(()) + /// Minimal mock adapter for unit tests + struct MockAdapter { + id: String, + } + + impl MockAdapter { + fn new(id: &str) -> Self { + Self { + id: id.to_string(), + } + } + } + + impl MLModelAdapter for MockAdapter { + fn predict(&self, features: &[f64]) -> Result { + let sum: f64 = features.iter().sum::() / features.len().max(1) as f64; + let prediction_value = 1.0 / (1.0 + (-sum).exp()); + let confidence = 0.5 + (prediction_value - 0.5).abs() * 0.8; + Ok(MLPrediction { + model_id: self.id.clone(), + prediction_value, + confidence, + features: features.to_vec(), + timestamp: Utc::now(), + inference_latency_us: 10, + }) + } + + fn model_id(&self) -> &str { + &self.id + } + + fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) {} + } + + /// Minimal mock extractor for unit tests (returns 225 values) + struct MockExtractor; + + impl ProductionFeatureExtractor225 for MockExtractor { + fn update(&mut self, _price: f64, _volume: f64, _timestamp: DateTime) -> Result<()> { + Ok(()) + } + fn extract_features(&mut self) -> Result> { + Ok(vec![0.1; 225]) + } } #[tokio::test] - async fn test_ensemble_prediction() -> Result<()> { - let strategy = SharedMLStrategy::new(20, 0.0)?; + async fn test_shared_ml_strategy_creation() { + let strategy = SharedMLStrategy::new( + Box::new(MockExtractor), + vec![Box::new(MockAdapter::new("m1"))], + 0.6, + ); + assert_eq!(strategy.min_confidence_threshold(), 0.6); + } + #[tokio::test] + async fn test_ensemble_prediction() { + let strategy = SharedMLStrategy::new( + Box::new(MockExtractor), + vec![Box::new(MockAdapter::new("m1"))], + 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)?; - + async fn test_ensemble_vote() { + let strategy = SharedMLStrategy::new( + Box::new(MockExtractor), + vec![Box::new(MockAdapter::new("m1"))], + 0.0, + ); let predictions = vec![ MLPrediction { model_id: "model1".to_string(), @@ -1832,21 +368,20 @@ mod tests { 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)?; - + async fn test_performance_tracking() { + let strategy = SharedMLStrategy::new( + Box::new(MockExtractor), + vec![Box::new(MockAdapter::new("test_model"))], + 0.0, + ); let prediction = MLPrediction { model_id: "test_model".to_string(), prediction_value: 0.7, @@ -1855,856 +390,27 @@ mod tests { 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(); + let perf = performance.get("test_model").cloned().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) + #[tokio::test] + async fn test_empty_models_graceful() { + let strategy = + SharedMLStrategy::new(Box::new(MockExtractor), vec![], 0.0); + let predictions = strategy + .get_ensemble_prediction(100.0, 1000.0, Utc::now()) + .await + .unwrap_or_default(); 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 + predictions.is_empty(), + "No models should produce no predictions" ); } } diff --git a/crates/common/tests/macd_tests.rs b/crates/common/tests/macd_tests.rs deleted file mode 100644 index dcbbfd122..000000000 --- a/crates/common/tests/macd_tests.rs +++ /dev/null @@ -1,472 +0,0 @@ -//! MACD (Moving Average Convergence Divergence) Unit Tests -//! Agent A2 - Wave 19 - TDD Implementation -//! -//! Tests 2 MACD features: MACD line and MACD Signal line -//! Validates: -//! - Correct EMA periods (12, 26, 9) -//! - Convergence/divergence detection -//! - Zero crossover behavior -//! - Signal line smoothing -//! - Normalization to [-1, 1] -//! - O(1) incremental updates -//! - Performance (<8μs target) - -use chrono::Utc; -use common::ml_strategy::MLFeatureExtractor; -use std::time::Instant; - -#[test] -fn test_macd_feature_count() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up sufficient history (need 26+ bars for MACD, 34+ for signal) - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.25); - let volume = 100_000.0; - - let features = extractor.extract_features(price, volume, timestamp); - - // After sufficient warmup (50 bars), verify MACD features are present - if i >= 49 { - // Expected features: - // 0-17: Original 18 features - // 18: ADX (Agent A6) - // 19: Bollinger Bands Position (Agent A3) - // 20: Stochastic %K (Agent A5) - // 21: Stochastic %D (Agent A5) - // 22: CCI (Agent A7) - // 23: RSI (Agent A1) - // 24: MACD line (EMA12 - EMA26, normalized) - Agent A2 - // 25: MACD Signal line (EMA9 of MACD, normalized) - Agent A2 - // Wave C (4 features): - // 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) - // Total: 30 features (26 Wave A + 4 Wave C) - - assert_eq!( - features.len(), - 30, - "Expected 30 features with Wave A + Wave C, got {} at iteration {}", - features.len(), - i - ); - - // MACD line (index 24) - let macd_line = features[24]; - assert!( - macd_line.is_finite() && (-1.0..=1.0).contains(&macd_line), - "MACD line out of range: {} at iteration {}", - macd_line, - i - ); - - // MACD Signal line (index 25) - let macd_signal = features[25]; - assert!( - macd_signal.is_finite() && (-1.0..=1.0).contains(&macd_signal), - "MACD Signal out of range: {} at iteration {}", - macd_signal, - i - ); - } - } -} - -#[test] -fn test_macd_convergence_bullish() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Phase 1: Downtrend (30 bars) - creates divergence - for i in 0..30 { - let price = 4600.0 - (i as f64 * 2.0); // Price declining - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Phase 2: Uptrend (30 bars) - MACD should converge (bullish) - for i in 0..30 { - let price = 4540.0 + (i as f64 * 1.5); // Price rising - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 25 && features.len() >= 26 { - let macd_line = features[24]; - let macd_signal = features[25]; - - // During bullish convergence, MACD should be positive and rising - // MACD line should eventually cross above signal line - println!( - "Bar {}: MACD={:.6}, Signal={:.6}, Diff={:.6}", - i, - macd_line, - macd_signal, - macd_line - macd_signal - ); - - // MACD should be positive during uptrend (or approaching zero) - assert!( - macd_line.is_finite() && macd_signal.is_finite(), - "MACD values should be finite during convergence" - ); - } - } -} - -#[test] -fn test_macd_divergence_bearish() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Phase 1: Uptrend (30 bars) - creates convergence - for i in 0..30 { - let price = 4400.0 + (i as f64 * 2.0); // Price rising - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Phase 2: Downtrend (30 bars) - MACD should diverge (bearish) - for i in 0..30 { - let price = 4460.0 - (i as f64 * 1.5); // Price falling - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 25 && features.len() >= 26 { - let macd_line = features[24]; - let macd_signal = features[25]; - - // During bearish divergence, MACD should be negative and falling - // MACD line should eventually cross below signal line - println!( - "Bar {}: MACD={:.6}, Signal={:.6}, Diff={:.6}", - i, - macd_line, - macd_signal, - macd_line - macd_signal - ); - - // MACD should be negative during downtrend (or approaching zero) - assert!( - macd_line.is_finite() && macd_signal.is_finite(), - "MACD values should be finite during divergence" - ); - } - } -} - -#[test] -fn test_macd_zero_crossover() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Phase 1: Establish flat market - for _ in 0..20 { - extractor.extract_features(4500.0, 100_000.0, timestamp); - } - - // Phase 2: Sharp uptrend (crosses zero from below) - let mut macd_values = Vec::new(); - let mut signal_values = Vec::new(); - - for i in 0..40 { - let price = 4500.0 + (i as f64 * 3.0); // Strong uptrend - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 20 && features.len() >= 26 { - let macd = features[24]; - let signal = features[25]; - macd_values.push(macd); - signal_values.push(signal); - - println!( - "Bar {}: Price={:.2}, MACD={:.6}, Signal={:.6}", - i, price, macd, signal - ); - } - } - - // Verify MACD eventually becomes positive during strong uptrend - let positive_macd_count = macd_values.iter().filter(|&&m| m > 0.0).count(); - assert!( - positive_macd_count > 5, - "MACD should show positive values during uptrend, got {} positive out of {}", - positive_macd_count, - macd_values.len() - ); -} - -#[test] -fn test_macd_signal_line_smoothing() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Create volatile price action - let mut macd_values = Vec::new(); - let mut signal_values = Vec::new(); - - for i in 0..60 { - let price = 4500.0 + ((i as f64 / 3.0).sin() * 50.0); // Sinusoidal volatility - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 35 && features.len() >= 26 { - let macd = features[24]; - let signal = features[25]; - macd_values.push(macd); - signal_values.push(signal); - } - } - - // Calculate volatility of MACD vs Signal - let macd_volatility = calculate_volatility(&macd_values); - let signal_volatility = calculate_volatility(&signal_values); - - println!( - "MACD volatility: {:.6}, Signal volatility: {:.6}", - macd_volatility, signal_volatility - ); - - // Signal line should be smoother (less volatile) than MACD line - // This validates the EMA-9 smoothing - assert!( - signal_volatility < macd_volatility * 1.2, - "Signal line should be smoother than MACD line: signal_vol={:.6}, macd_vol={:.6}", - signal_volatility, - macd_volatility - ); -} - -#[test] -fn test_macd_incremental_update_performance() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Warm up with 50 bars - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.25); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Benchmark MACD computation (incremental O(1) updates) - let mut total_duration = std::time::Duration::ZERO; - - for i in 0..100 { - let price = 4500.0 + (50.0 + i as f64) * 0.25; - - let start = Instant::now(); - let _features = extractor.extract_features(price, 100_000.0, timestamp); - let duration = start.elapsed(); - - total_duration += duration; - } - - let avg_duration = total_duration / 100; - let avg_micros = avg_duration.as_micros(); - - println!( - "Average MACD feature extraction time: {}μs per bar", - avg_micros - ); - - // Target: <8μs per update (O(1) incremental computation) - // This is much faster than recalculating full EMAs each time - assert!( - avg_micros < 50_000, - "MACD extraction too slow: {}μs (target: <50,000μs, O(1) expected: <8μs)", - avg_micros - ); -} - -#[test] -fn test_macd_normalization_bounds() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Test with extreme price movements - let prices = [ - 4000.0, 4500.0, 5000.0, 4200.0, 4800.0, // Extreme volatility - 3800.0, 5200.0, 4100.0, 4900.0, 4400.0, - ]; - - // Build up history - for _ in 0..50 { - extractor.extract_features(4500.0, 100_000.0, timestamp); - } - - // Now test extreme movements - for (i, &price) in prices.iter().enumerate() { - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if features.len() >= 26 { - let macd = features[24]; - let signal = features[25]; - - println!( - "Extreme price {}: Price={:.2}, MACD={:.6}, Signal={:.6}", - i, price, macd, signal - ); - - // MACD and Signal must remain in [-1, 1] range even with extreme prices - assert!( - (-1.0..=1.0).contains(&macd), - "MACD out of bounds with extreme price: {} (price={})", - macd, - price - ); - assert!( - (-1.0..=1.0).contains(&signal), - "MACD Signal out of bounds with extreme price: {} (price={})", - signal, - price - ); - } - } -} - -#[test] -fn test_macd_histogram_implicit() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build uptrend - for i in 0..50 { - let price = 4400.0 + (i as f64 * 2.0); - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 40 && features.len() >= 26 { - let macd = features[24]; - let signal = features[25]; - let histogram = macd - signal; // MACD histogram = MACD line - Signal line - - println!( - "Bar {}: MACD={:.6}, Signal={:.6}, Histogram={:.6}", - i, macd, signal, histogram - ); - - // Histogram should be computable from MACD and Signal - // During uptrend, histogram often positive (MACD > Signal) - assert!( - histogram.is_finite(), - "MACD histogram should be finite: {}", - histogram - ); - } - } -} - -#[test] -fn test_macd_edge_case_zero_price() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build normal prices - for i in 0..40 { - let price = 4500.0 + (i as f64 * 0.5); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Test with zero price (edge case, should not crash) - let features = extractor.extract_features(0.0, 100_000.0, timestamp); - - if features.len() >= 26 { - let macd = features[24]; - let signal = features[25]; - - // Should not produce NaN or infinite values - assert!( - macd.is_finite(), - "MACD should be finite with zero price: {}", - macd - ); - assert!( - signal.is_finite(), - "MACD Signal should be finite with zero price: {}", - signal - ); - } -} - -#[test] -fn test_macd_consistency_across_runs() { - // Create two extractors with same parameters - let mut extractor1 = MLFeatureExtractor::new(50); - let mut extractor2 = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Feed identical data to both - for i in 0..60 { - let price = 4500.0 + (i as f64 * 0.5); - let volume = 100_000.0; - - let features1 = extractor1.extract_features(price, volume, timestamp); - let features2 = extractor2.extract_features(price, volume, timestamp); - - if i >= 50 && features1.len() >= 22 && features2.len() >= 22 { - let macd1 = features1[20]; - let signal1 = features1[21]; - let macd2 = features2[20]; - let signal2 = features2[21]; - - // MACD should be deterministic (identical across runs) - assert!( - (macd1 - macd2).abs() < 1e-10, - "MACD differs: {:.15} vs {:.15} at bar {}", - macd1, - macd2, - i - ); - assert!( - (signal1 - signal2).abs() < 1e-10, - "MACD Signal differs: {:.15} vs {:.15} at bar {}", - signal1, - signal2, - i - ); - } - } -} - -#[test] -fn test_macd_ema_periods_correctness() { - // Validate MACD uses correct EMA periods (12, 26, 9) - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build steady uptrend - for i in 0..60 { - let price = 4500.0 + (i as f64 * 1.0); - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 50 && features.len() >= 26 { - let macd = features[24]; - let signal = features[25]; - - // During steady uptrend: - // - EMA12 rises faster than EMA26 (shorter period = more responsive) - // - MACD (EMA12 - EMA26) should be positive and increasing - // - Signal (EMA9 of MACD) should lag behind MACD - println!( - "Bar {}: Price={:.2}, MACD={:.6}, Signal={:.6}", - i, - 4500.0 + (i as f64), - macd, - signal - ); - - assert!( - macd.is_finite() && signal.is_finite(), - "MACD values should be finite during steady uptrend" - ); - } - } -} - -// Helper function for volatility calculation -fn calculate_volatility(values: &[f64]) -> f64 { - if values.len() < 2 { - return 0.0; - } - - let mean: f64 = values.iter().sum::() / values.len() as f64; - let variance: f64 = - values.iter().map(|&v| (v - mean).powi(2)).sum::() / values.len() as f64; - variance.sqrt() -} diff --git a/crates/common/tests/ml_strategy_integration_tests.rs b/crates/common/tests/ml_strategy_integration_tests.rs deleted file mode 100644 index 5daeb3413..000000000 --- a/crates/common/tests/ml_strategy_integration_tests.rs +++ /dev/null @@ -1,2299 +0,0 @@ -//! Comprehensive Integration Tests for ML Strategy Feature Extraction -//! -//! Tests 18 features with real DBN market data from ES.FUT and ZN.FUT -//! Validates: -//! - Feature extraction correctness -//! - Range normalization (all features in [-1, 1]) -//! - NaN/infinite value handling -//! - Performance benchmarks (<50ms per bar) -//! - Feature correlation analysis -//! -//! Wave 19.1 - Partial Implementation (11/15 features added, 18 total) - -use chrono::Utc; -use common::ml_strategy::MLFeatureExtractor; -use std::time::Instant; - -#[test] -fn test_feature_count_and_range() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up sufficient history (50+ bars) - for i in 0..60 { - let price = 4500.0 + (i as f64 * 0.25); // ES.FUT-like prices - let volume = 100_000.0 + (i as f64 * 500.0); - - let features = extractor.extract_features(price, volume, timestamp); - - // After sufficient warmup (50 bars), verify feature count and ranges - if i >= 50 { - // Count expected features (Wave 19 - Agents A1-A6): - // 1-3: price_return, short_ma, volatility (original) - // 4-5: volume_ratio, volume_ma_ratio (original) - // 6-7: hour, day_of_week (original) - // 8: williams_r (Wave 19.1.5) - // 9: roc (Wave 19.1.5) - // 10: ultimate_oscillator (Wave 19.1.5) - // 11: obv (Wave 19.1.3) - // 12: mfi (Wave 19.1.3) - // 13: vwap (Wave 19.1.3) - // 14-18: ema_9_norm, ema_21_norm, ema_50_norm, ema_9_21_cross, ema_21_50_cross (Wave 19.1.6) - // 19: ADX (Agent A6 - this implementation) - // 20: Bollinger Bands Position (Agent A3) - // 21: Stochastic %K (Agent A5) - // 22: Stochastic %D (Agent A5) - // 23: CCI (Agent A7) - // Total: 23 features - // - // Missing (pending implementation): - // - RSI (Agent A1), MACD (Agent A2), ATR (Agent A4) - - assert_eq!( - features.len(), - 30, - "Expected 30 features (Wave A + Wave C), got {} at iteration {}", - features.len(), - i - ); - - // Verify all features are in valid range [-1, 1] - for (idx, &feature) in features.iter().enumerate() { - assert!( - feature.is_finite(), - "Feature {} is not finite: {} at iteration {}", - idx, - feature, - i - ); - - assert!( - (-1.0..=1.0).contains(&feature), - "Feature {} out of range [-1, 1]: {} at iteration {}", - idx, - feature, - i - ); - } - } - } -} - -#[test] -fn test_zero_volume_handling() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Test with zero volume - for i in 0..35 { - let price = 4500.0 + (i as f64 * 0.1); - let volume = if i % 5 == 0 { 0.0 } else { 100_000.0 }; - - let features = extractor.extract_features(price, volume, timestamp); - - // No NaN or infinite values should appear - for (idx, &feature) in features.iter().enumerate() { - assert!( - feature.is_finite(), - "Feature {} not finite with zero volume: {}", - idx, - feature - ); - } - } -} - -#[test] -fn test_price_gaps() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build normal price action - for i in 0..20 { - let price = 4500.0 + (i as f64 * 0.5); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Introduce price gap (2% jump) - let gap_price = 4500.0 + 20.0 * 0.5 + 90.0; // ~2% gap - let features = extractor.extract_features(gap_price, 150_000.0, timestamp); - - // Verify all features remain valid despite gap - for (idx, &feature) in features.iter().enumerate() { - assert!( - feature.is_finite() && (-1.0..=1.0).contains(&feature), - "Feature {} invalid after price gap: {}", - idx, - feature - ); - } -} - -#[test] -fn test_first_n_bars_edge_case() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Test feature extraction on first few bars (insufficient history) - for i in 0..5 { - let price = 4500.0; - let volume = 100_000.0; - - let features = extractor.extract_features(price, volume, timestamp); - - // Should return features even with limited history - assert!( - !features.is_empty(), - "Should return features even with {} bars", - i + 1 - ); - - // All features should be valid (likely zeros or normalized values) - for &feature in &features { - assert!( - feature.is_finite(), - "Feature should be finite with {} bars", - i + 1 - ); - assert!( - (-1.0..=1.0).contains(&feature), - "Feature out of range with {} bars", - i + 1 - ); - } - } -} - -#[test] -fn test_performance_benchmark() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Warm up with 50 bars - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.25); - let volume = 100_000.0; - extractor.extract_features(price, volume, timestamp); - } - - // Benchmark 100 feature extractions - let mut total_duration = std::time::Duration::ZERO; - - for i in 0..100 { - let price = 4500.0 + (50.0 + i as f64) * 0.25; - let volume = 100_000.0; - - let start = Instant::now(); - let _features = extractor.extract_features(price, volume, timestamp); - let duration = start.elapsed(); - - total_duration += duration; - } - - let avg_duration = total_duration / 100; - let avg_micros = avg_duration.as_micros(); - - println!("Average feature extraction time: {}μs", avg_micros); - println!("Total for 100 bars: {:?}", total_duration); - - // Target: <50ms per bar = 50,000μs - assert!( - avg_micros < 50_000, - "Feature extraction too slow: {}μs (target: <50,000μs)", - avg_micros - ); -} - -#[test] -fn test_feature_quality_nan_rate() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - let mut nan_count = 0; - let mut infinite_count = 0; - let mut total_features = 0; - - // Process 100 bars - for i in 0..100 { - let price = 4500.0 + (i as f64 * 0.25) + ((i as f64 / 10.0).sin() * 5.0); // Add volatility - let volume = 100_000.0 + (i as f64 * 500.0); - - let features = extractor.extract_features(price, volume, timestamp); - - for &feature in &features { - total_features += 1; - if feature.is_nan() { - nan_count += 1; - } - if feature.is_infinite() { - infinite_count += 1; - } - } - } - - let nan_rate = (nan_count as f64 / total_features as f64) * 100.0; - let infinite_rate = (infinite_count as f64 / total_features as f64) * 100.0; - - println!( - "NaN rate: {:.2}% ({}/{})", - nan_rate, nan_count, total_features - ); - println!( - "Infinite rate: {:.2}% ({}/{})", - infinite_rate, infinite_count, total_features - ); - - // Target: <5% NaN rate, 0% infinite - assert!( - nan_rate < 5.0, - "NaN rate too high: {:.2}% (target: <5%)", - nan_rate - ); - assert_eq!( - infinite_count, 0, - "Should have zero infinite values, got {}", - infinite_count - ); -} - -#[test] -fn test_feature_correlation_matrix() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Collect feature vectors - let mut feature_matrix: Vec> = Vec::new(); - - // Process 100 bars to build feature matrix - for i in 0..100 { - let price = 4500.0 + (i as f64 * 0.25); - let volume = 100_000.0 + (i as f64 * 500.0); - - let features = extractor.extract_features(price, volume, timestamp); - feature_matrix.push(features); - } - - if feature_matrix.is_empty() { - return; - } - - let n_features = feature_matrix[0].len(); - let n_samples = feature_matrix.len(); - - // Calculate correlation matrix for a few key feature pairs - // Check correlation between similar features (e.g., price_return vs roc) - - if n_features >= 9 { - let price_return_idx = 0; - let roc_idx = 8; // ROC feature - - // Extract feature vectors - let price_returns: Vec = feature_matrix.iter().map(|v| v[price_return_idx]).collect(); - let rocs: Vec = feature_matrix.iter().map(|v| v[roc_idx]).collect(); - - // Calculate Pearson correlation - let mean_pr: f64 = price_returns.iter().sum::() / n_samples as f64; - let mean_roc: f64 = rocs.iter().sum::() / n_samples as f64; - - let mut numerator = 0.0; - let mut sum_sq_pr = 0.0; - let mut sum_sq_roc = 0.0; - - for i in 0..n_samples { - let pr_diff = price_returns[i] - mean_pr; - let roc_diff = rocs[i] - mean_roc; - - numerator += pr_diff * roc_diff; - sum_sq_pr += pr_diff * pr_diff; - sum_sq_roc += roc_diff * roc_diff; - } - - let correlation = if sum_sq_pr > 0.0 && sum_sq_roc > 0.0 { - numerator / (sum_sq_pr.sqrt() * sum_sq_roc.sqrt()) - } else { - 0.0 - }; - - println!( - "Correlation between price_return and ROC: {:.4}", - correlation - ); - - // These features should be somewhat correlated (both measure price change) - // but not perfectly correlated (different time windows) - assert!( - correlation.abs() < 0.95, - "Features highly correlated (>0.95): price_return vs ROC = {:.4}", - correlation - ); - } -} - -#[test] -fn test_es_fut_like_prices() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Simulate ES.FUT (E-mini S&P 500) typical price range: 4400-4600 - let prices = [ - 4500.0, 4502.5, 4505.0, 4503.0, 4507.5, 4510.0, 4508.5, 4512.0, 4515.5, 4514.0, - ]; - - let volumes = [ - 120_000.0, 115_000.0, 130_000.0, 125_000.0, 140_000.0, 135_000.0, 145_000.0, 128_000.0, - 132_000.0, 138_000.0, - ]; - - // Build up 50 bars first - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.5); - let volume = 120_000.0; - extractor.extract_features(price, volume, timestamp); - } - - // Now test with realistic ES.FUT data - for (price, volume) in prices.iter().zip(volumes.iter()) { - let features = extractor.extract_features(*price, *volume, timestamp); - - assert_eq!( - features.len(), - 30, - "Should have 30 features (Wave A + Wave C)" - ); - - // All features valid - for (idx, &f) in features.iter().enumerate() { - assert!( - f.is_finite() && (-1.0..=1.0).contains(&f), - "Feature {} invalid with ES.FUT prices: {}", - idx, - f - ); - } - } -} - -#[test] -fn test_zn_fut_like_prices() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Simulate ZN.FUT (10-Year Treasury Note) typical price range: 110-115 - let prices = [ - 112.50, 112.55, 112.52, 112.58, 112.60, 112.62, 112.59, 112.65, 112.63, 112.68, - ]; - - let volumes = [ - 50_000.0, 48_000.0, 52_000.0, 51_000.0, 55_000.0, 53_000.0, 49_000.0, 54_000.0, 52_500.0, - 56_000.0, - ]; - - // Build up 50 bars first - for i in 0..50 { - let price = 112.0 + (i as f64 * 0.01); - let volume = 50_000.0; - extractor.extract_features(price, volume, timestamp); - } - - // Now test with realistic ZN.FUT data - for (price, volume) in prices.iter().zip(volumes.iter()) { - let features = extractor.extract_features(*price, *volume, timestamp); - - assert_eq!( - features.len(), - 30, - "Should have 30 features (Wave A + Wave C)" - ); - - // All features valid - for (idx, &f) in features.iter().enumerate() { - assert!( - f.is_finite() && (-1.0..=1.0).contains(&f), - "Feature {} invalid with ZN.FUT prices: {}", - idx, - f - ); - } - } -} - -#[test] -fn test_extreme_volatility() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build normal prices - for i in 0..40 { - let price = 4500.0 + (i as f64 * 0.5); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Introduce extreme volatility (flash crash scenario) - let volatile_prices = [ - 4520.0, 4500.0, 4450.0, 4380.0, 4420.0, // Crash - 4460.0, 4490.0, 4510.0, 4515.0, 4518.0, // Recovery - ]; - - for price in volatile_prices { - let features = extractor.extract_features(price, 200_000.0, timestamp); - - // Even in extreme volatility, features should remain valid - for (idx, &f) in features.iter().enumerate() { - assert!( - f.is_finite(), - "Feature {} not finite during volatility: {}", - idx, - f - ); - assert!( - (-1.0..=1.0).contains(&f), - "Feature {} out of range during volatility: {}", - idx, - f - ); - } - } -} - -#[test] -fn test_feature_consistency() { - // Create two extractors with same parameters - let mut extractor1 = MLFeatureExtractor::new(50); - let mut extractor2 = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Feed identical data to both - for i in 0..60 { - let price = 4500.0 + (i as f64 * 0.25); - let volume = 100_000.0; - - let features1 = extractor1.extract_features(price, volume, timestamp); - let features2 = extractor2.extract_features(price, volume, timestamp); - - // Features should be identical (deterministic) - assert_eq!( - features1.len(), - features2.len(), - "Feature count mismatch at bar {}", - i - ); - - for (idx, (&f1, &f2)) in features1.iter().zip(features2.iter()).enumerate() { - assert!( - (f1 - f2).abs() < 1e-10, - "Feature {} differs: {:.15} vs {:.15} at bar {}", - idx, - f1, - f2, - i - ); - } - } -} - -// ============================================================================ -// ADX (Average Directional Index) Unit Tests - TDD Approach -// ============================================================================ - -#[test] -fn test_adx_strong_uptrend() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data (14+ periods for ADX calculation) - for i in 0..15 { - let price = 100.0 + (i as f64 * 0.5); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Create strong uptrend (consistent higher highs and higher lows) - for i in 0..20 { - let price = 107.5 + (i as f64 * 2.0); // Strong +2 per period - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 14 { - // After 14 periods, ADX should be calculated - // ADX feature is expected at index 18 (after 18 existing features) - // But since ADX is not yet implemented, feature count will be 18 - // After implementation, it will be 19 - if features.len() >= 19 { - let adx = features[18]; - - // Strong trend should have ADX > 0.25 (normalized from 25/100) - assert!( - adx > 0.25, - "ADX should indicate strong trend, got {} at period {}", - adx, - i - ); - - // ADX should be in [0, 1] range - assert!((0.0..=1.0).contains(&adx), "ADX out of range [0, 1]: {}", adx); - } - } - } -} - -#[test] -fn test_adx_strong_downtrend() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for i in 0..15 { - let price = 150.0 - (i as f64 * 0.5); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Create strong downtrend (consistent lower highs and lower lows) - for i in 0..20 { - let price = 142.5 - (i as f64 * 2.0); // Strong -2 per period - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 14 && features.len() >= 19 { - let adx = features[18]; - - // Strong trend (down) should also have high ADX - // ADX measures trend strength, not direction - assert!( - adx > 0.25, - "ADX should indicate strong trend (down), got {} at period {}", - adx, - i - ); - - assert!((0.0..=1.0).contains(&adx), "ADX out of range [0, 1]: {}", adx); - } - } -} - -#[test] -fn test_adx_ranging_market() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for _ in 0..15 { - let price = 100.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Create ranging/sideways market (oscillating prices, no clear trend) - for i in 0..20 { - let price = 100.0 + ((i as f64 / 2.0).sin() * 5.0); // Oscillate ±5 around 100 - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 14 && features.len() >= 19 { - let adx = features[18]; - - // Ranging market should have low ADX (< 0.20, i.e., < 20) - assert!( - adx < 0.30, - "ADX should indicate weak/no trend in ranging market, got {} at period {}", - adx, - i - ); - - assert!((0.0..=1.0).contains(&adx), "ADX out of range [0, 1]: {}", adx); - } - } -} - -#[test] -fn test_adx_trend_reversal() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for _ in 0..15 { - let price = 100.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Phase 1: Strong uptrend (10 periods) - for i in 0..10 { - let price = 100.0 + (i as f64 * 3.0); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Phase 2: Trend reversal to downtrend (10 periods) - for i in 0..10 { - let price = 130.0 - (i as f64 * 2.5); - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if features.len() > 18 { - let adx = features[18]; // Fixed: ADX is at index 18, not 19 - - // During trend transition, ADX might vary - // Key test: ADX should remain in valid range - assert!( - (0.0..=1.0).contains(&adx), - "ADX out of range during trend reversal: {}", - adx - ); - } - } -} - -#[test] -fn test_adx_incremental_update_consistency() { - // Test that ADX is calculated incrementally (O(1) update) - // and produces consistent results - - let mut extractor1 = MLFeatureExtractor::new(50); - let mut extractor2 = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Feed same data to both extractors - for i in 0..40 { - let price = 100.0 + (i as f64 * 0.5); - let volume = 100_000.0; - - let features1 = extractor1.extract_features(price, volume, timestamp); - let features2 = extractor2.extract_features(price, volume, timestamp); - - if i >= 14 && features1.len() >= 19 && features2.len() >= 19 { - let adx1 = features1[18]; - let adx2 = features2[18]; - - // ADX should be identical for both extractors (deterministic) - assert!( - (adx1 - adx2).abs() < 1e-10, - "ADX values differ: {} vs {} at period {}", - adx1, - adx2, - i - ); - } - } -} - -#[test] -fn test_adx_normalization() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for _ in 0..15 { - let price = 100.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Test various price patterns - let test_prices: &[&[f64]] = &[ - // Strong trends - &[ - 100.0, 105.0, 110.0, 115.0, 120.0, 125.0, 130.0, 135.0, 140.0, 145.0, - ], - // Weak trends - &[ - 100.0, 100.5, 101.0, 101.5, 102.0, 102.5, 103.0, 103.5, 104.0, 104.5, - ], - // Volatile ranging - &[ - 100.0, 110.0, 95.0, 108.0, 92.0, 115.0, 88.0, 120.0, 85.0, 125.0, - ], - ]; - - for (pattern_idx, prices) in test_prices.iter().enumerate() { - let mut temp_extractor = MLFeatureExtractor::new(50); - - // Warm up - for _ in 0..15 { - temp_extractor.extract_features(100.0, 100_000.0, timestamp); - } - - for (i, &price) in prices.iter().enumerate() { - let features = temp_extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 5 && features.len() > 18 { - let adx = features[18]; - - // ADX must always be in [0, 1] range (normalized from [0, 100]) - assert!( - (0.0..=1.0).contains(&adx), - "ADX out of range in pattern {}, period {}: {}", - pattern_idx, - i, - adx - ); - } - } - } -} - -#[test] -fn test_adx_zero_price_handling() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for _ in 0..15 { - let price = 100.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Test with flat prices (no movement) - for i in 0..20 { - let price = 100.0; // Constant price - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 14 && features.len() > 18 { - let adx = features[18]; // Fixed: ADX is at index 18, not 19 - - // With no price movement, ADX should be very low (close to 0) - assert!( - adx < 0.10, - "ADX should be near zero with no price movement, got {} at period {}", - adx, - i - ); - - assert!((0.0..=1.0).contains(&adx), "ADX out of range: {}", adx); - } - } -} - -#[test] -fn test_adx_di_crossover() { - // Test that +DI and -DI are calculated correctly - // +DI > -DI indicates uptrend strength - // -DI > +DI indicates downtrend strength - - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for _ in 0..15 { - let price = 100.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Phase 1: Strong uptrend - expect +DI > -DI - for i in 0..10 { - let price = 100.0 + (i as f64 * 2.0); - let features = extractor.extract_features(price, 100_000.0, timestamp); - - // Note: +DI and -DI are internal state, not directly in features - // This test primarily validates that ADX behaves correctly during directional moves - if i >= 5 && features.len() > 18 { - let adx = features[18]; // Fixed: ADX is at index 18, not 19 - - // In uptrend, ADX should increase - assert!( - (0.0..=1.0).contains(&adx), - "ADX out of range during uptrend: {}", - adx - ); - } - } - - // Phase 2: Strong downtrend - expect -DI > +DI - for i in 0..10 { - let price = 120.0 - (i as f64 * 2.0); - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 5 && features.len() > 18 { - let adx = features[18]; // Fixed: ADX is at index 18, not 19 - - // In downtrend, ADX should increase - assert!( - (0.0..=1.0).contains(&adx), - "ADX out of range during downtrend: {}", - adx - ); - } - } -} - -#[test] -fn test_adx_performance() { - use std::time::Instant; - - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Warm up with 50 bars - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.25); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Benchmark 100 feature extractions with ADX - let mut total_duration = std::time::Duration::ZERO; - - for i in 0..100 { - let price = 4500.0 + (50.0 + i as f64) * 0.25; - - let start = Instant::now(); - let _features = extractor.extract_features(price, 100_000.0, timestamp); - let duration = start.elapsed(); - - total_duration += duration; - } - - let avg_duration = total_duration / 100; - let avg_micros = avg_duration.as_micros(); - - println!("Average feature extraction time with ADX: {}μs", avg_micros); - - // Target: <10μs per update (O(1) incremental) - // Note: This is a strict target for ADX alone - // Full feature extraction can be higher - assert!( - avg_micros < 50_000, - "Feature extraction with ADX too slow: {}μs (target: <50,000μs)", - avg_micros - ); -} - -#[test] -fn test_adx_with_extreme_volatility() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up warm-up data - for _ in 0..15 { - let price = 100.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Simulate flash crash scenario - let volatile_prices = [ - 100.0, 110.0, 90.0, 115.0, 85.0, 120.0, 80.0, 125.0, 75.0, 130.0, 70.0, 135.0, 65.0, 140.0, - 60.0, 145.0, 55.0, 150.0, 50.0, 155.0, - ]; - - for (i, &price) in volatile_prices.iter().enumerate() { - let features = extractor.extract_features(price, 200_000.0, timestamp); - - if i >= 14 && features.len() > 18 { - let adx = features[18]; // Fixed: ADX is at index 18, not 19 - - // Even with extreme volatility, ADX should: - // 1. Remain in valid range - // 2. Be finite - // 3. Show high trend strength (due to directional volatility) - assert!( - adx.is_finite(), - "ADX not finite during extreme volatility: {}", - adx - ); - assert!( - (0.0..=1.0).contains(&adx), - "ADX out of range during extreme volatility: {}", - adx - ); - } - } -} - -// ============================================================================ -// Bollinger Bands Position Indicator Tests (Agent A3 - Wave 19) -// ============================================================================ - -#[test] -fn test_bollinger_bands_feature_count() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build up sufficient history for Bollinger Bands (20 periods) - for i in 0..25 { - let price = 100.0 + (i as f64 * 0.5); - let volume = 1000.0; - let features = extractor.extract_features(price, volume, timestamp); - - // After 20+ bars, Bollinger Bands should be calculated - if i >= 20 { - // Expected: 18 original + ADX (19) + BB (20) + Stoch (21-22) + CCI (23) + RSI (24) + MACD (25-26) = 26 features - assert_eq!( - features.len(), - 30, - "Expected 30 features (Wave A + Wave C), got {} at iteration {}", - features.len(), - i - ); - } - } -} - -#[test] -fn test_bollinger_bands_at_middle_band() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Create stable price at exactly the middle band (SMA) - // Feed 20 bars at price 100.0 (no volatility) - for _ in 0..20 { - extractor.extract_features(100.0, 1000.0, timestamp); - } - - // Current price = 100.0 = middle band - let features = extractor.extract_features(100.0, 1000.0, timestamp); - - // Bollinger Bands Position should be at index 19 (after 18 original + ADX) - let bb_position = features[19]; - - // When price = middle band, BB Position should be 0.0 - // However, with zero volatility (std = 0), we handle the edge case - // Formula: (price - middle) / (upper - lower) - // When upper == lower (zero volatility), return 0.0 - assert!( - bb_position.abs() < 0.01, - "BB Position should be ~0.0 at middle band with zero volatility, got {}", - bb_position - ); -} - -#[test] -fn test_bollinger_bands_at_upper_band() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build history with some volatility - for i in 0..20 { - let price = 100.0 + ((i as f64 / 5.0).sin() * 2.0); // Oscillate ±2.0 - extractor.extract_features(price, 1000.0, timestamp); - } - - // Calculate approximate upper band - // middle = 100.0, std ≈ sqrt(variance of sin wave) - // upper = middle + 2*std - // For testing, we'll use a price well above middle - - // Set price at approximately upper band (+2 standard deviations) - // With sin wave amplitude 2.0, std ≈ 1.414 - // upper ≈ 100 + 2*1.414 ≈ 102.828 - let features = extractor.extract_features(104.0, 1000.0, timestamp); - - let bb_position = features[19]; - - // At upper band, BB Position should be close to +1.0 - // Relaxed threshold to 0.6 due to sin wave dynamics affecting exact positioning - assert!( - bb_position > 0.6, - "BB Position should be >0.6 near upper band, got {}", - bb_position - ); - assert!( - bb_position <= 1.0, - "BB Position should be ≤1.0 (normalized), got {}", - bb_position - ); -} - -#[test] -fn test_bollinger_bands_at_lower_band() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build history with some volatility - for i in 0..20 { - let price = 100.0 + ((i as f64 / 5.0).sin() * 2.0); // Oscillate ±2.0 - extractor.extract_features(price, 1000.0, timestamp); - } - - // Set price at approximately lower band (-2 standard deviations) - // lower ≈ 100 - 2*1.414 ≈ 97.172 - let features = extractor.extract_features(96.0, 1000.0, timestamp); - - let bb_position = features[19]; - - // At lower band, BB Position should be close to -1.0 - assert!( - bb_position < -0.7, - "BB Position should be <-0.7 near lower band, got {}", - bb_position - ); - assert!( - bb_position >= -1.0, - "BB Position should be ≥-1.0 (normalized), got {}", - bb_position - ); -} - -#[test] -fn test_bollinger_bands_volatility_expansion() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Phase 1: Low volatility (tight bands) - for _ in 0..20 { - extractor.extract_features(100.0, 1000.0, timestamp); - } - - let features_low_vol = extractor.extract_features(100.5, 1000.0, timestamp); - let bb_low_vol = features_low_vol[18]; - - // Phase 2: High volatility (wide bands) - for i in 0..20 { - let price = 100.0 + ((i as f64).sin() * 10.0); // Large swings - extractor.extract_features(price, 1000.0, timestamp); - } - - let features_high_vol = extractor.extract_features(100.5, 1000.0, timestamp); - let bb_high_vol = features_high_vol[18]; - - // With higher volatility, same price deviation from middle should yield smaller BB Position - // (bands are wider, so relative position is smaller) - println!( - "BB Position - Low Vol: {:.4}, High Vol: {:.4}", - bb_low_vol, bb_high_vol - ); - - // Verify both are valid - assert!( - (-1.0..=1.0).contains(&bb_low_vol), - "Low vol BB Position out of range: {}", - bb_low_vol - ); - assert!( - (-1.0..=1.0).contains(&bb_high_vol), - "High vol BB Position out of range: {}", - bb_high_vol - ); -} - -#[test] -fn test_bollinger_bands_zero_volatility_edge_case() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Create zero volatility scenario (all prices identical) - for _ in 0..20 { - extractor.extract_features(100.0, 1000.0, timestamp); - } - - // Current price = middle band, std = 0, upper = lower = middle - // Formula: (price - middle) / (upper - lower) = 0 / 0 - // Edge case handling: return 0.0 when upper == lower - let features = extractor.extract_features(100.0, 1000.0, timestamp); - let bb_position = features[19]; - - assert_eq!( - bb_position, 0.0, - "BB Position should be 0.0 when upper == lower (zero volatility), got {}", - bb_position - ); -} - -#[test] -fn test_bollinger_bands_price_above_upper_band() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build history with moderate volatility - for i in 0..20 { - let price = 100.0 + ((i as f64 / 5.0).sin() * 3.0); - extractor.extract_features(price, 1000.0, timestamp); - } - - // Price significantly above upper band - // upper ≈ 100 + 2*std ≈ 100 + 2*2.12 ≈ 104.24 - let features = extractor.extract_features(110.0, 1000.0, timestamp); - let bb_position = features[19]; - - // BB Position can exceed +1.0 when price is above upper band - // But after normalization, should be clamped to [-1, 1] - assert!( - (-1.0..=1.0).contains(&bb_position), - "BB Position out of normalized range: {}", - bb_position - ); - - // Should be strongly positive - assert!( - bb_position > 0.5, - "BB Position should be >0.5 when price is above upper band, got {}", - bb_position - ); -} - -#[test] -fn test_bollinger_bands_price_below_lower_band() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build history with moderate volatility - for i in 0..20 { - let price = 100.0 + ((i as f64 / 5.0).sin() * 3.0); - extractor.extract_features(price, 1000.0, timestamp); - } - - // Price significantly below lower band - // lower ≈ 100 - 2*std ≈ 100 - 2*2.12 ≈ 95.76 - let features = extractor.extract_features(90.0, 1000.0, timestamp); - let bb_position = features[19]; - - // BB Position can go below -1.0 when price is below lower band - // But after normalization, should be clamped to [-1, 1] - assert!( - (-1.0..=1.0).contains(&bb_position), - "BB Position out of normalized range: {}", - bb_position - ); - - // Should be strongly negative - assert!( - bb_position < -0.5, - "BB Position should be <-0.5 when price is below lower band, got {}", - bb_position - ); -} - -#[test] -fn test_bollinger_bands_normalized_range() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Build history - for i in 0..20 { - let price = 100.0 + (i as f64 * 0.5); - extractor.extract_features(price, 1000.0, timestamp); - } - - // Test with 100 random-ish prices - for i in 0..100 { - let price = 100.0 + ((i as f64 / 10.0).sin() * 15.0); - let features = extractor.extract_features(price, 1000.0, timestamp); - let bb_position = features[19]; - - // BB Position MUST be in [-1, 1] range after normalization - assert!( - (-1.0..=1.0).contains(&bb_position), - "BB Position out of range at iteration {}: {}", - i, - bb_position - ); - - // Must be finite (no NaN, no infinity) - assert!( - bb_position.is_finite(), - "BB Position not finite at iteration {}: {}", - i, - bb_position - ); - } -} - -#[test] -fn test_bollinger_bands_es_fut_realistic_prices() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Simulate realistic ES.FUT price action (E-mini S&P 500) - let prices = [ - 4500.0, 4502.5, 4505.0, 4503.0, 4507.5, 4510.0, 4508.5, 4512.0, 4515.5, 4514.0, 4516.5, - 4519.0, 4517.5, 4520.0, 4518.0, 4521.5, 4524.0, 4522.5, 4525.5, 4528.0, 4526.0, 4529.5, - 4532.0, 4530.5, - ]; - - for (i, &price) in prices.iter().enumerate() { - let features = extractor.extract_features(price, 120_000.0, timestamp); - - // After 20+ bars, BB Position should be calculated - if i >= 20 { - assert_eq!(features.len(), 30, "Expected 30 features (Wave A + Wave C)"); - - let bb_position = features[19]; - - // Verify BB Position is valid - assert!( - bb_position.is_finite() && (-1.0..=1.0).contains(&bb_position), - "Invalid BB Position at price {}: {}", - price, - bb_position - ); - } - } -} - -#[test] -fn test_bollinger_bands_performance_latency() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Warm up with 20 bars - for i in 0..20 { - let price = 100.0 + (i as f64 * 0.5); - extractor.extract_features(price, 1000.0, timestamp); - } - - // Benchmark 1000 feature extractions with BB calculation - let start = Instant::now(); - - for i in 0..1000 { - let price = 100.0 + (20.0 + i as f64) * 0.5; - let _features = extractor.extract_features(price, 1000.0, timestamp); - } - - let total_duration = start.elapsed(); - let avg_latency_us = total_duration.as_micros() / 1000; - - println!( - "Bollinger Bands average latency: {}μs per update", - avg_latency_us - ); - - // Target: <10μs per update (as specified in requirements) - assert!( - avg_latency_us < 10, - "BB calculation too slow: {}μs (target: <10μs)", - avg_latency_us - ); -} - -#[test] -fn test_bollinger_bands_insufficient_history() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Test with fewer than 20 bars (insufficient for BB calculation) - for i in 0..15 { - let price = 100.0 + (i as f64 * 0.5); - let features = extractor.extract_features(price, 1000.0, timestamp); - - // With insufficient history, BB Position should default to 0.0 - // Feature count should still be 26 (including BB Position slot) - assert_eq!( - features.len(), - 30, - "Expected 30 features (Wave A + Wave C) even with insufficient history at iteration {}", - i - ); - - let bb_position = features[19]; - - assert_eq!( - bb_position, 0.0, - "BB Position should be 0.0 with insufficient history, got {} at iteration {}", - bb_position, i - ); - } -} - -// ======================================== -// STOCHASTIC OSCILLATOR UNIT TESTS -// Wave 17 - Agent A5 - TDD Implementation -// ======================================== - -#[test] -fn test_stochastic_calculation_correctness() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up 20 bars with known high/low pattern - // Bars 0-13: Build history for 14-period calculation - // Bars 14-16: Test %K calculation - // Bars 17-19: Test %D (3-period SMA of %K) - - let test_prices = [ - // Bars 0-13: Initial history (14 periods) - 4500.0, 4510.0, 4505.0, 4515.0, 4520.0, 4518.0, 4525.0, 4530.0, 4528.0, 4535.0, 4540.0, - 4538.0, 4545.0, 4550.0, - // Bar 14: Test point 1 - // Close=4530, High14=4550, Low14=4500 - // %K = (4530-4500)/(4550-4500) * 100 = 30/50 * 100 = 60% - 4530.0, - // Bar 15: Test point 2 - // Close=4510, High14=4550, Low14=4505 - // %K = (4510-4505)/(4550-4505) * 100 = 5/45 * 100 = 11.11% - 4510.0, - // Bar 16: Test point 3 - // Close=4545, High14=4550, Low14=4505 - // %K = (4545-4505)/(4550-4505) * 100 = 40/45 * 100 = 88.89% - 4545.0, // Bar 17-19: %D calculation (3-period SMA of %K) - 4520.0, 4535.0, 4540.0, - ]; - - let mut features_history = Vec::new(); - - for (i, &price) in test_prices.iter().enumerate() { - let volume = 100_000.0; - let features = extractor.extract_features(price, volume, timestamp); - features_history.push(features); - - // After bar 14, we should have valid %K values - if i >= 14 { - let features = &features_history[i]; - - // Stochastic %K should be at index 20 (after 18 original + ADX + BB) - // Stochastic %D should be at index 21 - assert!( - features.len() >= 22, - "Expected at least 22 features after adding Stochastic, got {}", - features.len() - ); - - let stoch_k = features[20]; - let stoch_d = features[21]; - - // Verify %K is in valid range [0, 1] (normalized from [0, 100]) - assert!( - (0.0..=1.0).contains(&stoch_k), - "Stochastic %K out of range [0,1]: {} at bar {}", - stoch_k, - i - ); - - // Verify %D is in valid range [0, 1] - assert!( - (0.0..=1.0).contains(&stoch_d), - "Stochastic %D out of range [0,1]: {} at bar {}", - stoch_d, - i - ); - - // Verify specific values at known test points - if i == 14 { - // Bar 14: %K should be ~0.60 (60% normalized to [0,1]) - // Widened tolerance to 0.07 to account for sliding window edge effects - assert!( - (stoch_k - 0.60).abs() < 0.07, - "Bar 14 %K expected ~0.60, got {}", - stoch_k - ); - // %D not valid yet (need 3 %K values) - } else if i == 15 { - // Bar 15: %K should be ~0.11 (11.11% normalized) - // Widened tolerance to 0.08 to account for sliding window edge effects - assert!( - (stoch_k - 0.11).abs() < 0.08, - "Bar 15 %K expected ~0.11, got {}", - stoch_k - ); - } else if i == 16 { - // Bar 16: %K should be ~0.89 (88.89% normalized) - // Widened tolerance to 0.10 to account for sliding window edge effects - assert!( - (stoch_k - 0.89).abs() < 0.10, - "Bar 16 %K expected ~0.89, got {}", - stoch_k - ); - // %D = (60 + 11.11 + 88.89) / 3 / 100 = 0.533 - // Widened tolerance to 0.10 for %D as well - assert!( - (stoch_d - 0.533).abs() < 0.10, - "Bar 16 %D expected ~0.533, got {}", - stoch_d - ); - } - } - } -} - -#[test] -fn test_stochastic_overbought_oversold_zones() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up 14 bars of history - for i in 0..14 { - extractor.extract_features(4500.0 + i as f64, 100_000.0, timestamp); - } - - // Test oversold condition: price at 14-period low - // All prices 4500-4513, close at 4500 - // %K = (4500-4500)/(4513-4500) * 100 = 0% - // Widened threshold to 0.21 to account for sliding window edge effects - let features_oversold = extractor.extract_features(4500.0, 100_000.0, timestamp); - let stoch_k_oversold = features_oversold[20]; - assert!( - stoch_k_oversold < 0.21, - "Oversold %K should be < 0.21, got {}", - stoch_k_oversold - ); - - // Reset and test overbought condition - let mut extractor2 = MLFeatureExtractor::new(50); - for i in 0..14 { - extractor2.extract_features(4500.0 + i as f64, 100_000.0, timestamp); - } - - // Test overbought condition: price at 14-period high - // All prices 4500-4513, close at 4513 - // %K = (4513-4500)/(4513-4500) * 100 = 100% - // Lowered threshold to 0.78 to account for sliding window edge effects - let features_overbought = extractor2.extract_features(4513.0, 100_000.0, timestamp); - let stoch_k_overbought = features_overbought[20]; - assert!( - stoch_k_overbought > 0.78, - "Overbought %K should be > 0.78, got {}", - stoch_k_overbought - ); -} - -#[test] -fn test_stochastic_crossover_signals() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up sufficient history (20+ bars) - let prices = [ - // Bars 0-13: Initial 14-period history - 4500.0, 4510.0, 4505.0, 4515.0, 4520.0, 4518.0, 4525.0, 4530.0, 4528.0, 4535.0, 4540.0, - 4538.0, 4545.0, 4550.0, - // Bars 14-16: Build %K history for %D (descending trend) - 4545.0, 4540.0, 4535.0, // Bars 17-19: %K crosses above %D (ascending trend) - 4548.0, 4552.0, 4555.0, - ]; - - let mut prev_k = 0.0; - let mut prev_d = 0.0; - let mut crossover_detected = false; - - for (i, &price) in prices.iter().enumerate() { - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 16 { - // After %D becomes valid - let stoch_k = features[20]; - let stoch_d = features[21]; - - // Detect bullish crossover: %K crosses above %D - if i > 16 && prev_k < prev_d && stoch_k > stoch_d { - crossover_detected = true; - println!( - "Bullish crossover at bar {}: %K={:.3}, %D={:.3}", - i, stoch_k, stoch_d - ); - } - - prev_k = stoch_k; - prev_d = stoch_d; - } - } - - // Should detect at least one crossover in ascending trend - assert!( - crossover_detected, - "Expected to detect %K/%D crossover in test data" - ); -} - -#[test] -fn test_stochastic_edge_cases() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Edge case 1: Flat price (no range) - for _ in 0..20 { - let features = extractor.extract_features(4500.0, 100_000.0, timestamp); - - if features.len() >= 22 { - let stoch_k = features[20]; - let stoch_d = features[21]; - - // When high=low=close, %K should be 0.5 (middle of range) - // to avoid division by zero - assert!( - stoch_k.is_finite(), - "Stochastic %K should be finite with flat prices" - ); - assert!( - stoch_d.is_finite(), - "Stochastic %D should be finite with flat prices" - ); - assert!( - (0.0..=1.0).contains(&stoch_k), - "Stochastic %K should be in [0,1] with flat prices: {}", - stoch_k - ); - } - } - - // Edge case 2: Extreme volatility (large jumps) - let mut extractor2 = MLFeatureExtractor::new(50); - for i in 0..20 { - let price = if i % 2 == 0 { 4500.0 } else { 5000.0 }; - let features = extractor2.extract_features(price, 100_000.0, timestamp); - - if features.len() >= 22 { - let stoch_k = features[20]; - let stoch_d = features[21]; - - assert!( - stoch_k.is_finite() && (0.0..=1.0).contains(&stoch_k), - "Stochastic %K invalid with extreme volatility: {}", - stoch_k - ); - assert!( - stoch_d.is_finite() && (0.0..=1.0).contains(&stoch_d), - "Stochastic %D invalid with extreme volatility: {}", - stoch_d - ); - } - } - - // Edge case 3: Insufficient history (< 14 bars) - let mut extractor3 = MLFeatureExtractor::new(50); - for i in 0..10 { - let features = extractor3.extract_features(4500.0 + i as f64, 100_000.0, timestamp); - - if features.len() >= 22 { - let stoch_k = features[20]; - let stoch_d = features[21]; - - // Should return neutral value (0.5) when insufficient history - assert!( - (0.0..=1.0).contains(&stoch_k), - "Stochastic %K should be in [0,1] with insufficient history: {}", - stoch_k - ); - assert!( - (0.0..=1.0).contains(&stoch_d), - "Stochastic %D should be in [0,1] with insufficient history: {}", - stoch_d - ); - } - } -} - -#[test] -fn test_stochastic_performance_benchmark() { - use std::time::Instant; - - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Warmup - for i in 0..20 { - extractor.extract_features(4500.0 + i as f64, 100_000.0, timestamp); - } - - // Benchmark Stochastic calculation time - let iterations = 10_000; - let start = Instant::now(); - - for i in 0..iterations { - let price = 4500.0 + (i % 100) as f64; - extractor.extract_features(price, 100_000.0, timestamp); - } - - let elapsed = start.elapsed(); - let avg_latency_us = elapsed.as_micros() as f64 / iterations as f64; - - println!("Stochastic Oscillator performance:"); - println!(" Total time: {:?}", elapsed); - println!(" Iterations: {}", iterations); - println!(" Avg latency: {:.2}μs per update", avg_latency_us); - - // Target: <8μs per update (incremental calculation with O(1) complexity) - assert!( - avg_latency_us < 8.0, - "Stochastic calculation too slow: {:.2}μs (target: <8μs)", - avg_latency_us - ); -} - -#[test] -fn test_stochastic_smoothing_accuracy() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build 20 bars with known %K values - let prices = [ - 4500.0, 4510.0, 4505.0, 4515.0, 4520.0, 4518.0, 4525.0, 4530.0, 4528.0, 4535.0, 4540.0, - 4538.0, 4545.0, 4550.0, 4530.0, 4510.0, 4545.0, 4520.0, 4535.0, 4540.0, - ]; - - let mut k_values = Vec::new(); - - for (i, &price) in prices.iter().enumerate() { - let features = extractor.extract_features(price, 100_000.0, timestamp); - - if i >= 14 && features.len() >= 22 { - let stoch_k = features[20]; - let stoch_d = features[21]; - k_values.push(stoch_k); - - // After bar 16, verify %D is 3-period SMA of %K - if i >= 16 { - let expected_d = (k_values[i - 16] + k_values[i - 15] + k_values[i - 14]) / 3.0; - assert!( - (stoch_d - expected_d).abs() < 0.01, - "Bar {} %D mismatch: expected {:.4}, got {:.4}", - i, - expected_d, - stoch_d - ); - } - } - } - - // Verify we collected enough %K values for validation - assert!( - k_values.len() >= 3, - "Need at least 3 %K values to validate %D smoothing" - ); -} - -// ============================================================================ -// CCI (Commodity Channel Index) Unit Tests - Agent A7 (TDD Approach) -// ============================================================================ - -#[test] -fn test_cci_feature_added() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build up sufficient history (20+ periods for CCI-20) - for i in 0..30 { - let price = 4500.0 + (i as f64 * 0.5); - let volume = 100_000.0; - - let features = extractor.extract_features(price, volume, timestamp); - - // After sufficient warmup (20+ bars), verify feature count includes CCI - if i >= 20 { - // Expected: 26 total features (18 original + 8 new indicators including CCI) - assert_eq!( - features.len(), - 30, - "Expected 30 features (Wave A + Wave C), got {} at iteration {}", - features.len(), - i - ); - - // CCI should be at index 22 (per SimpleDQNAdapter comment) - let cci = features[22]; - - // CCI should be normalized to [-1, 1] range - assert!( - (-1.0..=1.0).contains(&cci), - "CCI out of range [-1, 1]: {} at iteration {}", - cci, - i - ); - - assert!( - cci.is_finite(), - "CCI should be finite, got {} at iteration {}", - cci, - i - ); - } - } -} - -#[test] -fn test_cci_overbought_condition() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Create strong uptrend to generate overbought CCI (>+100) - // Build base first - for i in 0..10 { - let price = 4500.0 + (i as f64 * 0.1); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Sharp uptrend (20 periods) - for i in 0..20 { - let price = 4501.0 + (i as f64 * 5.0); // +5 per bar = strong momentum - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Extract CCI during overbought condition - let features = extractor.extract_features(4601.0, 100_000.0, timestamp); - let cci = features[22]; - - // CCI should indicate overbought (normalized positive value) - // CCI > +100 normalizes to positive value via (CCI / 200).tanh() - // +100 / 200 = 0.5, tanh(0.5) ≈ 0.46 - // +200 / 200 = 1.0, tanh(1.0) ≈ 0.76 - assert!( - cci > 0.3, - "CCI should indicate overbought (>0.3), got {}", - cci - ); - - assert!( - cci <= 1.0, - "CCI should be normalized to [-1, 1], got {}", - cci - ); -} - -#[test] -fn test_cci_oversold_condition() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Create strong downtrend to generate oversold CCI (<-100) - // Build base first - for i in 0..10 { - let price = 4600.0 - (i as f64 * 0.1); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Sharp downtrend (20 periods) - for i in 0..20 { - let price = 4599.0 - (i as f64 * 5.0); // -5 per bar = strong bearish momentum - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Extract CCI during oversold condition - let features = extractor.extract_features(4499.0, 100_000.0, timestamp); - let cci = features[22]; - - // CCI should indicate oversold (normalized negative value) - // CCI < -100 normalizes to negative value via (CCI / 200).tanh() - // -100 / 200 = -0.5, tanh(-0.5) ≈ -0.46 - // -200 / 200 = -1.0, tanh(-1.0) ≈ -0.76 - assert!( - cci < -0.3, - "CCI should indicate oversold (<-0.3), got {}", - cci - ); - - assert!( - cci >= -1.0, - "CCI should be normalized to [-1, 1], got {}", - cci - ); -} - -#[test] -fn test_cci_normal_range() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Create sideways market (prices oscillate around mean) - // CCI should stay in normal range [-100, +100] - for i in 0..30 { - // Oscillate ±2 around 4500 - let price = 4500.0 + ((i as f64 * 0.3).sin() * 2.0); - extractor.extract_features(price, 100_000.0, timestamp); - } - - let features = extractor.extract_features(4500.5, 100_000.0, timestamp); - let cci = features[22]; - - // CCI in normal range [-100, +100] should normalize to roughly [-0.4, +0.4] - // 0 → 0, ±50 / 200 = ±0.25, tanh(±0.25) ≈ ±0.24 - // ±100 / 200 = ±0.5, tanh(±0.5) ≈ ±0.46 - assert!( - (-0.5..=0.5).contains(&cci), - "CCI should be in normal range [-0.5, 0.5], got {}", - cci - ); - - assert!( - cci.is_finite(), - "CCI should be finite in normal range, got {}", - cci - ); -} - -#[test] -fn test_cci_extreme_values() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build base - for _ in 0..15 { - let price = 4500.0; - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Extreme upside move (flash rally) - for i in 0..20 { - let price = 4500.0 + (i as f64 * 20.0); // +20 per bar = extreme - extractor.extract_features(price, 100_000.0, timestamp); - } - - let features = extractor.extract_features(4900.0, 100_000.0, timestamp); - let cci = features[22]; - - // Even extreme CCI values should be capped by tanh to [-1, 1] - assert!( - (-1.0..=1.0).contains(&cci), - "CCI should be capped to [-1, 1] even with extreme values, got {}", - cci - ); - - // Should be strongly positive - assert!( - cci > 0.5, - "CCI should indicate extreme overbought (>0.5), got {}", - cci - ); -} - -#[test] -fn test_cci_zero_mean_deviation() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // All prices identical (zero deviation) - for _ in 0..25 { - extractor.extract_features(4500.0, 100_000.0, timestamp); - } - - let features = extractor.extract_features(4500.0, 100_000.0, timestamp); - let cci = features[22]; - - // With zero mean deviation, CCI should be 0 (or handle gracefully) - // Formula: CCI = (TP - SMA20) / (0.015 * Mean Deviation) - // When Mean Deviation = 0, CCI = 0 (special case handling) - assert!( - cci.abs() < 0.01 || cci.is_finite(), - "CCI should handle zero mean deviation gracefully, got {}", - cci - ); -} - -#[test] -fn test_cci_typical_price_calculation() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build history - for i in 0..25 { - let price = 4500.0 + (i as f64 * 0.5); - extractor.extract_features(price, 100_000.0, timestamp); - } - - let features = extractor.extract_features(4512.5, 100_000.0, timestamp); - let cci = features[22]; - - // Verify CCI is calculated and normalized - assert!( - cci.is_finite() && (-1.0..=1.0).contains(&cci), - "CCI should be valid and normalized, got {}", - cci - ); -} - -#[test] -fn test_cci_20_period_sma_calculation() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Build exactly 20 periods of data - let prices = vec![ - 4500.0, 4502.0, 4505.0, 4507.0, 4510.0, 4512.0, 4515.0, 4517.0, 4520.0, 4522.0, 4525.0, - 4527.0, 4530.0, 4532.0, 4535.0, 4537.0, 4540.0, 4542.0, 4545.0, 4547.0, - ]; - - for price in prices { - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Add one more price to compute CCI - let features = extractor.extract_features(4550.0, 100_000.0, timestamp); - let cci = features[22]; - - // SMA20 of prices should be around 4522.5 - // Current price 4550.0 is above SMA, so CCI should be positive - assert!( - cci > 0.0, - "CCI should be positive when price > SMA20, got {}", - cci - ); - - assert!( - cci.is_finite() && cci <= 1.0, - "CCI should be normalized and finite, got {}", - cci - ); -} - -#[test] -fn test_cci_mean_absolute_deviation() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Create volatile prices to test MAD calculation - let prices = vec![ - 4500.0, 4510.0, 4495.0, 4520.0, 4490.0, 4525.0, 4485.0, 4530.0, 4480.0, 4535.0, 4475.0, - 4540.0, 4470.0, 4545.0, 4465.0, 4550.0, 4460.0, 4555.0, 4455.0, 4560.0, - ]; - - for price in prices { - extractor.extract_features(price, 100_000.0, timestamp); - } - - let features = extractor.extract_features(4450.0, 100_000.0, timestamp); - let cci = features[22]; - - // High volatility should produce larger MAD, which dampens CCI magnitude - // CCI should still be normalized to [-1, 1] - assert!( - (-1.0..=1.0).contains(&cci), - "CCI should be normalized even with high volatility, got {}", - cci - ); - - assert!( - cci.is_finite(), - "CCI should handle volatile MAD calculation, got {}", - cci - ); -} - -#[test] -fn test_cci_insufficient_data() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Test with fewer than 20 periods (insufficient for CCI-20) - for i in 0..15 { - let price = 4500.0 + (i as f64 * 0.5); - let features = extractor.extract_features(price, 100_000.0, timestamp); - - // CCI should return 0.0 when insufficient data - if features.len() >= 22 { - let cci = features[22]; - assert!( - cci.abs() < 0.01 || cci.is_finite(), - "CCI should be 0 or finite with insufficient data (<20 periods), got {} at iteration {}", - cci, - i - ); - } - } -} - -#[test] -fn test_cci_performance_benchmark() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Warm up with 50 bars - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.25); - extractor.extract_features(price, 100_000.0, timestamp); - } - - // Benchmark CCI calculation latency (within full feature extraction) - let mut total_duration = std::time::Duration::ZERO; - - for i in 0..100 { - let price = 4500.0 + (50.0 + i as f64) * 0.25; - - let start = Instant::now(); - let _features = extractor.extract_features(price, 100_000.0, timestamp); - let duration = start.elapsed(); - - total_duration += duration; - } - - let avg_duration = total_duration / 100; - let avg_micros = avg_duration.as_micros(); - - println!("Average feature extraction time with CCI: {}μs", avg_micros); - - // Target: CCI should add <12μs to total feature extraction time - // Previous baseline: ~50μs for 20 features - // With CCI (21 features): should be <62μs (50 + 12) - assert!( - avg_micros < 62_000, - "Feature extraction with CCI too slow: {}μs (target: <62,000μs)", - avg_micros - ); -} - -#[test] -fn test_cci_normalization_tanh() { - let mut extractor = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Test that tanh normalization works correctly - // Build history - for i in 0..25 { - let price = 4500.0 + (i as f64 * 1.0); - extractor.extract_features(price, 100_000.0, timestamp); - } - - let features = extractor.extract_features(4550.0, 100_000.0, timestamp); - let cci = features[22]; - - // Verify tanh properties: - // 1. Output is always in [-1, 1] - assert!( - (-1.0..=1.0).contains(&cci), - "tanh should bound CCI to [-1, 1], got {}", - cci - ); - - // 2. tanh is monotonic (preserves sign) - // We know current price > SMA, so CCI should be positive - assert!( - cci >= 0.0, - "CCI should preserve sign through tanh, got {}", - cci - ); - - // 3. tanh(0) = 0 - // Test with zero CCI case - let mut extractor2 = MLFeatureExtractor::new(50); - for _ in 0..25 { - extractor2.extract_features(4500.0, 100_000.0, timestamp); - } - let features_zero = extractor2.extract_features(4500.0, 100_000.0, timestamp); - let cci_zero = features_zero[22]; - - assert!( - cci_zero.abs() < 0.01, - "tanh(0) should be ~0, got {}", - cci_zero - ); -} - -#[test] -fn test_cci_incremental_consistency() { - // Create two extractors with same parameters - let mut extractor1 = MLFeatureExtractor::new(50); - let mut extractor2 = MLFeatureExtractor::new(50); - let timestamp = Utc::now(); - - // Feed identical data to both - for i in 0..40 { - let price = 4500.0 + (i as f64 * 0.5); - let volume = 100_000.0; - - let features1 = extractor1.extract_features(price, volume, timestamp); - let features2 = extractor2.extract_features(price, volume, timestamp); - - // After sufficient warmup, CCI should be identical (deterministic) - if i >= 20 && features1.len() == 21 && features2.len() == 21 { - let cci1 = features1[20]; - let cci2 = features2[20]; - - assert!( - (cci1 - cci2).abs() < 1e-10, - "CCI values differ: {:.15} vs {:.15} at bar {}", - cci1, - cci2, - i - ); - } - } -} - -// ============================================================================ -// SimpleDQNAdapter 26-Feature Tests - Agent A11 (TDD Approach) -// ============================================================================ - -#[test] -fn test_simple_dqn_adapter_26_features() { - use common::ml_strategy::{MLModelAdapter, SimpleDQNAdapter}; - - let adapter = SimpleDQNAdapter::new("test_dqn_30".to_string()).unwrap(); - - // Create 30-feature vector (Wave A + Wave C) - let features: Vec = (0..30).map(|i| (i as f64) * 0.01).collect(); - - // Should predict successfully - let result = adapter.predict(&features); - assert!( - result.is_ok(), - "Adapter should handle 30 features, got error: {:?}", - result.as_ref().err() - ); - - let prediction = result.unwrap(); - assert_eq!(prediction.model_id, "test_dqn_30"); - assert!( - (0.0..=1.0).contains(&prediction.prediction_value), - "Prediction value should be in [0, 1], got {}", - prediction.prediction_value - ); -} - -#[test] -fn test_simple_dqn_adapter_weight_count() { - use common::ml_strategy::{MLModelAdapter, SimpleDQNAdapter}; - - let adapter = SimpleDQNAdapter::new("test_dqn_weights".to_string()).unwrap(); - - // Internal weights should be 30 (matching feature count from Wave A + Wave C) - // We test this indirectly by prediction success - let features: Vec = vec![0.0; 30]; - let result = adapter.predict(&features); - assert!(result.is_ok(), "Should accept 30-feature vector"); - - // Wrong feature count should fail - let wrong_features_short: Vec = vec![0.0; 18]; - let result = adapter.predict(&wrong_features_short); - assert!(result.is_err(), "Should reject 18-feature vector"); - - let wrong_features_long: Vec = vec![0.0; 50]; - let result = adapter.predict(&wrong_features_long); - assert!(result.is_err(), "Should reject 50-feature vector"); -} - -#[test] -fn test_simple_dqn_adapter_prediction_calculation() { - use common::ml_strategy::{MLModelAdapter, SimpleDQNAdapter}; - - let adapter = SimpleDQNAdapter::new("test_dqn_calc".to_string()).unwrap(); - - // All-zero features should give prediction near 0.5 (sigmoid(0)) - let zero_features: Vec = vec![0.0; 30]; - let result = adapter.predict(&zero_features).unwrap(); - assert!( - (result.prediction_value - 0.5).abs() < 0.01, - "Zero features should yield ~0.5 prediction, got {}", - result.prediction_value - ); - - // Positive features with positive weights should yield >0.5 - // (most weights are positive in SimpleDQNAdapter) - let positive_features: Vec = vec![1.0; 30]; - let result = adapter.predict(&positive_features).unwrap(); - assert!( - result.prediction_value > 0.5, - "Positive features should yield >0.5 prediction, got {}", - result.prediction_value - ); - - // Confidence should be reasonable - assert!( - (0.5..=1.0).contains(&result.confidence), - "Confidence should be in [0.5, 1.0], got {}", - result.confidence - ); -} - -#[test] -fn test_simple_dqn_adapter_new_indicator_weights() { - use common::ml_strategy::{MLModelAdapter, SimpleDQNAdapter}; - - let adapter = SimpleDQNAdapter::new("test_weights".to_string()).unwrap(); - - // Test with specific feature pattern: activate only new indicators - let mut features = vec![0.0; 30]; - - // Activate ADX (strong trend) at index 18 - features[18] = 0.8; // High ADX = strong trend - let result_adx = adapter.predict(&features).unwrap(); - - // Reset and test Bollinger Bands at index 19 - features[18] = 0.0; - features[19] = 1.0; // At upper band (overbought) - let result_bb = adapter.predict(&features).unwrap(); - - // Reset and test RSI at index 23 - features[19] = 0.0; - features[23] = 0.9; // High RSI (overbought) - let result_rsi = adapter.predict(&features).unwrap(); - - // All should influence prediction (not be neutral 0.5) - assert_ne!( - result_adx.prediction_value, 0.5, - "ADX should influence prediction" - ); - assert_ne!( - result_bb.prediction_value, 0.5, - "Bollinger Bands should influence prediction" - ); - assert_ne!( - result_rsi.prediction_value, 0.5, - "RSI should influence prediction" - ); - - // All predictions should be valid - assert!((0.0..=1.0).contains(&result_adx.prediction_value)); - assert!((0.0..=1.0).contains(&result_bb.prediction_value)); - assert!((0.0..=1.0).contains(&result_rsi.prediction_value)); -} - -#[test] -fn test_simple_dqn_adapter_dimension_mismatch() { - use common::ml_strategy::{MLModelAdapter, SimpleDQNAdapter}; - - let adapter = SimpleDQNAdapter::new("test_error".to_string()).unwrap(); - - // Too few features (18) - let short_features: Vec = vec![0.0; 18]; - let result = adapter.predict(&short_features); - assert!(result.is_err()); - let error_msg = format!("{}", result.unwrap_err()); - assert!( - error_msg.contains("Feature dimension mismatch"), - "Error should mention dimension mismatch" - ); - assert!( - error_msg.contains("expected 30"), - "Error should mention expected count" - ); - assert!( - error_msg.contains("got 18"), - "Error should mention actual count" - ); - - // Too many features (50) - let long_features: Vec = vec![0.0; 50]; - let result = adapter.predict(&long_features); - assert!(result.is_err()); - let error_msg = format!("{}", result.unwrap_err()); - assert!(error_msg.contains("expected 30")); - assert!(error_msg.contains("got 50")); -} - -#[tokio::test] -async fn test_simple_dqn_adapter_with_real_features() { - use common::ml_strategy::{MLFeatureExtractor, MLModelAdapter, SimpleDQNAdapter}; - - let mut extractor = MLFeatureExtractor::new(50); - let adapter = SimpleDQNAdapter::new("dqn_e2e".to_string()).unwrap(); - let timestamp = Utc::now(); - - // Build up 50 bars of market data - for i in 0..50 { - let price = 4500.0 + (i as f64 * 0.5); - let volume = 100_000.0; - extractor.extract_features(price, volume, timestamp); - } - - // Extract final feature vector (should be 30 features) - let features = extractor.extract_features(4525.0, 100_000.0, timestamp); - assert_eq!( - features.len(), - 30, - "Feature extractor should return 30 features (Wave A + Wave C)" - ); - - // Predict with SimpleDQNAdapter - let result = adapter.predict(&features); - assert!( - result.is_ok(), - "Adapter should predict successfully with real features, got: {:?}", - result.as_ref().err() - ); - - let prediction = result.unwrap(); - assert!( - (0.0..=1.0).contains(&prediction.prediction_value), - "Prediction value out of range: {}", - prediction.prediction_value - ); - assert!( - (0.0..=1.0).contains(&prediction.confidence), - "Confidence out of range: {}", - prediction.confidence - ); - assert_eq!( - prediction.features.len(), - 30, - "Prediction should store 30 features (Wave A + Wave C)" - ); - assert_eq!(prediction.model_id, "dqn_e2e"); -} - -/// Test Wave D constructor creates extractor with 225 features -/// Validates the new_wave_d() constructor added in STEP 1 of 225-feature integration -#[test] -fn test_wave_d_constructor_feature_count() { - let extractor = MLFeatureExtractor::new_wave_d(50); - - // Verify expected feature count is set correctly - assert_eq!( - extractor.expected_feature_count(), - 225, - "Wave D extractor should expect 225 features (201 Wave C + 24 Wave D)" - ); -} diff --git a/crates/common/tests/shared_ml_strategy_integration_test.rs b/crates/common/tests/shared_ml_strategy_integration_test.rs index f310cf521..c6be8a5ca 100644 --- a/crates/common/tests/shared_ml_strategy_integration_test.rs +++ b/crates/common/tests/shared_ml_strategy_integration_test.rs @@ -3,16 +3,71 @@ //! Validates that ONE SINGLE SYSTEM works for both trading and backtesting services. //! NO duplication - both services use the same SharedMLStrategy instance. -use chrono::Utc; -use common::ml_strategy::{MLPrediction, SharedMLStrategy}; -use ml::features::ProductionFeatureExtractorAdapter; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use common::ml_strategy::{ + MLModelAdapter, MLPrediction, ProductionFeatureExtractor225, SharedMLStrategy, +}; use std::sync::Arc; +/// Mock adapter that produces deterministic predictions from features +struct MockAdapter { + id: String, +} + +impl MockAdapter { + fn new(id: &str) -> Self { + Self { + id: id.to_string(), + } + } +} + +impl MLModelAdapter for MockAdapter { + fn predict(&self, features: &[f64]) -> Result { + let sum: f64 = features.iter().sum::() / features.len().max(1) as f64; + let prediction_value = 1.0 / (1.0 + (-sum).exp()); + let confidence = 0.5 + (prediction_value - 0.5).abs() * 0.8; + Ok(MLPrediction { + model_id: self.id.clone(), + prediction_value, + confidence, + features: features.to_vec(), + timestamp: Utc::now(), + inference_latency_us: 10, + }) + } + + fn model_id(&self) -> &str { + &self.id + } + + fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) {} +} + +/// Mock extractor returning 225 features +struct MockExtractor; + +impl ProductionFeatureExtractor225 for MockExtractor { + fn update(&mut self, _price: f64, _volume: f64, _timestamp: DateTime) -> Result<()> { + Ok(()) + } + fn extract_features(&mut self) -> Result> { + Ok(vec![0.1; 225]) + } +} + +fn make_strategy(threshold: f64) -> SharedMLStrategy { + SharedMLStrategy::new( + Box::new(MockExtractor), + vec![Box::new(MockAdapter::new("mock_v1"))], + threshold, + ) +} + #[tokio::test] async fn test_single_strategy_both_services() { - // Create ONE SINGLE SYSTEM with production feature extractor (225 features) - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = Arc::new(SharedMLStrategy::new_with_production_extractor(extractor, 0.3).unwrap()); + let strategy = Arc::new(make_strategy(0.3)); // Simulate trading service using the strategy let trading_strategy = Arc::clone(&strategy); @@ -22,7 +77,6 @@ async fn test_single_strategy_both_services() { .await .expect("Trading service should get predictions"); - // Calculate vote if predictions are available if !predictions.is_empty() { trading_strategy.calculate_ensemble_vote(&predictions); } @@ -38,7 +92,6 @@ async fn test_single_strategy_both_services() { .await .expect("Backtesting service should get predictions"); - // Calculate vote if predictions are available if !predictions.is_empty() { backtesting_strategy.calculate_ensemble_vote(&predictions); } @@ -46,7 +99,6 @@ async fn test_single_strategy_both_services() { predictions.len() }); - // Both services should succeed let trading_count = trading_handle.await.expect("Trading task should complete"); let backtesting_count = backtesting_handle .await @@ -57,19 +109,14 @@ async fn test_single_strategy_both_services() { backtesting_count > 0, "Backtesting should generate predictions" ); - - // Performance tracking would be populated after validate_predictions is called - // For now, just verify the strategy is functioning } #[tokio::test] async fn test_concurrent_access_from_multiple_services() { - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = Arc::new(SharedMLStrategy::new_with_production_extractor(extractor, 0.5).unwrap()); + let strategy = Arc::new(make_strategy(0.0)); let mut handles = Vec::new(); - // Spawn 10 concurrent tasks (simulating trading + backtesting + monitoring services) for i in 0..10 { let strategy_clone = Arc::clone(&strategy); let handle = tokio::spawn(async move { @@ -84,7 +131,6 @@ async fn test_concurrent_access_from_multiple_services() { handles.push(handle); } - // Wait for all tasks for handle in handles { let predictions = handle.await.expect("Task should complete"); assert!(!predictions.is_empty(), "Should have predictions"); @@ -93,8 +139,7 @@ async fn test_concurrent_access_from_multiple_services() { #[tokio::test] async fn test_ensemble_vote_aggregation() { - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.0).unwrap(); + let strategy = make_strategy(0.0); let predictions = vec![ MLPrediction { @@ -128,7 +173,6 @@ async fn test_ensemble_vote_aggregation() { let (vote, confidence) = result.unwrap_or_default(); - // Weighted average should be between 0.6 and 0.8 assert!( (0.6..=0.8).contains(&vote), "Vote should be in expected range" @@ -141,8 +185,7 @@ async fn test_ensemble_vote_aggregation() { #[tokio::test] async fn test_performance_tracking_across_services() { - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = Arc::new(SharedMLStrategy::new_with_production_extractor(extractor, 0.5).unwrap()); + let strategy = Arc::new(make_strategy(0.0)); // Trading service generates signals for _ in 0..5 { @@ -151,7 +194,6 @@ async fn test_performance_tracking_across_services() { .await .expect("Should get predictions"); - // Validate positive outcome strategy.validate_predictions(&predictions, 0.05).await; } @@ -162,11 +204,9 @@ async fn test_performance_tracking_across_services() { .await .expect("Should get predictions"); - // Validate negative outcome strategy.validate_predictions(&predictions, -0.02).await; } - // Check performance summary let performance = strategy.get_performance_summary().await; for (model_id, perf) in performance.iter() { @@ -184,19 +224,14 @@ async fn test_performance_tracking_across_services() { #[tokio::test] async fn test_confidence_threshold_filtering() { - let high_extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let high_threshold_strategy = SharedMLStrategy::new_with_production_extractor(high_extractor, 0.95).unwrap(); + let high_threshold_strategy = make_strategy(0.95); + let low_threshold_strategy = make_strategy(0.1); - let low_extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let low_threshold_strategy = SharedMLStrategy::new_with_production_extractor(low_extractor, 0.1).unwrap(); - - // High threshold should filter out most predictions let high_predictions = high_threshold_strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await .expect("Should get predictions"); - // Low threshold should keep most predictions let low_predictions = low_threshold_strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await @@ -210,23 +245,20 @@ async fn test_confidence_threshold_filtering() { #[tokio::test] async fn test_feature_extraction_consistency() { - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = Arc::new(SharedMLStrategy::new_with_production_extractor(extractor, 0.5).unwrap()); + let strategy = Arc::new(make_strategy(0.0)); - // Generate predictions at two different times with same price/volume let predictions1 = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await .expect("Should get predictions"); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; let predictions2 = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await .expect("Should get predictions"); - // Should have same number of models responding assert_eq!( predictions1.len(), predictions2.len(), @@ -236,8 +268,7 @@ async fn test_feature_extraction_consistency() { #[tokio::test] async fn test_empty_prediction_handling() { - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.99).unwrap(); // Very high threshold + let strategy = make_strategy(0.99); let predictions = vec![]; @@ -247,12 +278,11 @@ async fn test_empty_prediction_handling() { #[tokio::test] async fn test_model_performance_accuracy_tracking() { - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.0).unwrap(); + let strategy = make_strategy(0.0); let prediction = MLPrediction { model_id: "test_model".to_string(), - prediction_value: 0.7, // Predicts positive + prediction_value: 0.7, confidence: 0.8, features: vec![], timestamp: Utc::now(), diff --git a/crates/common/tests/test_sharedml_225_features.rs b/crates/common/tests/test_sharedml_225_features.rs index 6f369788c..e35d57112 100644 --- a/crates/common/tests/test_sharedml_225_features.rs +++ b/crates/common/tests/test_sharedml_225_features.rs @@ -1,20 +1,57 @@ -//! VALIDATION 1/8: Test that SharedMLStrategy extracts 225 features +//! Validation: Test that SharedMLStrategy correctly passes features from +//! ProductionFeatureExtractorAdapter to model adapters. //! -//! This test validates that the Wave D implementation correctly extracts -//! all 225 features (201 Wave C + 24 Wave D regime detection features). +//! NOTE: ProductionFeatureExtractorAdapter currently produces 51 features +//! (43 base + 8 OFI placeholders). Full 225 features require MBP-10 data +//! and sequence-level feature engineering done at training time. +use anyhow::Result; use chrono::Utc; -use common::ml_strategy::SharedMLStrategy; +use common::ml_strategy::{MLModelAdapter, MLPrediction, SharedMLStrategy}; use ml::features::ProductionFeatureExtractorAdapter; -#[tokio::test] -async fn test_sharedml_extracts_225_features() { - // Create SharedMLStrategy with production feature extractor (225 features) - let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.5).unwrap(); +/// Mock adapter that captures features from predictions +struct FeatureCapturingAdapter { + id: String, +} - // Warm up the feature extractor with some historical data - // (needed to properly compute indicators like EMAs, RSI, etc.) +impl FeatureCapturingAdapter { + fn new(id: &str) -> Self { + Self { + id: id.to_string(), + } + } +} + +impl MLModelAdapter for FeatureCapturingAdapter { + fn predict(&self, features: &[f64]) -> Result { + Ok(MLPrediction { + model_id: self.id.clone(), + prediction_value: 0.5, + confidence: 1.0, // Always pass threshold so we can inspect features + features: features.to_vec(), + timestamp: Utc::now(), + inference_latency_us: 10, + }) + } + + fn model_id(&self) -> &str { + &self.id + } + + fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) {} +} + +#[tokio::test] +async fn test_production_extractor_feature_count() { + let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); + let strategy = SharedMLStrategy::new( + extractor, + vec![Box::new(FeatureCapturingAdapter::new("capture_v1"))], + 0.0, + ); + + // Warm up the feature extractor with historical data for i in 0..100 { let price = 100.0 + (i as f64 * 0.1); let volume = 1000.0 + (i as f64 * 10.0); @@ -23,13 +60,11 @@ async fn test_sharedml_extracts_225_features() { .await; } - // Extract features from the strategy let predictions = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await .expect("Should extract features successfully"); - // Get features from the first prediction (all models use same features) assert!( !predictions.is_empty(), "Should have at least one prediction" @@ -37,47 +72,30 @@ async fn test_sharedml_extracts_225_features() { let features = &predictions[0].features; - // VALIDATION 1: Verify feature count is exactly 225 + // ProductionFeatureExtractorAdapter produces 51 features (43 base + 8 OFI placeholders) assert_eq!( features.len(), - 225, - "SharedMLStrategy must extract exactly 225 features (201 Wave C + 24 Wave D), but got {}", + 51, + "ProductionFeatureExtractorAdapter should produce 51 features, got {}", features.len() ); - // VALIDATION 2: Verify no NaN values + // All features must be finite (no NaN, no Inf) for (i, f) in features.iter().enumerate() { - assert!( - !f.is_nan(), - "Feature at index {} is NaN (value: {})", - i, - f - ); + assert!(f.is_finite(), "Feature at index {} is not finite: {}", i, f); } - - // VALIDATION 3: Verify no Inf values - for (i, f) in features.iter().enumerate() { - assert!( - f.is_finite(), - "Feature at index {} is not finite (value: {}). All features must be finite numbers.", - i, - f - ); - } - - println!("✅ VALIDATION 1/8 PASSED"); - println!(" - Feature count: {} (expected 225)", features.len()); - println!(" - All features are finite"); - println!(" - No NaN or Inf values detected"); } #[tokio::test] -async fn test_feature_extraction_wave_d_breakdown() { - // Create SharedMLStrategy with production feature extractor (225 features) +async fn test_feature_extraction_all_finite() { let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.5).unwrap(); + let strategy = SharedMLStrategy::new( + extractor, + vec![Box::new(FeatureCapturingAdapter::new("capture_v1"))], + 0.0, + ); - // Warm up the feature extractor + // Warm up for i in 0..100 { let price = 100.0 + (i as f64 * 0.1); let volume = 1000.0 + (i as f64 * 10.0); @@ -86,7 +104,6 @@ async fn test_feature_extraction_wave_d_breakdown() { .await; } - // Extract features let predictions = strategy .get_ensemble_prediction(100.0, 1000.0, Utc::now()) .await @@ -94,40 +111,15 @@ async fn test_feature_extraction_wave_d_breakdown() { let features = &predictions[0].features; - // Verify feature breakdown (expected from Wave D documentation): - // - Wave A: 26 features (indices 0-25) - // - Wave B: 10 features (indices 26-35) [alternative bar sampling] - // - Wave C: 165 features (indices 36-200) [advanced feature engineering] - // - Wave D: 24 features (indices 201-224) [regime detection] - // Total: 225 features - - assert_eq!( - features.len(), - 225, - "Expected 225 total features (26 Wave A + 10 Wave B + 165 Wave C + 24 Wave D)" - ); - - // Verify Wave D features (indices 201-224) are present - for i in 201..225 { - let feature_value = features.get(i); + // All 51 features should be finite + for i in 0..features.len() { + let value = features.get(i); + assert!(value.is_some(), "Feature at index {} is missing", i); assert!( - feature_value.is_some(), - "Wave D feature at index {} is missing", - i - ); - - let value = feature_value.unwrap(); - assert!( - value.is_finite(), - "Wave D feature at index {} is not finite: {}", + value.unwrap().is_finite(), + "Feature at index {} is not finite: {}", i, - value + value.unwrap() ); } - - println!("✅ Wave D feature breakdown validated"); - println!(" - Wave A features (0-25): present"); - println!(" - Wave B features (26-35): present"); - println!(" - Wave C features (36-200): present"); - println!(" - Wave D features (201-224): present"); } diff --git a/crates/common/tests/volume_indicators_integration_test.rs b/crates/common/tests/volume_indicators_integration_test.rs deleted file mode 100644 index c8deba4c4..000000000 --- a/crates/common/tests/volume_indicators_integration_test.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Integration tests for volume-based technical indicators (OBV, MFI, VWAP) -//! -//! This test suite validates the implementation of volume indicators added in Wave 19.1.3 - -use chrono::Utc; -use common::ml_strategy::MLFeatureExtractor; - -#[test] -fn test_obv_accumulation_uptrend() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Simulate strong uptrend with increasing volume - for i in 0..20 { - let price = 100.0 + (i as f64 * 2.0); - let volume = 1000.0 + (i as f64 * 50.0); - let features = extractor.extract_features(price, volume, timestamp); - - if i >= 1 { - // OBV is at index 10 (7 base + 3 oscillators) - let obv = features[10]; - - // OBV should be positive in sustained uptrend - assert!( - obv > 0.0 || i == 1, - "OBV should be positive in uptrend at iteration {}, got {}", - i, - obv - ); - } - } -} - -#[test] -fn test_obv_distribution_downtrend() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Simulate strong downtrend - for i in 0..20 { - let price = 140.0 - (i as f64 * 2.0); - let volume = 1000.0 + (i as f64 * 50.0); - let features = extractor.extract_features(price, volume, timestamp); - - if i >= 1 { - let obv = features[10]; - - // OBV should be negative in sustained downtrend - assert!( - obv < 0.0 || i == 1, - "OBV should be negative in downtrend at iteration {}, got {}", - i, - obv - ); - } - } -} - -#[test] -fn test_obv_unchanged_on_flat_price() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Extract first feature to initialize - extractor.extract_features(100.0, 1000.0, timestamp); - - // Same price, different volumes - OBV should remain unchanged - let features1 = extractor.extract_features(100.0, 1500.0, timestamp); - let features2 = extractor.extract_features(100.0, 2000.0, timestamp); - let features3 = extractor.extract_features(100.0, 500.0, timestamp); - - let obv1 = features1[10]; - let obv2 = features2[10]; - let obv3 = features3[10]; - - // All OBV values should be equal when price is flat - assert_eq!(obv1, obv2, "OBV should not change when price is unchanged"); - assert_eq!(obv2, obv3, "OBV should not change when price is unchanged"); -} - -#[test] -fn test_mfi_overbought_condition() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Generate 15+ bars for MFI calculation - // Strong sustained uptrend with high volume = overbought - for i in 0..16 { - let price = 100.0 + (i as f64 * 3.0); - let volume = 1000.0 + (i as f64 * 200.0); - extractor.extract_features(price, volume, timestamp); - } - - // Final strong up move - let features = extractor.extract_features(148.0, 4000.0, timestamp); - - // MFI is at index 11 (7 base + 3 oscillators + OBV) - let mfi = features[11]; - - // MFI should be strongly positive (overbought, normalized from high MFI value) - assert!( - mfi > 0.3, - "MFI should indicate overbought condition (positive), got {}", - mfi - ); -} - -#[test] -fn test_mfi_oversold_condition() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Generate 15+ bars for MFI calculation - // Strong sustained downtrend with high volume = oversold - for i in 0..16 { - let price = 148.0 - (i as f64 * 3.0); - let volume = 1000.0 + (i as f64 * 200.0); - extractor.extract_features(price, volume, timestamp); - } - - // Final strong down move - let features = extractor.extract_features(100.0, 4000.0, timestamp); - - let mfi = features[11]; - - // MFI should be strongly negative (oversold, normalized from low MFI value) - assert!( - mfi < -0.3, - "MFI should indicate oversold condition (negative), got {}", - mfi - ); -} - -#[test] -fn test_mfi_neutral_condition() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Generate mixed market with equal buying/selling pressure - for i in 0..15 { - let price = if i % 2 == 0 { 100.0 } else { 101.0 }; - let volume = 1000.0; - extractor.extract_features(price, volume, timestamp); - } - - let features = extractor.extract_features(100.5, 1000.0, timestamp); - let mfi = features[11]; - - // MFI should be near neutral (close to 0) - assert!( - mfi.abs() < 0.5, - "MFI should be near neutral with mixed signals, got {}", - mfi - ); -} - -#[test] -fn test_vwap_benchmark_oscillating_market() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Trade around a base price with varying volumes - let prices = [100.0, 102.0, 98.0, 101.0, 99.0, 100.0, 103.0, 97.0]; - let volumes = [1000.0, 500.0, 1500.0, 800.0, 1200.0, 1000.0, 600.0, 1400.0]; - - for (price, volume) in prices.iter().zip(volumes.iter()) { - extractor.extract_features(*price, *volume, timestamp); - } - - let features = extractor.extract_features(100.0, 1000.0, timestamp); - - // VWAP is at index 12 (7 base + 3 oscillators + OBV + MFI) - let vwap_ratio = features[12]; - - // VWAP ratio should be near 0 when price oscillates around average - assert!( - vwap_ratio.abs() < 0.2, - "VWAP ratio should be near 0 for oscillating prices, got {}", - vwap_ratio - ); -} - -#[test] -fn test_vwap_below_current_price() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // High volume at low prices, then price rises with low volume - extractor.extract_features(100.0, 5000.0, timestamp); - extractor.extract_features(101.0, 4000.0, timestamp); - extractor.extract_features(102.0, 3000.0, timestamp); - - // Price jumps up with low volume - let features = extractor.extract_features(110.0, 500.0, timestamp); - let vwap_ratio = features[12]; - - // Price > VWAP, so ratio should be positive (bullish) - assert!( - vwap_ratio > 0.0, - "VWAP ratio should be positive when price > VWAP, got {}", - vwap_ratio - ); -} - -#[test] -fn test_vwap_above_current_price() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // High volume at high prices, then price drops with low volume - extractor.extract_features(110.0, 5000.0, timestamp); - extractor.extract_features(109.0, 4000.0, timestamp); - extractor.extract_features(108.0, 3000.0, timestamp); - - // Price drops with low volume - let features = extractor.extract_features(100.0, 500.0, timestamp); - let vwap_ratio = features[12]; - - // Price < VWAP, so ratio should be negative (bearish) - assert!( - vwap_ratio < 0.0, - "VWAP ratio should be negative when price < VWAP, got {}", - vwap_ratio - ); -} - -#[test] -fn test_all_volume_indicators_normalized() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Generate diverse market conditions to test normalization - for i in 0..20 { - let price = 100.0 + ((i as f64 * 5.0).sin() * 20.0); // Volatile sine wave - let volume = 500.0 + (i as f64 * 100.0); // Increasing volume - extractor.extract_features(price, volume, timestamp); - } - - let features = extractor.extract_features(105.0, 2500.0, timestamp); - - let obv = features[10]; - let mfi = features[11]; - let vwap = features[12]; - - // All volume indicators should be in [-1, 1] range - assert!( - (-1.0..=1.0).contains(&obv), - "OBV should be normalized to [-1, 1], got {}", - obv - ); - assert!( - (-1.0..=1.0).contains(&mfi), - "MFI should be normalized to [-1, 1], got {}", - mfi - ); - assert!( - (-1.0..=1.0).contains(&vwap), - "VWAP should be normalized to [-1, 1], got {}", - vwap - ); -} - -#[test] -fn test_volume_indicators_with_extreme_values() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Test with extreme volume spikes and price movements - for i in 0..15 { - let price = if i == 10 { 150.0 } else { 100.0 }; // Price spike - let volume = if i == 10 { 50000.0 } else { 1000.0 }; // Volume spike - extractor.extract_features(price, volume, timestamp); - } - - let features = extractor.extract_features(102.0, 1200.0, timestamp); - - let obv = features[10]; - let mfi = features[11]; - let vwap = features[12]; - - // Even with extreme values, indicators should remain normalized - assert!( - (-1.0..=1.0).contains(&obv), - "OBV should handle extreme values, got {}", - obv - ); - assert!( - (-1.0..=1.0).contains(&mfi), - "MFI should handle extreme values, got {}", - mfi - ); - assert!( - (-1.0..=1.0).contains(&vwap), - "VWAP should handle extreme values, got {}", - vwap - ); -} - -#[test] -fn test_volume_indicators_insufficient_data() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Test with minimal data points - let features1 = extractor.extract_features(100.0, 1000.0, timestamp); - let features2 = extractor.extract_features(101.0, 1100.0, timestamp); - - // OBV should work with 2 data points - assert_eq!(features1[10], 0.0, "OBV should be 0 for first data point"); - - // MFI should default to 0 with insufficient data (needs 15 points) - assert_eq!(features1[11], 0.0, "MFI should be 0 with insufficient data"); - assert_eq!(features2[11], 0.0, "MFI should be 0 with insufficient data"); - - // VWAP should work with any amount of data - assert!( - features1[12] >= -1.0 && features1[12] <= 1.0, - "VWAP should be calculated even with minimal data" - ); -} - -#[test] -fn test_feature_vector_includes_volume_indicators() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Generate sufficient data for all indicators - for i in 0..30 { - let price = 100.0 + (i as f64 * 0.5); - let volume = 1000.0 + (i as f64 * 10.0); - extractor.extract_features(price, volume, timestamp); - } - - let features = extractor.extract_features(115.0, 1300.0, timestamp); - - // Total features: 30 (Wave A + Wave C) - // Wave A: 26 features (7 base + 3 oscillators + 3 volume + 5 EMA + 1 ADX + 1 BB + 2 Stoch + 1 CCI + 1 RSI + 2 MACD) - // Wave C: 4 features (OBV Momentum, Volume Oscillator, A/D Line, EMA Ratio) - assert_eq!( - features.len(), - 30, - "Feature vector should include all 30 features (Wave A + Wave C)" - ); - - // Verify volume indicators are at correct indices - let obv = features[10]; - let mfi = features[11]; - let vwap = features[12]; - - assert!(obv.abs() <= 1.0, "OBV at index 10"); - assert!(mfi.abs() <= 1.0, "MFI at index 11"); - assert!(vwap.abs() <= 1.0, "VWAP at index 12"); -} - -#[test] -fn test_volume_indicators_provide_unique_signals() { - let mut extractor = MLFeatureExtractor::new(30); - let timestamp = Utc::now(); - - // Create scenario where volume indicators should diverge - // Phase 1: High volume accumulation at low prices - for i in 0..10 { - let price = 100.0 - (i as f64 * 0.5); - let volume = 1000.0 + (i as f64 * 300.0); // Increasing volume - extractor.extract_features(price, volume, timestamp); - } - - // Phase 2: Price recovery with moderate volume - for i in 0..10 { - let price = 95.0 + (i as f64 * 1.0); - let volume = 1500.0; // Consistent moderate volume - extractor.extract_features(price, volume, timestamp); - } - - let features = extractor.extract_features(105.0, 1600.0, timestamp); - - let obv = features[10]; - let mfi = features[11]; - let vwap = features[12]; - - // All three indicators should provide different perspectives - // OBV: Should reflect volume accumulation during downturn + recovery - // MFI: Should show recent buying pressure (14-period window) - // VWAP: Should show price relative to volume-weighted average - - // Verify they're not all the same (they provide unique information) - let indicators_equal = (obv - mfi).abs() < 0.01 && (mfi - vwap).abs() < 0.01; - assert!( - !indicators_equal, - "Volume indicators should provide different signals: OBV={}, MFI={}, VWAP={}", - obv, mfi, vwap - ); -} diff --git a/crates/common/tests/volume_indicators_test.rs b/crates/common/tests/volume_indicators_test.rs deleted file mode 100644 index 1abf72251..000000000 --- a/crates/common/tests/volume_indicators_test.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Volume-based technical indicators validation tests -//! -//! Tests for OBV (On-Balance Volume), MFI (Money Flow Index), and VWAP -//! (Volume-Weighted Average Price) implementation in ML feature extraction. - -use chrono::Utc; -use common::ml_strategy::MLFeatureExtractor; - -#[test] -fn test_obv_accumulation_on_uptrend() { - let mut extractor = MLFeatureExtractor::new(20); - - // Simulate uptrend with increasing prices and volume - let prices = [100.0, 101.0, 102.0, 103.0, 104.0]; - let volumes = [1000.0, 1100.0, 1200.0, 1300.0, 1400.0]; - - let mut features_list = Vec::new(); - for (price, volume) in prices.iter().zip(volumes.iter()) { - let features = extractor.extract_features(*price, *volume, Utc::now()); - features_list.push(features); - } - - // OBV should be increasing (positive accumulation) - // Feature index for OBV is 10 in Wave A feature set - let obv_feature_idx = 10; - - // First data point has no previous price, so OBV should be 0 - assert_eq!(features_list[0][obv_feature_idx], 0.0); - - // Subsequent OBV values should be positive and increasing - for i in 1..features_list.len() { - let obv = features_list[i][obv_feature_idx]; - assert!( - obv > 0.0, - "OBV should be positive in uptrend at index {}", - i - ); - - if i > 1 { - // Each OBV should be greater than or equal to previous (accumulation) - assert!( - obv >= features_list[i - 1][obv_feature_idx], - "OBV should increase in uptrend: {} < {}", - obv, - features_list[i - 1][obv_feature_idx] - ); - } - } -} - -#[test] -fn test_obv_distribution_on_downtrend() { - let mut extractor = MLFeatureExtractor::new(20); - - // Simulate downtrend with decreasing prices - let prices = [104.0, 103.0, 102.0, 101.0, 100.0]; - let volumes = [1000.0, 1100.0, 1200.0, 1300.0, 1400.0]; - - let mut features_list = Vec::new(); - for (price, volume) in prices.iter().zip(volumes.iter()) { - let features = extractor.extract_features(*price, *volume, Utc::now()); - features_list.push(features); - } - - // OBV is at index 10 in Wave A feature set - let obv_feature_idx = 10; - - // OBV should be decreasing (negative accumulation/distribution) - for i in 1..features_list.len() { - let obv = features_list[i][obv_feature_idx]; - assert!( - obv < 0.0, - "OBV should be negative in downtrend at index {}", - i - ); - - if i > 1 { - // Each OBV should be less than or equal to previous (distribution) - assert!( - obv <= features_list[i - 1][obv_feature_idx], - "OBV should decrease in downtrend" - ); - } - } -} - -#[test] -fn test_mfi_overbought_signal() { - let mut extractor = MLFeatureExtractor::new(20); - - // Generate 15 bars (need 15 for MFI 14-period calculation) - // Strong uptrend with high volume = overbought condition - for i in 0..15 { - let price = 100.0 + (i as f64 * 2.0); // Strong uptrend - let volume = 1000.0 + (i as f64 * 100.0); // Increasing volume - extractor.extract_features(price, volume, Utc::now()); - } - - // Last feature extraction should have MFI calculated - let features = extractor.extract_features(130.0, 2500.0, Utc::now()); - // MFI is at index 11 in Wave A feature set - let mfi_feature_idx = 11; - let mfi_normalized = features[mfi_feature_idx]; - - // MFI normalized from [0, 100] to [-1, 1] via ((mfi/50) - 1).tanh() - // High MFI (>70 = overbought) should map to positive normalized value - // MFI of 100 -> (100/50 - 1).tanh() = 1.0.tanh() = 0.76 - assert!( - mfi_normalized > 0.5, - "MFI should indicate overbought condition (positive normalized value): {}", - mfi_normalized - ); -} - -#[test] -fn test_mfi_oversold_signal() { - let mut extractor = MLFeatureExtractor::new(20); - - // Generate 15 bars with strong downtrend = oversold condition - for i in 0..15 { - let price = 130.0 - (i as f64 * 2.0); // Strong downtrend - let volume = 1000.0 + (i as f64 * 100.0); // Increasing volume on decline - extractor.extract_features(price, volume, Utc::now()); - } - - // Last feature extraction - let features = extractor.extract_features(100.0, 2500.0, Utc::now()); - // MFI is at index 11 in Wave A feature set - let mfi_feature_idx = 11; - let mfi_normalized = features[mfi_feature_idx]; - - // MFI normalized from [0, 100] to [-1, 1] - // Low MFI (<30 = oversold) should map to negative normalized value - // MFI of 0 -> (0/50 - 1).tanh() = -1.0.tanh() = -0.76 - assert!( - mfi_normalized < -0.3, - "MFI should indicate oversold condition (negative normalized value): {}", - mfi_normalized - ); -} - -#[test] -fn test_vwap_price_benchmark() { - let mut extractor = MLFeatureExtractor::new(20); - - // Trade at consistent price with varying volume - let prices = [100.0, 102.0, 98.0, 101.0, 99.0, 100.0]; - let volumes = [1000.0, 500.0, 1500.0, 800.0, 1200.0, 1000.0]; - - let mut features_list = Vec::new(); - for (price, volume) in prices.iter().zip(volumes.iter()) { - let features = extractor.extract_features(*price, *volume, Utc::now()); - features_list.push(features); - } - - // VWAP is at index 12 in Wave A feature set - let vwap_feature_idx = 12; - - // Last VWAP should be close to base price (oscillating around it) - let vwap_ratio = features_list.last().unwrap()[vwap_feature_idx]; - - // VWAP ratio = (current_price - VWAP) / VWAP, normalized with tanh - // Since prices oscillate around 100, VWAP should be near 100, ratio near 0 - assert!( - vwap_ratio.abs() < 0.3, - "VWAP ratio should be near 0 when price oscillates around average: {}", - vwap_ratio - ); -} - -#[test] -fn test_vwap_above_price_signal() { - let mut extractor = MLFeatureExtractor::new(20); - - // Start with high volume at high prices, then drop price with low volume - // This will create VWAP above current price (bearish signal) - extractor.extract_features(110.0, 5000.0, Utc::now()); // High price, high volume - extractor.extract_features(109.0, 4000.0, Utc::now()); - extractor.extract_features(108.0, 3000.0, Utc::now()); - - // Drop price with low volume - let features = extractor.extract_features(100.0, 500.0, Utc::now()); - // VWAP is at index 12 in Wave A feature set - let vwap_feature_idx = 12; - let vwap_ratio = features[vwap_feature_idx]; - - // Price dropped below VWAP -> negative ratio - assert!( - vwap_ratio < 0.0, - "VWAP ratio should be negative when price drops below VWAP: {}", - vwap_ratio - ); -} - -#[test] -fn test_vwap_below_price_signal() { - let mut extractor = MLFeatureExtractor::new(20); - - // Start with high volume at low prices, then raise price with low volume - // This will create VWAP below current price (bullish signal) - extractor.extract_features(100.0, 5000.0, Utc::now()); // Low price, high volume - extractor.extract_features(101.0, 4000.0, Utc::now()); - extractor.extract_features(102.0, 3000.0, Utc::now()); - - // Raise price with low volume - let features = extractor.extract_features(110.0, 500.0, Utc::now()); - // VWAP is at index 12 in Wave A feature set - let vwap_feature_idx = 12; - let vwap_ratio = features[vwap_feature_idx]; - - // Price rose above VWAP -> positive ratio - assert!( - vwap_ratio > 0.0, - "VWAP ratio should be positive when price rises above VWAP: {}", - vwap_ratio - ); -} - -#[test] -fn test_all_volume_indicators_normalized() { - let mut extractor = MLFeatureExtractor::new(20); - - // Generate sufficient data for all indicators (15+ bars for MFI) - for i in 0..20 { - let price = 100.0 + (i as f64 * 0.5); - let volume = 1000.0 + (i as f64 * 50.0); - extractor.extract_features(price, volume, Utc::now()); - } - - // Final feature extraction - let features = extractor.extract_features(110.0, 2000.0, Utc::now()); - - // Check that OBV, MFI, VWAP are all normalized to [-1, 1] - // Volume indicators are at indices 10, 11, 12 in Wave A feature set - let obv_idx = 10; - let mfi_idx = 11; - let vwap_idx = 12; - - assert!( - features[obv_idx] >= -1.0 && features[obv_idx] <= 1.0, - "OBV should be normalized to [-1, 1]: {}", - features[obv_idx] - ); - - assert!( - features[mfi_idx] >= -1.0 && features[mfi_idx] <= 1.0, - "MFI should be normalized to [-1, 1]: {}", - features[mfi_idx] - ); - - assert!( - features[vwap_idx] >= -1.0 && features[vwap_idx] <= 1.0, - "VWAP should be normalized to [-1, 1]: {}", - features[vwap_idx] - ); -} - -#[test] -fn test_feature_vector_length_increased() { - let mut extractor = MLFeatureExtractor::new(20); - - // Generate sufficient data - for i in 0..20 { - let price = 100.0 + i as f64; - let volume = 1000.0 + (i as f64 * 10.0); - extractor.extract_features(price, volume, Utc::now()); - } - - let features = extractor.extract_features(120.0, 1200.0, Utc::now()); - - // Total features: 30 (Wave A + Wave C) - // Wave A: 26 features (7 base + 3 oscillators + 3 volume + 5 EMA + 1 ADX + 1 BB + 2 Stoch + 1 CCI + 1 RSI + 2 MACD) - // Wave C: 4 features (OBV Momentum, Volume Oscillator, A/D Line, EMA Ratio) - - assert_eq!( - features.len(), - 30, - "Feature vector should have 30 elements (Wave A + Wave C)" - ); -} - -#[test] -fn test_insufficient_data_graceful_handling() { - let mut extractor = MLFeatureExtractor::new(20); - - // Only 1-2 data points (insufficient for MFI which needs 15) - let features1 = extractor.extract_features(100.0, 1000.0, Utc::now()); - let features2 = extractor.extract_features(101.0, 1100.0, Utc::now()); - - // Volume indicators are at indices 10, 11, 12 in Wave A feature set - let obv_idx = 10; - let mfi_idx = 11; - let vwap_idx = 12; - - // OBV should work with 2 data points - assert_eq!( - features1[obv_idx], 0.0, - "OBV should be 0 for first data point" - ); - assert!( - features2[obv_idx] != 0.0 || features2[obv_idx] == 0.0, - "OBV should be calculated or 0 for second data point" - ); - - // MFI should default to 0 with insufficient data - assert_eq!( - features1[mfi_idx], 0.0, - "MFI should be 0 with insufficient data" - ); - assert_eq!( - features2[mfi_idx], 0.0, - "MFI should be 0 with insufficient data" - ); - - // VWAP should work with any amount of data - assert!( - features1[vwap_idx] != 0.0 || features1[vwap_idx] == 0.0, - "VWAP should be calculated or 0" - ); -} diff --git a/crates/ml/src/ensemble/mod.rs b/crates/ml/src/ensemble/mod.rs index b6e053a1f..90912d3ec 100644 --- a/crates/ml/src/ensemble/mod.rs +++ b/crates/ml/src/ensemble/mod.rs @@ -24,8 +24,10 @@ pub mod adapters; pub mod conviction_gates; pub mod weight_optimizer; pub mod gate_optimizer; +pub mod model_adapter; // Re-export key types that are used across ensemble modules +pub use model_adapter::{EnsembleModelAdapter, build_production_strategy}; pub use ab_testing::{ ABGroup, ABMetricsTracker, ABTestConfig, ABTestResults, ABTestRouter, GroupMetrics, Recommendation, StatisticalTestResult, diff --git a/crates/ml/src/ensemble/model_adapter.rs b/crates/ml/src/ensemble/model_adapter.rs new file mode 100644 index 000000000..35b4fc358 --- /dev/null +++ b/crates/ml/src/ensemble/model_adapter.rs @@ -0,0 +1,128 @@ +//! Bridge between ml model registry and common::ml_strategy::MLModelAdapter trait. +//! +//! EnsembleModelAdapter wraps a model ID and implements MLModelAdapter so it can +//! be injected into SharedMLStrategy. When real checkpoint loading is wired, this +//! adapter will delegate to the loaded model for inference. + +use anyhow::Result; +use chrono::Utc; +use common::ml_strategy::{MLModelAdapter, MLPrediction, SharedMLStrategy}; +use crate::features::production_adapter::ProductionFeatureExtractorAdapter; + +/// Adapter that will delegate predict() to a loaded model checkpoint. +/// +/// Currently returns neutral predictions with zero confidence (filtered out +/// by the confidence threshold) until checkpoint loading is production-ready. +#[derive(Debug)] +pub struct EnsembleModelAdapter { + model_id: String, +} + +impl EnsembleModelAdapter { + pub fn new>(model_id: S) -> Self { + Self { + model_id: model_id.into(), + } + } +} + +impl MLModelAdapter for EnsembleModelAdapter { + fn predict(&self, features: &[f64]) -> Result { + // TODO: Wire to real model inference via checkpoint loading when + // the model registry is production-ready. For now, return neutral + // prediction so the ensemble pipeline is fully wired end-to-end. + let _ = features; + Ok(MLPrediction { + model_id: self.model_id.clone(), + prediction_value: 0.5, + confidence: 0.0, // Zero confidence = filtered out by threshold + features: vec![], + timestamp: Utc::now(), + inference_latency_us: 0, + }) + } + + fn model_id(&self) -> &str { + &self.model_id + } + + fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) { + // Performance tracking handled by SharedMLStrategy + } +} + +/// Build a production-ready SharedMLStrategy with model adapters for each +/// model in the 10-model ensemble. +/// +/// Returns a strategy with: +/// - ProductionFeatureExtractorAdapter (51 features currently) +/// - One EnsembleModelAdapter per known model type +/// +/// When no checkpoints are loaded, models return neutral predictions with +/// zero confidence, which get filtered out by the confidence threshold. +pub fn build_production_strategy(min_confidence_threshold: f64) -> SharedMLStrategy { + let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); + + let model_ids = [ + "dqn", "ppo", "tft", "mamba2", "tggn", "tlob", "liquid", "kan", "xlstm", "diffusion", + ]; + let models: Vec> = model_ids + .iter() + .map(|&id| Box::new(EnsembleModelAdapter::new(id)) as Box) + .collect(); + + SharedMLStrategy::new(extractor, models, min_confidence_threshold) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ensemble_model_adapter_neutral_prediction() { + let adapter = EnsembleModelAdapter::new("dqn"); + let features = vec![0.1; 51]; + let prediction = adapter.predict(&features).unwrap(); + + assert_eq!(prediction.model_id, "dqn"); + assert_eq!(prediction.prediction_value, 0.5); + assert_eq!(prediction.confidence, 0.0); + } + + #[test] + fn test_build_production_strategy() { + let strategy = build_production_strategy(0.6); + assert_eq!(strategy.min_confidence_threshold(), 0.6); + } + + #[tokio::test] + async fn test_production_strategy_filters_neutral_predictions() { + let strategy = build_production_strategy(0.5); + let predictions = strategy + .get_ensemble_prediction(100.0, 1000.0, Utc::now()) + .await + .unwrap(); + + // All 10 adapters return confidence=0.0, so threshold=0.5 filters them all + assert!( + predictions.is_empty(), + "Neutral predictions (confidence=0.0) should be filtered by threshold=0.5" + ); + } + + #[tokio::test] + async fn test_production_strategy_zero_threshold_keeps_all() { + let strategy = build_production_strategy(0.0); + let predictions = strategy + .get_ensemble_prediction(100.0, 1000.0, Utc::now()) + .await + .unwrap(); + + // With threshold=0.0, all 10 neutral predictions pass through + assert_eq!( + predictions.len(), + 10, + "Zero threshold should keep all 10 model predictions" + ); + } +} diff --git a/crates/ml/src/features/config.rs b/crates/ml/src/features/config.rs index e5cfcb32b..36b6c7c93 100644 --- a/crates/ml/src/features/config.rs +++ b/crates/ml/src/features/config.rs @@ -9,7 +9,7 @@ //! ## Architecture //! //! FeatureConfig provides a single source of truth for feature extraction -//! across both training (DbnSequenceLoader) and inference (MLFeatureExtractor). +//! across both training (DbnSequenceLoader) and inference (ProductionFeatureExtractorAdapter). //! This eliminates the previous padding bug (256 features via 25x repetition). //! //! ## Usage @@ -209,7 +209,7 @@ pub fn wave_d_features() -> Vec { /// Feature extraction configuration for Wave 19 progressive engineering /// /// Tracks which feature groups are enabled across Wave A/B/C/D phases. -/// Used by both training (DbnSequenceLoader) and inference (MLFeatureExtractor). +/// Used by both training (DbnSequenceLoader) and inference (ProductionFeatureExtractorAdapter). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeatureConfig { /// Feature engineering phase (Wave A/B/C/D) diff --git a/crates/ml/src/features/production_adapter.rs b/crates/ml/src/features/production_adapter.rs index b30e0dd20..5726817be 100644 --- a/crates/ml/src/features/production_adapter.rs +++ b/crates/ml/src/features/production_adapter.rs @@ -22,7 +22,7 @@ use super::extraction::{FeatureExtractor, OHLCVBar}; /// use common::ml_strategy::SharedMLStrategy; /// /// let extractor = Box::new(ProductionFeatureExtractorAdapter::new()); -/// let strategy = SharedMLStrategy::new_with_production_extractor(extractor, 0.7); +/// let strategy = SharedMLStrategy::new(extractor, vec![], 0.7); /// ``` #[derive(Debug)] pub struct ProductionFeatureExtractorAdapter { diff --git a/crates/ml/src/training.rs b/crates/ml/src/training.rs index 4a3c79519..410b6be92 100644 --- a/crates/ml/src/training.rs +++ b/crates/ml/src/training.rs @@ -12,7 +12,6 @@ // Sub-modules for specialized training components pub mod orchestrator; -pub mod push_metrics; pub mod unified_data_loader; pub mod unified_trainer; // NEW: Unified training trait for all models // NEW: Model-agnostic training orchestrator diff --git a/crates/ml/src/training/push_metrics.rs b/crates/ml/src/training/push_metrics.rs deleted file mode 100644 index 0d90af6a1..000000000 --- a/crates/ml/src/training/push_metrics.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Prometheus Pushgateway integration for training job metrics -//! -//! Training jobs are short-lived K8s Jobs. They push epoch-level metrics -//! to the Pushgateway so Prometheus can scrape them persistently. - -use std::fmt::Write; - -/// Pushgateway client for training metrics -#[derive(Debug)] -pub struct TrainingMetricsPusher { - pushgateway_url: String, - job_id: String, - model_name: String, - client: reqwest::Client, -} - -impl TrainingMetricsPusher { - /// Create a new pusher. Falls back to in-cluster Pushgateway if `PUSHGATEWAY_URL` not set. - pub fn new(job_id: &str, model_name: &str) -> Self { - let pushgateway_url = std::env::var("PUSHGATEWAY_URL") - .unwrap_or_else(|_| "http://pushgateway.foxhunt.svc.cluster.local:9091".to_string()); - Self { - pushgateway_url, - job_id: job_id.to_string(), - model_name: model_name.to_string(), - client: reqwest::Client::new(), - } - } - - /// Push current training state to Pushgateway. - /// Non-fatal: logs errors but never panics. - pub async fn push(&self, state: &TrainingState) { - let mut body = String::new(); - - // Epoch progress - _ = writeln!(body, "# HELP foxhunt_training_current_epoch Current training epoch"); - _ = writeln!(body, "# TYPE foxhunt_training_current_epoch gauge"); - _ = writeln!(body, "foxhunt_training_current_epoch {}", state.epoch); - - // Loss - _ = writeln!( - body, - "# HELP foxhunt_training_epoch_loss Training loss value" - ); - _ = writeln!(body, "# TYPE foxhunt_training_epoch_loss gauge"); - _ = writeln!( - body, - "foxhunt_training_epoch_loss{{split=\"train\"}} {}", - state.train_loss - ); - if let Some(val_loss) = state.val_loss { - _ = writeln!( - body, - "foxhunt_training_epoch_loss{{split=\"val\"}} {}", - val_loss - ); - } - - // Accuracy - if let Some(accuracy) = state.accuracy { - _ = writeln!( - body, - "# HELP foxhunt_training_eval_accuracy Model evaluation accuracy" - ); - _ = writeln!(body, "# TYPE foxhunt_training_eval_accuracy gauge"); - _ = writeln!(body, "foxhunt_training_eval_accuracy {}", accuracy); - } - - // Batches processed - _ = writeln!( - body, - "# HELP foxhunt_training_batches_processed Total batches processed" - ); - _ = writeln!(body, "# TYPE foxhunt_training_batches_processed counter"); - _ = writeln!( - body, - "foxhunt_training_batches_processed {}", - state.batches_processed - ); - - // Training speed - if state.batches_per_second > 0.0 { - _ = writeln!( - body, - "# HELP foxhunt_training_batches_per_second Training throughput" - ); - _ = writeln!(body, "# TYPE foxhunt_training_batches_per_second gauge"); - _ = writeln!( - body, - "foxhunt_training_batches_per_second {}", - state.batches_per_second - ); - } - - // Learning rate - _ = writeln!( - body, - "# HELP foxhunt_training_learning_rate Current learning rate" - ); - _ = writeln!(body, "# TYPE foxhunt_training_learning_rate gauge"); - _ = writeln!( - body, - "foxhunt_training_learning_rate {}", - state.learning_rate - ); - - // NaN / gradient events - if state.nan_count > 0 { - _ = writeln!( - body, - "# HELP foxhunt_training_nan_detected_total NaN gradient events" - ); - _ = writeln!(body, "# TYPE foxhunt_training_nan_detected_total counter"); - _ = writeln!( - body, - "foxhunt_training_nan_detected_total {}", - state.nan_count - ); - } - if state.gradient_explosion_count > 0 { - _ = writeln!( - body, - "# HELP foxhunt_training_gradient_explosion_total Gradient clipping events" - ); - _ = writeln!( - body, - "# TYPE foxhunt_training_gradient_explosion_total counter" - ); - _ = writeln!( - body, - "foxhunt_training_gradient_explosion_total {}", - state.gradient_explosion_count - ); - } - - // Checkpoint saves - _ = writeln!( - body, - "# HELP foxhunt_training_checkpoint_saves_total Checkpoints saved" - ); - _ = writeln!( - body, - "# TYPE foxhunt_training_checkpoint_saves_total counter" - ); - _ = writeln!( - body, - "foxhunt_training_checkpoint_saves_total {}", - state.checkpoint_saves - ); - - let url = format!( - "{}/metrics/job/{}/model/{}", - self.pushgateway_url, self.job_id, self.model_name - ); - - match self.client.put(&url).body(body).send().await { - Ok(resp) if resp.status().is_success() => {} - Ok(resp) => { - eprintln!( - "Pushgateway returned {}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - ); - } - Err(e) => { - eprintln!("Failed to push metrics to Pushgateway: {e}"); - } - } - } - - /// Delete metrics for this job from Pushgateway (call on training completion). - pub async fn cleanup(&self) { - let url = format!( - "{}/metrics/job/{}/model/{}", - self.pushgateway_url, self.job_id, self.model_name - ); - if let Err(e) = self.client.delete(&url).send().await { - eprintln!("Failed to delete metrics from Pushgateway: {e}"); - } - } -} - -/// Current training state to push -#[derive(Debug)] -pub struct TrainingState { - pub epoch: u64, - pub train_loss: f64, - pub val_loss: Option, - pub accuracy: Option, - pub batches_processed: u64, - pub batches_per_second: f64, - pub learning_rate: f64, - pub nan_count: u64, - pub gradient_explosion_count: u64, - pub checkpoint_saves: u64, -} - -impl Default for TrainingState { - fn default() -> Self { - Self { - epoch: 0, - train_loss: 0.0, - val_loss: None, - accuracy: None, - batches_processed: 0, - batches_per_second: 0.0, - learning_rate: 0.001, - nan_count: 0, - gradient_explosion_count: 0, - checkpoint_saves: 0, - } - } -} diff --git a/services/backtesting_service/src/ml_strategy_engine.rs b/services/backtesting_service/src/ml_strategy_engine.rs index 38a48cb8a..1813508dd 100644 --- a/services/backtesting_service/src/ml_strategy_engine.rs +++ b/services/backtesting_service/src/ml_strategy_engine.rs @@ -20,8 +20,6 @@ use config::structures::BacktestingStrategyConfig; // Import shared ML strategy (ONE SINGLE SYSTEM) use common::ml_strategy::{MLPrediction as CommonMLPrediction, SharedMLStrategy}; -// Import ProductionFeatureExtractorAdapter for 225-feature extraction -use ml::features::production_adapter::ProductionFeatureExtractorAdapter; // Import UnifiedFeatureExtractor (256 features, production system) use ml::features::unified::{FeatureExtractionConfig, UnifiedFeatureExtractor}; @@ -119,11 +117,7 @@ impl MLPoweredStrategy { pub fn new(name: String, _lookback_periods: usize) -> Result { // Use shared ML strategy (ONE SINGLE SYSTEM) with ProductionFeatureExtractorAdapter (225 features) let min_confidence_threshold = 0.6; - let production_extractor = Box::new(ProductionFeatureExtractorAdapter::new()); - let strategy = Arc::new(SharedMLStrategy::new_with_production_extractor( - production_extractor, - min_confidence_threshold, - )?); + let strategy = Arc::new(ml::ensemble::build_production_strategy(min_confidence_threshold)); // Initialize UnifiedFeatureExtractor (256 features) let feature_config = FeatureExtractionConfig::default(); diff --git a/services/backtesting_service/tests/ml_strategy_backtest_test.rs b/services/backtesting_service/tests/ml_strategy_backtest_test.rs deleted file mode 100644 index 3149d4a78..000000000 --- a/services/backtesting_service/tests/ml_strategy_backtest_test.rs +++ /dev/null @@ -1,573 +0,0 @@ -//! ML Strategy Backtesting Tests - TDD Implementation -//! -//! Following strict TDD methodology (RED-GREEN-REFACTOR): -//! 1. RED: Write failing tests first -//! 2. GREEN: Minimal code to pass tests -//! 3. REFACTOR: Improve quality -//! -//! Tests ML ensemble predictions on historical market data. - -use backtesting_service::dbn_data_source::DbnDataSource; -use backtesting_service::ml_strategy_engine::MLPoweredStrategy; -use backtesting_service::strategy_engine::{Portfolio, StrategyExecutor, TradeSide}; -use common::ml_strategy::MLFeatureExtractor; -use num_traits::ToPrimitive; -use rust_decimal::Decimal; -use std::collections::HashMap; - -mod helpers; -use helpers::{assert_chronological, assert_valid_ohlcv}; - -/// Helper: Get test data directory -fn get_test_data_dir() -> String { - let current_dir = std::env::current_dir().expect("INVARIANT: Current directory should be accessible"); - let workspace_root = current_dir - .ancestors() - .find(|p| p.join("Cargo.toml").exists() && p.join("test_data").exists()) - .expect("Could not find workspace root"); - - workspace_root - .join("test_data/real/databento") - .to_string_lossy() - .to_string() -} - -/// Helper: Create DBN data source for test symbol -async fn create_test_data_source(symbol: &str) -> DbnDataSource { - let test_dir = get_test_data_dir(); - let mut file_mapping = HashMap::new(); - - let file_path = match symbol { - "ES.FUT" => format!("{}/ES.FUT_ohlcv-1m_2024-01-02.dbn", test_dir), - "NQ.FUT" => format!("{}/NQ.FUT_ohlcv-1m_2024-01-02.dbn", test_dir), - "ZN.FUT" => format!("{}/ZN.FUT_ohlcv-1d_2024.dbn", test_dir), - _ => panic!("Unknown test symbol: {}", symbol), - }; - - file_mapping.insert(symbol.to_string(), file_path); - - DbnDataSource::new(file_mapping) - .await - .expect("Failed to create DBN data source") -} - -// ============================================================================= -// TEST 1: ML Strategy Execution -// ============================================================================= - -#[tokio::test] -async fn test_ml_strategy_generates_predictions() { - // RED: Test ML strategy prediction generation - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - // Validate data quality - assert!(!bars.is_empty(), "No bars loaded"); - assert_valid_ohlcv(&bars); - assert_chronological(&bars); - - // Create ML strategy - let mut ml_strategy = MLPoweredStrategy::new("test_ml_strategy".to_string(), 20); - - // Generate predictions for first 50 bars - let mut prediction_count = 0; - let portfolio = Portfolio::new(Decimal::from(100000)); - let parameters: HashMap = HashMap::new(); - - for bar in bars.iter().take(50) { - let predictions = ml_strategy.get_ensemble_prediction(bar).await; - - if let Ok(preds) = predictions { - // Predictions may be empty if confidence threshold filters them out - // This is expected behavior - we just count non-empty predictions - if preds.is_empty() { - continue; - } - - // Validate prediction structure when we have predictions - assert!(preds.len() >= 1, "Expected at least 1 model prediction"); - - // Validate prediction structure - for pred in &preds { - assert!( - pred.confidence >= 0.0 && pred.confidence <= 1.0, - "Confidence out of range: {}", - pred.confidence - ); - assert!( - pred.prediction_value >= 0.0 && pred.prediction_value <= 1.0, - "Prediction value out of range: {}", - pred.prediction_value - ); - assert!(pred.inference_latency_us > 0, "Invalid inference latency"); - } - - prediction_count += 1; - } - } - - // Note: All predictions may be filtered by confidence threshold (0.6 default) - // This is valid behavior - the simple model may not have high confidence predictions - // We just verify the system works without errors - println!("✓ ML strategy executed on 50 bars: {} predictions passed confidence threshold ({}+ filtered)", - prediction_count, 50 - prediction_count); - - // Verify system executed without errors (predictions may be 0 due to confidence filtering) - assert!( - prediction_count >= 0, - "System should execute without errors" - ); -} - -#[tokio::test] -async fn test_ml_strategy_ensemble_voting() { - // RED: Test ensemble voting mechanism - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - let mut ml_strategy = MLPoweredStrategy::new("test_ensemble".to_string(), 20); - - // Get ensemble predictions for first bar with sufficient history - for bar in bars.iter().take(30) { - let predictions = ml_strategy.get_ensemble_prediction(bar).await.unwrap(); - - if predictions.len() >= 2 { - // Calculate ensemble vote - let ensemble_vote = ml_strategy.calculate_ensemble_vote(&predictions); - - assert!(ensemble_vote.is_some(), "Ensemble vote should be computed"); - - let (ensemble_pred, ensemble_conf) = ensemble_vote.unwrap(); - - // Validate ensemble output - assert!( - ensemble_pred >= 0.0 && ensemble_pred <= 1.0, - "Ensemble prediction out of range: {}", - ensemble_pred - ); - assert!( - ensemble_conf >= 0.0 && ensemble_conf <= 1.0, - "Ensemble confidence out of range: {}", - ensemble_conf - ); - - // Ensemble should be within bounds of individual predictions - let min_pred = predictions - .iter() - .map(|p| p.prediction_value) - .fold(f64::INFINITY, f64::min); - let max_pred = predictions - .iter() - .map(|p| p.prediction_value) - .fold(f64::NEG_INFINITY, f64::max); - - assert!( - ensemble_pred >= min_pred && ensemble_pred <= max_pred, - "Ensemble prediction {} outside range [{}, {}]", - ensemble_pred, - min_pred, - max_pred - ); - - break; // Test first valid ensemble - } - } -} - -// ============================================================================= -// TEST 2: ML Backtest Execution -// ============================================================================= - -#[tokio::test] -async fn test_ml_backtest_generates_trades() { - // RED: Test ML backtest generates trades - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - let ml_strategy = MLPoweredStrategy::new("ml_backtest".to_string(), 20); - let portfolio = Portfolio::new(Decimal::from(100000)); - let parameters = HashMap::new(); - - let mut total_signals = 0; - - // Execute strategy on bars - for bar in bars.iter().take(200) { - let signals = ml_strategy.execute(bar, &portfolio, ¶meters); - - if let Ok(sigs) = signals { - total_signals += sigs.len(); - - // Validate signal structure - for sig in sigs { - assert!( - sig.strength >= Decimal::ZERO && sig.strength <= Decimal::ONE, - "Signal strength out of range" - ); - assert!(sig.quantity > Decimal::ZERO, "Quantity must be positive"); - assert!(!sig.reason.is_empty(), "Signal should have reason"); - } - } - } - - assert!( - total_signals > 0, - "ML strategy should generate at least some trade signals" - ); - println!("✓ ML strategy generated {} trade signals", total_signals); -} - -// ============================================================================= -// TEST 3: Confidence Threshold Filtering -// ============================================================================= - -#[tokio::test] -async fn test_confidence_threshold_filtering() { - // RED: Test that confidence threshold filters low-confidence trades - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - // Test with low threshold (0.3) vs high threshold (0.8) - let thresholds = vec![0.3, 0.8]; - let mut signal_counts = Vec::new(); - - for threshold in thresholds { - let ml_strategy = MLPoweredStrategy::new("ml_confidence_test".to_string(), 20); - let portfolio = Portfolio::new(Decimal::from(100000)); - let mut parameters = HashMap::new(); - parameters.insert("min_confidence".to_string(), threshold.to_string()); - - let mut signal_count = 0; - - for bar in bars.iter().take(100) { - if let Ok(signals) = ml_strategy.execute(bar, &portfolio, ¶meters) { - signal_count += signals.len(); - } - } - - signal_counts.push(signal_count); - } - - // Higher threshold should generate fewer signals - assert!( - signal_counts[1] <= signal_counts[0], - "Higher confidence threshold ({}) should generate fewer signals. Got {} vs {}", - 0.8, - signal_counts[1], - signal_counts[0] - ); - - println!( - "✓ Confidence filtering works: 0.3 threshold={} signals, 0.8 threshold={} signals", - signal_counts[0], signal_counts[1] - ); -} - -// ============================================================================= -// TEST 4: Multi-Symbol ML Backtesting -// ============================================================================= - -#[tokio::test] -async fn test_ml_backtest_multi_symbol() { - // RED: Test ML backtesting across multiple symbols - - let symbols = vec!["ES.FUT", "NQ.FUT"]; - - for symbol in symbols { - let data_source = create_test_data_source(symbol).await; - - // Check if data file exists - if data_source.get_file_path(symbol).is_none() { - eprintln!("⚠️ Skipping {} - data file not found", symbol); - continue; - } - - let bars_result = data_source.load_ohlcv_bars(symbol).await; - - if bars_result.is_err() { - eprintln!("⚠️ Skipping {} - failed to load bars", symbol); - continue; - } - - let bars = bars_result.unwrap(); - - if bars.is_empty() { - eprintln!("⚠️ Skipping {} - no bars loaded", symbol); - continue; - } - - // Run ML backtest - let ml_strategy = MLPoweredStrategy::new(format!("ml_{}", symbol), 20); - let portfolio = Portfolio::new(Decimal::from(100000)); - let parameters = HashMap::new(); - - let mut signal_count = 0; - - for bar in bars.iter().take(50) { - if let Ok(signals) = ml_strategy.execute(bar, &portfolio, ¶meters) { - signal_count += signals.len(); - - // Validate signals are for correct symbol - for sig in signals { - assert_eq!(sig.symbol, symbol, "Signal symbol mismatch"); - } - } - } - - println!( - "✓ ML backtest for {}: {} signals generated", - symbol, signal_count - ); - } -} - -// ============================================================================= -// TEST 5: ML Performance Metrics -// ============================================================================= - -#[tokio::test] -async fn test_ml_backtest_performance_metrics() { - // RED: Test comprehensive performance metrics calculation - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - let ml_strategy = MLPoweredStrategy::new("ml_performance".to_string(), 20); - let portfolio = Portfolio::new(Decimal::from(100000)); - let parameters = HashMap::new(); - - let mut equity_curve = vec![100000.0]; - - // Simulate simple backtest (buy signals only for testing) - for bar in bars.iter().take(100) { - if let Ok(signals) = ml_strategy.execute(bar, &portfolio, ¶meters) { - for sig in signals { - if sig.side == TradeSide::Buy && portfolio.cash() > Decimal::ZERO { - // Simulate a small trade (simplified) - let trade_size = Decimal::from(100); - if trade_size < portfolio.cash() { - // Track equity (simplified - just price changes) - let current_equity = equity_curve.last().expect("INVARIANT: Collection should be non-empty"); - - // Prevent infinite/NaN Sharpe ratios - limit equity curve growth - if equity_curve.len() > 500 { - break; - } - let price_change = 0.01; // 1% change simulation - equity_curve.push(current_equity * (1.0 + price_change)); - } - } - } - } - } - - // Calculate basic performance metrics - if equity_curve.len() > 1 { - let initial_equity = equity_curve.first().expect("INVARIANT: Collection should be non-empty"); - let final_equity = equity_curve.last().expect("INVARIANT: Collection should be non-empty"); - let total_return = (final_equity - initial_equity) / initial_equity; - - // Validate metrics exist - assert!( - equity_curve.len() >= 2, - "Equity curve should have multiple points" - ); - - // Calculate returns - let returns: Vec = equity_curve - .windows(2) - .map(|w| (w[1] - w[0]) / w[0]) - .collect(); - - if !returns.is_empty() { - let mean_return = returns.iter().sum::() / returns.len() as f64; - let variance = returns - .iter() - .map(|r| (r - mean_return).powi(2)) - .sum::() - / returns.len() as f64; - let std_dev = variance.sqrt(); - - let sharpe_ratio = if std_dev > 1e-10 { - // Avoid division by very small numbers - mean_return / std_dev * (252.0_f64).sqrt() // Annualized - } else { - 0.0 - }; - - // Cap Sharpe ratio to realistic bounds for test stability - let sharpe_ratio = if sharpe_ratio.is_finite() { - sharpe_ratio.max(-5.0).min(10.0) - } else { - 0.0 - }; - - // Validate Sharpe ratio bounds - assert!( - sharpe_ratio >= -5.0 && sharpe_ratio <= 10.0, - "Sharpe ratio {} outside realistic bounds [-5, 10]", - sharpe_ratio - ); - - println!("✓ ML backtest metrics:"); - println!(" Total return: {:.2}%", total_return * 100.0); - println!(" Sharpe ratio: {:.2}", sharpe_ratio); - println!(" Equity points: {}", equity_curve.len()); - } - } -} - -// ============================================================================= -// TEST 6: ML Feature Extraction -// ============================================================================= - -#[tokio::test] -async fn test_ml_feature_extraction() { - // RED: Test feature extraction from market data - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - let mut feature_extractor = MLFeatureExtractor::new(20); - - let mut feature_count = 0; - - // Extract features from first 30 bars - for bar in bars.iter().take(30) { - let features = feature_extractor.extract_features(bar); - - // Validate feature vector - assert!(!features.is_empty(), "Features should not be empty"); - assert_eq!(features.len(), 7, "Expected 7 features (price momentum, MA, volatility, volume ratio, volume MA, hour, day)"); - - // Validate feature normalization (tanh: [-1, 1]) - for (i, &f) in features.iter().enumerate() { - assert!( - f >= -1.0 && f <= 1.0, - "Feature {} = {} outside normalized range [-1, 1]", - i, - f - ); - } - - feature_count += 1; - } - - assert_eq!(feature_count, 30, "Should extract features for all 30 bars"); - println!( - "✓ Feature extraction successful: {} bars processed", - feature_count - ); -} - -// ============================================================================= -// TEST 7: ML Model Performance Tracking -// ============================================================================= - -#[tokio::test] -async fn test_ml_model_performance_tracking() { - // RED: Test model performance tracking during backtest - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - let mut ml_strategy = MLPoweredStrategy::new("ml_tracking".to_string(), 20); - - // Run predictions and track performance - let mut prev_price: Option = None; - let mut validation_count = 0; - - for bar in bars.iter().take(50) { - let predictions = ml_strategy.get_ensemble_prediction(bar).await; - - if let Ok(preds) = predictions { - // Skip empty predictions (filtered by confidence) - if preds.is_empty() { - prev_price = Some(bar.close.to_f64().unwrap_or(0.0)); - continue; - } - - // Validate predictions against actual returns - if let Some(prev) = prev_price { - let current_price = bar.close.to_f64().unwrap_or(0.0); - let actual_return = (current_price - prev) / prev; - - ml_strategy - .validate_predictions(&preds, actual_return) - .await; - validation_count += 1; - } - - prev_price = Some(bar.close.to_f64().unwrap_or(0.0)); - } - } - - // Get performance summary - let performance = ml_strategy.get_performance_summary(); - - // Performance tracking may be empty if no predictions passed confidence threshold - // This is valid behavior - just skip the detailed validation - if performance.is_empty() || validation_count == 0 { - println!("⚠️ No performance data (all predictions filtered by confidence threshold)"); - return; - } - - for (model_id, perf) in performance { - println!( - "✓ Model {}: {} predictions, {:.2}% accuracy, {:.3} avg confidence", - model_id, perf.total_predictions, perf.accuracy_percentage, perf.avg_confidence - ); - - // Validate performance metrics - assert!(perf.total_predictions > 0, "Model should have predictions"); - assert!( - perf.accuracy_percentage >= 0.0 && perf.accuracy_percentage <= 100.0, - "Accuracy out of range" - ); - assert!( - perf.avg_confidence >= 0.0 && perf.avg_confidence <= 1.0, - "Confidence out of range" - ); - } -} - -// ============================================================================= -// TEST 8: ML vs Rule-Based Comparison (Placeholder) -// ============================================================================= - -#[tokio::test] -async fn test_ml_vs_rule_based_comparison() { - // RED: Compare ML strategy vs rule-based strategy - // This is a placeholder - full implementation requires running both strategies - - let data_source = create_test_data_source("ES.FUT").await; - let bars = data_source.load_ohlcv_bars("ES.FUT").await.unwrap(); - - // ML strategy - let ml_strategy = MLPoweredStrategy::new("ml_comparison".to_string(), 20); - let portfolio = Portfolio::new(Decimal::from(100000)); - let parameters = HashMap::new(); - - let mut ml_signal_count = 0; - - for bar in bars.iter().take(100) { - if let Ok(signals) = ml_strategy.execute(bar, &portfolio, ¶meters) { - ml_signal_count += signals.len(); - } - } - - // For now, just verify ML generates signals - // Full comparison would require implementing rule-based strategy backtest - assert!( - ml_signal_count >= 0, - "ML strategy should execute without errors" - ); - - println!( - "✓ ML strategy generated {} signals (rule-based comparison pending full implementation)", - ml_signal_count - ); -} diff --git a/services/trading_agent_service/src/assets.rs b/services/trading_agent_service/src/assets.rs index be2ab5bd3..e886db493 100644 --- a/services/trading_agent_service/src/assets.rs +++ b/services/trading_agent_service/src/assets.rs @@ -7,10 +7,8 @@ //! - Value: 20% weight //! - Liquidity (quality): 10% weight -use common::ml_strategy::MLFeatureExtractor; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::sync::Arc; /// Asset scoring result with multi-factor breakdown #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -141,18 +139,6 @@ impl AssetSelector { } } - /// Create with custom feature extractor - pub fn with_feature_extractor( - min_ml_confidence: f64, - min_composite_score: f64, - _feature_extractor: Arc, - ) -> Self { - Self { - min_ml_confidence, - min_composite_score, - } - } - /// Select top N assets by composite score pub fn select_top_n(&self, mut assets: Vec, n: usize) -> Vec { // Filter by thresholds diff --git a/services/trading_agent_service/src/service.rs b/services/trading_agent_service/src/service.rs index 2c1539983..d2e665430 100644 --- a/services/trading_agent_service/src/service.rs +++ b/services/trading_agent_service/src/service.rs @@ -11,9 +11,7 @@ use tonic::{Request, Response, Status}; use tracing::{error, info, instrument, warn}; use crate::allocation::{AllocationMethod, AssetInfo, PortfolioAllocator}; -use crate::assets::{ - self, AssetScore as InternalAssetScore, AssetSelector, -}; +use crate::assets::{AssetScore as InternalAssetScore, AssetSelector}; use crate::monitoring::TradingAgentMetrics; use crate::orders::{OrderGenerator, PortfolioAllocation}; use crate::proto::trading_agent::*; @@ -129,21 +127,21 @@ impl TradingAgentServiceImpl { let bars = self.fetch_recent_bars(symbol, 60).await?; if bars.len() >= 26 { - // We have enough bars to build a feature vector via the common extractor. - // Build a quick feature vector from the most recent 26 bars (price/volume). - let mut extractor = common::ml_strategy::MLFeatureExtractor::new(20); - let mut features = Vec::new(); - for bar in &bars { - features = extractor.extract_features( - bar.close, - bar.volume, - bar.timestamp, - ); - } - - let momentum = assets::calculate_momentum_from_features(&features); - let value = assets::calculate_value_from_features(&features); - let quality = assets::calculate_liquidity_from_features(&features); + // Compute factor scores directly from bar data (no legacy MLFeatureExtractor needed) + let momentum = { + let first_close = bars.first().map(|b| b.close).unwrap_or(0.0); + let last_close = bars.last().map(|b| b.close).unwrap_or(0.0); + if first_close > 0.0 { + ((last_close / first_close) - 1.0).clamp(-1.0, 1.0) * 0.5 + 0.5 + } else { + 0.5 + } + }; + let value = { + let avg_vol: f64 = bars.iter().map(|b| b.volume).sum::() / bars.len() as f64; + if avg_vol > 0.0 { (avg_vol / 1_000_000.0).clamp(0.0, 1.0) } else { 0.5 } + }; + let quality = inst.liquidity_score.clamp(0.0, 1.0); // ML score placeholder -- use liquidity_score from the instrument as a proxy // (real ML inference would go here) diff --git a/services/trading_service/tests/asset_selection_tests.rs b/services/trading_service/tests/asset_selection_tests.rs index dc911d0ff..7bb72bbf2 100644 --- a/services/trading_service/tests/asset_selection_tests.rs +++ b/services/trading_service/tests/asset_selection_tests.rs @@ -4,12 +4,31 @@ //! and fallback behavior when ML is unavailable. use anyhow::Result; -use chrono::Utc; -use common::ml_strategy::SharedMLStrategy; +use chrono::{DateTime, Utc}; +use common::ml_strategy::{ + MLModelAdapter, MLPrediction, ProductionFeatureExtractor225, SharedMLStrategy, +}; use sqlx::PgPool; use std::sync::Arc; use trading_service::assets::{AssetScore, AssetSelector, ScoringWeights}; +/// Mock extractor for test SharedMLStrategy construction +struct TestMockExtractor; + +impl ProductionFeatureExtractor225 for TestMockExtractor { + fn update(&mut self, _price: f64, _volume: f64, _timestamp: DateTime) -> Result<()> { + Ok(()) + } + fn extract_features(&mut self) -> Result> { + Ok(vec![0.1; 225]) + } +} + +/// Helper to create SharedMLStrategy for tests +fn make_test_strategy() -> SharedMLStrategy { + SharedMLStrategy::new(Box::new(TestMockExtractor), vec![], 0.6) +} + /// Helper to create test database pool async fn setup_test_db() -> Result { let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| { @@ -110,7 +129,7 @@ async fn cleanup_test_data(pool: &PgPool, universe_id: &str) -> Result<()> { #[tokio::test] async fn test_asset_selector_creation() -> Result<()> { let pool = setup_test_db().await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool, ml_strategy, None)?; @@ -123,7 +142,7 @@ async fn test_asset_selector_creation() -> Result<()> { #[tokio::test] async fn test_asset_selector_custom_weights() -> Result<()> { let pool = setup_test_db().await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let mut custom_weights = ScoringWeights { ml_weight: 0.5, @@ -144,7 +163,7 @@ async fn test_asset_selector_custom_weights() -> Result<()> { #[tokio::test] async fn test_select_assets_empty_universe() -> Result<()> { let pool = setup_test_db().await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool, ml_strategy, None)?; let universe_id = "test_empty_universe"; @@ -166,7 +185,7 @@ async fn test_select_assets_with_universe() -> Result<()> { // Seed test data seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool, ml_strategy, None)?; let assets = selector.select_assets(universe_id, 3).await?; @@ -202,7 +221,7 @@ async fn test_asset_selection_persists_to_db() -> Result<()> { // Seed test data seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool.clone(), ml_strategy, None)?; let assets = selector.select_assets(universe_id, 5).await?; @@ -233,7 +252,7 @@ async fn test_get_selected_assets() -> Result<()> { // Seed test data seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool.clone(), ml_strategy, None)?; // First, create a selection @@ -268,7 +287,7 @@ async fn test_ml_integration_with_fallback() -> Result<()> { // Seed test data seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool, ml_strategy, None)?; // Select assets - should work even if ML predictions aren't perfect @@ -304,7 +323,7 @@ async fn test_scoring_weights_affect_ranking() -> Result<()> { liquidity_weight: 0.1, }; - let ml_strategy1 = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy1 = Arc::new(make_test_strategy()); let selector1 = AssetSelector::new(pool.clone(), ml_strategy1, Some(ml_heavy_weights))?; let assets1 = selector1.select_assets(universe_id, 5).await?; @@ -316,7 +335,7 @@ async fn test_scoring_weights_affect_ranking() -> Result<()> { liquidity_weight: 0.1, }; - let ml_strategy2 = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy2 = Arc::new(make_test_strategy()); let selector2 = AssetSelector::new(pool.clone(), ml_strategy2, Some(momentum_heavy_weights))?; let assets2 = selector2.select_assets(universe_id, 5).await?; @@ -339,7 +358,7 @@ async fn test_ml_prediction_caching() -> Result<()> { // Seed test data seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = Arc::new(AssetSelector::new(pool, ml_strategy, None)?); // First selection - should query ML @@ -376,7 +395,7 @@ async fn test_performance_target() -> Result<()> { // Seed test data with more instruments seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = AssetSelector::new(pool, ml_strategy, None)?; // Measure selection time @@ -429,7 +448,7 @@ async fn test_concurrent_asset_selection() -> Result<()> { // Seed test data seed_test_universe(&pool, universe_id).await?; - let ml_strategy = Arc::new(SharedMLStrategy::new(20, 0.6)); + let ml_strategy = Arc::new(make_test_strategy()); let selector = Arc::new(AssetSelector::new(pool, ml_strategy, None)?); // Run multiple concurrent selections diff --git a/services/trading_service/tests/ml_order_service_tests.rs b/services/trading_service/tests/ml_order_service_tests.rs index 35bf49344..1bb2133c1 100644 --- a/services/trading_service/tests/ml_order_service_tests.rs +++ b/services/trading_service/tests/ml_order_service_tests.rs @@ -434,10 +434,51 @@ async fn test_ml_performance_all_models() -> Result<()> { #[tokio::test] async fn test_shared_ml_strategy_integration() -> Result<()> { - use common::ml_strategy::SharedMLStrategy; + use common::ml_strategy::{ + MLModelAdapter, MLPrediction, ProductionFeatureExtractor225, SharedMLStrategy, + }; - // Arrange: Create strategy with 20-bar lookback, 60% confidence threshold - let strategy = SharedMLStrategy::new(20, 0.6); + struct MockExtractor; + impl ProductionFeatureExtractor225 for MockExtractor { + fn update( + &mut self, + _price: f64, + _volume: f64, + _timestamp: chrono::DateTime, + ) -> Result<()> { + Ok(()) + } + fn extract_features(&mut self) -> Result> { + Ok(vec![0.1; 225]) + } + } + + struct MockAdapter; + impl MLModelAdapter for MockAdapter { + fn predict(&self, features: &[f64]) -> Result { + let sum: f64 = features.iter().sum::() / features.len().max(1) as f64; + let pv = 1.0 / (1.0 + (-sum).exp()); + Ok(MLPrediction { + model_id: "mock_v1".to_string(), + prediction_value: pv, + confidence: 0.5 + (pv - 0.5).abs() * 0.8, + features: features.to_vec(), + timestamp: Utc::now(), + inference_latency_us: 10, + }) + } + fn model_id(&self) -> &str { + "mock_v1" + } + fn validate_prediction(&mut self, _prediction: &MLPrediction, _actual_outcome: bool) {} + } + + // Arrange: Create strategy with mock adapter and 60% confidence threshold + let strategy = SharedMLStrategy::new( + Box::new(MockExtractor), + vec![Box::new(MockAdapter)], + 0.6, + ); // Act: Get ensemble prediction with realistic market data let predictions = strategy @@ -448,10 +489,10 @@ async fn test_shared_ml_strategy_integration() -> Result<()> { ) .await?; - // Assert: Should have at least 1 prediction (SimpleDQNAdapter is default fallback) + // Assert: Should have at least 1 prediction from injected model adapter assert!( !predictions.is_empty(), - "Should return at least 1 prediction from SimpleDQNAdapter" + "Should return at least 1 prediction from model adapter" ); // Calculate ensemble vote