From 88c04c178d4cd7d6118d0e155ccbd355a6d6e9b3 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sun, 22 Feb 2026 00:54:37 +0100 Subject: [PATCH] refactor: consolidate duplicates and delete 19k lines of dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete 22 orphaned files (.backup, .broken_backup, .old, .rej, .disabled) - Remove duplicate KillSwitch stub from risk_engine.rs, use AtomicKillSwitch - Deduplicate UnixSocketKillSwitch via re-export from unix_socket module - Rename StreamingConfig → EventStreamingConfig to resolve naming collision - Guard MockTradingRepository behind #[cfg(test)] in trading_service - Replace adaptive-strategy EnsembleConfig with re-export from ml crate - Merge error_recovery.rs fields into canonical RetryConfig (circuit breaker, jitter, HFT precision mode) and delete the 328-line dead module - Replace local 3-variant RiskError with risk::error::RiskError import - Fix all RetryConfig struct literals with ..Default::default() Co-Authored-By: Claude Opus 4.6 --- adaptive-strategy/src/config_types.rs | 14 +- ...egime_transition_tests.rs.synthetic_backup | 814 --- common/src/error_enhanced.rs | 602 -- common/src/error_recovery.rs | 509 -- common/src/ml_strategy_backup.rs | 526 -- common/src/ml_strategy_fix.rs | 526 -- common/src/ml_strategy_rsi_macd.rs | 105 - common/src/resilience/integration_examples.rs | 4 + common/src/resilience/retry.rs | 55 +- common/src/resilience/tests/retry_test.rs | 6 + common/src/types.rs.rej | 65 - data/src/error_consolidated.rs | 288 - .../evaluate_dqn_main_orchestrator.rs.backup | 1401 ----- ml/src/dqn/prioritized_replay.rs.backup | 727 --- ml/src/hyperopt/adapters/dqn.rs.backup | 1527 ----- .../hyperopt/adapters/mamba2.rs.broken_backup | 747 --- ml/src/trainers/dqn.rs.backup | 4975 ----------------- ml/src/trainers/tft.rs.backup | 2915 ---------- ...listic_constraints_integration.rs.disabled | 542 -- ...training_loop_integration_test.rs.disabled | 444 -- ml/tests/dqn_zero_price_fix_test.rs.disabled | 238 - ml/tests/mamba2_hyperopt_edge_cases.rs.backup | 597 -- ml/tests/tlob_transformer_test.rs.disabled | 13 - ...wave16o_bug_reproduction_tests.rs.disabled | 540 -- risk/src/error_consolidated.rs | 473 -- risk/src/risk_engine.rs | 71 +- risk/src/safety/kill_switch.rs | 71 +- .../trading_service/src/core/risk_manager.rs | 66 +- .../src/event_streaming/mod.rs | 14 +- .../trading_service/src/repository_impls.rs | 459 +- test_data/ES_FUT_unseen.dbn.old | Bin 192991 -> 0 bytes test_data/ES_FUT_unseen.parquet.old | Bin 228552 -> 0 bytes 32 files changed, 362 insertions(+), 18972 deletions(-) delete mode 100644 adaptive-strategy/tests/regime_transition_tests.rs.synthetic_backup delete mode 100644 common/src/error_enhanced.rs delete mode 100644 common/src/error_recovery.rs delete mode 100644 common/src/ml_strategy_backup.rs delete mode 100644 common/src/ml_strategy_fix.rs delete mode 100644 common/src/ml_strategy_rsi_macd.rs delete mode 100644 common/src/types.rs.rej delete mode 100644 data/src/error_consolidated.rs delete mode 100644 ml/examples/evaluate_dqn_main_orchestrator.rs.backup delete mode 100644 ml/src/dqn/prioritized_replay.rs.backup delete mode 100644 ml/src/hyperopt/adapters/dqn.rs.backup delete mode 100644 ml/src/hyperopt/adapters/mamba2.rs.broken_backup delete mode 100644 ml/src/trainers/dqn.rs.backup delete mode 100644 ml/src/trainers/tft.rs.backup delete mode 100644 ml/tests/dqn_realistic_constraints_integration.rs.disabled delete mode 100644 ml/tests/dqn_training_loop_integration_test.rs.disabled delete mode 100644 ml/tests/dqn_zero_price_fix_test.rs.disabled delete mode 100644 ml/tests/mamba2_hyperopt_edge_cases.rs.backup delete mode 100644 ml/tests/tlob_transformer_test.rs.disabled delete mode 100644 ml/tests/wave16o_bug_reproduction_tests.rs.disabled delete mode 100644 risk/src/error_consolidated.rs delete mode 100644 test_data/ES_FUT_unseen.dbn.old delete mode 100644 test_data/ES_FUT_unseen.parquet.old diff --git a/adaptive-strategy/src/config_types.rs b/adaptive-strategy/src/config_types.rs index 642e12522..94870a5b4 100644 --- a/adaptive-strategy/src/config_types.rs +++ b/adaptive-strategy/src/config_types.rs @@ -129,13 +129,12 @@ pub struct GeneralConfig { } /// Ensemble model coordination `configuration` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EnsembleConfig { - pub max_parallel_models: usize, - pub rebalancing_interval: Duration, - pub min_model_weight: f64, - pub max_model_weight: f64, -} +/// +/// Re-exported from `crate::config::EnsembleConfig` to consolidate duplicate definitions. +/// Both config types use the same base structure, but interpret the `models` field differently: +/// - In-memory config: models vector is populated from configuration +/// - Database-backed config: models vector is populated separately from `ModelConfigRow` entries +pub use crate::config::EnsembleConfig; /// Risk management `configuration` #[derive(Debug, Clone, Serialize, Deserialize)] @@ -421,6 +420,7 @@ impl AdaptiveStrategyConfigRow { ), min_model_weight: self.min_model_weight, max_model_weight: self.max_model_weight, + models: vec![], // Database-backed config populates models separately }, risk: RiskConfig { max_position_size: self.max_position_size, diff --git a/adaptive-strategy/tests/regime_transition_tests.rs.synthetic_backup b/adaptive-strategy/tests/regime_transition_tests.rs.synthetic_backup deleted file mode 100644 index bdadbf481..000000000 --- a/adaptive-strategy/tests/regime_transition_tests.rs.synthetic_backup +++ /dev/null @@ -1,814 +0,0 @@ -//! Comprehensive regime detection and transition tests -//! -//! This test suite covers edge cases for: -//! - Regime detection across different market conditions -//! - Smooth transitions between regimes -//! - False signal prevention -//! - Strategy switching without position loss -//! - Microstructure regime detection -//! - Volatility regime transitions -//! - Volume regime transitions -//! - Correlation regime shifts -//! - Crisis detection and recovery - -use adaptive_strategy::config::{RegimeConfig, RegimeDetectionMethod}; -use adaptive_strategy::regime::{ - MarketRegime, RegimeDetector, RegimeTransitionTracker, RegimeTransition, - PricePoint, VolumePoint, RegimeFeatureExtractor, StrategyAdaptationManager, - StrategyAdaptationConfig, -}; -use chrono::{Duration, Utc}; -use std::collections::HashMap; - -// ============================================================================ -// Test Data Generators -// ============================================================================ - -/// Generate trending price data (consistent directional movement) -fn generate_trending_data(count: usize, start_price: f64, trend: f64) -> Vec { - let base_time = Utc::now(); - (0..count) - .map(|i| { - let price = start_price + (i as f64 * trend); - PricePoint { - timestamp: base_time + Duration::seconds(i as i64), - price, - high: price + 0.5, - low: price - 0.5, - open: price - 0.2, - } - }) - .collect() -} - -/// Generate ranging/sideways price data (oscillation without trend) -fn generate_ranging_data(count: usize, center_price: f64, amplitude: f64) -> Vec { - let base_time = Utc::now(); - (0..count) - .map(|i| { - let angle = (i as f64) * 0.3; // Oscillation frequency - let price = center_price + amplitude * angle.sin(); - PricePoint { - timestamp: base_time + Duration::seconds(i as i64), - price, - high: price + 0.3, - low: price - 0.3, - open: price - 0.1, - } - }) - .collect() -} - -/// Generate high volatility price data (large price swings) -fn generate_volatile_data(count: usize, start_price: f64, volatility: f64) -> Vec { - let base_time = Utc::now(); - (0..count) - .map(|i| { - // Combine multiple frequencies for chaotic movement - let swing1 = volatility * ((i as f64) * 0.5).sin(); - let swing2 = volatility * 0.7 * ((i as f64) * 1.3).cos(); - let price = start_price + swing1 + swing2; - PricePoint { - timestamp: base_time + Duration::seconds(i as i64), - price, - high: price + volatility * 0.5, - low: price - volatility * 0.5, - open: price - volatility * 0.2, - } - }) - .collect() -} - -/// Generate stable/low volatility data -fn generate_stable_data(count: usize, price: f64) -> Vec { - let base_time = Utc::now(); - (0..count) - .map(|i| { - let tiny_noise = ((i as f64) * 0.1).sin() * 0.05; // Very small fluctuations - let p = price + tiny_noise; - PricePoint { - timestamp: base_time + Duration::seconds(i as i64), - price: p, - high: p + 0.02, - low: p - 0.02, - open: p, - } - }) - .collect() -} - -/// Generate crisis data (sharp drop with high volatility) -fn generate_crisis_data(count: usize, start_price: f64) -> Vec { - let base_time = Utc::now(); - (0..count) - .map(|i| { - // Sharp exponential decline with volatility spikes - let decline_factor = 1.0 - (i as f64 / count as f64) * 0.3; // 30% drop - let volatility = 5.0 * ((i as f64) * 0.8).sin(); // High volatility - let price = start_price * decline_factor + volatility; - PricePoint { - timestamp: base_time + Duration::seconds(i as i64), - price, - high: price + 3.0, - low: price - 3.0, - open: price - 1.0, - } - }) - .collect() -} - -/// Generate volume data -fn generate_volume_data(count: usize, base_volume: f64, variance: f64) -> Vec { - let base_time = Utc::now(); - (0..count) - .map(|i| { - let volume = base_volume + variance * ((i as f64) * 0.2).sin(); - VolumePoint { - timestamp: base_time + Duration::seconds(i as i64), - volume: volume.max(0.0), - dollar_volume: volume.max(0.0) * 50000.0, // Assume $50k average price - } - }) - .collect() -} - -// ============================================================================ -// Regime Detection Tests -// ============================================================================ - -#[tokio::test] -async fn test_regime_detection_trending_to_ranging() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 50, - transition_threshold: 0.7, - features: vec!["volatility".to_string(), "returns".to_string(), "trend".to_string()], - }; - - let volume_data = generate_volume_data(100, 500.0, 100.0); - - // Phase 1: Trending market - use fresh detector - { - let mut detector = RegimeDetector::new(config.clone()).await.unwrap(); - // Use slope of 15.0 to ensure clear trending detection (exceeds threshold of 12.0) - let trending_data = generate_trending_data(100, 50000.0, 15.0); - let trending_detection = detector.detect_regime(&trending_data, &volume_data).await.unwrap(); - - // Should detect trending or bull regime - assert!( - matches!(trending_detection.regime, MarketRegime::Trending | MarketRegime::Bull), - "Expected trending regime, got {:?}", - trending_detection.regime - ); - } - - // Phase 2: Ranging market - use fresh detector - { - let mut detector = RegimeDetector::new(config).await.unwrap(); - let ranging_data = generate_ranging_data(100, 51000.0, 50.0); - let ranging_detection = detector.detect_regime(&ranging_data, &volume_data).await.unwrap(); - - // Should detect sideways/ranging regime (including LowVolatility for gentle oscillations) - assert!( - matches!(ranging_detection.regime, MarketRegime::Sideways | MarketRegime::Normal | MarketRegime::LowVolatility), - "Expected sideways/ranging regime (Sideways, Normal, or LowVolatility), got {:?}", - ranging_detection.regime - ); - } -} - -#[tokio::test] -async fn test_regime_detection_volatile_to_stable() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 50, - transition_threshold: 0.7, - features: vec!["volatility".to_string(), "returns".to_string()], - }; - - let volume_data = generate_volume_data(100, 500.0, 100.0); - - // Phase 1: Volatile period - use fresh detector - { - let mut detector = RegimeDetector::new(config.clone()).await.unwrap(); - let volatile_data = generate_volatile_data(100, 50000.0, 500.0); - let volatile_detection = detector.detect_regime(&volatile_data, &volume_data).await.unwrap(); - - assert_eq!( - volatile_detection.regime, - MarketRegime::HighVolatility, - "Expected high volatility regime" - ); - } - - // Phase 2: Stable period - use fresh detector to avoid transition threshold blocking - { - let mut detector = RegimeDetector::new(config).await.unwrap(); - let stable_data = generate_stable_data(100, 50000.0); - let stable_detection = detector.detect_regime(&stable_data, &volume_data).await.unwrap(); - - assert_eq!( - stable_detection.regime, - MarketRegime::LowVolatility, - "Expected low volatility regime" - ); - } -} - -#[tokio::test] -async fn test_false_signal_prevention_whipsaw() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 50, - transition_threshold: 0.85, // High threshold to avoid false positives - features: vec!["volatility".to_string(), "returns".to_string()], - }; - - let mut detector = RegimeDetector::new(config).await.unwrap(); - let volume_data = generate_volume_data(50, 500.0, 100.0); - - // Initial stable regime - let stable_data = generate_stable_data(50, 50000.0); - let initial_detection = detector.detect_regime(&stable_data, &volume_data).await.unwrap(); - let initial_regime = initial_detection.regime; - - // Brief volatile spike (should not trigger regime change due to high threshold) - let brief_volatile = generate_volatile_data(10, 50000.0, 300.0); - let small_volume = generate_volume_data(10, 500.0, 100.0); - let spike_detection = detector.detect_regime(&brief_volatile, &small_volume).await.unwrap(); - - // Regime should be stable due to high transition_threshold - assert_eq!( - spike_detection.regime, initial_regime, - "Brief spike should not cause regime change" - ); - - // Return to stable - let stable_data2 = generate_stable_data(50, 50000.0); - let final_detection = detector.detect_regime(&stable_data2, &volume_data).await.unwrap(); - - assert_eq!( - final_detection.regime, initial_regime, - "Regime should remain stable after whipsaw" - ); -} - -#[tokio::test] -async fn test_crisis_detection_flash_crash() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 30, - transition_threshold: 0.6, // Lower threshold for crisis detection - features: vec!["volatility".to_string(), "returns".to_string(), "trend".to_string()], - }; - - let volume_data = generate_volume_data(50, 500.0, 100.0); - let crisis_volume = generate_volume_data(50, 2000.0, 500.0); // High volume for crisis - - // Phase 1: Normal market before crash - use fresh detector - { - let mut detector = RegimeDetector::new(config.clone()).await.unwrap(); - let normal_data = generate_stable_data(50, 50000.0); - let normal_detection = detector.detect_regime(&normal_data, &volume_data).await.unwrap(); - assert!(matches!( - normal_detection.regime, - MarketRegime::Normal | MarketRegime::LowVolatility - )); - } - - // Phase 2: Flash crash event - use fresh detector to avoid state accumulation - { - let mut detector = RegimeDetector::new(config).await.unwrap(); - let crisis_data = generate_crisis_data(50, 50000.0); - let crisis_detection = detector.detect_regime(&crisis_data, &crisis_volume).await.unwrap(); - - // Should detect crisis, high volatility, or strong downtrend (Bear/Trending) - // A 30% flash crash can legitimately be classified as Crisis, HighVolatility, - // Bear (strong negative returns), or Trending (strong downward slope) - assert!( - matches!( - crisis_detection.regime, - MarketRegime::Crisis | MarketRegime::HighVolatility | MarketRegime::Bear | MarketRegime::Trending - ), - "Expected crisis-like regime (Crisis/HighVolatility/Bear/Trending), got {:?}", - crisis_detection.regime - ); - - // Note: Confidence varies by regime type - Trending may have different confidence than Crisis - // The key is that we detect the crash-like behavior, not the exact confidence level - } -} - -// ============================================================================ -// Regime Transition Tests -// ============================================================================ - -#[test] -fn test_transition_tracker_records_changes() { - let mut tracker = RegimeTransitionTracker::new(); - - let transition1 = RegimeTransition { - from_regime: MarketRegime::Normal, - to_regime: MarketRegime::Trending, - timestamp: Utc::now(), - confidence: 0.85, - duration_in_previous: Duration::minutes(30), - transition_features: vec![0.02, 0.015, 0.8], - }; - - tracker.add_transition(transition1.clone()).unwrap(); - - // Note: RegimeTransitionTracker doesn't expose get_transition_history - // We can only verify via get_transition_probability - let prob = tracker.get_transition_probability(&MarketRegime::Normal, &MarketRegime::Trending); - assert!(prob >= 0.0, "Transition should be tracked"); -} - -#[test] -fn test_transition_probability_calculation() { - let mut tracker = RegimeTransitionTracker::new(); - - // Add multiple transitions from Bull to Bear - for _ in 0..5 { - let transition = RegimeTransition { - from_regime: MarketRegime::Bull, - to_regime: MarketRegime::Bear, - timestamp: Utc::now(), - confidence: 0.85, - duration_in_previous: Duration::hours(2), - transition_features: vec![0.03, -0.02, 0.7], - }; - tracker.add_transition(transition).unwrap(); - } - - // Add transitions from Bull to Sideways - for _ in 0..3 { - let transition = RegimeTransition { - from_regime: MarketRegime::Bull, - to_regime: MarketRegime::Sideways, - timestamp: Utc::now(), - confidence: 0.75, - duration_in_previous: Duration::hours(1), - transition_features: vec![0.01, 0.005, 0.5], - }; - tracker.add_transition(transition).unwrap(); - } - - // Verify transitions were recorded - let bear_prob = tracker.get_transition_probability(&MarketRegime::Bull, &MarketRegime::Bear); - let sideways_prob = tracker.get_transition_probability(&MarketRegime::Bull, &MarketRegime::Sideways); - - // Both should be recorded (exact probabilities depend on implementation) - assert!(bear_prob >= 0.0 || sideways_prob >= 0.0, "At least one transition should be tracked"); -} - -#[tokio::test] -async fn test_smooth_transition_no_position_loss() { - let adaptation_config = StrategyAdaptationConfig::default(); - let manager = StrategyAdaptationManager::new(adaptation_config); - - // Simulate initial regime with positions - let initial_detection = create_test_detection(MarketRegime::Bull, 0.85); - let _actions1 = manager.process_regime_change(&initial_detection).await.unwrap(); - - // Record some performance in this regime - manager.update_performance(1.5, 0.10, 0.65, 0.002).await.unwrap(); - - // Transition to new regime - let new_detection = create_test_detection(MarketRegime::Sideways, 0.80); - let actions2 = manager.process_regime_change(&new_detection).await.unwrap(); - - // Verify adaptation actions were generated - assert!(!actions2.is_empty(), "Regime transition should trigger adaptations"); - - // Performance should still be trackable - let performance_summary = manager.get_regime_performance_summary().await; - assert!( - performance_summary.contains_key(&MarketRegime::Bull), - "Previous regime performance should be preserved" - ); -} - -#[tokio::test] -async fn test_multiple_rapid_transitions_whipsaw() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 30, - transition_threshold: 0.85, // High threshold to prevent whipsaws - features: vec!["volatility".to_string(), "returns".to_string()], - }; - - let mut detector = RegimeDetector::new(config).await.unwrap(); - - // Start stable - let stable_data = generate_stable_data(50, 50000.0); - let volume_data = generate_volume_data(50, 500.0, 100.0); - let detection1 = detector.detect_regime(&stable_data, &volume_data).await.unwrap(); - let _initial_regime = detection1.regime; - - // Brief volatile period - let volatile_data = generate_volatile_data(20, 50000.0, 200.0); - let volatile_volume = generate_volume_data(20, 500.0, 100.0); - let detection2 = detector.detect_regime(&volatile_data, &volatile_volume).await.unwrap(); - - // Back to stable - let stable_data2 = generate_stable_data(30, 50000.0); - let stable_volume2 = generate_volume_data(30, 500.0, 100.0); - let detection3 = detector.detect_regime(&stable_data2, &stable_volume2).await.unwrap(); - - // With high transition_threshold, should resist rapid changes - let transition_count = [detection1.regime, detection2.regime, detection3.regime] - .windows(2) - .filter(|w| w[0] != w[1]) - .count(); - - assert!( - transition_count <= 1, - "Too many regime transitions during whipsaw: {}", - transition_count - ); -} - -// ============================================================================ -// Strategy Switching Tests -// ============================================================================ - -#[tokio::test] -async fn test_strategy_parameter_adjustment_during_transition() { - let mut adaptation_config = StrategyAdaptationConfig::default(); - - // Set specific weights for different regimes - let mut regime_weights = HashMap::new(); - regime_weights.insert("momentum".to_string(), 0.6); - regime_weights.insert("mean_reversion".to_string(), 0.4); - adaptation_config.regime_strategy_weights.insert( - MarketRegime::Trending, - regime_weights.clone() - ); - - let mut ranging_weights = HashMap::new(); - ranging_weights.insert("momentum".to_string(), 0.3); - ranging_weights.insert("mean_reversion".to_string(), 0.7); - adaptation_config.regime_strategy_weights.insert( - MarketRegime::Sideways, - ranging_weights.clone() - ); - - let manager = StrategyAdaptationManager::new(adaptation_config); - - // Start in trending regime - let trending_detection = create_test_detection(MarketRegime::Trending, 0.85); - manager.process_regime_change(&trending_detection).await.unwrap(); - - let trending_weights = manager.get_strategy_weights().await; - assert_eq!(trending_weights.get("momentum"), Some(&0.6)); - - // Transition to ranging regime - let ranging_detection = create_test_detection(MarketRegime::Sideways, 0.80); - manager.process_regime_change(&ranging_detection).await.unwrap(); - - let ranging_weights_result = manager.get_strategy_weights().await; - assert_eq!(ranging_weights_result.get("mean_reversion"), Some(&0.7)); - assert_eq!(ranging_weights_result.get("momentum"), Some(&0.3)); -} - -#[tokio::test] -async fn test_risk_adjustment_during_regime_transition() { - let adaptation_config = StrategyAdaptationConfig::default(); - let manager = StrategyAdaptationManager::new(adaptation_config); - - // Normal regime with standard risk - let normal_detection = create_test_detection(MarketRegime::Normal, 0.85); - manager.process_regime_change(&normal_detection).await.unwrap(); - - let normal_risk = manager.get_risk_adjustment().await; - assert!(normal_risk.is_some()); - - // Crisis regime should trigger risk reduction - let crisis_detection = create_test_detection(MarketRegime::Crisis, 0.90); - let actions = manager.process_regime_change(&crisis_detection).await.unwrap(); - - // Should have adaptation actions - assert!(!actions.is_empty(), "Crisis transition should trigger adaptations"); - - let crisis_risk = manager.get_risk_adjustment().await; - assert!(crisis_risk.is_some(), "Crisis regime should have risk adjustments"); -} - -// ============================================================================ -// Volatility Regime Tests -// ============================================================================ - -#[tokio::test] -async fn test_volatility_regime_low_to_high_to_low() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 40, - transition_threshold: 0.75, - features: vec!["volatility".to_string(), "vol_of_vol".to_string()], - }; - - let volume_data = generate_volume_data(50, 500.0, 100.0); - - // Phase 1: Low volatility period - use fresh detector - { - let mut detector = RegimeDetector::new(config.clone()).await.unwrap(); - let low_vol = generate_stable_data(50, 50000.0); - let low_detection = detector.detect_regime(&low_vol, &volume_data).await.unwrap(); - assert_eq!(low_detection.regime, MarketRegime::LowVolatility); - } - - // Phase 2: High volatility period - use fresh detector to avoid state accumulation - { - let mut detector = RegimeDetector::new(config.clone()).await.unwrap(); - let high_vol = generate_volatile_data(50, 50000.0, 500.0); - let high_detection = detector.detect_regime(&high_vol, &volume_data).await.unwrap(); - assert_eq!(high_detection.regime, MarketRegime::HighVolatility); - } - - // Phase 3: Return to low volatility - use fresh detector - { - let mut detector = RegimeDetector::new(config).await.unwrap(); - let low_vol2 = generate_stable_data(50, 50000.0); - let low_detection2 = detector.detect_regime(&low_vol2, &volume_data).await.unwrap(); - assert_eq!(low_detection2.regime, MarketRegime::LowVolatility); - } -} - -#[tokio::test] -async fn test_volatility_spike_detection() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 30, - transition_threshold: 0.70, - features: vec!["volatility".to_string(), "returns".to_string()], - }; - - let mut detector = RegimeDetector::new(config).await.unwrap(); - - // Normal market - let normal = generate_ranging_data(40, 50000.0, 100.0); - let volume_data = generate_volume_data(40, 500.0, 100.0); - let _normal_detection = detector.detect_regime(&normal, &volume_data).await.unwrap(); - - // Sudden volatility spike - let mut spike_data = normal.clone(); - spike_data.extend(generate_volatile_data(20, 50000.0, 1000.0)); // Massive spike - let mut spike_volume = volume_data.clone(); - spike_volume.extend(generate_volume_data(20, 1500.0, 300.0)); - - let spike_detection = detector.detect_regime(&spike_data, &spike_volume).await.unwrap(); - - // Should detect the volatility change - assert!( - matches!( - spike_detection.regime, - MarketRegime::HighVolatility | MarketRegime::Crisis - ), - "Should detect volatility spike, got {:?}", - spike_detection.regime - ); -} - -// ============================================================================ -// Volume Regime Tests -// ============================================================================ - -#[test] -fn test_volume_regime_thin_to_thick_liquidity() { - let features = vec!["volume".to_string(), "dollar_volume".to_string()]; - let mut extractor = RegimeFeatureExtractor::new(&features).unwrap(); - - // Establish baseline with low volume - let baseline_volume = generate_volume_data(50, 100.0, 20.0); - let baseline_prices = generate_stable_data(50, 50000.0); - extractor.update_data(&baseline_prices, &baseline_volume).unwrap(); - - let baseline_features = extractor.extract_features().unwrap(); - // In simplified mode with specific feature names, features are returned in order - // Volume feature (ratio of recent/long-term) should be around 1.0 for stable volume - let baseline_volume_ratio = baseline_features.get(0).copied().unwrap_or(0.0); - - // Simulate volume regime shift: recent period with much higher volume - // This creates a transition from thin to thick liquidity - let transition_volume = generate_volume_data(25, 500.0, 100.0); // 5x increase - let transition_prices = generate_stable_data(25, 50000.0); - extractor.update_data(&transition_prices, &transition_volume).unwrap(); - - let transition_features = extractor.extract_features().unwrap(); - // Recent volume (last 20) now includes high-volume data - // Long-term average (last 50) includes both low and high volume - // Ratio should be > 1.0, indicating increased recent activity - let transition_volume_ratio = transition_features.get(0).copied().unwrap_or(0.0); - - // Volume ratio should increase significantly during transition - // Baseline ~1.0 (stable), transition should be >2.0 (recent spike vs historical average) - assert!( - transition_volume_ratio > baseline_volume_ratio * 1.5, - "Expected volume ratio increase during transition: baseline={:.3}, transition={:.3}", - baseline_volume_ratio, - transition_volume_ratio - ); -} - -// ============================================================================ -// Feature Extraction Tests -// ============================================================================ - -#[test] -fn test_feature_extraction_with_regime_change() { - let features = vec![ - "volatility".to_string(), - "returns".to_string(), - "trend".to_string(), - "volume".to_string(), - ]; - let mut extractor = RegimeFeatureExtractor::new(&features).unwrap(); - - // Feed trending data - let trending = generate_trending_data(100, 50000.0, 10.0); - let volume_data = generate_volume_data(100, 500.0, 100.0); - extractor.update_data(&trending, &volume_data).unwrap(); - - let trending_features = extractor.extract_features().unwrap(); - // Feature count explanation: - // - volatility: 2 values (2 time windows for statistical robustness) - // - returns: 3 values (mean, skewness, kurtosis) - // - trend: 1 value (slope) - // - volume: 1 value (ratio) - // Total: 2 + 3 + 1 + 1 = 7 values - assert_eq!(trending_features.len(), 7, "Expected 7 feature values: volatility(2) + returns(3) + trend(1) + volume(1)"); - - // Clear state to ensure independent regime measurement - extractor.clear(); - - // Feed ranging data - let ranging = generate_ranging_data(100, 51000.0, 50.0); - extractor.update_data(&ranging, &volume_data).unwrap(); - - let ranging_features = extractor.extract_features().unwrap(); - assert_eq!(ranging_features.len(), 7, "Expected 7 feature values: volatility(2) + returns(3) + trend(1) + volume(1)"); - - // Features should differ between regimes - let feature_diff: f64 = trending_features - .iter() - .zip(&ranging_features) - .map(|(t, r)| (t - r).abs()) - .sum(); - - assert!( - feature_diff > 0.01, - "Features should change between trending and ranging regimes" - ); -} - -// ============================================================================ -// Performance Tracking Tests -// ============================================================================ - -#[tokio::test] -async fn test_regime_performance_tracking() { - let adaptation_config = StrategyAdaptationConfig::default(); - let manager = StrategyAdaptationManager::new(adaptation_config); - - // Track performance in Bull regime - let bull_detection = create_test_detection(MarketRegime::Bull, 0.85); - manager.process_regime_change(&bull_detection).await.unwrap(); - - // Record multiple performance updates - for _ in 0..10 { - manager.update_performance(1.05, 0.08, 0.70, 0.001).await.unwrap(); - } - - let performance_summary = manager.get_regime_performance_summary().await; - let bull_performance = performance_summary.get(&MarketRegime::Bull); - - assert!(bull_performance.is_some(), "Bull regime should have performance data"); -} - -#[tokio::test] -async fn test_adaptation_history_tracking() { - let adaptation_config = StrategyAdaptationConfig::default(); - let manager = StrategyAdaptationManager::new(adaptation_config); - - // Trigger multiple regime changes - let regimes = vec![ - MarketRegime::Normal, - MarketRegime::Trending, - MarketRegime::HighVolatility, - MarketRegime::Sideways, - ]; - - for regime in regimes { - let detection = create_test_detection(regime, 0.80); - manager.process_regime_change(&detection).await.unwrap(); - } - - let history = manager.get_adaptation_history().await; - assert!( - history.len() >= 3, - "Should have tracked multiple adaptations, got {}", - history.len() - ); -} - -// ============================================================================ -// Edge Case Tests -// ============================================================================ - -#[tokio::test] -async fn test_regime_detection_with_missing_data() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 50, - transition_threshold: 0.75, - features: vec!["volatility".to_string(), "returns".to_string()], - }; - - let mut detector = RegimeDetector::new(config).await.unwrap(); - - // Very sparse data (only 10 points instead of 50) - let sparse_data = generate_stable_data(10, 50000.0); - let sparse_volume = generate_volume_data(10, 500.0, 100.0); - let result = detector.detect_regime(&sparse_data, &sparse_volume).await; - - // Should handle gracefully (either succeed with lower confidence or return Unknown) - assert!(result.is_ok(), "Should handle sparse data gracefully"); -} - -#[tokio::test] -async fn test_low_confidence_regime_detection() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 40, - transition_threshold: 0.90, // Very high threshold - features: vec!["volatility".to_string(), "returns".to_string()], - }; - - let mut detector = RegimeDetector::new(config).await.unwrap(); - - // Ambiguous market (neither clearly trending nor ranging) - let mut ambiguous_data = generate_ranging_data(30, 50000.0, 100.0); - ambiguous_data.extend(generate_trending_data(20, 50000.0, 5.0)); - let volume_data = generate_volume_data(50, 500.0, 100.0); - - let detection = detector.detect_regime(&ambiguous_data, &volume_data).await.unwrap(); - - // With high transition_threshold, confidence might be lower - assert!( - detection.confidence > 0.0 && detection.confidence <= 1.0, - "Confidence should be in valid range: {}", - detection.confidence - ); -} - -#[tokio::test] -async fn test_extreme_market_conditions() { - let config = RegimeConfig { - detection_method: RegimeDetectionMethod::Threshold, - lookback_window: 30, - transition_threshold: 0.60, - features: vec!["volatility".to_string(), "returns".to_string(), "trend".to_string()], - }; - - let mut detector = RegimeDetector::new(config).await.unwrap(); - let volume_data = generate_volume_data(50, 500.0, 100.0); - - // Extreme uptrend - let extreme_bull = generate_trending_data(50, 50000.0, 100.0); // Huge trend - let bull_detection = detector.detect_regime(&extreme_bull, &volume_data).await.unwrap(); - assert!(matches!( - bull_detection.regime, - MarketRegime::Trending | MarketRegime::Bull | MarketRegime::Bubble - )); - - // Extreme downtrend - let extreme_bear = generate_trending_data(50, 70000.0, -100.0); // Sharp decline - let bear_detection = detector.detect_regime(&extreme_bear, &volume_data).await.unwrap(); - assert!(matches!( - bear_detection.regime, - MarketRegime::Trending | MarketRegime::Bear | MarketRegime::Crisis - )); -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/// Create a test regime detection result -fn create_test_detection(regime: MarketRegime, confidence: f64) -> adaptive_strategy::regime::RegimeDetection { - adaptive_strategy::regime::RegimeDetection { - regime, - confidence, - regime_probabilities: HashMap::new(), - timestamp: Utc::now(), - features_used: vec!["volatility".to_string(), "returns".to_string()], - model_metadata: adaptive_strategy::regime::RegimeModelMetadata { - model_name: "test_model".to_string(), - model_version: "1.0".to_string(), - training_period: None, - accuracy: 0.85, - last_trained: None, - }, - } -} diff --git a/common/src/error_enhanced.rs b/common/src/error_enhanced.rs deleted file mode 100644 index cd8fe8897..000000000 --- a/common/src/error_enhanced.rs +++ /dev/null @@ -1,602 +0,0 @@ -//! Enhanced common error types and utilities for HFT error consolidation -//! -//! This module provides consolidated error types and utilities used across -//! all Foxhunt services with proper categorization for metrics. - -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::time::Duration; -use thiserror::Error; -use crate::error::ErrorCategory; - -/// Enhanced common error type for all Foxhunt services -#[derive(Debug, Error)] -pub enum CommonError { - /// Database operation failed - wraps database-specific errors - #[error("Database error: {0}")] - Database(#[from] crate::database::DatabaseError), - - /// Configuration is invalid or missing required parameters - #[error("Configuration error: {0}")] - Configuration(String), - - /// Network communication error occurred - #[error("Network error: {0}")] - Network(String), - - /// Service-specific error with categorization for metrics - #[error("Service error: {category} - {message}")] - Service { - /// Error category for classification - category: ErrorCategory, - /// Descriptive error message - message: String - }, - - /// Input validation failed with field context - #[error("Validation error: {field} - {message}")] - Validation { - /// Field that failed validation - field: String, - /// Validation error message - message: String - }, - - /// Operation exceeded maximum allowed execution time - #[error("Timeout error: operation took {actual_ms}ms, max allowed {max_ms}ms")] - Timeout { - /// Actual execution time in milliseconds - actual_ms: u64, - /// Maximum allowed execution time in milliseconds - max_ms: u64 - }, - - /// Authentication failed - #[error("Authentication error: {0}")] - Authentication(String), - - /// Authorization/Permission denied - #[error("Authorization error: {0}")] - Authorization(String), - - /// Resource not found - #[error("{resource} not found: {identifier}")] - NotFound { - /// Type of resource - resource: String, - /// Resource identifier - identifier: String - }, - - /// Service unavailable - #[error("Service unavailable: {service} - {reason}")] - ServiceUnavailable { - /// Service name - service: String, - /// Reason for unavailability - reason: String - }, - - /// Rate limit exceeded - #[error("Rate limit exceeded: {limit_type}")] - RateLimited { - /// Type of rate limit - limit_type: String - }, - - /// Resource exhausted - #[error("Resource exhausted: {resource}")] - ResourceExhausted { - /// Resource that was exhausted - resource: String - }, - - /// Serialization/Deserialization error - #[error("Serialization error: {0}")] - Serialization(String), - - /// Internal server error - #[error("Internal error: {0}")] - Internal(String), - - /// Connection error - #[error("Connection error: {endpoint} - {reason}")] - Connection { - /// Connection endpoint - endpoint: String, - /// Connection failure reason - reason: String - }, - - /// Order/Trading specific errors - #[error("Trading error: {0}")] - Trading(String), - - /// ML/Model specific errors - #[error("ML error: {model} - {message}")] - ML { - /// Model name - model: String, - /// Error message - message: String - }, - - /// Risk management errors - #[error("Risk error: {risk_type} - {message}")] - Risk { - /// Type of risk violation - risk_type: String, - /// Risk error message - message: String - }, -} - -// ErrorCategory is now imported from crate::error - -impl fmt::Display for ErrorCategory { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MarketData => write!(f, "MARKET_DATA"), - Self::Trading => write!(f, "TRADING"), - Self::Network => write!(f, "NETWORK"), - Self::System => write!(f, "SYSTEM"), - Self::Configuration => write!(f, "CONFIGURATION"), - Self::Validation => write!(f, "VALIDATION"), - Self::Critical => write!(f, "CRITICAL"), - Self::Security => write!(f, "SECURITY"), - Self::ML => write!(f, "ML"), - Self::Risk => write!(f, "RISK"), - Self::Database => write!(f, "DATABASE"), - } - } -} - -/// Enhanced retry strategies for error recovery -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RetryStrategy { - /// Do not retry - error is permanent - NoRetry, - /// Retry immediately without delay - Immediate, - /// Linear backoff with fixed intervals - Linear { - /// Base delay in milliseconds between retries - base_delay_ms: u64, - }, - /// Exponential backoff with jitter - Exponential { - /// Base delay in milliseconds for exponential backoff - base_delay_ms: u64, - /// Maximum delay cap in milliseconds - max_delay_ms: u64, - }, - /// Wait for circuit breaker to close - CircuitBreaker, - /// Custom retry for HFT scenarios - HftCustom { - /// Initial delay in nanoseconds for HFT - initial_delay_ns: u64, - /// Maximum retries before giving up - max_retries: u32, - }, -} - -impl RetryStrategy { - /// Calculate delay for retry attempt - #[must_use] - pub fn calculate_delay(&self, attempt: u32) -> Option { - match self { - Self::NoRetry => None, - Self::Immediate => Some(Duration::from_millis(0)), - Self::Linear { base_delay_ms } => { - Some(Duration::from_millis(base_delay_ms * u64::from(attempt))) - } - Self::Exponential { - base_delay_ms, - max_delay_ms, - } => { - let delay_ms = base_delay_ms * 2_u64.pow(attempt.min(10)); - let capped_delay = delay_ms.min(*max_delay_ms); - - // Add simple jitter (±10%) - let jitter_ms = capped_delay / 10; - let final_delay = capped_delay.saturating_sub(jitter_ms / 2); - - Some(Duration::from_millis(final_delay)) - } - Self::CircuitBreaker => Some(Duration::from_secs(30)), - Self::HftCustom { initial_delay_ns, .. } => { - let delay_ns = initial_delay_ns * u64::from(attempt); - Some(Duration::from_nanos(delay_ns)) - } - } - } - - /// Get maximum recommended retry attempts - #[must_use] - pub const fn max_attempts(&self) -> Option { - match self { - Self::NoRetry => Some(0), - Self::Immediate => Some(3), - Self::Linear { .. } => Some(5), - Self::Exponential { .. } => Some(7), - Self::CircuitBreaker => Some(1), - Self::HftCustom { max_retries, .. } => Some(*max_retries), - } - } -} - -/// Error severity for HFT metrics -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ErrorSeverity { - /// Trace level - detailed diagnostic information - Trace, - /// Debug level - debugging information - Debug, - /// Info level - informational messages - Info, - /// Warn level - warning messages - Warn, - /// Error level - error messages - Error, - /// Critical level - critical errors requiring immediate attention - Critical, -} - -impl fmt::Display for ErrorSeverity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Trace => write!(f, "TRACE"), - Self::Debug => write!(f, "DEBUG"), - Self::Info => write!(f, "INFO"), - Self::Warn => write!(f, "WARN"), - Self::Error => write!(f, "ERROR"), - Self::Critical => write!(f, "CRITICAL"), - } - } -} - -impl CommonError { - /// Get error category for metrics - pub fn category(&self) -> ErrorCategory { - match self { - Self::Database(_) => ErrorCategory::Database, - Self::Configuration(_) => ErrorCategory::Configuration, - Self::Network(_) => ErrorCategory::Network, - Self::Service { category, .. } => *category, - Self::Validation { .. } => ErrorCategory::Validation, - Self::Timeout { .. } => ErrorCategory::System, - Self::Authentication(_) => ErrorCategory::Security, - Self::Authorization(_) => ErrorCategory::Security, - Self::NotFound { .. } => ErrorCategory::System, - Self::ServiceUnavailable { .. } => ErrorCategory::System, - Self::RateLimited { .. } => ErrorCategory::System, - Self::ResourceExhausted { .. } => ErrorCategory::System, - Self::Serialization(_) => ErrorCategory::System, - Self::Internal(_) => ErrorCategory::Critical, - Self::Connection { .. } => ErrorCategory::Network, - Self::Trading(_) => ErrorCategory::Trading, - Self::ML { .. } => ErrorCategory::ML, - Self::Risk { .. } => ErrorCategory::Risk, - } - } - - /// Get error severity for HFT monitoring - pub fn severity(&self) -> ErrorSeverity { - match self { - Self::Internal(_) => ErrorSeverity::Critical, - Self::Authentication(_) => ErrorSeverity::Critical, - Self::Configuration(_) => ErrorSeverity::Critical, - Self::Risk { .. } => ErrorSeverity::Critical, - Self::Trading(_) => ErrorSeverity::Error, - Self::ML { .. } => ErrorSeverity::Error, - Self::Database(_) => ErrorSeverity::Error, - Self::Authorization(_) => ErrorSeverity::Warn, - Self::Timeout { .. } => ErrorSeverity::Warn, - Self::Network(_) => ErrorSeverity::Warn, - Self::Connection { .. } => ErrorSeverity::Warn, - Self::ServiceUnavailable { .. } => ErrorSeverity::Warn, - Self::RateLimited { .. } => ErrorSeverity::Warn, - Self::ResourceExhausted { .. } => ErrorSeverity::Warn, - Self::Validation { .. } => ErrorSeverity::Info, - Self::NotFound { .. } => ErrorSeverity::Info, - Self::Serialization(_) => ErrorSeverity::Info, - Self::Service { .. } => ErrorSeverity::Error, - } - } - - /// Get retry strategy for this error - pub fn retry_strategy(&self) -> RetryStrategy { - match self { - Self::Authentication(_) => RetryStrategy::NoRetry, - Self::Authorization(_) => RetryStrategy::NoRetry, - Self::Configuration(_) => RetryStrategy::NoRetry, - Self::Validation { .. } => RetryStrategy::NoRetry, - Self::NotFound { .. } => RetryStrategy::NoRetry, - Self::Network(_) => RetryStrategy::Exponential { - base_delay_ms: 100, - max_delay_ms: 5000, - }, - Self::Connection { .. } => RetryStrategy::Exponential { - base_delay_ms: 100, - max_delay_ms: 5000, - }, - Self::Timeout { .. } => RetryStrategy::Linear { base_delay_ms: 1000 }, - Self::ServiceUnavailable { .. } => RetryStrategy::Exponential { - base_delay_ms: 1000, - max_delay_ms: 30000, - }, - Self::RateLimited { .. } => RetryStrategy::Linear { base_delay_ms: 5000 }, - Self::ResourceExhausted { .. } => RetryStrategy::Linear { base_delay_ms: 2000 }, - Self::Database(_) => RetryStrategy::Exponential { - base_delay_ms: 250, - max_delay_ms: 2000, - }, - Self::Trading(_) => RetryStrategy::HftCustom { - initial_delay_ns: 50000, // 50µs - max_retries: 3, - }, - Self::ML { .. } => RetryStrategy::Linear { base_delay_ms: 500 }, - Self::Risk { .. } => RetryStrategy::NoRetry, // Risk errors should not be retried - _ => RetryStrategy::Immediate, - } - } - - /// Check if error is retryable - pub fn is_retryable(&self) -> bool { - !matches!(self.retry_strategy(), RetryStrategy::NoRetry) - } - - /// Get error code for monitoring - pub fn error_code(&self) -> &'static str { - match self { - Self::Database(_) => "DATABASE_ERROR", - Self::Configuration(_) => "CONFIGURATION_ERROR", - Self::Network(_) => "NETWORK_ERROR", - Self::Service { .. } => "SERVICE_ERROR", - Self::Validation { .. } => "VALIDATION_ERROR", - Self::Timeout { .. } => "TIMEOUT_ERROR", - Self::Authentication(_) => "AUTHENTICATION_ERROR", - Self::Authorization(_) => "AUTHORIZATION_ERROR", - Self::NotFound { .. } => "NOT_FOUND_ERROR", - Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE_ERROR", - Self::RateLimited { .. } => "RATE_LIMITED_ERROR", - Self::ResourceExhausted { .. } => "RESOURCE_EXHAUSTED_ERROR", - Self::Serialization(_) => "SERIALIZATION_ERROR", - Self::Internal(_) => "INTERNAL_ERROR", - Self::Connection { .. } => "CONNECTION_ERROR", - Self::Trading(_) => "TRADING_ERROR", - Self::ML { .. } => "ML_ERROR", - Self::Risk { .. } => "RISK_ERROR", - } - } -} - -/// Enhanced convenience functions for creating common errors -impl CommonError { - /// Create a configuration error - pub fn config>(message: S) -> Self { - Self::Configuration(message.into()) - } - - /// Create a network error - pub fn network>(message: S) -> Self { - Self::Network(message.into()) - } - - /// Create a service error with category - pub fn service>(category: ErrorCategory, message: S) -> Self { - Self::Service { - category, - message: message.into(), - } - } - - /// Create a validation error with field context - pub fn validation, S: Into>(field: F, message: S) -> Self { - Self::Validation { - field: field.into(), - message: message.into(), - } - } - - /// Create a timeout error - pub fn timeout(actual_ms: u64, max_ms: u64) -> Self { - Self::Timeout { actual_ms, max_ms } - } - - /// Create an authentication error - pub fn authentication>(message: S) -> Self { - Self::Authentication(message.into()) - } - - /// Create an authorization error - pub fn authorization>(message: S) -> Self { - Self::Authorization(message.into()) - } - - /// Create a not found error - pub fn not_found, I: Into>(resource: R, identifier: I) -> Self { - Self::NotFound { - resource: resource.into(), - identifier: identifier.into(), - } - } - - /// Create a service unavailable error - pub fn service_unavailable, R: Into>(service: S, reason: R) -> Self { - Self::ServiceUnavailable { - service: service.into(), - reason: reason.into(), - } - } - - /// Create a rate limited error - pub fn rate_limited>(limit_type: S) -> Self { - Self::RateLimited { - limit_type: limit_type.into(), - } - } - - /// Create a resource exhausted error - pub fn resource_exhausted>(resource: S) -> Self { - Self::ResourceExhausted { - resource: resource.into(), - } - } - - /// Create a serialization error - pub fn serialization>(message: S) -> Self { - Self::Serialization(message.into()) - } - - /// Create an internal error - pub fn internal>(message: S) -> Self { - Self::Internal(message.into()) - } - - /// Create a connection error - pub fn connection, R: Into>(endpoint: E, reason: R) -> Self { - Self::Connection { - endpoint: endpoint.into(), - reason: reason.into(), - } - } - - /// Create a trading error - pub fn trading>(message: S) -> Self { - Self::Trading(message.into()) - } - - /// Create an ML error - pub fn ml, S: Into>(model: M, message: S) -> Self { - Self::ML { - model: model.into(), - message: message.into(), - } - } - - /// Create a risk error - pub fn risk, S: Into>(risk_type: T, message: S) -> Self { - Self::Risk { - risk_type: risk_type.into(), - message: message.into(), - } - } -} - -/// Result type for common operations -pub type CommonResult = Result; - -/// Conversion from standard library errors -impl From for CommonError { - fn from(err: std::io::Error) -> Self { - Self::Network(format!("IO error: {}", err)) - } -} - -impl From for CommonError { - fn from(err: serde_json::Error) -> Self { - Self::Serialization(format!("JSON error: {}", err)) - } -} - -impl From for CommonError { - fn from(err: reqwest::Error) -> Self { - Self::Network(format!("HTTP error: {}", err)) - } -} - -/// gRPC Status conversion for TLI service -impl From for tonic::Status { - fn from(err: CommonError) -> Self { - match err { - CommonError::Authentication(_) => { - tonic::Status::unauthenticated(err.to_owned()) - } - CommonError::Authorization(_) => { - tonic::Status::permission_denied(err.to_owned()) - } - CommonError::Validation { .. } => { - tonic::Status::invalid_argument(err.to_owned()) - } - CommonError::NotFound { .. } => { - tonic::Status::not_found(err.to_owned()) - } - CommonError::ServiceUnavailable { .. } => { - tonic::Status::unavailable(err.to_owned()) - } - CommonError::RateLimited { .. } => { - tonic::Status::resource_exhausted(err.to_owned()) - } - CommonError::ResourceExhausted { .. } => { - tonic::Status::resource_exhausted(err.to_owned()) - } - CommonError::Timeout { .. } => { - tonic::Status::deadline_exceeded(err.to_owned()) - } - CommonError::Connection { .. } => { - tonic::Status::unavailable(err.to_owned()) - } - CommonError::Network(_) => { - tonic::Status::unavailable(err.to_owned()) - } - _ => tonic::Status::internal(err.to_owned()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_categorization() { - let error = CommonError::trading("Order validation failed"); - assert_eq!(error.category(), ErrorCategory::Trading); - assert_eq!(error.severity(), ErrorSeverity::Error); - assert!(error.is_retryable()); - } - - #[test] - fn test_retry_strategy() { - let auth_error = CommonError::authentication("Invalid token"); - assert_eq!(auth_error.retry_strategy(), RetryStrategy::NoRetry); - assert!(!auth_error.is_retryable()); - - let network_error = CommonError::network("Connection refused"); - assert!(network_error.is_retryable()); - match network_error.retry_strategy() { - RetryStrategy::Exponential { .. } => (), - _ => panic!("Expected exponential backoff for network errors"), - } - } - - #[test] - fn test_hft_retry_strategy() { - let trading_error = CommonError::trading("Order rejected"); - match trading_error.retry_strategy() { - RetryStrategy::HftCustom { initial_delay_ns, max_retries } => { - assert_eq!(initial_delay_ns, 50000); // 50µs - assert_eq!(max_retries, 3); - } - _ => panic!("Expected HFT custom retry for trading errors"), - } - } - - #[test] - fn test_error_severity() { - let risk_error = CommonError::risk("position_limit", "Exceeded maximum position"); - assert_eq!(risk_error.severity(), ErrorSeverity::Critical); - - let validation_error = CommonError::validation("price", "Must be positive"); - assert_eq!(validation_error.severity(), ErrorSeverity::Info); - } - - #[test] - fn test_grpc_conversion() { - let error = CommonError::authentication("Invalid credentials"); - let status: tonic::Status = error.into(); - assert_eq!(status.code(), tonic::Code::Unauthenticated); - } -} \ No newline at end of file diff --git a/common/src/error_recovery.rs b/common/src/error_recovery.rs deleted file mode 100644 index a157d54e2..000000000 --- a/common/src/error_recovery.rs +++ /dev/null @@ -1,509 +0,0 @@ -//! Consolidated error recovery and retry strategies for HFT systems -//! -//! This module provides unified retry strategies, circuit breakers, and error recovery -//! patterns used across all Foxhunt services for consistent error handling. - -use crate::error::{CommonError, ErrorCategory, RetryStrategy, ErrorSeverity}; -use serde::{Deserialize, Serialize}; -use std::time::{Duration, Instant}; -use tokio::time::sleep; -use tracing::{error, warn, info, debug}; - -/// HFT-optimized retry configuration with circuit breaker support -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RetryConfig { - /// Maximum number of retry attempts - pub max_attempts: u32, - /// Base delay between retries - pub base_delay: Duration, - /// Maximum delay cap for exponential backoff - pub max_delay: Duration, - /// Circuit breaker failure threshold - pub circuit_breaker_threshold: u32, - /// Circuit breaker timeout before reset attempt - pub circuit_breaker_timeout: Duration, - /// Enable jitter for exponential backoff - pub enable_jitter: bool, - /// HFT-specific nanosecond precision delays - pub hft_precision_mode: bool, -} - -impl Default for RetryConfig { - fn default() -> Self { - Self { - max_attempts: 3, - base_delay: Duration::from_millis(100), - max_delay: Duration::from_secs(30), - circuit_breaker_threshold: 5, - circuit_breaker_timeout: Duration::from_secs(60), - enable_jitter: true, - hft_precision_mode: false, - } - } -} - -impl RetryConfig { - /// Create HFT-optimized configuration for low-latency operations - pub fn hft_optimized() -> Self { - Self { - max_attempts: 3, - base_delay: Duration::from_micros(50), // 50µs base delay - max_delay: Duration::from_millis(5), // 5ms max delay - circuit_breaker_threshold: 10, - circuit_breaker_timeout: Duration::from_secs(30), - enable_jitter: false, // No jitter for HFT - predictable timing - hft_precision_mode: true, - } - } - - /// Create configuration for network operations - pub fn network_optimized() -> Self { - Self { - max_attempts: 5, - base_delay: Duration::from_millis(500), - max_delay: Duration::from_secs(10), - circuit_breaker_threshold: 3, - circuit_breaker_timeout: Duration::from_secs(30), - enable_jitter: true, - hft_precision_mode: false, - } - } - - /// Create configuration for database operations - pub fn database_optimized() -> Self { - Self { - max_attempts: 7, - base_delay: Duration::from_millis(250), - max_delay: Duration::from_secs(5), - circuit_breaker_threshold: 5, - circuit_breaker_timeout: Duration::from_secs(45), - enable_jitter: true, - hft_precision_mode: false, - } - } -} - -/// Circuit breaker states for error recovery -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum CircuitBreakerState { - /// Circuit is closed - operations proceed normally - Closed, - /// Circuit is open - operations fail immediately - Open, - /// Circuit is half-open - testing if service has recovered - HalfOpen, -} - -/// Circuit breaker for managing service failures -#[derive(Debug)] -pub struct CircuitBreaker { - state: CircuitBreakerState, - failure_count: u32, - last_failure_time: Option, - config: RetryConfig, -} - -impl CircuitBreaker { - /// Create new circuit breaker with configuration - pub fn new(config: RetryConfig) -> Self { - Self { - state: CircuitBreakerState::Closed, - failure_count: 0, - last_failure_time: None, - config, - } - } - - /// Check if operation should be allowed - pub fn can_proceed(&mut self) -> bool { - match self.state { - CircuitBreakerState::Closed => true, - CircuitBreakerState::Open => { - if let Some(last_failure) = self.last_failure_time { - if last_failure.elapsed() >= self.config.circuit_breaker_timeout { - info!("Circuit breaker transitioning to half-open state"); - self.state = CircuitBreakerState::HalfOpen; - return true; - } - } - false - } - CircuitBreakerState::HalfOpen => true, - } - } - - /// Record successful operation - pub fn record_success(&mut self) { - if self.state == CircuitBreakerState::HalfOpen { - info!("Circuit breaker closing after successful operation"); - self.state = CircuitBreakerState::Closed; - self.failure_count = 0; - self.last_failure_time = None; - } - } - - /// Record failed operation - pub fn record_failure(&mut self) { - self.failure_count += 1; - self.last_failure_time = Some(Instant::now()); - - match self.state { - CircuitBreakerState::Closed => { - if self.failure_count >= self.config.circuit_breaker_threshold { - warn!("Circuit breaker opening after {} failures", self.failure_count); - self.state = CircuitBreakerState::Open; - } - } - CircuitBreakerState::HalfOpen => { - warn!("Circuit breaker reopening after failure in half-open state"); - self.state = CircuitBreakerState::Open; - } - CircuitBreakerState::Open => { - // Already open, just update timestamp - } - } - } - - /// Get current state - pub fn state(&self) -> CircuitBreakerState { - self.state.clone() - } - - /// Get current failure count - pub fn failure_count(&self) -> u32 { - self.failure_count - } -} - -/// Retry executor with circuit breaker and telemetry -pub struct RetryExecutor { - circuit_breaker: CircuitBreaker, - config: RetryConfig, - operation_name: String, -} - -impl RetryExecutor { - /// Create new retry executor - pub fn new>(operation_name: S, config: RetryConfig) -> Self { - Self { - circuit_breaker: CircuitBreaker::new(config.clone()), - config, - operation_name: operation_name.into(), - } - } - - /// Execute operation with retry logic and circuit breaker - pub async fn execute(&mut self, mut operation: F) -> Result - where - F: FnMut() -> Fut, - Fut: std::future::Future>, - { - let mut attempt = 0; - let mut last_error = None; - - while attempt < self.config.max_attempts { - // Check circuit breaker - if !self.circuit_breaker.can_proceed() { - return Err(CommonError::service_unavailable( - &self.operation_name, - "Circuit breaker is open" - )); - } - - attempt += 1; - debug!("Executing {} attempt {}/{}", self.operation_name, attempt, self.config.max_attempts); - - match operation().await { - Ok(result) => { - if attempt > 1 { - info!("Operation {} succeeded on attempt {}", self.operation_name, attempt); - } - self.circuit_breaker.record_success(); - return Ok(result); - } - Err(error) => { - last_error = Some(error.clone()); - self.circuit_breaker.record_failure(); - - // Check if error is retryable - if !error.is_retryable() { - warn!("Operation {} failed with non-retryable error: {}", self.operation_name, error); - return Err(error); - } - - if attempt < self.config.max_attempts { - let delay = self.calculate_delay(attempt, &error); - warn!("Operation {} failed on attempt {}, retrying in {:?}: {}", - self.operation_name, attempt, delay, error); - sleep(delay).await; - } - } - } - } - - // All retries exhausted - let final_error = last_error.unwrap_or_else(|| { - CommonError::internal(format!("Operation {} failed after {} attempts", self.operation_name, self.config.max_attempts)) - }); - - error!("Operation {} failed after {} attempts: {}", self.operation_name, self.config.max_attempts, final_error); - Err(final_error) - } - - /// Calculate delay for retry attempt based on error type and configuration - fn calculate_delay(&self, attempt: u32, error: &CommonError) -> Duration { - let base_delay = match error.retry_strategy() { - RetryStrategy::Immediate => Duration::from_millis(0), - RetryStrategy::Linear { base_delay_ms } => Duration::from_millis(base_delay_ms * u64::from(attempt)), - RetryStrategy::Exponential { base_delay_ms, max_delay_ms } => { - let delay_ms = base_delay_ms * 2_u64.pow(attempt.saturating_sub(1).min(10)); - let capped_delay = delay_ms.min(max_delay_ms); - Duration::from_millis(capped_delay) - } - RetryStrategy::HftCustom { initial_delay_ns, .. } => { - if self.config.hft_precision_mode { - Duration::from_nanos(initial_delay_ns * u64::from(attempt)) - } else { - Duration::from_micros((initial_delay_ns / 1000) * u64::from(attempt)) - } - } - RetryStrategy::CircuitBreaker => self.config.circuit_breaker_timeout, - RetryStrategy::NoRetry => Duration::from_millis(0), // Should not reach here - }; - - let final_delay = base_delay.min(self.config.max_delay); - - // Add jitter for non-HFT operations to prevent thundering herd - if self.config.enable_jitter && !self.config.hft_precision_mode { - self.add_jitter(final_delay) - } else { - final_delay - } - } - - /// Add jitter to delay to prevent thundering herd problem - fn add_jitter(&self, delay: Duration) -> Duration { - use rand::Rng; - let jitter_range = delay.as_millis() / 10; // ±10% jitter - let jitter_offset = rand::thread_rng().gen_range(0..=jitter_range * 2); - let jitter_delay = jitter_offset.saturating_sub(jitter_range); - - delay.saturating_add(Duration::from_millis(jitter_delay as u64)) - } - - /// Get circuit breaker state - pub fn circuit_breaker_state(&self) -> CircuitBreakerState { - self.circuit_breaker.state() - } - - /// Get circuit breaker failure count - pub fn circuit_breaker_failure_count(&self) -> u32 { - self.circuit_breaker.failure_count() - } -} - -/// Error recovery policy based on error characteristics -#[derive(Debug, Clone)] -pub struct ErrorRecoveryPolicy { - /// Default retry configuration - pub default_config: RetryConfig, - /// Category-specific configurations - pub category_configs: std::collections::HashMap, - /// Severity-specific overrides - pub severity_overrides: std::collections::HashMap, -} - -impl Default for ErrorRecoveryPolicy { - fn default() -> Self { - let mut category_configs = std::collections::HashMap::new(); - category_configs.insert(ErrorCategory::Network, RetryConfig::network_optimized()); - category_configs.insert(ErrorCategory::Database, RetryConfig::database_optimized()); - category_configs.insert(ErrorCategory::Trading, RetryConfig::hft_optimized()); - category_configs.insert(ErrorCategory::Risk, RetryConfig { - max_attempts: 1, // Risk errors should generally not be retried - ..RetryConfig::default() - }); - - let mut severity_overrides = std::collections::HashMap::new(); - severity_overrides.insert(ErrorSeverity::Critical, RetryConfig { - max_attempts: 1, // Critical errors should not be retried - ..RetryConfig::default() - }); - - Self { - default_config: RetryConfig::default(), - category_configs, - severity_overrides, - } - } -} - -impl ErrorRecoveryPolicy { - /// Get retry configuration for a specific error - pub fn get_config_for_error(&self, error: &CommonError) -> RetryConfig { - // Check severity overrides first - if let Some(config) = self.severity_overrides.get(&error.severity()) { - return config.clone(); - } - - // Check category-specific configuration - if let Some(config) = self.category_configs.get(&error.category()) { - return config.clone(); - } - - // Fall back to default - self.default_config.clone() - } - - /// Create retry executor for a specific error type - pub fn create_executor>(&self, operation_name: S, error: &CommonError) -> RetryExecutor { - let config = self.get_config_for_error(error); - RetryExecutor::new(operation_name, config) - } -} - -/// Convenience function to execute operation with automatic retry based on error characteristics -pub async fn retry_with_policy( - operation_name: &str, - policy: &ErrorRecoveryPolicy, - sample_error: &CommonError, - operation: F, -) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - let mut executor = policy.create_executor(operation_name, sample_error); - executor.execute(operation).await -} - -/// Macro for easy retry execution with automatic policy detection -#[macro_export] -macro_rules! retry_operation { - ($name:expr, $operation:expr) => {{ - use $crate::error_recovery::{ErrorRecoveryPolicy, retry_with_policy}; - - let policy = ErrorRecoveryPolicy::default(); - - // Execute once to get error type for policy detection - let sample_result = $operation().await; - match sample_result { - Ok(result) => Ok(result), - Err(sample_error) => { - retry_with_policy($name, &policy, &sample_error, $operation).await - } - } - }}; -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::test; - - #[test] - async fn test_circuit_breaker_transitions() { - let config = RetryConfig { - circuit_breaker_threshold: 2, - circuit_breaker_timeout: Duration::from_millis(100), - ..RetryConfig::default() - }; - - let mut circuit_breaker = CircuitBreaker::new(config); - - // Should start closed - assert_eq!(circuit_breaker.state(), CircuitBreakerState::Closed); - assert!(circuit_breaker.can_proceed()); - - // Record failures to open circuit - circuit_breaker.record_failure(); - assert_eq!(circuit_breaker.state(), CircuitBreakerState::Closed); - - circuit_breaker.record_failure(); - assert_eq!(circuit_breaker.state(), CircuitBreakerState::Open); - assert!(!circuit_breaker.can_proceed()); - - // Wait for timeout and transition to half-open - tokio::time::sleep(Duration::from_millis(150)).await; - assert!(circuit_breaker.can_proceed()); - assert_eq!(circuit_breaker.state(), CircuitBreakerState::HalfOpen); - - // Record success to close circuit - circuit_breaker.record_success(); - assert_eq!(circuit_breaker.state(), CircuitBreakerState::Closed); - } - - #[test] - async fn test_retry_executor_success() { - let config = RetryConfig::default(); - let mut executor = RetryExecutor::new("test_operation", config); - - let mut attempt_count = 0; - let result = executor.execute(|| async { - attempt_count += 1; - if attempt_count == 2 { - Ok("success") - } else { - Err(CommonError::network("Connection failed")) - } - }).await; - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "success"); - assert_eq!(attempt_count, 2); - } - - #[test] - async fn test_retry_executor_non_retryable_error() { - let config = RetryConfig::default(); - let mut executor = RetryExecutor::new("test_operation", config); - - let mut attempt_count = 0; - let result = executor.execute(|| async { - attempt_count += 1; - Err(CommonError::authentication("Invalid token")) - }).await; - - assert!(result.is_err()); - assert_eq!(attempt_count, 1); // Should not retry authentication errors - } - - #[test] - async fn test_hft_retry_config() { - let config = RetryConfig::hft_optimized(); - assert!(config.hft_precision_mode); - assert_eq!(config.base_delay, Duration::from_micros(50)); - assert_eq!(config.max_delay, Duration::from_millis(5)); - assert!(!config.enable_jitter); // No jitter for HFT - } - - #[test] - fn test_error_recovery_policy() { - let policy = ErrorRecoveryPolicy::default(); - - let network_error = CommonError::network("Connection failed"); - let config = policy.get_config_for_error(&network_error); - assert_eq!(config.max_attempts, 5); // Network optimized - - let trading_error = CommonError::trading("Order rejected"); - let config = policy.get_config_for_error(&trading_error); - assert!(config.hft_precision_mode); // HFT optimized - - let critical_error = CommonError::authentication("Invalid token"); - let config = policy.get_config_for_error(&critical_error); - assert_eq!(config.max_attempts, 1); // Critical errors should not retry - } - - #[test] - fn test_retry_delay_calculation() { - let config = RetryConfig::default(); - let mut executor = RetryExecutor::new("test", config); - - let network_error = CommonError::network("Connection failed"); - let delay1 = executor.calculate_delay(1, &network_error); - let delay2 = executor.calculate_delay(2, &network_error); - - // Exponential backoff should increase delay - assert!(delay2 > delay1); - } -} \ No newline at end of file diff --git a/common/src/ml_strategy_backup.rs b/common/src/ml_strategy_backup.rs deleted file mode 100644 index 4208c6733..000000000 --- a/common/src/ml_strategy_backup.rs +++ /dev/null @@ -1,526 +0,0 @@ -//! Shared ML Strategy for Foxhunt Trading System - FIXED VERSION WITH MOMENTUM INDICATORS -//! -//! This module provides a unified ML strategy implementation with advanced momentum and trend indicators. - -use anyhow::Result; -use chrono::{DateTime, Datelike, Utc, Timelike}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// ML prediction result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MLPrediction { - /// Model identifier - pub model_id: String, - /// Prediction value (0.0-1.0) - pub prediction_value: f64, - /// Confidence score (0.0-1.0) - pub confidence: f64, - /// Features used for prediction - pub features: Vec, - /// Prediction timestamp - pub timestamp: DateTime, - /// Inference latency in microseconds - pub inference_latency_us: u64, -} - -/// ML model performance metrics -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MLModelPerformance { - /// Model identifier - pub model_id: String, - /// Total predictions made - pub total_predictions: u64, - /// Correct predictions - pub correct_predictions: u64, - /// Average inference latency - pub avg_latency_us: f64, - /// Average confidence score - pub avg_confidence: f64, - /// Model accuracy percentage - pub accuracy_percentage: f64, - /// Returns generated - pub returns: Vec, - /// Sharpe ratio - pub sharpe_ratio: f64, - /// Maximum drawdown - pub max_drawdown: f64, -} - -/// Feature extraction for ML models -#[derive(Debug, Clone)] -pub struct MLFeatureExtractor { - /// Lookback window for features - pub lookback_periods: usize, - /// Price history buffer - price_history: Vec, - /// Volume history buffer - volume_history: Vec, - /// High price history (for ADX, Stochastic, ATR) - high_history: Vec, - /// Low price history (for ADX, Stochastic, ATR) - low_history: Vec, - /// Typical price history (for CCI) - typical_price_history: Vec, - /// High/low price history for oscillators - high_low_history: Vec<(f64, f64)>, - /// On-Balance Volume (OBV) cumulative value - obv: f64, - /// VWAP cumulative values (price * volume sum, volume sum) - vwap_pv_sum: f64, - vwap_volume_sum: f64, - /// EMA-9 state - ema_9: Option, - /// EMA-21 state - ema_21: Option, - /// EMA-50 state - ema_50: Option, -} - -impl MLFeatureExtractor { - /// Create new feature extractor - pub fn new(lookback_periods: usize) -> Self { - Self { - lookback_periods, - price_history: Vec::with_capacity(lookback_periods + 1), - volume_history: Vec::with_capacity(lookback_periods + 1), - high_history: Vec::with_capacity(lookback_periods + 1), - low_history: Vec::with_capacity(lookback_periods + 1), - typical_price_history: Vec::with_capacity(lookback_periods + 1), - high_low_history: Vec::with_capacity(lookback_periods + 1), - obv: 0.0, - vwap_pv_sum: 0.0, - vwap_volume_sum: 0.0, - ema_9: None, - ema_21: None, - ema_50: None, - } - } - - /// Calculate RSI (Relative Strength Index) - fn calculate_rsi(&self, period: usize) -> f64 { - if self.price_history.len() < period + 1 { - return 0.0; - } - - let mut gains = Vec::new(); - let mut losses = Vec::new(); - - for i in 1..=period { - let idx = self.price_history.len() - period - 1 + i; - let change = self.price_history[idx] - self.price_history[idx - 1]; - if change > 0.0 { - gains.push(change); - losses.push(0.0); - } else { - gains.push(0.0); - losses.push(-change); - } - } - - let avg_gain = gains.iter().sum::() / period as f64; - let avg_loss = losses.iter().sum::() / period as f64; - - if avg_loss == 0.0 { - return 1.0; // Max RSI when no losses - } - - let rs = avg_gain / avg_loss; - let rsi = 1.0 - (1.0 / (1.0 + rs)); - - // RSI in [0, 1], normalize to [-1, 1] - (rsi - 0.5) * 2.0 - } - - /// Calculate MACD (Moving Average Convergence Divergence) - fn calculate_macd(&self) -> (f64, f64) { - if self.price_history.len() < 26 { - return (0.0, 0.0); - } - - // EMA-12 and EMA-26 - let ema_12 = self.calculate_ema(12); - let ema_26 = self.calculate_ema(26); - - let macd_line = ema_12 - ema_26; - - // Signal line is EMA-9 of MACD line (simplified: use MACD line itself) - let macd_signal = macd_line * 0.5; // Simplified approximation - - // Normalize to [-1, 1] - let current_price = self.price_history.last().copied().unwrap_or(1.0); - let macd_norm = (macd_line / current_price).tanh(); - let signal_norm = (macd_signal / current_price).tanh(); - - (macd_norm, signal_norm) - } - - /// Calculate EMA for a given period - fn calculate_ema(&self, period: usize) -> f64 { - if self.price_history.len() < period { - return self.price_history.last().copied().unwrap_or(0.0); - } - - let alpha = 2.0 / (period as f64 + 1.0); - let mut ema = self.price_history[self.price_history.len() - period]; - - for i in (self.price_history.len() - period + 1)..self.price_history.len() { - ema = alpha * self.price_history[i] + (1.0 - alpha) * ema; - } - - ema - } - - /// Calculate ADX (Average Directional Index) for trend strength - /// ADX measures trend strength on a scale of 0-100, not direction - /// Returns normalized ADX in range [0, 1] - fn calculate_adx(&self, period: usize) -> f64 { - if self.high_history.len() < period + 1 || self.low_history.len() < period + 1 || self.price_history.len() < period + 1 { - return 0.0; - } - - // Calculate True Range (TR) and Directional Movements (+DM, -DM) - let mut tr_values = Vec::new(); - let mut plus_dm_values = Vec::new(); - let mut minus_dm_values = Vec::new(); - - for i in 1..self.high_history.len() { - let high = self.high_history[i]; - let low = self.low_history[i]; - let prev_close = self.price_history[i - 1]; - - // True Range: max(high-low, |high-prev_close|, |low-prev_close|) - let tr = (high - low) - .max((high - prev_close).abs()) - .max((low - prev_close).abs()); - tr_values.push(tr); - - // Directional Movements - let prev_high = self.high_history[i - 1]; - let prev_low = self.low_history[i - 1]; - - let up_move = high - prev_high; - let down_move = prev_low - low; - - let plus_dm = if up_move > down_move && up_move > 0.0 { up_move } else { 0.0 }; - let minus_dm = if down_move > up_move && down_move > 0.0 { down_move } else { 0.0 }; - - plus_dm_values.push(plus_dm); - minus_dm_values.push(minus_dm); - } - - if tr_values.len() < period { - return 0.0; - } - - // Calculate smoothed TR, +DM, -DM (using Wilder's smoothing) - let smooth_tr = self.wilder_smoothing(&tr_values, period); - let smooth_plus_dm = self.wilder_smoothing(&plus_dm_values, period); - let smooth_minus_dm = self.wilder_smoothing(&minus_dm_values, period); - - if smooth_tr == 0.0 { - return 0.0; - } - - // Calculate Directional Indicators (+DI, -DI) - let plus_di = 100.0 * smooth_plus_dm / smooth_tr; - let minus_di = 100.0 * smooth_minus_dm / smooth_tr; - - // Calculate DX (Directional Index) - let di_sum = plus_di + minus_di; - let dx = if di_sum > 0.0 { - 100.0 * (plus_di - minus_di).abs() / di_sum - } else { - 0.0 - }; - - // ADX is the smoothed average of DX - // For simplicity, return DX as ADX proxy (full ADX needs DX history smoothing) - dx / 100.0 // Normalize to [0, 1] - } - - /// Wilder's smoothing method for ADX calculation - fn wilder_smoothing(&self, values: &[f64], period: usize) -> f64 { - if values.len() < period { - return 0.0; - } - - // First smoothed value is simple average - let first_smooth: f64 = values.iter().take(period).sum::() / period as f64; - - // Apply Wilder's smoothing for remaining values - let mut smoothed = first_smooth; - for &value in values.iter().skip(period) { - smoothed = (smoothed * (period as f64 - 1.0) + value) / period as f64; - } - - smoothed - } - - /// Calculate Stochastic Oscillator (%K and %D) - /// %K = (current_close - lowest_low) / (highest_high - lowest_low) * 100 - /// %D = SMA of %K over d_period - /// Returns normalized values in range [-1, 1] - fn calculate_stochastic(&self, k_period: usize, _k_slowing: usize, _d_period: usize) -> (f64, f64) { - if self.high_history.len() < k_period || self.low_history.len() < k_period || self.price_history.len() < k_period { - return (0.0, 0.0); - } - - // Calculate raw %K - let recent_highs = &self.high_history[self.high_history.len().saturating_sub(k_period)..]; - let recent_lows = &self.low_history[self.low_history.len().saturating_sub(k_period)..]; - let current_close = self.price_history.last().copied().unwrap_or(0.0); - - let highest_high = recent_highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let lowest_low = recent_lows.iter().copied().fold(f64::INFINITY, f64::min); - - let raw_k = if highest_high != lowest_low { - 100.0 * (current_close - lowest_low) / (highest_high - lowest_low) - } else { - 50.0 // Neutral when no price movement - }; - - // Apply %K slowing (SMA of raw %K) - simplified as single value here - let stoch_k = raw_k; - - // Calculate %D (SMA of %K) - simplified as %K itself since we don't have %K history - let stoch_d = stoch_k; - - // Normalize to [-1, 1] range: (value/100)*2 - 1 - let norm_k = (stoch_k / 100.0) * 2.0 - 1.0; - let norm_d = (stoch_d / 100.0) * 2.0 - 1.0; - - (norm_k, norm_d) - } - - /// Calculate CCI (Commodity Channel Index) - /// CCI = (typical_price - SMA) / (0.015 * mean_deviation) - /// Returns normalized CCI using tanh (unbounded indicator) - fn calculate_cci(&self, period: usize) -> f64 { - if self.typical_price_history.len() < period { - return 0.0; - } - - let recent_typical = &self.typical_price_history[self.typical_price_history.len() - period..]; - - // Calculate SMA of typical price - let sma: f64 = recent_typical.iter().sum::() / period as f64; - - // Calculate mean deviation - let mean_deviation: f64 = recent_typical.iter() - .map(|&tp| (tp - sma).abs()) - .sum::() / period as f64; - - let current_typical = self.typical_price_history.last().copied().unwrap_or(0.0); - - // CCI formula - let cci = if mean_deviation > 0.0 { - (current_typical - sma) / (0.015 * mean_deviation) - } else { - 0.0 - }; - - // CCI is unbounded, normalize with tanh will be applied later - cci / 100.0 // Scale down for better tanh normalization - } - - /// 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 from price (0.1% spread) - let high_price = price * 1.001; - let low_price = price * 0.999; - self.high_history.push(high_price); - self.low_history.push(low_price); - self.high_low_history.push((high_price, low_price)); - - // Calculate typical price: (high + low + close) / 3 - let typical_price = (high_price + low_price + price) / 3.0; - self.typical_price_history.push(typical_price); - - // 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_history.len() > self.lookback_periods { - self.high_history.remove(0); - } - if self.low_history.len() > self.lookback_periods { - self.low_history.remove(0); - } - if self.typical_price_history.len() > self.lookback_periods { - self.typical_price_history.remove(0); - } - if self.high_low_history.len() > self.lookback_periods { - self.high_low_history.remove(0); - } - - // Calculate EMAs with exponential smoothing - let alpha_9 = 2.0 / (9.0 + 1.0); - let alpha_21 = 2.0 / (21.0 + 1.0); - let alpha_50 = 2.0 / (50.0 + 1.0); - - self.ema_9 = Some(match self.ema_9 { - Some(prev_ema) => price * alpha_9 + prev_ema * (1.0 - alpha_9), - None => price, - }); - - self.ema_21 = Some(match self.ema_21 { - Some(prev_ema) => price * alpha_21 + prev_ema * (1.0 - alpha_21), - None => price, - }); - - self.ema_50 = Some(match self.ema_50 { - Some(prev_ema) => price * alpha_50 + prev_ema * (1.0 - alpha_50), - None => 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) - .map(|w| (w[1] - w[0]) / w[0]) - .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; - let day_of_week = timestamp.weekday().num_days_from_monday() as f64 / 6.0; - features.push(hour); - features.push(day_of_week); - - // === MOMENTUM & TREND INDICATORS (Wave 17) === - - // 1. ADX (Average Directional Index) - Trend strength indicator - if self.high_history.len() >= 14 && self.low_history.len() >= 14 && self.price_history.len() >= 14 { - let adx = self.calculate_adx(14); - features.push(adx); - } else { - features.push(0.0); - } - - // 2. Stochastic Oscillator - Overbought/oversold indicator - if self.high_history.len() >= 14 && self.low_history.len() >= 14 && self.price_history.len() >= 14 { - let (stoch_k, stoch_d) = self.calculate_stochastic(14, 3, 3); - features.push(stoch_k); - features.push(stoch_d); - } else { - features.push(0.0); - features.push(0.0); - } - - // 3. CCI (Commodity Channel Index) - Cyclical trend detection - if self.typical_price_history.len() >= 20 { - let cci = self.calculate_cci(20); - features.push(cci); - } else { - features.push(0.0); - } - - // Add RSI feature (14-period) - let rsi = self.calculate_rsi(14); - features.push(rsi); - - // Add MACD features (12, 26, 9) - let (macd_line, macd_signal) = self.calculate_macd(); - features.push(macd_line); - features.push(macd_signal); - - // Add EMA features (normalized to [-1, 1]) - 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 - }; - - 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]); - - // Normalize all features to [-1, 1] range using tanh - features.iter().map(|&f| if f.abs() <= 1.0 { f } else { f.tanh() }).collect() - } -} diff --git a/common/src/ml_strategy_fix.rs b/common/src/ml_strategy_fix.rs deleted file mode 100644 index 4208c6733..000000000 --- a/common/src/ml_strategy_fix.rs +++ /dev/null @@ -1,526 +0,0 @@ -//! Shared ML Strategy for Foxhunt Trading System - FIXED VERSION WITH MOMENTUM INDICATORS -//! -//! This module provides a unified ML strategy implementation with advanced momentum and trend indicators. - -use anyhow::Result; -use chrono::{DateTime, Datelike, Utc, Timelike}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// ML prediction result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MLPrediction { - /// Model identifier - pub model_id: String, - /// Prediction value (0.0-1.0) - pub prediction_value: f64, - /// Confidence score (0.0-1.0) - pub confidence: f64, - /// Features used for prediction - pub features: Vec, - /// Prediction timestamp - pub timestamp: DateTime, - /// Inference latency in microseconds - pub inference_latency_us: u64, -} - -/// ML model performance metrics -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct MLModelPerformance { - /// Model identifier - pub model_id: String, - /// Total predictions made - pub total_predictions: u64, - /// Correct predictions - pub correct_predictions: u64, - /// Average inference latency - pub avg_latency_us: f64, - /// Average confidence score - pub avg_confidence: f64, - /// Model accuracy percentage - pub accuracy_percentage: f64, - /// Returns generated - pub returns: Vec, - /// Sharpe ratio - pub sharpe_ratio: f64, - /// Maximum drawdown - pub max_drawdown: f64, -} - -/// Feature extraction for ML models -#[derive(Debug, Clone)] -pub struct MLFeatureExtractor { - /// Lookback window for features - pub lookback_periods: usize, - /// Price history buffer - price_history: Vec, - /// Volume history buffer - volume_history: Vec, - /// High price history (for ADX, Stochastic, ATR) - high_history: Vec, - /// Low price history (for ADX, Stochastic, ATR) - low_history: Vec, - /// Typical price history (for CCI) - typical_price_history: Vec, - /// High/low price history for oscillators - high_low_history: Vec<(f64, f64)>, - /// On-Balance Volume (OBV) cumulative value - obv: f64, - /// VWAP cumulative values (price * volume sum, volume sum) - vwap_pv_sum: f64, - vwap_volume_sum: f64, - /// EMA-9 state - ema_9: Option, - /// EMA-21 state - ema_21: Option, - /// EMA-50 state - ema_50: Option, -} - -impl MLFeatureExtractor { - /// Create new feature extractor - pub fn new(lookback_periods: usize) -> Self { - Self { - lookback_periods, - price_history: Vec::with_capacity(lookback_periods + 1), - volume_history: Vec::with_capacity(lookback_periods + 1), - high_history: Vec::with_capacity(lookback_periods + 1), - low_history: Vec::with_capacity(lookback_periods + 1), - typical_price_history: Vec::with_capacity(lookback_periods + 1), - high_low_history: Vec::with_capacity(lookback_periods + 1), - obv: 0.0, - vwap_pv_sum: 0.0, - vwap_volume_sum: 0.0, - ema_9: None, - ema_21: None, - ema_50: None, - } - } - - /// Calculate RSI (Relative Strength Index) - fn calculate_rsi(&self, period: usize) -> f64 { - if self.price_history.len() < period + 1 { - return 0.0; - } - - let mut gains = Vec::new(); - let mut losses = Vec::new(); - - for i in 1..=period { - let idx = self.price_history.len() - period - 1 + i; - let change = self.price_history[idx] - self.price_history[idx - 1]; - if change > 0.0 { - gains.push(change); - losses.push(0.0); - } else { - gains.push(0.0); - losses.push(-change); - } - } - - let avg_gain = gains.iter().sum::() / period as f64; - let avg_loss = losses.iter().sum::() / period as f64; - - if avg_loss == 0.0 { - return 1.0; // Max RSI when no losses - } - - let rs = avg_gain / avg_loss; - let rsi = 1.0 - (1.0 / (1.0 + rs)); - - // RSI in [0, 1], normalize to [-1, 1] - (rsi - 0.5) * 2.0 - } - - /// Calculate MACD (Moving Average Convergence Divergence) - fn calculate_macd(&self) -> (f64, f64) { - if self.price_history.len() < 26 { - return (0.0, 0.0); - } - - // EMA-12 and EMA-26 - let ema_12 = self.calculate_ema(12); - let ema_26 = self.calculate_ema(26); - - let macd_line = ema_12 - ema_26; - - // Signal line is EMA-9 of MACD line (simplified: use MACD line itself) - let macd_signal = macd_line * 0.5; // Simplified approximation - - // Normalize to [-1, 1] - let current_price = self.price_history.last().copied().unwrap_or(1.0); - let macd_norm = (macd_line / current_price).tanh(); - let signal_norm = (macd_signal / current_price).tanh(); - - (macd_norm, signal_norm) - } - - /// Calculate EMA for a given period - fn calculate_ema(&self, period: usize) -> f64 { - if self.price_history.len() < period { - return self.price_history.last().copied().unwrap_or(0.0); - } - - let alpha = 2.0 / (period as f64 + 1.0); - let mut ema = self.price_history[self.price_history.len() - period]; - - for i in (self.price_history.len() - period + 1)..self.price_history.len() { - ema = alpha * self.price_history[i] + (1.0 - alpha) * ema; - } - - ema - } - - /// Calculate ADX (Average Directional Index) for trend strength - /// ADX measures trend strength on a scale of 0-100, not direction - /// Returns normalized ADX in range [0, 1] - fn calculate_adx(&self, period: usize) -> f64 { - if self.high_history.len() < period + 1 || self.low_history.len() < period + 1 || self.price_history.len() < period + 1 { - return 0.0; - } - - // Calculate True Range (TR) and Directional Movements (+DM, -DM) - let mut tr_values = Vec::new(); - let mut plus_dm_values = Vec::new(); - let mut minus_dm_values = Vec::new(); - - for i in 1..self.high_history.len() { - let high = self.high_history[i]; - let low = self.low_history[i]; - let prev_close = self.price_history[i - 1]; - - // True Range: max(high-low, |high-prev_close|, |low-prev_close|) - let tr = (high - low) - .max((high - prev_close).abs()) - .max((low - prev_close).abs()); - tr_values.push(tr); - - // Directional Movements - let prev_high = self.high_history[i - 1]; - let prev_low = self.low_history[i - 1]; - - let up_move = high - prev_high; - let down_move = prev_low - low; - - let plus_dm = if up_move > down_move && up_move > 0.0 { up_move } else { 0.0 }; - let minus_dm = if down_move > up_move && down_move > 0.0 { down_move } else { 0.0 }; - - plus_dm_values.push(plus_dm); - minus_dm_values.push(minus_dm); - } - - if tr_values.len() < period { - return 0.0; - } - - // Calculate smoothed TR, +DM, -DM (using Wilder's smoothing) - let smooth_tr = self.wilder_smoothing(&tr_values, period); - let smooth_plus_dm = self.wilder_smoothing(&plus_dm_values, period); - let smooth_minus_dm = self.wilder_smoothing(&minus_dm_values, period); - - if smooth_tr == 0.0 { - return 0.0; - } - - // Calculate Directional Indicators (+DI, -DI) - let plus_di = 100.0 * smooth_plus_dm / smooth_tr; - let minus_di = 100.0 * smooth_minus_dm / smooth_tr; - - // Calculate DX (Directional Index) - let di_sum = plus_di + minus_di; - let dx = if di_sum > 0.0 { - 100.0 * (plus_di - minus_di).abs() / di_sum - } else { - 0.0 - }; - - // ADX is the smoothed average of DX - // For simplicity, return DX as ADX proxy (full ADX needs DX history smoothing) - dx / 100.0 // Normalize to [0, 1] - } - - /// Wilder's smoothing method for ADX calculation - fn wilder_smoothing(&self, values: &[f64], period: usize) -> f64 { - if values.len() < period { - return 0.0; - } - - // First smoothed value is simple average - let first_smooth: f64 = values.iter().take(period).sum::() / period as f64; - - // Apply Wilder's smoothing for remaining values - let mut smoothed = first_smooth; - for &value in values.iter().skip(period) { - smoothed = (smoothed * (period as f64 - 1.0) + value) / period as f64; - } - - smoothed - } - - /// Calculate Stochastic Oscillator (%K and %D) - /// %K = (current_close - lowest_low) / (highest_high - lowest_low) * 100 - /// %D = SMA of %K over d_period - /// Returns normalized values in range [-1, 1] - fn calculate_stochastic(&self, k_period: usize, _k_slowing: usize, _d_period: usize) -> (f64, f64) { - if self.high_history.len() < k_period || self.low_history.len() < k_period || self.price_history.len() < k_period { - return (0.0, 0.0); - } - - // Calculate raw %K - let recent_highs = &self.high_history[self.high_history.len().saturating_sub(k_period)..]; - let recent_lows = &self.low_history[self.low_history.len().saturating_sub(k_period)..]; - let current_close = self.price_history.last().copied().unwrap_or(0.0); - - let highest_high = recent_highs.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let lowest_low = recent_lows.iter().copied().fold(f64::INFINITY, f64::min); - - let raw_k = if highest_high != lowest_low { - 100.0 * (current_close - lowest_low) / (highest_high - lowest_low) - } else { - 50.0 // Neutral when no price movement - }; - - // Apply %K slowing (SMA of raw %K) - simplified as single value here - let stoch_k = raw_k; - - // Calculate %D (SMA of %K) - simplified as %K itself since we don't have %K history - let stoch_d = stoch_k; - - // Normalize to [-1, 1] range: (value/100)*2 - 1 - let norm_k = (stoch_k / 100.0) * 2.0 - 1.0; - let norm_d = (stoch_d / 100.0) * 2.0 - 1.0; - - (norm_k, norm_d) - } - - /// Calculate CCI (Commodity Channel Index) - /// CCI = (typical_price - SMA) / (0.015 * mean_deviation) - /// Returns normalized CCI using tanh (unbounded indicator) - fn calculate_cci(&self, period: usize) -> f64 { - if self.typical_price_history.len() < period { - return 0.0; - } - - let recent_typical = &self.typical_price_history[self.typical_price_history.len() - period..]; - - // Calculate SMA of typical price - let sma: f64 = recent_typical.iter().sum::() / period as f64; - - // Calculate mean deviation - let mean_deviation: f64 = recent_typical.iter() - .map(|&tp| (tp - sma).abs()) - .sum::() / period as f64; - - let current_typical = self.typical_price_history.last().copied().unwrap_or(0.0); - - // CCI formula - let cci = if mean_deviation > 0.0 { - (current_typical - sma) / (0.015 * mean_deviation) - } else { - 0.0 - }; - - // CCI is unbounded, normalize with tanh will be applied later - cci / 100.0 // Scale down for better tanh normalization - } - - /// 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 from price (0.1% spread) - let high_price = price * 1.001; - let low_price = price * 0.999; - self.high_history.push(high_price); - self.low_history.push(low_price); - self.high_low_history.push((high_price, low_price)); - - // Calculate typical price: (high + low + close) / 3 - let typical_price = (high_price + low_price + price) / 3.0; - self.typical_price_history.push(typical_price); - - // 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_history.len() > self.lookback_periods { - self.high_history.remove(0); - } - if self.low_history.len() > self.lookback_periods { - self.low_history.remove(0); - } - if self.typical_price_history.len() > self.lookback_periods { - self.typical_price_history.remove(0); - } - if self.high_low_history.len() > self.lookback_periods { - self.high_low_history.remove(0); - } - - // Calculate EMAs with exponential smoothing - let alpha_9 = 2.0 / (9.0 + 1.0); - let alpha_21 = 2.0 / (21.0 + 1.0); - let alpha_50 = 2.0 / (50.0 + 1.0); - - self.ema_9 = Some(match self.ema_9 { - Some(prev_ema) => price * alpha_9 + prev_ema * (1.0 - alpha_9), - None => price, - }); - - self.ema_21 = Some(match self.ema_21 { - Some(prev_ema) => price * alpha_21 + prev_ema * (1.0 - alpha_21), - None => price, - }); - - self.ema_50 = Some(match self.ema_50 { - Some(prev_ema) => price * alpha_50 + prev_ema * (1.0 - alpha_50), - None => 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) - .map(|w| (w[1] - w[0]) / w[0]) - .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; - let day_of_week = timestamp.weekday().num_days_from_monday() as f64 / 6.0; - features.push(hour); - features.push(day_of_week); - - // === MOMENTUM & TREND INDICATORS (Wave 17) === - - // 1. ADX (Average Directional Index) - Trend strength indicator - if self.high_history.len() >= 14 && self.low_history.len() >= 14 && self.price_history.len() >= 14 { - let adx = self.calculate_adx(14); - features.push(adx); - } else { - features.push(0.0); - } - - // 2. Stochastic Oscillator - Overbought/oversold indicator - if self.high_history.len() >= 14 && self.low_history.len() >= 14 && self.price_history.len() >= 14 { - let (stoch_k, stoch_d) = self.calculate_stochastic(14, 3, 3); - features.push(stoch_k); - features.push(stoch_d); - } else { - features.push(0.0); - features.push(0.0); - } - - // 3. CCI (Commodity Channel Index) - Cyclical trend detection - if self.typical_price_history.len() >= 20 { - let cci = self.calculate_cci(20); - features.push(cci); - } else { - features.push(0.0); - } - - // Add RSI feature (14-period) - let rsi = self.calculate_rsi(14); - features.push(rsi); - - // Add MACD features (12, 26, 9) - let (macd_line, macd_signal) = self.calculate_macd(); - features.push(macd_line); - features.push(macd_signal); - - // Add EMA features (normalized to [-1, 1]) - 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 - }; - - 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]); - - // Normalize all features to [-1, 1] range using tanh - features.iter().map(|&f| if f.abs() <= 1.0 { f } else { f.tanh() }).collect() - } -} diff --git a/common/src/ml_strategy_rsi_macd.rs b/common/src/ml_strategy_rsi_macd.rs deleted file mode 100644 index 1d831374f..000000000 --- a/common/src/ml_strategy_rsi_macd.rs +++ /dev/null @@ -1,105 +0,0 @@ -// RSI and MACD calculation methods to be added to MLFeatureExtractor - -/// Calculate RSI (Relative Strength Index) - 14 period -fn calculate_rsi(&self, period: usize) -> f64 { - if self.price_history.len() < period + 1 { - return 0.5; // Neutral RSI (normalized to [-1, 1] range later) - } - - let mut gains = Vec::new(); - let mut losses = Vec::new(); - - // Calculate price changes - for i in (self.price_history.len().saturating_sub(period + 1))..self.price_history.len() { - if i > 0 { - let change = self.price_history[i] - self.price_history[i - 1]; - if change > 0.0 { - gains.push(change); - losses.push(0.0); - } else { - gains.push(0.0); - losses.push(-change); - } - } - } - - if gains.is_empty() { - return 0.5; // Neutral RSI - } - - // Calculate average gain and loss - let avg_gain = gains.iter().sum::() / gains.len() as f64; - let avg_loss = losses.iter().sum::() / losses.len() as f64; - - // Avoid division by zero - if avg_loss == 0.0 { - return 1.0; // Maximum RSI (100) - } - - let rs = avg_gain / avg_loss; - let rsi = 100.0 - (100.0 / (1.0 + rs)); - - // Return RSI as 0.0-1.0 (will be normalized to [-1, 1] with tanh later) - rsi / 100.0 -} - -/// Calculate EMA (Exponential Moving Average) for MACD calculation -fn calculate_ema_for_macd(&self, period: usize) -> f64 { - if self.price_history.len() < period { - return self.price_history.last().copied().unwrap_or(0.0); - } - - let multiplier = 2.0 / (period as f64 + 1.0); - let recent_prices: Vec = self.price_history.iter().rev().take(period).copied().collect(); - - // Start with SMA as initial EMA - let mut ema = recent_prices.iter().sum::() / recent_prices.len() as f64; - - // Calculate EMA from oldest to newest - for price in recent_prices.iter().rev() { - ema = (price - ema) * multiplier + ema; - } - - ema -} - -/// Calculate MACD (Moving Average Convergence Divergence) -/// Returns (MACD line, Signal line) normalized to price -fn calculate_macd(&self) -> (f64, f64) { - if self.price_history.len() < 26 { - return (0.0, 0.0); - } - - // Calculate 12-period and 26-period EMAs - let ema_12 = self.calculate_ema_for_macd(12); - let ema_26 = self.calculate_ema_for_macd(26); - - // MACD line = EMA(12) - EMA(26) - let macd_line = ema_12 - ema_26; - - // For signal line, we need historical MACD values (simplified: use current for demo) - // In production, you'd maintain a MACD history buffer and calculate 9-period EMA of that - // For now, we'll use a simplified approach: normalize MACD by current price - let current_price = self.price_history.last().copied().unwrap_or(1.0); - let normalized_macd = if current_price != 0.0 { - macd_line / current_price - } else { - 0.0 - }; - - // Signal line approximation (in production, maintain MACD history for proper 9-EMA) - let signal_line = normalized_macd * 0.9; // Simplified: signal follows MACD with lag - - (normalized_macd, signal_line) -} - -// To add to extract_features() method (after EMA features, before final normalization): - - // Add RSI feature (14-period) - let rsi = self.calculate_rsi(14); - features.push(rsi); - - // Add MACD features (12, 26, 9) - let (macd_line, macd_signal) = self.calculate_macd(); - features.push(macd_line); - features.push(macd_signal); diff --git a/common/src/resilience/integration_examples.rs b/common/src/resilience/integration_examples.rs index 13f060dbf..dfd24be4e 100644 --- a/common/src/resilience/integration_examples.rs +++ b/common/src/resilience/integration_examples.rs @@ -312,6 +312,7 @@ pub mod presets { max_retries: 3, base_delay: Duration::from_millis(100), max_delay: Duration::from_secs(10), + ..Default::default() }; (cb_config, retry_config) @@ -329,6 +330,7 @@ pub mod presets { max_retries: 5, base_delay: Duration::from_millis(200), max_delay: Duration::from_secs(30), + ..Default::default() }; (cb_config, retry_config) @@ -346,6 +348,7 @@ pub mod presets { max_retries: 3, base_delay: Duration::from_millis(50), max_delay: Duration::from_secs(5), + ..Default::default() }; (cb_config, retry_config) @@ -363,6 +366,7 @@ pub mod presets { max_retries: 5, base_delay: Duration::from_millis(500), max_delay: Duration::from_secs(60), + ..Default::default() }; (cb_config, retry_config) diff --git a/common/src/resilience/retry.rs b/common/src/resilience/retry.rs index bb4419f14..35492f514 100644 --- a/common/src/resilience/retry.rs +++ b/common/src/resilience/retry.rs @@ -35,7 +35,7 @@ use std::time::Duration; use thiserror::Error; use tokio::time::sleep; -/// Retry configuration +/// Retry configuration with circuit breaker and HFT optimization support #[derive(Debug, Clone)] pub struct RetryConfig { /// Maximum number of retry attempts (not including initial attempt) @@ -44,6 +44,14 @@ pub struct RetryConfig { pub base_delay: Duration, /// Maximum delay cap for exponential backoff pub max_delay: Duration, + /// Circuit breaker failure threshold + pub circuit_breaker_threshold: u32, + /// Circuit breaker timeout before reset attempt + pub circuit_breaker_timeout: Duration, + /// Enable jitter for exponential backoff to prevent thundering herd + pub enable_jitter: bool, + /// HFT-specific nanosecond precision delays + pub hft_precision_mode: bool, } impl Default for RetryConfig { @@ -52,6 +60,51 @@ impl Default for RetryConfig { max_retries: 3, base_delay: Duration::from_millis(100), max_delay: Duration::from_secs(10), + circuit_breaker_threshold: 5, + circuit_breaker_timeout: Duration::from_secs(60), + enable_jitter: true, + hft_precision_mode: false, + } + } +} + +impl RetryConfig { + /// Create HFT-optimized configuration for low-latency operations + pub fn hft_optimized() -> Self { + Self { + max_retries: 3, + base_delay: Duration::from_micros(50), // 50µs base delay + max_delay: Duration::from_millis(5), // 5ms max delay + circuit_breaker_threshold: 10, + circuit_breaker_timeout: Duration::from_secs(30), + enable_jitter: false, // No jitter for HFT - predictable timing + hft_precision_mode: true, + } + } + + /// Create configuration for network operations + pub fn network_optimized() -> Self { + Self { + max_retries: 5, + base_delay: Duration::from_millis(500), + max_delay: Duration::from_secs(10), + circuit_breaker_threshold: 3, + circuit_breaker_timeout: Duration::from_secs(30), + enable_jitter: true, + hft_precision_mode: false, + } + } + + /// Create configuration for database operations + pub fn database_optimized() -> Self { + Self { + max_retries: 7, + base_delay: Duration::from_millis(250), + max_delay: Duration::from_secs(5), + circuit_breaker_threshold: 5, + circuit_breaker_timeout: Duration::from_secs(45), + enable_jitter: true, + hft_precision_mode: false, } } } diff --git a/common/src/resilience/tests/retry_test.rs b/common/src/resilience/tests/retry_test.rs index 564991353..cb9f29488 100644 --- a/common/src/resilience/tests/retry_test.rs +++ b/common/src/resilience/tests/retry_test.rs @@ -52,6 +52,7 @@ async fn test_retry_max_attempts_exceeded() { max_retries: 3, base_delay: Duration::from_millis(10), max_delay: Duration::from_millis(100), + ..Default::default() }; let result = retry_with_backoff( @@ -108,6 +109,7 @@ async fn test_retry_exponential_backoff_timing() { max_retries: 3, base_delay: Duration::from_millis(100), max_delay: Duration::from_millis(1000), + ..Default::default() }; let start = Instant::now(); @@ -146,6 +148,7 @@ async fn test_retry_max_delay_cap() { max_retries: 5, base_delay: Duration::from_millis(100), max_delay: Duration::from_millis(300), + ..Default::default() }; let start = Instant::now(); @@ -196,6 +199,7 @@ async fn test_retry_database_error() { max_retries: 3, base_delay: Duration::from_millis(10), max_delay: Duration::from_millis(100), + ..Default::default() }, ) .await; @@ -224,6 +228,7 @@ async fn test_retry_timeout_error() { max_retries: 2, base_delay: Duration::from_millis(10), max_delay: Duration::from_millis(100), + ..Default::default() }, ) .await; @@ -240,6 +245,7 @@ async fn test_retry_error_contains_last_error() { max_retries: 2, base_delay: Duration::from_millis(10), max_delay: Duration::from_millis(50), + ..Default::default() }, ) .await; diff --git a/common/src/types.rs.rej b/common/src/types.rs.rej deleted file mode 100644 index 5e9baaa24..000000000 --- a/common/src/types.rs.rej +++ /dev/null @@ -1,65 +0,0 @@ ---- common/src/types.rs -+++ common/src/types.rs -@@ -1461,7 +1461,7 @@ impl DecimalExt for Decimal { - if self.is_sign_negative() { - return None; - } -- let value_f64: f64 = self.to_owned().parse().ok()?; -+ let value_f64: f64 = self.to_string().parse().ok()?; - let sqrt_f64 = value_f64.sqrt(); - Decimal::from_f64_retain(sqrt_f64) - } -@@ -2205,7 +2205,7 @@ impl Price { - pub fn from_f64(value: f64) -> Result { - if value < 0.0_f64 || !value.is_finite() { - return Err(CommonTypeError::InvalidPrice { -- value: value.to_owned(), -+ value: value.to_string(), - reason: "Price validation failed".to_owned(), - }); - } -@@ -2596,7 +2596,7 @@ impl Quantity { - pub fn from_f64(value: f64) -> Result { - if !value.is_finite() { - return Err(CommonTypeError::InvalidQuantity { -- value: value.to_owned(), -+ value: value.to_string(), - reason: "Quantity validation failed".to_owned(), - }); - } -@@ -2683,7 +2683,7 @@ impl TryFrom for Quantity { - - fn try_from(decimal: Decimal) -> Result { - Self::try_from(decimal).map_err(|_| CommonTypeError::InvalidQuantity { -- value: decimal.to_owned(), -+ value: decimal.to_string(), - reason: "Failed to convert Decimal to Quantity".to_owned(), - }) - } -@@ -3023,7 +3023,7 @@ impl<'q> Encode<'q, Postgres> for TimeInForce { - ) -> Result> { - use sqlx::types::Type; - // Use the Display trait to convert enum to string representation -- <&str as Encode>::encode(self.to_owned().as_str(), buf) -+ <&str as Encode>::encode(&self.to_string(), buf) - } - fn produces(&self) -> Option { - <&str as sqlx::Type>::type_info() -@@ -3328,7 +3328,7 @@ impl OrderId { - - /// Get the string representation - pub fn as_str(&self) -> String { -- self.0.to_owned() -+ self.0.to_string() - } - - /// Parse from a string -@@ -3398,7 +3398,7 @@ impl ExecutionId { - - /// Generate a new random execution ID (alias for new) - pub fn generate() -> Self { -- Self(uuid::Uuid::new_v4().to_owned()) -+ Self(uuid::Uuid::new_v4().to_string()) - } - - /// Create an execution ID from a string diff --git a/data/src/error_consolidated.rs b/data/src/error_consolidated.rs deleted file mode 100644 index 784638686..000000000 --- a/data/src/error_consolidated.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Consolidated error handling for the data module using CommonError -//! -//! This module demonstrates the consolidated error handling pattern -//! using the common error system across all Foxhunt services. - -// REMOVED: All pub use statements eliminated per cleanup requirements -// Use direct import: common::error::{CommonError, CommonResult, ErrorCategory, RetryStrategy, ErrorSeverity} - -/// Result type for data module operations using CommonError -pub type DataResult = common::error::CommonResult; - -/// Data module specific error extensions -/// For cases where we need domain-specific error information beyond CommonError -#[derive(Debug, thiserror::Error)] -pub enum DataServiceError { - /// Common error with context - #[error("Data service error: {0}")] - Common(#[from] common::error::CommonError), - - /// `FIX` protocol specific error with detailed context - #[error("FIX protocol error: {session_id} - {message}")] - FixProtocol { - session_id: String, - message: String, - }, - - /// Broker connection with specific broker context - #[error("Broker connection error: {broker} - {message}")] - BrokerConnection { - broker: String, - message: String, - }, - - /// Market data provider specific error - #[error("Market data provider error: {provider} - {symbol} - {message}")] - MarketDataProvider { - provider: String, - symbol: String, - message: String, - }, -} - -impl DataServiceError { - /// Convert to CommonError for metrics and monitoring - pub fn to_common_error(self) -> CommonError { - match self { - DataServiceError::Common(err) => err, - DataServiceError::FixProtocol { session_id, message } => { - CommonError::service( - ErrorCategory::Trading, - format!("FIX protocol error [{}]: {}", session_id, message) - ) - } - DataServiceError::BrokerConnection { broker, message } => { - CommonError::connection(broker, message) - } - DataServiceError::MarketDataProvider { provider, symbol, message } => { - CommonError::service( - ErrorCategory::MarketData, - format!("Provider {} symbol {}: {}", provider, symbol, message) - ) - } - } - } - - /// Get error category for metrics - pub fn category(&self) -> ErrorCategory { - self.to_common_error().category() - } - - /// Get error severity - pub fn severity(&self) -> ErrorSeverity { - self.to_common_error().severity() - } - - /// Get retry strategy - pub fn retry_strategy(&self) -> RetryStrategy { - self.to_common_error().retry_strategy() - } - - /// Check if error is retryable - pub fn is_retryable(&self) -> bool { - self.to_common_error().is_retryable() - } - - /// Get error code for monitoring - pub fn error_code(&self) -> &'static str { - match self { - DataServiceError::Common(_) => "DATA_COMMON_ERROR", - DataServiceError::FixProtocol { .. } => "DATA_FIX_PROTOCOL_ERROR", - DataServiceError::BrokerConnection { .. } => "DATA_BROKER_CONNECTION_ERROR", - DataServiceError::MarketDataProvider { .. } => "DATA_MARKET_DATA_PROVIDER_ERROR", - } - } -} - -/// Convert standard errors to CommonError for consistent handling -impl From for DataServiceError { - fn from(err: std::io::Error) -> Self { - DataServiceError::Common(CommonError::network(format!("IO error: {}", err))) - } -} - -impl From for DataServiceError { - fn from(err: serde_json::Error) -> Self { - DataServiceError::Common(CommonError::serialization(format!("JSON error: {}", err))) - } -} - -impl From for DataServiceError { - fn from(err: reqwest::Error) -> Self { - DataServiceError::Common(CommonError::network(format!("HTTP error: {}", err))) - } -} - -impl From for DataServiceError { - fn from(err: tokio_tungstenite::tungstenite::Error) -> Self { - DataServiceError::Common(CommonError::connection("websocket", format!("{}", err))) - } -} - -impl From for DataServiceError { - fn from(err: chrono::ParseError) -> Self { - DataServiceError::Common(CommonError::validation("timestamp", format!("Parse error: {}", err))) - } -} - -impl From for DataServiceError { - fn from(err: url::ParseError) -> Self { - DataServiceError::Common(CommonError::validation("url", format!("URL parse error: {}", err))) - } -} - -impl From for DataServiceError { - fn from(err: anyhow::Error) -> Self { - DataServiceError::Common(CommonError::internal(format!("Anyhow error: {}", err))) - } -} - -/// Convenience functions for creating data service errors -impl DataServiceError { - /// Create `FIX` protocol error - pub fn fix_protocol, M: Into>(session_id: S, message: M) -> Self { - Self::FixProtocol { - session_id: session_id.into(), - message: message.into(), - } - } - - /// Create broker connection error - pub fn broker_connection, M: Into>(broker: B, message: M) -> Self { - Self::BrokerConnection { - broker: broker.into(), - message: message.into(), - } - } - - /// Create market data provider error - pub fn market_data_provider, S: Into, M: Into>( - provider: P, - symbol: S, - message: M, - ) -> Self { - Self::MarketDataProvider { - provider: provider.into(), - symbol: symbol.into(), - message: message.into(), - } - } - - /// Create network error using CommonError - pub fn network>(message: M) -> Self { - Self::Common(CommonError::network(message)) - } - - /// Create authentication error using CommonError - pub fn authentication>(message: M) -> Self { - Self::Common(CommonError::authentication(message)) - } - - /// Create configuration error using CommonError - pub fn configuration>(message: M) -> Self { - Self::Common(CommonError::config(message)) - } - - /// Create validation error using CommonError - pub fn validation, M: Into>(field: F, message: M) -> Self { - Self::Common(CommonError::validation(field, message)) - } - - /// Create timeout error using CommonError - pub fn timeout(actual_ms: u64, max_ms: u64) -> Self { - Self::Common(CommonError::timeout(actual_ms, max_ms)) - } - - /// Create serialization error using CommonError - pub fn serialization>(message: M) -> Self { - Self::Common(CommonError::serialization(message)) - } - - /// Create internal error using CommonError - pub fn internal>(message: M) -> Self { - Self::Common(CommonError::internal(message)) - } - - /// Create not found error using CommonError - pub fn not_found, I: Into>(resource: R, identifier: I) -> Self { - Self::Common(CommonError::not_found(resource, identifier)) - } - - /// Create trading error using CommonError - pub fn trading>(message: M) -> Self { - Self::Common(CommonError::trading(message)) - } -} - -/// Convert to CommonError automatically for interop -impl From for CommonError { - fn from(err: DataServiceError) -> Self { - err.to_common_error() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_data_service_error_categorization() { - let fix_error = DataServiceError::fix_protocol("SESSION_001", "Heartbeat timeout"); - assert_eq!(fix_error.category(), ErrorCategory::Trading); - assert_eq!(fix_error.error_code(), "DATA_FIX_PROTOCOL_ERROR"); - - let provider_error = DataServiceError::market_data_provider("DATABENTO", "AAPL", "Connection lost"); - assert_eq!(provider_error.category(), ErrorCategory::MarketData); - assert!(provider_error.is_retryable()); - } - - #[test] - fn test_common_error_integration() { - let network_error = DataServiceError::network("Connection refused"); - let common_error: CommonError = network_error.into(); - - assert_eq!(common_error.category(), ErrorCategory::Network); - assert!(common_error.is_retryable()); - - match common_error.retry_strategy() { - RetryStrategy::Exponential { .. } => (), - _ => panic!("Expected exponential backoff for network errors"), - } - } - - #[test] - fn test_error_conversion_chain() { - let io_error = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"); - let data_error: DataServiceError = io_error.into(); - let common_error: CommonError = data_error.into(); - - assert_eq!(common_error.category(), ErrorCategory::Network); - assert_eq!(common_error.severity(), ErrorSeverity::Warn); - } - - #[test] - fn test_retry_strategies() { - let auth_error = DataServiceError::authentication("Invalid token"); - assert!(!auth_error.is_retryable()); - assert_eq!(auth_error.retry_strategy(), RetryStrategy::NoRetry); - - let timeout_error = DataServiceError::timeout(5000, 2000); - assert!(timeout_error.is_retryable()); - match timeout_error.retry_strategy() { - RetryStrategy::Linear { .. } => (), - _ => panic!("Expected linear backoff for timeout errors"), - } - } - - #[test] - fn test_error_severity_classification() { - let config_error = DataServiceError::configuration("Missing API key"); - assert_eq!(config_error.severity(), ErrorSeverity::Critical); - - let validation_error = DataServiceError::validation("price", "Must be positive"); - assert_eq!(validation_error.severity(), ErrorSeverity::Info); - - let broker_error = DataServiceError::broker_connection("INTERACTIVE_BROKERS", "Connection lost"); - assert_eq!(broker_error.severity(), ErrorSeverity::Warn); - } -} \ No newline at end of file diff --git a/ml/examples/evaluate_dqn_main_orchestrator.rs.backup b/ml/examples/evaluate_dqn_main_orchestrator.rs.backup deleted file mode 100644 index eb87bc7ce..000000000 --- a/ml/examples/evaluate_dqn_main_orchestrator.rs.backup +++ /dev/null @@ -1,1401 +0,0 @@ -//! Component 7: Main Orchestrator - Complete DQN Evaluation Pipeline -//! -//! Integrates all 6 components into a production-ready evaluation system: -//! - Component 1: CLI Configuration (EvaluationConfig) -//! - Component 2: Model Loading (load_dqn_model) -//! - Component 3: Parquet Data Loading (load_parquet_data) -//! - Component 4: Inference Engine (run_inference) -//! - Component 5: Metrics Calculator (calculate_metrics) -//! - Component 6: Report Generator (generate_report) -//! - Component 7: Main Orchestrator (main) -//! -//! # Usage -//! -//! ```bash -//! # Evaluate with default settings (auto-detect CUDA) -//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release --features cuda -//! -//! # Evaluate on CPU with custom model -//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release -- \ -//! --model-path ml/trained_models/dqn_final_epoch100.safetensors \ -//! --device cpu -//! -//! # Evaluate with JSON output for CI/CD -//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release --features cuda -- \ -//! --parquet-file test_data/ES_FUT_unseen.parquet \ -//! --output-json evaluation_results.json -//! -//! # Custom warmup period (skip first 30 bars) -//! cargo run -p ml --example evaluate_dqn_main_orchestrator --release --features cuda -- \ -//! --warmup-bars 30 \ -//! --parquet-file test_data/ES_FUT_validation.parquet -//! ``` -//! -//! # Architecture -//! -//! ```text -//! ┌─────────────────────────────────────────────────────────────────┐ -//! │ MAIN ORCHESTRATOR │ -//! │ │ -//! │ 1. INITIALIZATION │ -//! │ ├─ Parse CLI args (Component 1) │ -//! │ ├─ Setup tracing (stdout + /tmp/dqn_eval.log) │ -//! │ ├─ Validate config │ -//! │ └─ Setup graceful shutdown (Ctrl+C / SIGTERM) │ -//! │ │ -//! │ 2. PARALLEL LOADING (tokio::try_join!) │ -//! │ ├─ Load Parquet data (Component 3) ─────┐ │ -//! │ └─ Load DQN model (Component 2) ────────┴─ Concurrent │ -//! │ │ -//! │ 3. SEQUENTIAL INFERENCE │ -//! │ ├─ Run inference (Component 4) │ -//! │ ├─ Calculate metrics (Component 5) │ -//! │ └─ Track total elapsed time │ -//! │ │ -//! │ 4. REPORT GENERATION │ -//! │ ├─ Generate report (Component 6) │ -//! │ └─ Export JSON (if configured) │ -//! │ │ -//! │ 5. GRACEFUL SHUTDOWN │ -//! │ ├─ Stop inference loop (if interrupted) │ -//! │ ├─ Generate partial report │ -//! │ └─ Exit with appropriate code (0=success, 1=error) │ -//! └─────────────────────────────────────────────────────────────────┘ -//! ``` - -use anyhow::{Context, Result}; -use candle_core::{Device, Tensor}; -use clap::Parser; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Instant; -use tokio::signal; -use tracing::{info, warn}; -use tracing_subscriber::FmtSubscriber; - -use ml::data_loaders::load_parquet_data_with_timestamps; -use ml::dqn::dqn::{WorkingDQN, WorkingDQNConfig}; -use ml::features::extraction::OHLCVBar; -use ml::preprocessing::{preprocess_prices, PreprocessConfig}; - -// ============================================================================ -// Component 1: CLI Configuration (imported from evaluate_dqn.rs) -// ============================================================================ - -/// DQN Model Evaluation Configuration -/// -/// Parses and validates CLI arguments for evaluating a trained DQN model. -/// -/// # Validation Rules -/// -/// - `model_path`: Must exist and be a valid SafeTensors file -/// - `parquet_file`: Must exist and contain OHLCV data -/// - `device`: Must be "cpu", "cuda", or "auto" -/// - `warmup_bars`: Must be in range [10, 100] (prevents over/under fitting) -#[derive(Parser, Debug)] -#[command( - name = "evaluate_dqn_main_orchestrator", - about = "Evaluate DQN model on unseen market data", - long_about = "Complete DQN evaluation pipeline with parallel loading, inference, \ - metrics calculation, and report generation." -)] -struct EvaluationConfig { - /// Path to trained DQN model (SafeTensors format) - #[arg(long, default_value = "/tmp/dqn_final_model.safetensors")] - model_path: PathBuf, - - /// Path to Parquet file with unseen OHLCV data - #[arg(long, default_value = "test_data/ES_FUT_unseen.parquet")] - parquet_file: PathBuf, - - /// Device selection: cpu, cuda, or auto - #[arg(long, default_value = "auto")] - device: String, - - /// Number of warmup bars to skip (insufficient history) - #[arg(long, default_value_t = 50)] - warmup_bars: usize, - - /// Optional JSON output path for CI/CD integration - #[arg(long)] - output_json: Option, - - /// Optional path to export DQN actions as CSV - #[arg(long)] - export_actions: Option, - - /// Verbose logging (DEBUG level) - #[arg(short, long)] - verbose: bool, -} - -impl EvaluationConfig { - /// Validates the configuration parameters - pub(crate) fn validate(&self) -> Result<()> { - // Validate model_path exists - if !self.model_path.exists() { - return Err(anyhow::anyhow!( - "Model file does not exist: {}\n\n\ - Suggestion: Train a model first using:\n\ - cargo run -p ml --example train_dqn --release --features cuda -- \\\n\ - --output {}", - self.model_path.display(), - self.model_path.display() - )); - } - - if !self.model_path.is_file() { - return Err(anyhow::anyhow!( - "Model path is not a regular file: {}", - self.model_path.display() - )); - } - - // Validate parquet_file exists - if !self.parquet_file.exists() { - return Err(anyhow::anyhow!( - "Parquet file does not exist: {}\n\n\ - Suggestion: Generate unseen data using:\n\ - # Download data from Databento API for a different time period\n\ - # than your training data (temporal split)", - self.parquet_file.display() - )); - } - - if !self.parquet_file.is_file() { - return Err(anyhow::anyhow!( - "Parquet path is not a regular file: {}", - self.parquet_file.display() - )); - } - - // Validate device string - match self.device.as_str() { - "cpu" | "cuda" | "auto" => { - // Valid device string - } - _ => { - return Err(anyhow::anyhow!( - "Invalid device: '{}'\n\n\ - Valid options:\n\ - - 'cpu': Force CPU execution\n\ - - 'cuda': Force CUDA GPU execution (requires NVIDIA GPU)\n\ - - 'auto': Auto-detect CUDA availability (recommended)", - self.device - )); - } - } - - // Validate warmup_bars range - if self.warmup_bars < 10 { - return Err(anyhow::anyhow!( - "Warmup bars too small: {} (minimum: 10)\n\n\ - At least 10 bars are required for basic feature computation.\n\ - Recommended: 50 bars for most models.", - self.warmup_bars - )); - } - - if self.warmup_bars > 100 { - return Err(anyhow::anyhow!( - "Warmup bars too large: {} (maximum: 100)\n\n\ - Using more than 100 warmup bars wastes evaluation data.\n\ - Recommended: 50 bars for most models.", - self.warmup_bars - )); - } - - // Validate output_json path (if specified) - if let Some(ref output_path) = self.output_json { - // Check if parent directory exists - if let Some(parent) = output_path.parent() { - if !parent.exists() { - return Err(anyhow::anyhow!( - "Output JSON parent directory does not exist: {}\n\n\ - Suggestion: Create the directory first:\n\ - mkdir -p {}", - parent.display(), - parent.display() - )); - } - } - - // Check if file already exists (warn, but don't fail) - if output_path.exists() { - info!( - "⚠️ Output JSON file already exists and will be overwritten: {}", - output_path.display() - ); - } - } - - Ok(()) - } -} - -// ============================================================================ -// Component 2: Model Loading -// ============================================================================ - -/// Load DQN model from SafeTensors file with device selection -/// -/// # Arguments -/// * `model_path` - Path to SafeTensors model file (.safetensors extension) -/// * `device_str` - Device selection: "cpu", "cuda", or "auto" -/// -/// # Returns -/// WorkingDQN ready for inference with loaded weights -/// -/// # Errors -/// Returns error if: -/// - File not found or not readable -/// - SafeTensors deserialization fails -/// - CUDA requested but unavailable -/// - Model dimensions incorrect (expected: 225 input features, 3 actions) -/// -/// # Note -/// Uses WorkingDQN which has production-ready load_from_safetensors() method. -/// Architecture: 225 input → [128, 64, 32] hidden → 3 output (8 tensors) -fn load_dqn_model(model_path: &Path, device_str: &str) -> Result { - info!("🔧 Component 2: Loading DQN model"); - info!(" Model path: {}", model_path.display()); - info!(" Device selection: {}", device_str); - - // 1. Parse device string to create Device - let device = match device_str.to_lowercase().as_str() { - "cpu" => { - info!(" Using CPU device (explicitly requested)"); - Device::Cpu - } - "cuda" => { - info!(" Using CUDA device (explicitly requested)"); - Device::new_cuda(0).context( - "CUDA device requested but unavailable. \ - Suggestions:\n\ - - Check nvidia-smi to verify GPU is available\n\ - - Try device_str=\"auto\" for automatic fallback to CPU\n\ - - Use device_str=\"cpu\" to force CPU execution" - )? - } - "auto" => { - match Device::cuda_if_available(0) { - Ok(cuda_device) => { - info!(" Auto-selected CUDA device (GPU available)"); - cuda_device - } - Err(e) => { - warn!(" CUDA unavailable, falling back to CPU: {}", e); - info!(" Using CPU device (auto-fallback)"); - Device::Cpu - } - } - } - _ => { - return Err(anyhow::anyhow!( - "Invalid device_str: '{}'. Must be one of: 'cpu', 'cuda', 'auto'", - device_str - )); - } - }; - - let device_name = match &device { - Device::Cpu => "CPU", - Device::Cuda(_) => "CUDA:0", - _ => "Unknown", - }; - - // 2. Check if model file exists and is readable - if !model_path.exists() { - return Err(anyhow::anyhow!( - "Model file not found: {}\n\ - Suggestions:\n\ - - Check the file path is correct\n\ - - Verify the model was saved successfully during training\n\ - - Look for checkpoint files in ml/trained_models/", - model_path.display() - )); - } - - if !model_path.is_file() { - return Err(anyhow::anyhow!( - "Path exists but is not a file: {}", - model_path.display() - )); - } - - // Check file permissions (read access) - match std::fs::metadata(model_path) { - Ok(metadata) => { - if metadata.permissions().readonly() { - warn!(" Model file is read-only: {}", model_path.display()); - } - info!(" Model file size: {} bytes ({:.2} KB)", - metadata.len(), - metadata.len() as f64 / 1024.0 - ); - } - Err(e) => { - return Err(anyhow::anyhow!( - "Cannot read file metadata for {}: {}", - model_path.display(), - e - )); - } - } - - // 3. Create WorkingDQNConfig with correct architecture - // Architecture: 125 input → [256, 128, 64] hidden → 3 output (matches training) - info!(" Creating WorkingDQN configuration..."); - let config = WorkingDQNConfig { - state_dim: 125, // Wave D: 125 features (101 Wave C + 24 Wave D) - hidden_dims: vec![256, 128, 64], // Match trainer architecture (Wave 10-A1) - num_actions: 3, - learning_rate: 0.001, - gamma: 0.99, - epsilon_start: 0.0, // No exploration during evaluation - epsilon_end: 0.0, - epsilon_decay: 1.0, - replay_buffer_capacity: 1000, - batch_size: 32, - min_replay_size: 64, - target_update_freq: 1000, - use_double_dqn: true, - use_huber_loss: true, // Huber loss default (more robust to outliers) - huber_delta: 1.0, // Standard Huber delta - leaky_relu_alpha: 0.01, // Standard LeakyReLU negative slope - gradient_clip_norm: 10.0, // Wave 11 Bug #1 fix - tau: 1.0, // Hard updates (full copy) - use_soft_updates: false, // Hard updates by default - warmup_steps: 0, // No warmup for evaluation - }; - - info!(" Model architecture (from training):"); - info!(" - Input: 125 features (Wave D)"); - info!(" - Hidden: [256, 128, 64]"); - info!(" - Output: 3 actions (BUY, SELL, HOLD)"); - - // 4. Create WorkingDQN (auto-selects device internally) - info!(" Creating WorkingDQN network..."); - let mut dqn = WorkingDQN::new(config) - .context("Failed to create WorkingDQN")?; - - // Log actual device used - let actual_device = match dqn.device() { - Device::Cpu => "CPU", - Device::Cuda(_) => "CUDA:0", - _ => "Unknown", - }; - info!(" Device used: {} (auto-selected)", actual_device); - - // 5. Load weights from SafeTensors - info!(" Loading weights from SafeTensors..."); - let model_path_str = model_path.to_str() - .ok_or_else(|| anyhow::anyhow!("Model path contains invalid UTF-8: {}", model_path.display()))?; - dqn.load_from_safetensors(model_path_str) - .context(format!( - "Failed to load weights from {}\n\ - Possible causes:\n\ - - File is corrupted (try retraining)\n\ - - Wrong architecture (expected: 225 → [128,64,32] → 3)\n\ - - Incompatible tensor types or shapes\n\ - - Device memory issue ({})", - model_path.display(), - device_name - ))?; - - info!("✅ Component 2: DQN checkpoint loaded successfully"); - info!(" Model: {} (8 tensors: layer_0-2.weight/bias, output.weight/bias)", model_path.display()); - - Ok(dqn) -} - -// ============================================================================ -// Component 3: Parquet Data Loading -// ============================================================================ -// -// Production 225-feature extraction pipeline is now imported from: -// ml::data_loaders::load_parquet_data -// -// This function: -// - Loads Parquet files with schema-agnostic OHLCV extraction -// - Extracts 225 features using Wave C + Wave D production pipeline -// - Handles warmup period (50 bars for technical indicators) -// - Validates NaN/Inf values -// - Sorts bars chronologically for rolling windows -// -// See: ml/src/data_loaders/parquet_utils.rs for implementation - -// ============================================================================ -// Component 4: Inference Engine -// ============================================================================ - -/// Result of a single DQN inference -#[derive(Debug, Clone)] -struct InferenceResult { - action: usize, // 0=BUY, 1=SELL, 2=HOLD - q_values: [f64; 3], // Q-value for each action - latency_us: u64, // Microseconds for this inference -} - -/// Run DQN inference on all feature vectors with progress tracking -/// -/// # Arguments -/// * `dqn` - WorkingDQN for inference (mutable for select_action) -/// * `features` - 225-dimensional feature vectors -/// * `shutdown_flag` - Atomic flag for graceful shutdown (Ctrl+C) -/// -/// # Returns -/// Vector of inference results (action, Q-values, latency per bar) -/// -/// # Notes -/// - Uses WorkingDQN's select_action() (returns TradingAction enum) -/// - Uses WorkingDQN's forward() to get Q-values separately -/// - Handles NaN/Inf gracefully by logging warnings and skipping bars -/// - Tracks latency per inference in microseconds -/// - Progress bar shows real-time inference speed -/// - Respects shutdown flag for graceful interruption -fn run_inference( - dqn: &mut WorkingDQN, - features: Vec<[f64; 125]>, - shutdown_flag: &Arc, -) -> Result> { - info!("🔍 Component 4: Running DQN inference"); - - let total_bars = features.len(); - if total_bars == 0 { - return Err(anyhow::anyhow!("No feature vectors provided for inference")); - } - - info!(" Total bars to process: {}", total_bars); - - let mut results = Vec::with_capacity(total_bars); - let mut total_inference_time_us = 0u64; - let mut skipped_bars = 0usize; - let start_time = Instant::now(); - let mut last_progress_update = Instant::now(); - - // Run inference for each feature vector - for (i, feature_vec) in features.iter().enumerate() { - // Check for shutdown signal - if shutdown_flag.load(Ordering::Relaxed) { - warn!(" ⚠️ Shutdown signal received, stopping inference at bar {}/{}", i, total_bars); - break; - } - - // Start timer for this inference - let timer = Instant::now(); - - // Convert f64 features to f32 for DQN network - let state_f32: Vec = feature_vec.iter().map(|&x| x as f32).collect(); - - // Use WorkingDQN's select_action() for greedy inference (epsilon=0.0) - // This returns TradingAction enum - let trading_action = match dqn.select_action(state_f32.as_slice()) { - Ok(a) => a, - Err(e) => { - if skipped_bars < 10 { - warn!(" ⚠️ Bar {}: select_action failed: {}. Skipping.", i, e); - } - skipped_bars += 1; - continue; - } - }; - - // Convert TradingAction to usize (0=BUY, 1=SELL, 2=HOLD) - let action = trading_action.to_int() as usize; - - // Get Q-values separately using forward pass - use candle_core::Tensor; - let state_tensor = match Tensor::from_vec( - state_f32, - (1, 125), - dqn.device(), - ) { - Ok(t) => t, - Err(e) => { - if skipped_bars < 10 { - warn!(" ⚠️ Bar {}: Failed to create tensor: {}. Skipping.", i, e); - } - skipped_bars += 1; - continue; - } - }; - - let q_values_tensor = match dqn.forward(&state_tensor) { - Ok(qv) => qv, - Err(e) => { - if skipped_bars < 10 { - warn!(" ⚠️ Bar {}: Forward pass failed: {}. Skipping.", i, e); - } - skipped_bars += 1; - continue; - } - }; - - // Extract Q-values from tensor [1, 3] -> [3] - let q_values_vec: Vec = match q_values_tensor.squeeze(0) - .and_then(|t| t.to_vec1()) { - Ok(v) => v, - Err(e) => { - if skipped_bars < 10 { - warn!(" ⚠️ Bar {}: Failed to extract Q-values: {}. Skipping.", i, e); - } - skipped_bars += 1; - continue; - } - }; - - // Validate Q-values shape - if q_values_vec.len() != 3 { - if skipped_bars < 10 { - warn!( - " ⚠️ Bar {}: Expected 3 Q-values, got {}. Skipping.", - i, - q_values_vec.len() - ); - } - skipped_bars += 1; - continue; - } - - let q_values: [f64; 3] = [ - q_values_vec[0] as f64, - q_values_vec[1] as f64, - q_values_vec[2] as f64, - ]; - - // Check for NaN/Inf in Q-values - if q_values.iter().any(|&q| !q.is_finite()) { - if skipped_bars < 10 { - warn!( - " ⚠️ Bar {}: Q-values contain NaN/Inf, skipping. Q-values: {:?}", - i, - q_values - ); - } - skipped_bars += 1; - continue; - } - - // Calculate latency for this inference - let latency_us = timer.elapsed().as_micros() as u64; - total_inference_time_us += latency_us; - - // Store result - results.push(InferenceResult { - action, - q_values, - latency_us, - }); - - // Update progress every 1 second or every 10% completion - let should_update = last_progress_update.elapsed().as_secs() >= 1 - || (i + 1) % (total_bars / 10).max(1) == 0 - || i == 0 - || i == total_bars - 1; - - if should_update { - let elapsed_sec = start_time.elapsed().as_secs_f64(); - let avg_speed = if elapsed_sec > 0.0 { - (i + 1) as f64 / elapsed_sec - } else { - 0.0 - }; - let progress_pct = ((i + 1) as f64 / total_bars as f64) * 100.0; - info!( - " Progress: {}/{} ({:.1}%) | Speed: {:.1} bars/sec | Skipped: {}", - i + 1, - total_bars, - progress_pct, - avg_speed, - skipped_bars - ); - last_progress_update = Instant::now(); - } - } - - info!("✅ Component 4: Inference complete"); - - // Calculate summary statistics - let processed_bars = results.len(); - let total_time_sec = start_time.elapsed().as_secs_f64(); - let avg_latency_us = if processed_bars > 0 { - total_inference_time_us / processed_bars as u64 - } else { - 0 - }; - let avg_speed = if total_time_sec > 0.0 { - processed_bars as f64 / total_time_sec - } else { - 0.0 - }; - - // Log summary with detailed metrics - info!(" Inference Summary:"); - info!(" - Total bars: {}", total_bars); - info!(" - Processed: {}", processed_bars); - info!(" - Skipped (NaN/Inf/errors): {}", skipped_bars); - info!(" - Skip rate: {:.2}%", (skipped_bars as f64 / total_bars as f64) * 100.0); - info!(" - Total time: {:.2}s", total_time_sec); - info!(" - Average latency: {}μs ({:.2}ms)", avg_latency_us, avg_latency_us as f64 / 1000.0); - info!(" - Average speed: {:.1} bars/sec", avg_speed); - - // Validate results - if results.is_empty() { - return Err(anyhow::anyhow!( - "All {} inference attempts failed (likely NaN/Inf in Q-values or network errors)", - total_bars - )); - } - - Ok(results) -} - -// ============================================================================ -// Component 5: Metrics Calculator (imported from evaluate_dqn_component5.rs) -// ============================================================================ - -/// DQN-specific inference result for metrics calculation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DQNInferenceResult { - pub action: usize, - pub q_values: [f64; 3], - pub latency_us: u64, -} - -/// Comprehensive evaluation metrics for DQN model validation -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EvaluationMetrics { - pub total_bars: usize, - pub action_distribution: ActionDistribution, - pub avg_q_values: AvgQValues, - pub latency_stats: LatencyStats, - pub policy_consistency: PolicyConsistency, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ActionDistribution { - pub buy_count: usize, - pub sell_count: usize, - pub hold_count: usize, - pub buy_pct: f64, - pub sell_pct: f64, - pub hold_pct: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AvgQValues { - pub buy_avg: f64, - pub sell_avg: f64, - pub hold_avg: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LatencyStats { - pub mean_us: f64, - pub median_us: u64, - pub p50_us: u64, - pub p95_us: u64, - pub p99_us: u64, - pub min_us: u64, - pub max_us: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PolicyConsistency { - pub total_switches: usize, - pub switch_rate: f64, - pub interpretation: String, -} - -/// Calculate comprehensive evaluation metrics from inference results -fn calculate_metrics(results: &[InferenceResult]) -> Result { - info!("📊 Component 5: Calculating metrics"); - - if results.is_empty() { - return Err(anyhow::anyhow!("Cannot calculate metrics from empty results")); - } - - let total_bars = results.len(); - info!(" Total bars: {}", total_bars); - - // 1. Action distribution - let mut buy_count = 0usize; - let mut sell_count = 0usize; - let mut hold_count = 0usize; - - for result in results { - match result.action { - 0 => buy_count += 1, - 1 => sell_count += 1, - 2 => hold_count += 1, - _ => warn!(" ⚠️ Invalid action: {}", result.action), - } - } - - let buy_pct = (buy_count as f64 / total_bars as f64) * 100.0; - let sell_pct = (sell_count as f64 / total_bars as f64) * 100.0; - let hold_pct = (hold_count as f64 / total_bars as f64) * 100.0; - - // 2. Average Q-values per action - let mut buy_q_sum = 0.0; - let mut sell_q_sum = 0.0; - let mut hold_q_sum = 0.0; - - for result in results { - match result.action { - 0 => buy_q_sum += result.q_values[0], - 1 => sell_q_sum += result.q_values[1], - 2 => hold_q_sum += result.q_values[2], - _ => {} - } - } - - let buy_avg = if buy_count > 0 { buy_q_sum / buy_count as f64 } else { 0.0 }; - let sell_avg = if sell_count > 0 { sell_q_sum / sell_count as f64 } else { 0.0 }; - let hold_avg = if hold_count > 0 { hold_q_sum / hold_count as f64 } else { 0.0 }; - - // 3. Latency statistics - let mut latencies: Vec = results.iter().map(|r| r.latency_us).collect(); - latencies.sort_unstable(); - - let mean_us = latencies.iter().sum::() as f64 / latencies.len() as f64; - let median_us = latencies[latencies.len() / 2]; - let p50_us = median_us; - let p95_us = latencies[(latencies.len() as f64 * 0.95) as usize]; - let p99_us = latencies[(latencies.len() as f64 * 0.99) as usize]; - let min_us = latencies[0]; - let max_us = latencies[latencies.len() - 1]; - - // 4. Policy consistency - let mut total_switches = 0usize; - for i in 1..results.len() { - if results[i].action != results[i - 1].action { - total_switches += 1; - } - } - - let switch_rate = if results.len() > 1 { - total_switches as f64 / (results.len() - 1) as f64 - } else { - 0.0 - }; - - let interpretation = if switch_rate < 0.10 { - "Stable - Low adaptability".to_string() - } else if switch_rate <= 0.30 { - "Moderate - Healthy adaptive behavior".to_string() - } else { - "Volatile - High uncertainty or noise".to_string() - }; - - info!("✅ Component 5: Metrics calculated successfully"); - - Ok(EvaluationMetrics { - total_bars, - action_distribution: ActionDistribution { - buy_count, - sell_count, - hold_count, - buy_pct, - sell_pct, - hold_pct, - }, - avg_q_values: AvgQValues { - buy_avg, - sell_avg, - hold_avg, - }, - latency_stats: LatencyStats { - mean_us, - median_us, - p50_us, - p95_us, - p99_us, - min_us, - max_us, - }, - policy_consistency: PolicyConsistency { - total_switches, - switch_rate, - interpretation, - }, - }) -} - -// ============================================================================ -// Component 6: Report Generator -// ============================================================================ - -/// Generate comprehensive evaluation report -/// -/// # Arguments -/// * `metrics` - Evaluation metrics from Component 5 -/// * `config` - CLI configuration -/// * `elapsed` - Total elapsed time (including data loading, inference, etc.) -/// -/// # Returns -/// Result indicating success/failure of report generation -/// -/// # Side Effects -/// - Prints formatted report to stdout -/// - Writes JSON file if config.output_json is specified -/// - Validates production readiness thresholds -fn generate_report( - metrics: &EvaluationMetrics, - config: &EvaluationConfig, - elapsed: std::time::Duration, -) -> Result<()> { - info!("📝 Component 6: Generating evaluation report"); - - // 1. Print header - println!("\n╔══════════════════════════════════════════════════════════════════════╗"); - println!("║ DQN MODEL EVALUATION REPORT ║"); - println!("╚══════════════════════════════════════════════════════════════════════╝"); - println!(); - - // 2. Configuration summary - println!("═══ Configuration ═══"); - println!(" Model path: {}", config.model_path.display()); - println!(" Data file: {}", config.parquet_file.display()); - println!(" Device: {}", config.device); - println!(" Warmup bars: {}", config.warmup_bars); - println!(" Total runtime: {:.2}s", elapsed.as_secs_f64()); - println!(); - - // 3. Action distribution - println!("═══ Action Distribution ═══"); - println!(" BUY: {:5} ({:5.1}%)", - metrics.action_distribution.buy_count, - metrics.action_distribution.buy_pct); - println!(" SELL: {:5} ({:5.1}%)", - metrics.action_distribution.sell_count, - metrics.action_distribution.sell_pct); - println!(" HOLD: {:5} ({:5.1}%)", - metrics.action_distribution.hold_count, - metrics.action_distribution.hold_pct); - println!(" ────────────────────"); - println!(" Total: {} bars", metrics.total_bars); - println!(); - - // 4. Q-value statistics - println!("═══ Average Q-Values ═══"); - println!(" BUY: {:8.4}", metrics.avg_q_values.buy_avg); - println!(" SELL: {:8.4}", metrics.avg_q_values.sell_avg); - println!(" HOLD: {:8.4}", metrics.avg_q_values.hold_avg); - println!(); - - // 5. Latency statistics - println!("═══ Latency Statistics ═══"); - println!(" Mean: {:6.1} μs ({:.3} ms)", - metrics.latency_stats.mean_us, - metrics.latency_stats.mean_us / 1000.0); - println!(" Median: {:6} μs ({:.3} ms)", - metrics.latency_stats.median_us, - metrics.latency_stats.median_us as f64 / 1000.0); - println!(" P95: {:6} μs ({:.3} ms)", - metrics.latency_stats.p95_us, - metrics.latency_stats.p95_us as f64 / 1000.0); - println!(" P99: {:6} μs ({:.3} ms)", - metrics.latency_stats.p99_us, - metrics.latency_stats.p99_us as f64 / 1000.0); - println!(" Range: {:6} - {:6} μs", - metrics.latency_stats.min_us, - metrics.latency_stats.max_us); - println!(); - - // 6. Policy consistency - println!("═══ Policy Consistency ═══"); - println!(" Switches: {} / {} bars", - metrics.policy_consistency.total_switches, - metrics.total_bars - 1); - println!(" Switch rate: {:.1}%", - metrics.policy_consistency.switch_rate * 100.0); - println!(" Interpretation: {}", - metrics.policy_consistency.interpretation); - println!(); - - // 7. Production readiness check - println!("═══ Production Readiness Check ═══"); - - let latency_ok = metrics.latency_stats.p99_us < 5_000; - println!(" {} Latency P99 < 5,000μs: {} (actual: {} μs)", - if latency_ok { "✅" } else { "❌" }, - latency_ok, - metrics.latency_stats.p99_us); - - let consistency_ok = metrics.policy_consistency.switch_rate >= 0.10 - && metrics.policy_consistency.switch_rate <= 0.30; - println!(" {} Policy switch rate 10-30%: {} (actual: {:.1}%)", - if consistency_ok { "✅" } else { "❌" }, - consistency_ok, - metrics.policy_consistency.switch_rate * 100.0); - - let balance_ok = metrics.action_distribution.buy_pct >= 5.0 - && metrics.action_distribution.sell_pct >= 5.0 - && metrics.action_distribution.hold_pct >= 5.0; - println!(" {} Balanced actions (each >5%): {}", - if balance_ok { "✅" } else { "⚠️ " }, - balance_ok); - - let q_ok = metrics.avg_q_values.buy_avg.is_finite() - && metrics.avg_q_values.sell_avg.is_finite() - && metrics.avg_q_values.hold_avg.is_finite(); - println!(" {} Q-values finite: {}", - if q_ok { "✅" } else { "❌" }, - q_ok); - - println!(); - let all_ok = latency_ok && consistency_ok && q_ok; - if all_ok { - println!("🎉 Model is PRODUCTION READY!"); - } else { - println!("⚠️ Model requires further tuning before production deployment"); - } - println!(); - - // 8. Export JSON (if configured) - if let Some(ref output_path) = config.output_json { - info!(" Exporting metrics to JSON: {}", output_path.display()); - - let json = serde_json::to_string_pretty(&metrics) - .context("Failed to serialize metrics to JSON")?; - - std::fs::write(output_path, json) - .context(format!("Failed to write JSON to {}", output_path.display()))?; - - println!("✅ Metrics exported to: {}", output_path.display()); - println!(); - } - - info!("✅ Component 6: Report generated successfully"); - - Ok(()) -} - -// ============================================================================ -// Component 6.5: Action Export (CSV Format) -// ============================================================================ - -/// Export DQN actions with timestamps to CSV format -/// -/// # Arguments -/// * `results` - Inference results (action, Q-values, latency) -/// * `bars` - Original OHLCV bars (synchronized with results) -/// * `output_path` - Path to CSV file (will be created/overwritten) -/// -/// # CSV Format -/// ```csv -/// timestamp,action,q_buy,q_sell,q_hold,open,high,low,close,volume -/// 2024-10-20T09:30:00.000000000Z,2,0.4523,-0.1234,0.8912,5720.25,5721.00,5719.50,5720.75,1234 -/// ``` -/// -/// # Errors -/// Returns error if: -/// - Input vectors have mismatched lengths -/// - Output directory doesn't exist -/// - File write fails -fn export_actions_to_csv( - results: &[InferenceResult], - bars: &[OHLCVBar], - output_path: &Path, -) -> Result<()> { - use csv::Writer; - - info!("💾 Component 6.5: Exporting actions to CSV"); - info!(" Output path: {}", output_path.display()); - - // 1. Validate input synchronization - if results.len() != bars.len() { - return Err(anyhow::anyhow!( - "Input vectors have mismatched lengths: results={}, bars={}", - results.len(), - bars.len() - )); - } - - let total_rows = results.len(); - info!(" Total rows to export: {}", total_rows); - - // 2. Validate output path - if let Some(parent) = output_path.parent() { - if !parent.exists() { - return Err(anyhow::anyhow!( - "Output directory does not exist: {}\n\ - Suggestion: Create directory first:\n\ - mkdir -p {}", - parent.display(), - parent.display() - )); - } - } - - // 3. Create CSV writer - let mut wtr = Writer::from_path(output_path) - .context(format!("Failed to create CSV file: {}", output_path.display()))?; - - // 4. Write CSV header - wtr.write_record(&[ - "timestamp", - "action", - "q_buy", - "q_sell", - "q_hold", - "open", - "high", - "low", - "close", - "volume", - ]) - .context("Failed to write CSV header")?; - - // 5. Write data rows - for (result, bar) in results.iter().zip(bars.iter()) { - // Format timestamp as RFC3339 with nanosecond precision - let timestamp_str = bar.timestamp.to_rfc3339_opts( - chrono::SecondsFormat::Nanos, - true, - ); - - wtr.write_record(&[ - timestamp_str, - result.action.to_string(), - format!("{:.4}", result.q_values[0]), // q_buy - format!("{:.4}", result.q_values[1]), // q_sell - format!("{:.4}", result.q_values[2]), // q_hold - format!("{:.2}", bar.open), - format!("{:.2}", bar.high), - format!("{:.2}", bar.low), - format!("{:.2}", bar.close), - (bar.volume as u64).to_string(), - ]) - .context(format!("Failed to write CSV row for timestamp {}", bar.timestamp))?; - } - - // 6. Flush writer - wtr.flush() - .context("Failed to flush CSV writer")?; - - // 7. Calculate file size - let metadata = std::fs::metadata(output_path) - .context("Failed to read file metadata")?; - let file_size_bytes = metadata.len(); - let file_size_kb = file_size_bytes as f64 / 1024.0; - - info!("✅ Component 6.5: CSV export complete"); - info!(" Rows written: {}", total_rows); - info!(" File size: {:.2} KB ({} bytes)", file_size_kb, file_size_bytes); - info!(" Average bytes/row: {:.1}", file_size_bytes as f64 / total_rows as f64); - - Ok(()) -} - -// ============================================================================ -// Component 7: Main Orchestrator -// ============================================================================ - -#[tokio::main] -async fn main() -> Result<()> { - // ======================================================================== - // PHASE 1: INITIALIZATION - // ======================================================================== - - // 1.1: Parse CLI arguments - let config = EvaluationConfig::parse(); - - // 1.2: Setup tracing subscriber (stdout logging) - let log_level = if config.verbose { - tracing::Level::DEBUG - } else { - tracing::Level::INFO - }; - - let subscriber = FmtSubscriber::builder() - .with_max_level(log_level) - .with_target(false) - .with_level(true) - .finish(); - - tracing::subscriber::set_global_default(subscriber) - .context("Failed to set tracing subscriber")?; - - // 1.3: Log startup banner - info!("╔══════════════════════════════════════════════════════════════════════╗"); - info!("║ DQN Model Evaluation Pipeline - Component 7 Orchestrator ║"); - info!("║ Version: 1.0.0 ║"); - info!("║ Timestamp: {} ║", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S")); - info!("╚══════════════════════════════════════════════════════════════════════╝"); - info!(""); - - // 1.4: Log configuration - info!("Configuration:"); - info!(" • Model path: {}", config.model_path.display()); - info!(" • Parquet file: {}", config.parquet_file.display()); - info!(" • Device: {}", config.device); - info!(" • Warmup bars: {}", config.warmup_bars); - if let Some(ref output_path) = config.output_json { - info!(" • JSON output: {}", output_path.display()); - } - info!(" • Verbose logging: {}", config.verbose); - info!(""); - - // 1.5: Validate configuration - info!("🔍 Validating configuration..."); - config.validate() - .context("Configuration validation failed")?; - info!("✅ Configuration validated successfully"); - info!(""); - - // 1.6: Setup graceful shutdown handler - let shutdown_flag = Arc::new(AtomicBool::new(false)); - let shutdown_clone = shutdown_flag.clone(); - - tokio::spawn(async move { - let ctrl_c = signal::ctrl_c(); - - #[cfg(unix)] - { - use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = signal(SignalKind::terminate()) - .expect("Failed to setup SIGTERM handler"); - - tokio::select! { - _ = ctrl_c => { - info!("🛑 Received Ctrl+C, initiating graceful shutdown..."); - } - _ = sigterm.recv() => { - info!("🛑 Received SIGTERM, initiating graceful shutdown..."); - } - } - } - - #[cfg(not(unix))] - { - ctrl_c.await.expect("Failed to listen for Ctrl+C"); - info!("🛑 Received Ctrl+C, initiating graceful shutdown..."); - } - - shutdown_clone.store(true, Ordering::Relaxed); - }); - - info!("✅ Graceful shutdown handler registered (Ctrl+C / SIGTERM)"); - info!(""); - - // Start total elapsed timer - let total_start = Instant::now(); - - // ======================================================================== - // PHASE 2: PARALLEL DATA + MODEL LOADING - // ======================================================================== - - info!("⚡ Phase 2: Parallel loading (Data + Model)"); - info!(""); - - // Clone paths for async move - let parquet_path = config.parquet_file.clone(); - let model_path = config.model_path.clone(); - let device_str = config.device.clone(); - let warmup_bars = config.warmup_bars; - let need_bars = config.export_actions.is_some(); - - // Parallel loading using tokio::try_join! - // CRITICAL: Always load with timestamps/bars for preprocessing (close prices needed) - let (data_result, dqn_result) = tokio::try_join!( - tokio::task::spawn_blocking(move || { - load_parquet_data_with_timestamps(&parquet_path, warmup_bars) - }), - tokio::task::spawn_blocking(move || { - load_dqn_model(&model_path, &device_str) - }) - ).context("Parallel loading failed")?; - - // Unwrap the spawn_blocking JoinError and the function Result - let (mut features, _timestamps, bars) = data_result?; - let mut dqn = dqn_result?; - - // Keep bars for action export if requested - let bars_opt = if need_bars { - Some(bars.clone()) - } else { - None - }; - - info!(""); - info!("✅ Phase 2 complete: Data and model loaded in parallel"); - info!(""); - - // ======================================================================== - // PHASE 2.5: PREPROCESSING (Match Training Pipeline) - // ======================================================================== - - info!("🔬 Phase 2.5: Applying preprocessing (log returns + normalization + clipping)"); - info!(""); - - // Extract close prices from OHLCV bars - let close_prices_f64: Vec = bars.iter().map(|b| b.close).collect(); - let close_prices_f32: Vec = close_prices_f64.iter().map(|&x| x as f32).collect(); - - info!(" • Input data: {} bars", close_prices_f32.len()); - info!(" • Close price range: [{:.2}, {:.2}]", - close_prices_f32.iter().cloned().fold(f32::INFINITY, f32::min), - close_prices_f32.iter().cloned().fold(f32::NEG_INFINITY, f32::max)); - - // Create tensor on same device as model - let device = dqn.device().clone(); - let close_tensor = Tensor::from_slice(&close_prices_f32, (close_prices_f32.len(),), &device) - .context("Failed to create close price tensor for preprocessing")?; - - // Configure preprocessing (match training hyperparameters) - let preprocess_config = PreprocessConfig { - window_size: 50, // Default preprocessing window - clip_sigma: 5.0, // Default clip sigma - use_log_returns: true, - }; - - info!(" • Window size: {}", preprocess_config.window_size); - info!(" • Clip sigma: ±{:.1}σ", preprocess_config.clip_sigma); - info!(" • Method: log returns + windowed normalization"); - - // Apply preprocessing pipeline - let preprocessed_tensor = preprocess_prices(&close_tensor, preprocess_config) - .context("Preprocessing failed - check close prices for NaN/Inf/zeros")?; - - let preprocessed_vec: Vec = preprocessed_tensor.to_vec1() - .context("Failed to convert preprocessed tensor to vec")?; - - // Convert f32 to f64 for consistency with feature pipeline - let preprocessed_f64: Vec = preprocessed_vec.iter().map(|&x| x as f64).collect(); - - // Compute statistics for validation - let warmup = preprocess_config.window_size as usize; - let post_warmup: Vec = preprocessed_f64[warmup..].to_vec(); - let mean = post_warmup.iter().sum::() / post_warmup.len() as f64; - let variance = post_warmup.iter().map(|&x| (x - mean).powi(2)).sum::() / post_warmup.len() as f64; - let std = variance.sqrt(); - let min_val = post_warmup.iter().cloned().fold(f64::INFINITY, f64::min); - let max_val = post_warmup.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - - info!("✅ Preprocessing complete:"); - info!(" • Mean: {:.6} (expected ~0 for normalized data)", mean); - info!(" • Std: {:.4} (expected ~1 for normalized data)", std); - info!(" • Range: [{:.4}, {:.4}] (clipped at ±{:.1}σ)", min_val, max_val, preprocess_config.clip_sigma); - - // Replace close prices in feature vectors with preprocessed values - // Feature vector structure: [101 Wave C features + 24 Wave D features] - // Close price is at index 3 (after timestamp, open, high, low) - info!(" • Replacing close prices in feature vectors with preprocessed values..."); - - for (i, feature_vec) in features.iter_mut().enumerate() { - if i < preprocessed_f64.len() { - feature_vec[3] = preprocessed_f64[i]; // Index 3 is close price - } - } - - info!(" • Updated {} feature vectors with preprocessed close prices", features.len()); - info!(""); - info!("✅ Phase 2.5 complete: Features preprocessed to match training distribution"); - info!(""); - - // ======================================================================== - // PHASE 3: SEQUENTIAL INFERENCE - // ======================================================================== - - info!("🔍 Phase 3: Sequential inference"); - info!(""); - - // Run inference - let inference_results = run_inference(&mut dqn, features, &shutdown_flag) - .context("Inference failed")?; - - // Check if interrupted - if shutdown_flag.load(Ordering::Relaxed) { - warn!("⚠️ Evaluation interrupted by shutdown signal"); - warn!(" Processed {} bars before interruption", inference_results.len()); - - // Still generate partial report - info!(""); - info!("📊 Generating partial evaluation report..."); - - if !inference_results.is_empty() { - let partial_metrics = calculate_metrics(&inference_results)?; - let elapsed = total_start.elapsed(); - generate_report(&partial_metrics, &config, elapsed)?; - } - - info!("💾 Partial results saved, safe to terminate"); - return Ok(()); - } - - info!(""); - info!("✅ Phase 3 complete: Inference finished"); - info!(""); - - // ======================================================================== - // PHASE 4: METRICS CALCULATION - // ======================================================================== - - info!("📊 Phase 4: Metrics calculation"); - info!(""); - - let metrics = calculate_metrics(&inference_results) - .context("Metrics calculation failed")?; - - info!(""); - info!("✅ Phase 4 complete: Metrics calculated"); - info!(""); - - // ======================================================================== - // PHASE 5: REPORT GENERATION - // ======================================================================== - - info!("📝 Phase 5: Report generation"); - info!(""); - - let total_elapsed = total_start.elapsed(); - generate_report(&metrics, &config, total_elapsed) - .context("Report generation failed")?; - - info!(""); - info!("✅ Phase 5 complete: Report generated"); - info!(""); - - // ======================================================================== - // PHASE 5.5: OPTIONAL ACTION EXPORT - // ======================================================================== - - if let Some(ref export_path) = config.export_actions { - info!("📤 Phase 5.5: Exporting actions to CSV"); - info!(""); - - // Verify we have bars available - if let Some(bars) = bars_opt.as_ref() { - export_actions_to_csv( - &inference_results, - bars, - export_path, - ) - .context("Action export failed")?; - - info!(""); - info!("✅ Phase 5.5 complete: Actions exported to {}", export_path.display()); - info!(""); - } else { - warn!("⚠️ Cannot export actions: bars were not loaded (internal error)"); - } - } - - // ======================================================================== - // COMPLETION - // ======================================================================== - - info!("╔══════════════════════════════════════════════════════════════════════╗"); - info!("║ EVALUATION COMPLETE ║"); - info!("╚══════════════════════════════════════════════════════════════════════╝"); - info!(" Total runtime: {:.2}s", total_elapsed.as_secs_f64()); - info!(""); - - Ok(()) -} diff --git a/ml/src/dqn/prioritized_replay.rs.backup b/ml/src/dqn/prioritized_replay.rs.backup deleted file mode 100644 index 773bc0907..000000000 --- a/ml/src/dqn/prioritized_replay.rs.backup +++ /dev/null @@ -1,727 +0,0 @@ -//! Enhanced Prioritized Experience Replay for Rainbow DQN -//! -//! High-performance implementation of prioritized experience replay with: -//! - Segment tree for O(log n) priority updates -//! - SIMD-optimized sampling with importance sampling corrections -//! - Lock-free queue for concurrent access -//! - Sub-microsecond sampling latency -//! - Proportional and rank-based prioritization support - -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::Instant; - -use rand::prelude::*; -use rand::rngs::StdRng; - -use parking_lot::{Mutex, RwLock}; -use serde::{Deserialize, Serialize}; - -use crate::dqn::experience::Experience; -use crate::MLError; - -/// Segment tree for efficient priority sampling -#[derive(Debug)] -pub struct SegmentTree { - capacity: usize, - tree: Vec, -} - -impl SegmentTree { - pub fn new(capacity: usize) -> Self { - let tree_size = 2 * capacity.next_power_of_two(); - Self { - capacity, - tree: vec![0.0; tree_size], - } - } - - pub fn update(&mut self, idx: usize, priority: f32) -> Result<(), MLError> { - if idx >= self.capacity { - return Err(MLError::InvalidInput("Index out of bounds".to_string())); - } - let mut tree_idx = idx + self.capacity; - self.tree[tree_idx] = priority; - - while tree_idx > 1 { - tree_idx /= 2; - self.tree[tree_idx] = self.tree[2 * tree_idx] + self.tree[2 * tree_idx + 1]; - } - Ok(()) - } - - pub fn total_sum(&self) -> f32 { - self.tree[1] - } - - pub fn get_priority(&self, idx: usize) -> f32 { - if idx < self.capacity { - self.tree[idx + self.capacity] - } else { - 0.0 // Return safe default for out-of-bounds access - } - } - - pub fn sample(&self, value: f32) -> Result { - let mut idx = 1; - let mut value = value; // Make value mutable for proper segment tree traversal - while idx < self.capacity { - let left_child = 2 * idx; - let right_child = left_child + 1; - - if left_child >= self.tree.len() { - break; // Proper termination - } - - if value <= self.tree[left_child] { - idx = left_child; - } else { - // Check right child bounds before access - if right_child >= self.tree.len() { - break; // Proper termination - } - // Subtract left child's sum when going right (standard segment tree algorithm) - value -= self.tree[left_child]; - idx = right_child; - } - } - - let result_idx = idx - self.capacity; - if result_idx >= self.capacity { - return Err(MLError::InvalidInput( - "Sampled index out of bounds".to_string(), - )); - } - - Ok(result_idx) - } -} - -/// Prioritization strategy -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum PrioritizationStrategy { - /// Proportional prioritization: P(i) = |δi|^α / Σ|δj|^α - Proportional, - /// Rank-based prioritization: P(i) = 1/rank(i)^α - RankBased, -} - -/// Prioritized replay buffer configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PrioritizedReplayConfig { - /// Buffer capacity - pub capacity: usize, - /// Prioritization exponent (0 = uniform, 1 = full prioritization) - pub alpha: f32, - /// Importance sampling correction exponent (0 = no correction, 1 = full correction) - pub beta: f32, - /// Initial priority for new experiences - pub initial_priority: f32, - /// Minimum priority to avoid zero probabilities - pub min_priority: f32, - /// Prioritization strategy - pub strategy: PrioritizationStrategy, - /// Beta annealing schedule end value - pub beta_max: f32, - /// Number of steps to anneal beta from initial to max - pub beta_annealing_steps: usize, -} - -impl Default for PrioritizedReplayConfig { - fn default() -> Self { - Self { - capacity: 100000, - alpha: 0.6, - beta: 0.4, - initial_priority: 1.0, - min_priority: 1e-6, - strategy: PrioritizationStrategy::Proportional, - beta_max: 1.0, - beta_annealing_steps: 500000, - } - } -} - -/// Metrics for prioritized replay buffer -#[derive(Debug, Clone, Default)] -pub struct PrioritizedReplayMetrics { - /// Total number of priority updates - pub priority_updates: usize, - /// Maximum priority in buffer - pub max_priority: f32, - /// Minimum priority in buffer - pub min_priority: f32, - /// Total samples taken - pub samples_taken: usize, - /// Average importance sampling weight - pub avg_is_weight: f32, - /// Current buffer utilization (0.0 to 1.0) - pub utilization: f32, - /// Average priority - pub avg_priority: f32, - /// Priority distribution statistics - pub priority_percentiles: [f32; 5], // 10th, 25th, 50th, 75th, 90th - /// Sampling latency statistics (microseconds) - pub sample_latency_us: f32, - /// Update latency statistics (microseconds) - pub update_latency_us: f32, -} - -/// Prioritized replay buffer implementation -#[derive(Debug)] -pub struct PrioritizedReplayBuffer { - config: PrioritizedReplayConfig, - experiences: Arc>>>, - priorities: Arc>, - position: AtomicUsize, - size: AtomicUsize, - max_priority: AtomicU64, - min_priority: AtomicU64, - metrics: Arc>, - training_step: AtomicUsize, - rng: Arc>, -} - -impl PrioritizedReplayBuffer { - pub fn new(config: PrioritizedReplayConfig) -> Result { - let initial_priority = config.initial_priority.to_bits() as u64; - let min_priority = config.min_priority.to_bits() as u64; - - Ok(Self { - experiences: Arc::new(RwLock::new(vec![None; config.capacity])), - priorities: Arc::new(Mutex::new(SegmentTree::new(config.capacity))), - position: AtomicUsize::new(0), - size: AtomicUsize::new(0), - max_priority: AtomicU64::new(initial_priority), - min_priority: AtomicU64::new(min_priority), - metrics: Arc::new(RwLock::new(PrioritizedReplayMetrics::default())), - training_step: AtomicUsize::new(0), - rng: Arc::new(Mutex::new(StdRng::from_entropy())), - config, - }) - } - - pub fn push(&self, experience: Experience) -> Result<(), MLError> { - let start_time = Instant::now(); - - let current_size = self.size.load(Ordering::Acquire); - let index = self.position.fetch_add(1, Ordering::AcqRel) % self.config.capacity; - - // Store experience - { - let mut experiences = self.experiences.write(); - if index < experiences.len() { - experiences[index] = Some(experience); - } else { - return Err(MLError::InvalidInput( - "Experience index out of bounds".to_string(), - )); - } - } - - // Set initial priority (use max priority for new experiences to ensure they get sampled) - let max_priority_bits = self.max_priority.load(Ordering::Acquire); - let max_priority = f32::from_bits(max_priority_bits as u32); - let priority = max_priority.max(self.config.initial_priority); - - { - let mut tree = self.priorities.lock(); - tree.update(index, priority)?; - } - - if current_size < self.config.capacity { - self.size.store(current_size + 1, Ordering::Release); - } - - // Update metrics - { - let mut metrics = self.metrics.write(); - metrics.utilization = if self.config.capacity > 0 { - self.size.load(Ordering::Acquire) as f32 / self.config.capacity as f32 - } else { - 0.0 // Prevent division by zero - }; - metrics.update_latency_us = start_time.elapsed().as_micros() as f32; - } - - Ok(()) - } - - pub fn sample( - &self, - batch_size: usize, - ) -> Result<(Vec, Vec, Vec), MLError> { - let start_time = Instant::now(); - - let size = self.size.load(Ordering::Acquire); - if size < batch_size { - return Err(MLError::TrainingError(format!( - "Not enough experiences: {} < {}", - size, batch_size - ))); - } - - let tree = self.priorities.lock(); - let total_priority = tree.total_sum(); - - if total_priority <= 0.0 { - return Err(MLError::TrainingError("No valid priorities".to_string())); - } - - // Calculate current beta with annealing - let current_step = self.training_step.load(Ordering::Acquire); - let annealing_progress = if self.config.beta_annealing_steps == 0 { - 1.0 // Prevent division by zero - } else { - (current_step as f32 / self.config.beta_annealing_steps as f32).min(1.0) - }; - let beta = - self.config.beta + (self.config.beta_max - self.config.beta) * annealing_progress; - - let mut experiences = Vec::with_capacity(batch_size); - let mut weights = Vec::with_capacity(batch_size); - let mut indices = Vec::with_capacity(batch_size); - - let experiences_guard = self.experiences.read(); - let mut rng = self.rng.lock(); - - // Calculate maximum weight for normalization - let min_priority_bits = self.min_priority.load(Ordering::Acquire); - let min_priority = f32::from_bits(min_priority_bits as u32); - let min_prob = if total_priority > 0.0 { - min_priority / total_priority - } else { - 1.0 // Prevent division by zero - }; - - let denominator = size as f32 * min_prob; - let max_weight = if denominator > 0.0 && denominator.is_finite() { - (1.0 / denominator).powf(beta).min(1e6) // Cap extreme weights - } else { - 1.0 // Safe fallback for edge cases - }; - - let mut total_is_weight = 0.0; - - for _ in 0..batch_size { - let value = rng.gen::() * total_priority; - let idx = tree.sample(value)?; - - if let Some(experience) = experiences_guard.get(idx).and_then(|e| e.as_ref()) { - experiences.push(experience.clone()); - - // Calculate importance sampling weight - let priority = tree.get_priority(idx); - let prob = if total_priority > 0.0 { - priority / total_priority - } else { - 1.0 / size as f32 // Uniform distribution fallback - }; - - let raw_weight = if prob > 0.0 && size > 0 { - let denominator = size as f32 * prob; - if denominator > 0.0 && denominator.is_finite() { - (1.0 / denominator).powf(beta) - } else { - 1.0 - } - } else { - 1.0 - }; - - let weight = if max_weight > 0.0 && max_weight.is_finite() { - (raw_weight / max_weight).min(10.0) // Clamp weights - } else { - 1.0 - }; - - weights.push(weight); - indices.push(idx); - total_is_weight += weight; - } - } - - let avg_is_weight = if !weights.is_empty() { - total_is_weight / weights.len() as f32 - } else { - 1.0 - }; - - // Update metrics - { - let mut metrics = self.metrics.write(); - metrics.samples_taken += batch_size; - metrics.avg_is_weight = avg_is_weight; - metrics.sample_latency_us = start_time.elapsed().as_micros() as f32; - } - - Ok((experiences, weights, indices)) - } - - pub fn update_priorities(&self, indices: &[usize], priorities: &[f32]) -> Result<(), MLError> { - let mut tree = self.priorities.lock(); - let mut max_priority = f32::from_bits(self.max_priority.load(Ordering::Acquire) as u32); - - let mut update_count = 0; - for (&idx, &priority) in indices.into_iter().zip(priorities.into_iter()) { - if idx >= self.config.capacity { - continue; - } - - // Clamp TD errors to prevent gradient explosion (WAVE 26 P0.1) - // Upper bound of 10.0 prevents extreme priorities that cause gradient explosion - // Lower bound of 1e-6 prevents zero probabilities - let clamped_error = priority.abs().clamp(1e-6, 10.0); - let final_priority = clamped_error.powf(self.config.alpha); - - tree.update(idx, final_priority)?; - max_priority = max_priority.max(final_priority); - update_count += 1; - } - - self.max_priority - .store(max_priority.to_bits() as u64, Ordering::Release); - - // Update metrics - { - let mut metrics = self.metrics.write(); - metrics.priority_updates += update_count; - metrics.max_priority = max_priority; - } - - Ok(()) - } - - pub fn can_sample(&self, batch_size: usize) -> bool { - self.size.load(Ordering::Acquire) >= batch_size - } - - pub fn len(&self) -> usize { - self.size.load(Ordering::Acquire) - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// Get current buffer capacity - pub fn capacity(&self) -> usize { - self.config.capacity - } - - /// Step the training counter for beta annealing - pub fn step(&self) { - self.training_step.fetch_add(1, Ordering::Relaxed); - } - - /// Get current beta value (with annealing) - pub fn current_beta(&self) -> f32 { - let current_step = self.training_step.load(Ordering::Acquire); - let annealing_progress = if self.config.beta_annealing_steps == 0 { - 1.0 // Prevent division by zero - } else { - (current_step as f32 / self.config.beta_annealing_steps as f32).min(1.0) - }; - self.config.beta + (self.config.beta_max - self.config.beta) * annealing_progress - } - - /// Get comprehensive metrics - pub fn get_metrics(&self) -> PrioritizedReplayMetrics { - let mut metrics = self.metrics.read().clone(); - - // Update real-time metrics - let size = self.size.load(Ordering::Acquire); - metrics.utilization = if self.config.capacity > 0 { - size as f32 / self.config.capacity as f32 - } else { - 0.0 // Prevent division by zero - }; - - // Calculate priority statistics - if size > 0 { - let tree = self.priorities.lock(); - let total_priority = tree.total_sum(); - metrics.avg_priority = if size > 0 { - total_priority / size as f32 - } else { - 0.0 // Prevent division by zero - }; - - // Sample priorities for percentile calculation - let mut sampled_priorities = Vec::with_capacity(size.min(1000)); - for i in 0..size.min(1000) { - sampled_priorities.push(tree.get_priority(i)); - } - sampled_priorities - .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - - if !sampled_priorities.is_empty() { - let len = sampled_priorities.len(); - // Ensure safe indexing by using min with len-1 and max with 0 - let safe_idx = |fraction: usize| -> usize { ((len * fraction) / 10).min(len - 1) }; - - metrics.priority_percentiles[0] = - sampled_priorities.get(safe_idx(1)).copied().unwrap_or(0.0); // 10th percentile - metrics.priority_percentiles[1] = sampled_priorities - .get(safe_idx(2).max(len / 4)) - .copied() - .unwrap_or(0.0); // 25th percentile - metrics.priority_percentiles[2] = - sampled_priorities.get(len / 2).copied().unwrap_or(0.0); // 50th percentile - metrics.priority_percentiles[3] = sampled_priorities - .get((3 * len / 4).min(len - 1)) - .copied() - .unwrap_or(0.0); // 75th percentile - metrics.priority_percentiles[4] = - sampled_priorities.get(safe_idx(9)).copied().unwrap_or(0.0); // 90th percentile - - metrics.min_priority = sampled_priorities.get(0).copied().unwrap_or(0.0); - metrics.max_priority = sampled_priorities.get(len - 1).copied().unwrap_or(0.0); - } - } - - metrics - } - - /// Reset buffer (clear all experiences) - pub fn clear(&self) { - { - let mut experiences = self.experiences.write(); - for exp in experiences.iter_mut() { - *exp = None; - } - } - - { - let mut tree = self.priorities.lock(); - for i in 0..self.config.capacity { - let _ = tree.update(i, 0.0); - } - } - - self.position.store(0, Ordering::Release); - self.size.store(0, Ordering::Release); - self.training_step.store(0, Ordering::Release); - - // Reset metrics - { - let mut metrics = self.metrics.write(); - *metrics = PrioritizedReplayMetrics::default(); - } - } - - /// Get current training step - pub fn training_step(&self) -> usize { - self.training_step.load(Ordering::Acquire) - } - - /// Force set training step (useful for loading from checkpoint) - pub fn set_training_step(&self, step: usize) { - self.training_step.store(step, Ordering::Release); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::dqn::experience::Experience; - - fn create_test_experience() -> Experience { - Experience::new(vec![1.0, 2.0, 3.0], 0, 1.0, vec![1.1, 2.1, 3.1], false) - } - - #[test] - fn test_buffer_creation() { - let config = PrioritizedReplayConfig::default(); - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - assert_eq!(buffer.len(), 0); - assert!(buffer.is_empty()); - assert_eq!(buffer.capacity(), 100000); - } - - #[test] - fn test_push_and_sample() { - let config = PrioritizedReplayConfig { - capacity: 1000, - ..Default::default() - }; - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - // Push some experiences - for _ in 0..100 { - buffer - .push(create_test_experience()) - .expect("Failed to push experience in test"); - } - - assert_eq!(buffer.len(), 100); - assert!(buffer.can_sample(32)); - - // Sample batch - let (experiences, weights, indices) = - buffer.sample(32).expect("Failed to sample batch in test"); - assert_eq!(experiences.len(), 32); - assert_eq!(weights.len(), 32); - assert_eq!(indices.len(), 32); - - // All weights should be positive - assert!(weights.iter().all(|&w| w > 0.0)); - } - - #[test] - fn test_priority_updates() { - let config = PrioritizedReplayConfig { - capacity: 100, - ..Default::default() - }; - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - // Push experiences - for _ in 0..50 { - buffer - .push(create_test_experience()) - .expect("Failed to push experience in test"); - } - - // Sample and update priorities - let (_, _, indices) = buffer.sample(10).expect("Failed to sample batch in test"); - let new_priorities: Vec = (0..10).map(|i| (i + 1) as f32).collect(); - - buffer - .update_priorities(&indices, &new_priorities) - .expect("Failed to update priorities in test"); - - // Metrics should reflect updates - let metrics = buffer.get_metrics(); - assert!(metrics.priority_updates > 0); - assert!(metrics.max_priority > 0.0); - } - - #[test] - fn test_beta_annealing() { - let config = PrioritizedReplayConfig { - capacity: 100, - beta: 0.4, - beta_max: 1.0, - beta_annealing_steps: 1000, - ..Default::default() - }; - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - // Initial beta - assert_eq!(buffer.current_beta(), 0.4); - - // Step halfway through annealing - buffer.set_training_step(500); - let mid_beta = buffer.current_beta(); - assert!(mid_beta > 0.4 && mid_beta < 1.0); - - // Step to end of annealing - buffer.set_training_step(1000); - assert_eq!(buffer.current_beta(), 1.0); - } - - #[test] - fn test_metrics() { - let config = PrioritizedReplayConfig { - capacity: 100, - ..Default::default() - }; - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - // Add experiences - for _ in 0..50 { - buffer - .push(create_test_experience()) - .expect("Failed to push experience in test"); - } - - let metrics = buffer.get_metrics(); - assert_eq!(metrics.utilization, 0.5); - assert!(metrics.avg_priority > 0.0); - assert_eq!(metrics.priority_percentiles.len(), 5); - } - - #[test] - fn test_clear() { - let config = PrioritizedReplayConfig { - capacity: 100, - ..Default::default() - }; - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - // Add experiences - for _ in 0..50 { - buffer - .push(create_test_experience()) - .expect("Failed to push experience in test"); - } - - assert_eq!(buffer.len(), 50); - - buffer.clear(); - - assert_eq!(buffer.len(), 0); - assert!(buffer.is_empty()); - assert_eq!(buffer.training_step(), 0); - } - - #[test] - fn test_td_error_clamping() { - // Test extreme TD errors are clamped to prevent gradient explosion - let extreme_error = 1e10; - let clamped = extreme_error.clamp(1e-6, 10.0); - assert!(clamped <= 10.0); - assert_eq!(clamped, 10.0); - - // Test very small errors are clamped to minimum - let tiny_error = 1e-10; - let clamped_min = tiny_error.clamp(1e-6, 10.0); - assert!(clamped_min >= 1e-6); - assert_eq!(clamped_min, 1e-6); - - // Test normal errors pass through - let normal_error = 2.5; - let clamped_normal = normal_error.clamp(1e-6, 10.0); - assert_eq!(clamped_normal, normal_error); - } - - #[test] - fn test_priority_update_with_clamping() { - let config = PrioritizedReplayConfig { - capacity: 100, - alpha: 0.6, - ..Default::default() - }; - let buffer = PrioritizedReplayBuffer::new(config) - .expect("Failed to create prioritized replay buffer in test"); - - // Push experiences - for _ in 0..10 { - buffer - .push(create_test_experience()) - .expect("Failed to push experience in test"); - } - - // Test with extreme TD errors that should be clamped - let indices: Vec = (0..5).collect(); - let extreme_priorities = vec![1e10, 1e-10, 100.0, 0.001, 5.0]; - - buffer - .update_priorities(&indices, &extreme_priorities) - .expect("Failed to update priorities with extreme values"); - - // Verify buffer didn't crash and metrics are sane - let metrics = buffer.get_metrics(); - assert!(metrics.max_priority.is_finite()); - assert!(metrics.max_priority > 0.0); - assert!(metrics.max_priority <= 1e6); // Should be bounded by clamping - } -} diff --git a/ml/src/hyperopt/adapters/dqn.rs.backup b/ml/src/hyperopt/adapters/dqn.rs.backup deleted file mode 100644 index ca7cb1a19..000000000 --- a/ml/src/hyperopt/adapters/dqn.rs.backup +++ /dev/null @@ -1,1527 +0,0 @@ -//! DQN Hyperparameter Optimization Adapter -//! -//! This module provides a production-ready adapter for optimizing DQN -//! hyperparameters using the generic optimization framework. It implements: -//! -//! - Parameter space with log-scale handling for learning rates -//! - Training wrapper that integrates with existing DQN pipeline -//! - Metrics extraction for episode reward optimization (NOT validation loss) -//! -//! ## Optimization Objective -//! -//! **CRITICAL**: This adapter maximizes `avg_episode_reward`, NOT validation loss. -//! Optimizing for loss encourages tiny batch sizes (32-43) that prevent learning -//! because noisy gradients keep Q-values near zero, minimizing loss artificially. -//! Episode rewards measure actual trading performance (PnL), which is what we care about. -//! -//! ## Usage Example -//! -//! ```rust,no_run -//! use ml::hyperopt::EgoboxOptimizer; -//! use ml::hyperopt::adapters::dqn::{DQNTrainer, DQNParams}; -//! -//! # async fn example() -> anyhow::Result<()> { -//! // Create trainer -//! let trainer = DQNTrainer::new( -//! "test_data/real/databento/ml_training/", -//! 100, // epochs per trial -//! )?; -//! -//! // Run optimization -//! let optimizer = EgoboxOptimizer::with_trials(30, 5); -//! let result = optimizer.optimize(trainer)?; -//! -//! println!("Best learning rate: {}", result.best_params.learning_rate); -//! println!("Best batch size: {}", result.best_params.batch_size); -//! println!("Best episode reward: {:.6}", -result.best_objective); // Negate to get actual reward -//! # Ok(()) -//! # } -//! ``` - -use anyhow::Context; -use serde::{Deserialize, Serialize}; -use std::fs::OpenOptions; -use std::io::Write as IoWrite; -use std::path::PathBuf; -use tracing::info; - -use crate::hyperopt::paths::TrainingPaths; -use crate::hyperopt::traits::{HyperparameterOptimizable, ParameterSpace}; -use crate::trainers::dqn::{DQNHyperparameters, DQNTrainer as InternalDQNTrainer}; -use crate::MLError; - -/// DQN hyperparameter space -/// -/// Defines the hyperparameters to optimize for DQN training: -/// - Learning rate (log-scale: 1e-5 to 1e-3) -/// - Batch size (linear scale: 32 to 230, GPU memory constrained) -/// - Gamma (discount factor, linear: 0.95 to 0.99) -/// - Epsilon decay (log-scale: 0.990 to 0.999) -/// - Buffer size (log-scale: 10k to 1M) -/// - Movement threshold (linear scale: 0.01 to 0.05, determines HOLD penalty trigger) -/// -/// ## Parameter Scaling -/// -/// - **Log-scale**: Learning rate, epsilon_decay, buffer_size (span multiple orders) -/// - **Linear scale**: Batch size, gamma, movement_threshold (span single order) -/// -/// This scaling ensures efficient exploration by argmin's optimization. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DQNParams { - /// Learning rate for Adam optimizer (log-scale) - pub learning_rate: f64, - /// Batch size for training (linear scale, integer, max 230 for RTX 3050 Ti) - pub batch_size: usize, - /// Discount factor for future rewards (linear scale) - pub gamma: f64, - /// Epsilon decay rate (log-scale, close to 1.0) - pub epsilon_decay: f64, - /// Replay buffer capacity (log-scale) - pub buffer_size: usize, - /// Movement threshold for HOLD penalty (linear scale, 1% to 5%) - pub movement_threshold: f64, -} - -impl Default for DQNParams { - fn default() -> Self { - Self { - learning_rate: 1e-4, - batch_size: 128, - gamma: 0.99, - epsilon_decay: 0.995, - buffer_size: 100_000, - movement_threshold: 0.02, // 2% default (matches production script) - } - } -} - -impl ParameterSpace for DQNParams { - fn continuous_bounds() -> Vec<(f64, f64)> { - vec![ - (1e-5_f64.ln(), 3e-4_f64.ln()), // learning_rate (log scale) - WAVE 6 FIX #1: Narrowed from 1e-3 to 3e-4 to prevent Q-collapse - (32.0, 230.0), // batch_size (linear, GPU constrained) - (0.95, 0.99), // gamma (linear) - (0.990_f64.ln(), 0.999_f64.ln()), // epsilon_decay (log scale) - (10_000_f64.ln(), 1_000_000_f64.ln()), // buffer_size (log scale) - (0.01, 0.05), // movement_threshold (linear, 1% to 5%) - ] - } - - fn from_continuous(x: &[f64]) -> Result { - if x.len() != 6 { - return Err(MLError::ConfigError { - reason: format!("Expected 6 parameters, got {}", x.len()), - }); - } - - let learning_rate = x[0].exp(); - let mut batch_size = x[1].round().max(32.0).min(230.0) as usize; - let buffer_size = x[4].exp().round().max(10_000.0) as usize; - let movement_threshold = x[5].clamp(0.01, 0.05); - - // WAVE 6 FIX #2: Batch size floor for high learning rates - // High LR + small batch = Q-collapse. Enforce minimum batch size for LR > 2e-4 - if learning_rate > 2e-4 && batch_size < 120 { - tracing::info!( - "⚠️ Adjusting batch_size from {} to 120 (LR={:.2e} requires larger batches)", - batch_size, learning_rate - ); - batch_size = 120; - } - - Ok(Self { - learning_rate, - batch_size, - gamma: x[2].clamp(0.95, 0.99), - epsilon_decay: x[3].exp(), - buffer_size, - movement_threshold, - }) - } - - fn to_continuous(&self) -> Vec { - vec![ - self.learning_rate.ln(), - self.batch_size as f64, - self.gamma, - self.epsilon_decay.ln(), - (self.buffer_size as f64).ln(), - self.movement_threshold, - ] - } - - fn param_names() -> Vec<&'static str> { - vec![ - "learning_rate", - "batch_size", - "gamma", - "epsilon_decay", - "buffer_size", - "movement_threshold", - ] - } -} - -/// DQN training metrics -/// -/// Contains all relevant metrics from a DQN training run. -/// The primary optimization target is avg_episode_reward (higher is better). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DQNMetrics { - /// Final training loss - pub train_loss: f64, - /// Final validation loss - pub val_loss: f64, - /// Average Q-value (higher indicates better value estimation) - pub avg_q_value: f64, - /// Final epsilon (exploration rate) - pub final_epsilon: f64, - /// Number of epochs completed - pub epochs_completed: usize, - /// Average episode reward (optimization target) - pub avg_episode_reward: f64, - /// Buy action percentage (0.0 to 1.0) - pub buy_action_pct: f64, - /// Sell action percentage (0.0 to 1.0) - pub sell_action_pct: f64, - /// HOLD action percentage (0.0 to 1.0) - pub hold_action_pct: f64, - /// Average gradient norm (for stability monitoring) - pub gradient_norm: f64, - /// Q-value standard deviation (for volatility monitoring) - pub q_value_std: f64, -} - -/// DQN trainer for hyperparameter optimization -/// -/// This struct wraps the DQN training pipeline and implements -/// `HyperparameterOptimizable` for use with optimization backends. -/// -/// ## Configuration -/// -/// - **DBN data dir**: Market data source (OHLCV bars from Databento) -/// - **Epochs**: Number of training epochs per trial -/// - **Device**: CUDA GPU (falls back to CPU if unavailable) -/// - **Features**: 225 features (Wave D configuration) -/// -/// ## Fixed Architecture -/// -/// The following parameters are fixed for consistency: -/// - `state_dim`: 225 (Wave D feature count) -/// - `num_actions`: 3 (Buy, Sell, Hold) -/// - `hidden_dims`: [128, 64, 32] -/// -/// ## Optimized Hyperparameters -/// -/// The following are optimized by `DQNParams`: -/// - Learning rate -/// - Batch size -/// - Gamma (discount factor) -/// - Epsilon decay -/// - Buffer size -#[derive(Debug)] -pub struct DQNTrainer { - dbn_data_dir: PathBuf, - epochs: usize, - buffer_size_max: usize, - runtime_handle: Option, - training_paths: TrainingPaths, - device: candle_core::Device, // Initialize CUDA early like MAMBA-2 - /// Early stopping plateau window (epochs to check for improvement) - early_stopping_plateau_window: usize, - /// Early stopping minimum epochs (minimum epochs before early stopping can trigger) - early_stopping_min_epochs: usize, - /// Trial counter for checkpoint naming (incremented on each train_with_params call) - trial_counter: usize, -} - -impl DQNTrainer { - /// Create a new DQN trainer - /// - /// # Arguments - /// - /// * `dbn_data_dir` - Path to directory with DBN market data files - /// * `epochs` - Number of training epochs per trial - /// - /// # Returns - /// - /// Configured trainer ready for optimization - /// - /// # Errors - /// - /// Returns error if: - /// - DBN data directory doesn't exist - /// - No DBN files found in directory - pub fn new(dbn_data_dir: impl Into, epochs: usize) -> anyhow::Result { - Self::with_buffer_max(dbn_data_dir, epochs, 100_000) - } - - /// Create a new DQN trainer with custom buffer size limit - /// - /// # Arguments - /// - /// * `dbn_data_dir` - Path to directory with DBN market data files - /// * `epochs` - Number of training epochs per trial - /// * `buffer_size_max` - Maximum replay buffer size (default: 100_000 for 4GB GPUs) - /// - /// # Returns - /// - /// Configured trainer ready for optimization - /// - /// # Errors - /// - /// Returns error if: - /// - DBN data directory doesn't exist - /// - No DBN files found in directory - pub fn with_buffer_max(dbn_data_dir: impl Into, epochs: usize, buffer_size_max: usize) -> anyhow::Result { - let dbn_data_dir = dbn_data_dir.into(); - - if !dbn_data_dir.exists() { - return Err(MLError::ConfigError { - reason: format!("DBN data directory not found: {}", dbn_data_dir.display()), - } - .into()); - } - - info!("DQN Trainer initialized:"); - info!(" Data directory: {}", dbn_data_dir.display()); - info!(" Epochs per trial: {}", epochs); - info!(" Max buffer size: {}", buffer_size_max); - - // CRITICAL FIX: Initialize CUDA device at construction time (like MAMBA-2) - // This ensures CUDA context is set up BEFORE any training trials - let device = candle_core::Device::new_cuda(0).unwrap_or_else(|e| { - tracing::warn!("CUDA unavailable ({}), falling back to CPU", e); - candle_core::Device::Cpu - }); - info!(" Device: {:?}", if device.is_cuda() { "CUDA GPU" } else { "CPU" }); - - // Try to reuse existing Tokio runtime, create new one if needed - let runtime_handle = match tokio::runtime::Handle::try_current() { - Ok(handle) => { - info!(" Runtime: Reusing existing Tokio runtime"); - Some(handle) - } - Err(_) => { - info!(" Runtime: Will create new Tokio runtime per trial"); - None - } - }; - - // Use temporary default paths - should be replaced with with_training_paths() - let training_paths = TrainingPaths::new("/tmp/ml_training", "dqn", "default"); - - Ok(Self { - dbn_data_dir, - epochs, - buffer_size_max, - runtime_handle, - training_paths, - device, - early_stopping_plateau_window: 5, // Default: 5 epochs (hyperopt optimized) - early_stopping_min_epochs: 10, // Default: 10 epochs (hyperopt optimized) - trial_counter: 0, // Start at trial 0 - }) - } - - /// Set maximum buffer size (for 4GB GPU memory constraints) - pub fn with_buffer_size_max(&mut self, max_size: usize) -> &mut Self { - self.buffer_size_max = max_size; - info!("Buffer size max updated to: {}", max_size); - self - } - - /// Set training paths configuration (recommended over hardcoded checkpoint directories) - /// - /// # Arguments - /// - /// * `paths` - Training paths configuration - /// - /// # Returns - /// - /// Self for method chaining - pub fn with_training_paths(mut self, paths: TrainingPaths) -> Self { - info!("DQN training paths set: run_dir={:?}", paths.run_dir()); - self.training_paths = paths; - self - } - - /// Configure early stopping parameters (overrides defaults) - pub fn with_early_stopping(mut self, plateau_window: usize, min_epochs: usize) -> Self { - self.early_stopping_plateau_window = plateau_window; - self.early_stopping_min_epochs = min_epochs; - self - } - - /// Load training data from Parquet or DBN files (auto-detect) - /// - /// This method checks if the data directory contains Parquet files, - /// and if so, uses them. Otherwise, falls back to DBN files. - /// - /// # Returns - /// - /// Vector of (state, reward) tuples for DQN training - /// - /// # Errors - /// - /// Returns error if: - /// - No Parquet or DBN files found - /// - File format is invalid - /// - Feature extraction fails - fn load_training_data(&self) -> anyhow::Result> { - use std::path::Path; - - let dir_path = Path::new(&self.dbn_data_dir); - - // Check if directory contains Parquet or DBN files - let has_parquet = std::fs::read_dir(dir_path)? - .filter_map(|entry| entry.ok()) - .any(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("parquet")); - - if has_parquet { - info!("Found Parquet files, loading from Parquet..."); - self.load_from_parquet() - } else { - info!("No Parquet files found, loading from DBN..."); - self.load_from_dbn() - } - } - - /// Load training data from Parquet file - /// - /// Reads OHLCV bars from Parquet file, extracts 225-feature vectors, - /// and creates (state, reward) tuples for DQN training. - /// - /// # Returns - /// - /// Vector of (state, reward) tuples - /// - /// # Errors - /// - /// Returns error if: - /// - No Parquet file found in directory - /// - Parquet file is malformed - /// - Feature extraction fails - fn load_from_parquet(&self) -> anyhow::Result> { - use arrow::array::{Array, Float64Array, PrimitiveArray, UInt64Array}; - use arrow::datatypes::TimestampNanosecondType; - use arrow::record_batch::RecordBatch; - use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; - use std::fs::File; - use crate::features::extraction::OHLCVBar; - - // Find first Parquet file in directory - let parquet_file = std::fs::read_dir(&self.dbn_data_dir)? - .filter_map(|entry| entry.ok()) - .find(|entry| { - entry.path().extension().and_then(|s| s.to_str()) == Some("parquet") - }) - .ok_or_else(|| anyhow::anyhow!("No Parquet file found in directory"))? - .path(); - - info!("Loading Parquet file: {}", parquet_file.display()); - - let file = File::open(&parquet_file)?; - let builder = ParquetRecordBatchReaderBuilder::try_new(file)?; - let reader = builder.build()?; - - let mut all_ohlcv_bars = Vec::new(); - - for batch_result in reader { - let batch: RecordBatch = batch_result?; - - // Extract columns (timestamp_ns/ts_event, open, high, low, close, volume) - let timestamp_col = batch - .column_by_name("timestamp_ns") - .or_else(|| batch.column_by_name("ts_event")) - .ok_or_else(|| { - anyhow::anyhow!("Missing timestamp column (expected 'timestamp_ns' or 'ts_event')") - })?; - - let timestamps = timestamp_col - .as_any() - .downcast_ref::>() - .ok_or_else(|| anyhow::anyhow!("Invalid timestamp column type"))?; - - let opens = batch - .column_by_name("open") - .ok_or_else(|| anyhow::anyhow!("Missing 'open' column"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'open' column type"))?; - - let highs = batch - .column_by_name("high") - .ok_or_else(|| anyhow::anyhow!("Missing 'high' column"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'high' column type"))?; - - let lows = batch - .column_by_name("low") - .ok_or_else(|| anyhow::anyhow!("Missing 'low' column"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'low' column type"))?; - - let closes = batch - .column_by_name("close") - .ok_or_else(|| anyhow::anyhow!("Missing 'close' column"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'close' column type"))?; - - let volumes = batch - .column_by_name("volume") - .ok_or_else(|| anyhow::anyhow!("Missing 'volume' column"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'volume' column type"))?; - - // Convert to OHLCV bars - for i in 0..batch.num_rows() { - let timestamp_ns = timestamps.value(i); - let timestamp = chrono::DateTime::from_timestamp_nanos(timestamp_ns); - - let bar = OHLCVBar { - timestamp, - open: opens.value(i), - high: highs.value(i), - low: lows.value(i), - close: closes.value(i), - volume: volumes.value(i) as f64, - }; - all_ohlcv_bars.push(bar); - } - } - - info!("Loaded {} OHLCV bars from Parquet", all_ohlcv_bars.len()); - - // Sort bars chronologically - all_ohlcv_bars.sort_by_key(|bar| bar.timestamp); - - // Extract features and create training data - self.extract_features_and_targets(&all_ohlcv_bars) - } - - /// Load training data from DBN files (stub) - /// - /// # Returns - /// - /// Error indicating DBN loading not yet implemented - fn load_from_dbn(&self) -> anyhow::Result> { - Err(anyhow::anyhow!( - "DBN file loading not yet implemented for DQN. Please use Parquet files instead." - )) - } - - /// Extract 225-feature vectors from OHLCV bars and create training data - /// - /// Uses the production feature extraction API (extract_ml_features) to - /// generate 225-feature vectors from OHLCV bars. Creates dummy rewards - /// for DQN training (actual rewards are computed during training). - /// - /// # Arguments - /// - /// * `ohlcv_bars` - Chronologically sorted OHLCV bars - /// - /// # Returns - /// - /// Vector of (state, reward) tuples where: - /// - state: [f32; 225] feature vector - /// - reward: f64 dummy reward (0.0) - /// - /// # Errors - /// - /// Returns error if: - /// - Insufficient bars for warmup period (need 51+) - /// - Feature extraction fails - fn extract_features_and_targets(&self, ohlcv_bars: &[crate::features::extraction::OHLCVBar]) -> anyhow::Result> { - use crate::features::extraction::extract_ml_features; - - info!("Extracting 225-feature vectors from {} OHLCV bars...", ohlcv_bars.len()); - - // Need at least 50 bars for warmup period - if ohlcv_bars.len() < 51 { - return Err(anyhow::anyhow!( - "Insufficient OHLCV bars for feature extraction: {} < 51", - ohlcv_bars.len() - )); - } - - // Extract features using production API (returns Vec<[f64; 225]>) - let feature_vectors = extract_ml_features(ohlcv_bars) - .map_err(|e| anyhow::anyhow!("Feature extraction failed: {}", e))?; - - info!("Extracted {} feature vectors", feature_vectors.len()); - - // Convert to [f32; 225] and create dummy rewards (actual rewards computed during training) - let training_data: Vec<([f32; 225], f64)> = feature_vectors - .into_iter() - .map(|vec_f64| { - // Convert [f64; 225] to [f32; 225] - let mut vec_f32 = [0.0_f32; 225]; - for (i, &val) in vec_f64.iter().enumerate() { - vec_f32[i] = val as f32; - } - (vec_f32, 0.0_f64) // Dummy reward (actual rewards computed during training) - }) - .collect(); - - info!("Created {} training samples", training_data.len()); - - Ok(training_data) - } -} - -/// Write a log entry to the training log file -fn write_training_log_dqn(logs_dir: &std::path::Path, message: &str) -> Result<(), std::io::Error> { - let log_file = logs_dir.join("training.log"); - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(log_file)?; - - let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"); - writeln!(file, "[{}] {}", timestamp, message)?; - Ok(()) -} - -/// Write trial results to JSON file -fn write_trial_result_dqn( - hyperopt_dir: &std::path::Path, - trial_result: &crate::hyperopt::traits::TrialResult, -) -> Result<(), std::io::Error> { - let trials_file = hyperopt_dir.join("trials.json"); - - // Read existing trials (if any) - let mut all_trials = if trials_file.exists() { - let content = std::fs::read_to_string(&trials_file)?; - serde_json::from_str::>(&content).unwrap_or_default() - } else { - Vec::new() - }; - - // Append new trial - let trial_json = serde_json::to_value(trial_result) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - all_trials.push(trial_json); - - // Write back to file (pretty printed) - let content = serde_json::to_string_pretty(&all_trials) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - std::fs::write(&trials_file, content)?; - - Ok(()) -} - -// ============================================================================ -// Multi-Objective Helper Functions (Wave 4) -// ============================================================================ - -/// Normalize reward for multi-objective optimization -/// -/// Normalizes avg_episode_reward from raw range [-10.0, 10.0] to [-1.0, 1.0]. -/// Values are clamped to prevent outliers from dominating the objective function. -/// -/// # Arguments -/// -/// * `reward` - Raw episode reward from training -/// -/// # Returns -/// -/// Normalized reward in range [-1.0, 1.0] -/// -/// # Why Normalize by 10.0 -/// -/// The expected reward range is [-10.0, 10.0] based on empirical observations: -/// - Typical rewards: [-0.001, 0.001] (0.01% to 0.1% of expected range) -/// - Extreme rewards: [-1.0, 1.0] (10% of expected range) -/// - Outliers: [-10.0, 10.0] (100% of expected range, rare) -/// -/// Dividing by 10.0 maps the expected range to [-1.0, 1.0] for consistent -/// weighting across objective components. -/// -/// # Why Clamp to [-1.0, 1.0] -/// -/// Prevents outliers (e.g., reward = 50.0) from dominating the objective: -/// - Without clamping: reward=50.0 → normalized=5.0 (10x penalty vs other components) -/// - With clamping: reward=50.0 → normalized=1.0 (equal weight to other components) -/// -/// # Why 40% Weight -/// -/// Reward is the primary signal (actual trading performance), but not the only signal: -/// - Reward (40%): Measures trading performance (PnL) -/// - Diversity (10%): Ensures balanced action exploration -/// - Stability (10%): Prevents Q-value collapse and gradient explosion -/// - Completion (5%): Encourages full training runs -/// - Hard constraints (35% implicit): Rejects catastrophically bad trials -fn normalize_reward(reward: f64) -> f64 { - // Target range: [-1.0, 1.0] corresponds to [-10.0, 10.0] raw reward - // Clamp to prevent outliers from dominating - (reward / 10.0).clamp(-1.0, 1.0) -} - - -/// Calculate diversity penalty for action distribution -/// -/// Penalizes configurations where any single action (BUY, SELL, or HOLD) dominates -/// the action distribution above 60%. This addresses the critical issue of 99.4% HOLD -/// bias discovered in baseline DQN training. -/// -/// # Arguments -/// -/// * `action_distribution` - Array of [buy_pct, sell_pct, hold_pct] where each is 0.0-1.0 -/// -/// # Returns -/// -/// Penalty value (0.0 if all actions ≤ 60%, else catastrophic penalty) -/// -/// # Penalty Formula -/// -/// ```text -/// if max_action_pct > 0.60: -/// penalty = 100000.0 × (max_action_pct - 0.60)² -/// else: -/// penalty = 0.0 -/// ``` -/// -/// # Why 100,000× Weight (Updated from 10,000×) -/// -/// The penalty must be **catastrophic** to prevent action homogeneity: -/// - 60% action → penalty = 0 (acceptable natural preference) -/// - 70% action → penalty = 100000 × 0.10² = 1,000 (moderate warning) -/// - 80% action → penalty = 100000 × 0.20² = 4,000 (severe) -/// - 90% action → penalty = 100000 × 0.30² = 9,000 (very severe) -/// - 99% action → penalty = 100000 × 0.39² = 15,210 (catastrophic) -/// -/// This 100,000× weight (10× increase from original) ensures penalties meaningfully -/// impact the optimizer even when all trials exceed threshold. The wider penalty range -/// (0 to 16,000) allows better differentiation between mildly-biased and severely-biased trials. -/// -/// # Why 0.60 Threshold (Updated from 0.80) -/// -/// The threshold was lowered from 80% to 60% to catch degenerate behavior earlier: -/// - **Problem with 80%**: By the time trials hit >80%, they're already degenerate -/// - **Solution**: 60% threshold kicks in earlier while still allowing natural preferences -/// - Acceptable range: 33%-60% per action (allows 1.8x natural preference) -/// - Violation range: >60% (triggers exponential penalty) -/// -/// Calibrated based on: -/// - Baseline DQN: 99.4% HOLD (catastrophic) -/// - Target: <60% max action for healthy diversity -/// - Natural preferences (55% HOLD) still acceptable -/// -/// # Why Quadratic Penalty -/// -/// The squared term `(max_action_pct - 0.60)²` creates exponential escalation: -/// - 70% → penalty = 1,000 (10% violation) -/// - 80% → penalty = 4,000 (20% violation, 4× worse than 70%) -/// - 90% → penalty = 9,000 (30% violation, 9× worse than 70%) -/// -/// This exponential growth ensures the optimizer aggressively avoids configurations -/// that approach 100% action bias. -/// -/// # Reference -/// -/// This addresses Bug #0 discovered in Wave 3-A1: -/// - Baseline DQN training: 99.4% HOLD, 0.4% BUY, 0.2% SELL -/// - Root cause: Reward function did not penalize action homogeneity -/// - Wave 4 fix: 100,000× catastrophic penalty for >60% bias (40× stronger than original) -fn calculate_diversity_penalty(action_distribution: &[f64; 3]) -> f64 { - // Find maximum action percentage across BUY, SELL, HOLD - let max_action_pct = action_distribution - .iter() - .copied() - .fold(f64::NEG_INFINITY, f64::max); - - // Apply catastrophic penalty if any action exceeds 60% threshold - // CHANGED from 0.80 to 0.60 threshold (more aggressive) - // Reasoning: 80% threshold was too permissive, allowing trials to degenerate - // before penalty kicked in. 60% threshold ensures penalty starts earlier, - // while still allowing natural preferences (55% HOLD is acceptable). - if max_action_pct > 0.60 { - // Penalty escalates quadratically: (violation%)² × 100,000 - // Example: 70% → 0.10² × 100,000 = 1,000 - // Example: 80% → 0.20² × 100,000 = 4,000 - // Example: 99% → 0.39² × 100,000 = 15,210 - 100000.0 * (max_action_pct - 0.60).powi(2) - } else { - // No penalty if all actions ≤ 60% (acceptable diversity) - 0.0 - } -} - - -/// Calculate stability penalty from gradient norms and Q-value volatility -/// -/// This function detects training instability via two key metrics: -/// 1. **Gradient norm**: Measures gradient explosion risk -/// 2. **Q-value std**: Measures Q-value volatility across epochs -/// -/// # Arguments -/// -/// * `gradient_norm` - Average gradient norm during training -/// * `q_value_std` - Standard deviation of Q-values across epochs -/// -/// # Returns -/// -/// Combined penalty (0.0 = stable, higher = unstable) -/// -/// # Penalty Formula -/// -/// ```text -/// gradient_penalty = if gradient_norm > 50.0: -/// (gradient_norm - 50.0) / 50.0 -/// else: -/// 0.0 -/// -/// q_value_penalty = if q_value_std > 100.0: -/// (q_value_std - 100.0) / 100.0 -/// else: -/// 0.0 -/// -/// stability_penalty = gradient_penalty + q_value_penalty -/// ``` -/// -/// # Why These Thresholds? -/// -/// - **gradient_norm = 50.0**: Empirical stability boundary from DQN training -/// - Normal range: 0.1 to 10.0 (healthy gradients) -/// - Warning range: 10.0 to 50.0 (elevated but acceptable) -/// - Danger zone: >50.0 (gradient explosion likely) -/// - Complements Bug #1 fix (gradient clipping at max_norm=10.0) -/// -/// - **q_value_std = 100.0**: Acceptable Q-value volatility range -/// - Normal range: 0.1 to 10.0 (stable Q-values) -/// - Warning range: 10.0 to 100.0 (elevated volatility) -/// - Danger zone: >100.0 (Q-value oscillation/instability) -/// -/// # Edge Cases -/// -/// - NaN/Inf gradient_norm: Assigns maximum penalty (gradient_norm = f64::MAX) -/// - NaN/Inf q_value_std: Assigns maximum penalty (q_value_std = f64::MAX) -fn calculate_stability_penalty(gradient_norm: f64, q_value_std: f64) -> f64 { - // Handle NaN/Inf gracefully (assign maximum penalty) - let gradient_norm = if gradient_norm.is_finite() { gradient_norm } else { f64::MAX }; - let q_value_std = if q_value_std.is_finite() { q_value_std } else { f64::MAX }; - - // Penalize gradient norms > 50.0 (indicates potential explosion) - let gradient_penalty = if gradient_norm > 50.0 { - (gradient_norm - 50.0) / 50.0 - } else { - 0.0 - }; - - // Penalize Q-value std > 100.0 (indicates high volatility) - let q_value_penalty = if q_value_std > 100.0 { - (q_value_std - 100.0) / 100.0 - } else { - 0.0 - }; - - // Return combined penalty (will be weighted 20% in final objective) - gradient_penalty + q_value_penalty -} - -/// Calculate completion penalty for multi-objective optimization -/// -/// This function detects catastrophic training failures by checking if trials -/// terminated prematurely (before reaching minimum required epochs). -/// -/// ## Penalty Logic -/// -/// - **1000.0**: Catastrophic penalty for premature termination -/// - Training failed or early stopping triggered before min_epochs -/// - This makes the trial highly undesirable to the optimizer -/// - Reference: Bug #1 fix (gradient explosion could cause early stops) -/// -/// - **500.0**: Moderate penalty for insufficient epochs without explicit early stop -/// - epochs_completed < min_epochs but early_stop_triggered = false -/// - This might indicate a configuration error (e.g., wrong epoch count) -/// - Still penalize to avoid training instability -/// -/// - **0.0**: No penalty for successful completion -/// - epochs_completed >= min_epochs -/// - Training completed as expected -/// -/// ## Arguments -/// -/// * `epochs_completed` - Actual number of epochs trained -/// * `min_epochs` - Minimum expected epochs (typically 10 for DQN hyperopt) -/// * `early_stop_triggered` - Whether early stopping was triggered -/// -/// ## Edge Cases -/// -/// - If `epochs_completed` is 0, maximum penalty is assigned (1000.0) -/// - If metrics are missing, caller should assign 1000.0 penalty -/// -/// ## Integration -/// -/// This is Component 4 of the multi-objective function. The final objective will be: -/// ``` -/// objective = reward_weighted + diversity_penalty + stability_penalty + completion_penalty -/// ``` -/// -/// ## References -/// -/// - Wave 3-A2: Multi-objective function design -/// - Wave 4-A5: Completion penalty implementation -/// - Bug #1: Gradient explosion fix (prevents early stops via gradient clipping) -fn calculate_completion_penalty( - epochs_completed: u32, - min_epochs: u32, - early_stop_triggered: bool, -) -> f64 { - // Edge case: Zero epochs completed (catastrophic failure) - if epochs_completed == 0 { - return 1000.0; - } - - // Catastrophic penalty for premature termination - if epochs_completed < min_epochs && early_stop_triggered { - 1000.0 // Training failed or stopped too early - } else if epochs_completed < min_epochs { - 500.0 // Moderate penalty (configuration error) - } else { - 0.0 // Training completed successfully - } -} - -impl HyperparameterOptimizable for DQNTrainer { - type Params = DQNParams; - type Metrics = DQNMetrics; - - fn train_with_params(&mut self, params: Self::Params) -> Result { - // START: Add trial timing - let trial_start = std::time::Instant::now(); - - // Get current trial number and increment for next trial - let current_trial = self.trial_counter; - self.trial_counter += 1; - - // Fix 1: Clamp buffer size to max (4GB GPU constraint) - let clamped_buffer_size = params.buffer_size.min(self.buffer_size_max); - - info!("Training DQN with parameters:"); - info!(" Learning rate: {:.6}", params.learning_rate); - info!(" Batch size: {}", params.batch_size); - info!(" Gamma: {:.3}", params.gamma); - info!(" Epsilon decay: {:.5}", params.epsilon_decay); - info!(" Buffer size: {} (requested: {})", clamped_buffer_size, params.buffer_size); - - // Log trial start (ensure directory exists first) - std::fs::create_dir_all(self.training_paths.logs_dir()).ok(); - write_training_log_dqn( - &self.training_paths.logs_dir(), - &format!("=== Starting DQN Trial ===\nParams: {:#?}", params) - ).ok(); - - // Create all training directories - self.training_paths - .create_all() - .map_err(|e| MLError::ConfigError { - reason: format!("Failed to create training directories: {}", e), - })?; - - info!("Training directories created:"); - info!(" Checkpoints: {:?}", self.training_paths.checkpoints_dir()); - info!(" Logs: {:?}", self.training_paths.logs_dir()); - info!(" Hyperopt: {:?}", self.training_paths.hyperopt_dir()); - - // Create checkpoint callback for saving models - let checkpoints_dir = self.training_paths.checkpoints_dir(); - let checkpoint_callback = move |epoch: usize, model_data: Vec, is_best: bool| -> Result { - let filename = if is_best { - // Best model checkpoint (final best checkpoint for this trial) - format!("trial_{}_best.safetensors", current_trial) - } else { - // Periodic checkpoint (overwrite previous periodic checkpoint for this trial) - format!("trial_{}_epoch_{}.safetensors", current_trial, epoch) - }; - - let checkpoint_path = checkpoints_dir.join(&filename); - - // Save checkpoint to disk - std::fs::write(&checkpoint_path, &model_data) - .context(format!("Failed to save checkpoint: {:?}", checkpoint_path))?; - - let checkpoint_type = if is_best { - "🎉 BEST" - } else { - "💾" - }; - - info!( - "{} Trial {} checkpoint saved: {} ({} bytes)", - checkpoint_type, - current_trial, - checkpoint_path.display(), - model_data.len() - ); - - Ok(checkpoint_path.to_string_lossy().to_string()) - }; - - // WAVE 6 FIX #4: Dynamic gradient clipping based on learning rate - // High LR causes larger gradient updates, needs tighter clipping to prevent explosions - let gradient_clip_norm = if params.learning_rate > 1e-4 { - 5.0 // Tighter clipping for high LR - } else { - 10.0 // Standard clipping for low LR - }; - - // Create DQN hyperparameters from optimization params - let hyperparams = DQNHyperparameters { - learning_rate: params.learning_rate, - batch_size: params.batch_size, - gamma: params.gamma, - epsilon_start: 1.0, // Fixed - epsilon_end: 0.01, // Fixed - epsilon_decay: params.epsilon_decay, - buffer_size: clamped_buffer_size, - min_replay_size: params.batch_size * 2, // Need at least 2x batch size - epochs: self.epochs, - checkpoint_frequency: (self.epochs / 5).max(1), // Save 5 checkpoints per trial, min 1 - early_stopping_enabled: true, - q_value_floor: 0.01, // WAVE 6 FIX #3: Lowered from 0.5 to 0.01 to reduce false-positive pruning - min_loss_improvement_pct: 2.0, - plateau_window: self.early_stopping_plateau_window, - min_epochs_before_stopping: self.early_stopping_min_epochs, - hold_penalty: -0.01, // BUG #3 FIX: Correct default (was -0.001, 10x too small) - // WAVE 1 AGENT 3: Huber loss configuration (matches production) - use_huber_loss: true, // CRITICAL: Must match production (--use-huber-loss) - huber_delta: 1.0, // CRITICAL: Must match production (--huber-delta 1.0) - use_double_dqn: true, // Production feature: --use-double-dqn - gradient_clip_norm: Some(gradient_clip_norm), // WAVE 6 FIX #4: Dynamic clipping (5.0 for LR > 1e-4, else 10.0) - hold_penalty_weight: 0.01, // Production: --hold-penalty-weight 0.01 - movement_threshold: params.movement_threshold, // WAVE 1 AGENT 5: Expose to hyperopt search space - }; - - let data_path_str = self - .dbn_data_dir - .to_str() - .ok_or_else(|| MLError::ConfigError { - reason: "Invalid UTF-8 in data path".to_string(), - })?; - - // Check if path is a parquet file - let is_parquet_file = self - .dbn_data_dir - .extension() - .and_then(|s| s.to_str()) - == Some("parquet"); - - // Create internal DQN trainer BEFORE catch_unwind to allow proper CUDA initialization - let mut internal_trainer = InternalDQNTrainer::new(hyperparams.clone()) - .map_err(|e| MLError::TrainingError(format!("Failed to create DQN trainer: {}", e)))?; - - // Wrap training in catch_unwind for CUDA OOM handling (NOT trainer creation) - let training_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - - // Reuse runtime handle or create new one + choose training method - let training_metrics = if let Some(handle) = &self.runtime_handle { - // Reuse existing runtime - if is_parquet_file { - info!("Training DQN with parquet file: {}", data_path_str); - handle.block_on( - internal_trainer.train_from_parquet(data_path_str, checkpoint_callback), - ) - } else { - info!("Training DQN with DBN directory: {}", data_path_str); - handle.block_on( - internal_trainer.train(data_path_str, checkpoint_callback), - ) - } - } else { - // Create new runtime (fallback) - let runtime = tokio::runtime::Runtime::new() - .map_err(|e| MLError::TrainingError(format!("Failed to create runtime: {}", e)))?; - if is_parquet_file { - info!("Training DQN with parquet file: {}", data_path_str); - runtime.block_on( - internal_trainer.train_from_parquet(data_path_str, checkpoint_callback), - ) - } else { - info!("Training DQN with DBN directory: {}", data_path_str); - runtime.block_on( - internal_trainer.train(data_path_str, checkpoint_callback), - ) - } - } - .map_err(|e| MLError::TrainingError(format!("DQN training failed: {}", e)))?; - - Ok::<_, MLError>(training_metrics) - })); - - // Handle panic (CUDA OOM or other catastrophic failure) - let training_metrics = match training_result { - Ok(Ok(metrics)) => metrics, - Ok(Err(e)) => { - // Normal error (non-panic) - return Err(e); - } - Err(panic_err) => { - // Panic occurred (likely CUDA OOM) - let panic_msg = if let Some(s) = panic_err.downcast_ref::<&str>() { - s.to_string() - } else if let Some(s) = panic_err.downcast_ref::() { - s.clone() - } else { - "Unknown panic".to_string() - }; - - tracing::warn!("DQN training panicked (likely CUDA OOM): {}", panic_msg); - tracing::warn!("Returning penalty metrics to continue hyperopt"); - - // Return penalty metrics to avoid crashing entire hyperopt run - // Use large negative reward so optimizer avoids this configuration - return Ok(DQNMetrics { - train_loss: 1000.0, - val_loss: 1000.0, - avg_q_value: 0.0, - final_epsilon: 1.0, - epochs_completed: 0, - avg_episode_reward: -1000.0, // Penalty reward (will give objective = +1000) - buy_action_pct: 0.0, - sell_action_pct: 0.0, - hold_action_pct: 1.0, // Assume worst case (100% HOLD) - gradient_norm: f64::MAX, // Maximum penalty - q_value_std: f64::MAX, // Maximum penalty - }); - } - }; - - // Extract metrics from TrainingMetrics struct - // Note: TrainingMetrics.loss is a single f64, not a Vec - // Q-values, epsilon, and rewards are stored in additional_metrics HashMap - let avg_q_value = training_metrics - .additional_metrics - .get("avg_q_value") - .copied() - .unwrap_or(0.0); - let avg_gradient_norm = training_metrics - .additional_metrics - .get("avg_gradient_norm") - .copied() - .unwrap_or(0.0); - let avg_episode_reward = training_metrics - .additional_metrics - .get("avg_episode_reward") - .copied() - .unwrap_or(0.0); - - // WAVE 3 AGENT A3: Check hard constraints for early trial pruning - let mut constraint_violated = false; - let mut violation_reason = String::new(); - - // Constraint 1: Check for extreme HOLD bias (>95%) - let hold_percentage = training_metrics - .additional_metrics - .get("hold_percentage") - .copied() - .unwrap_or(0.0); - if hold_percentage > 95.0 { - constraint_violated = true; - violation_reason = format!( - "Extreme HOLD bias detected: {:.1}% > 95.0%", - hold_percentage - ); - } - - // Constraint 2: Check for gradient explosion (grad_norm > 50.0) - if avg_gradient_norm > 50.0 { - constraint_violated = true; - violation_reason = format!( - "Gradient explosion detected: avg_grad_norm={:.2} > 50.0", - avg_gradient_norm - ); - } - - // Constraint 3: Check for Q-value collapse (all Q-values < 0.01) - if avg_q_value < 0.01 { - constraint_violated = true; - violation_reason = format!( - "Q-value collapse detected: avg_q_value={:.6} < 0.01", - avg_q_value - ); - } - - // If constraint violated, return early with large penalty - if constraint_violated { - tracing::warn!("⚠️ Trial {} PRUNED: {}", current_trial, violation_reason); - - // Log pruning event - write_training_log_dqn( - &self.training_paths.logs_dir(), - &format!("Trial PRUNED: {}", violation_reason) - ).ok(); - - // Return penalty metrics (-1000 reward -> +1000 objective) - return Ok(DQNMetrics { - train_loss: 1000.0, - val_loss: 1000.0, - avg_q_value: 0.0, - final_epsilon: 1.0, - epochs_completed: training_metrics.epochs_trained as usize, - avg_episode_reward: -1000.0, // Large penalty - buy_action_pct: 0.0, - sell_action_pct: 0.0, - hold_action_pct: 1.0, // Assume worst case (100% HOLD) - gradient_norm: avg_gradient_norm, // Include actual gradient norm for diagnostics - q_value_std: 0.0, // No q_value_std available yet - }); - } - - // Extract action counts from metrics - let buy_count = training_metrics - .additional_metrics - .get("buy_count") - .copied() - .unwrap_or(0.0); - let sell_count = training_metrics - .additional_metrics - .get("sell_count") - .copied() - .unwrap_or(0.0); - let hold_count = training_metrics - .additional_metrics - .get("hold_count") - .copied() - .unwrap_or(0.0); - let total_actions = training_metrics - .additional_metrics - .get("total_actions") - .copied() - .unwrap_or(1.0); // Avoid division by zero - - // Calculate action percentages (0.0 to 1.0, not 0-100) - let buy_action_pct = buy_count / total_actions; - let sell_action_pct = sell_count / total_actions; - let hold_action_pct = hold_count / total_actions; - - // Extract stability metrics - // avg_gradient_norm is already extracted earlier (line ~918) - // q_value_std: TODO - needs to be calculated in DQN trainer and added to additional_metrics - // For now, use a safe default of 0.0 (indicates no volatility data available) - let q_value_std = training_metrics - .additional_metrics - .get("q_value_std") - .copied() - .unwrap_or(0.0); - - let metrics = DQNMetrics { - train_loss: training_metrics.loss, - val_loss: internal_trainer.get_best_val_loss(), // Use best validation loss for hyperopt - avg_q_value, - final_epsilon: training_metrics - .additional_metrics - .get("final_epsilon") - .copied() - .unwrap_or(0.01), - epochs_completed: training_metrics.epochs_trained as usize, - avg_episode_reward, - buy_action_pct, - sell_action_pct, - hold_action_pct, - gradient_norm: avg_gradient_norm, // Already extracted earlier - q_value_std, - }; - - info!("Training completed:"); - info!(" Final train loss: {:.6}", metrics.train_loss); - info!(" Best val loss: {:.6} at epoch {}", metrics.val_loss, internal_trainer.get_best_epoch()); - info!(" Avg Q-value: {:.4}", metrics.avg_q_value); - - // CRITICAL FIX: Save final model checkpoint after training completes - // This ensures the final trained model is persisted (complements periodic checkpoints saved during training) - info!("Saving final model checkpoint..."); - - // Use trial number for consistent naming (matches checkpoint callback naming scheme) - let checkpoint_filename = format!("trial_{}_model.safetensors", current_trial); - let checkpoint_path = self.training_paths.checkpoints_dir().join(&checkpoint_filename); - - // Access trained DQN model to extract weights (blocking read for sync context) - let agent_guard = internal_trainer.get_agent().blocking_read(); - - // Get VarMap containing all model weights - let q_network_vars = agent_guard.get_q_network_vars(); - let vars_data = q_network_vars.data().lock().map_err(|e| { - MLError::LockError(format!("Failed to lock VarMap for checkpoint save: {}", e)) - })?; - - // Extract tensors from VarMap (clones data, releases lock quickly) - let mut tensors = std::collections::HashMap::new(); - for (name, var) in vars_data.iter() { - tensors.insert(name.clone(), var.as_tensor().clone()); - } - - // Release locks before I/O operation - drop(vars_data); - drop(agent_guard); - - // Save tensors to safetensors file - candle_core::safetensors::save(&tensors, &checkpoint_path).map_err(|e| { - MLError::CheckpointError(format!("Failed to save checkpoint to {:?}: {}", checkpoint_path, e)) - })?; - - info!("✓ Model checkpoint saved: {:?} ({} tensors)", checkpoint_path, tensors.len()); - - // END: Add trial completion logging - let duration_secs = trial_start.elapsed().as_secs_f64(); - write_training_log_dqn( - &self.training_paths.logs_dir(), - &format!("Training completed in {:.2}s: train_loss={:.6}, val_loss={:.6}, q_value={:.4}", - duration_secs, metrics.train_loss, metrics.val_loss, metrics.avg_q_value) - ).ok(); - - // CRITICAL FIX: Explicit memory cleanup to prevent OOM between trials - // Drop training_metrics and sync CUDA to free GPU/RAM - info!("Cleaning up resources..."); - drop(training_metrics); - - // Sync CUDA to ensure GPU memory is freed - let device = candle_core::Device::cuda_if_available(0) - .unwrap_or(candle_core::Device::Cpu); - if device.is_cuda() { - use candle_core::Device; - if let Device::Cuda(_) = &device { - // Force CUDA synchronization to release GPU memory - std::thread::sleep(std::time::Duration::from_millis(100)); - } - } - info!("Resource cleanup complete"); - - // Write trial result to JSON (ensure directory exists first) - let trial_result = crate::hyperopt::traits::TrialResult { - trial_num: current_trial, - params, - objective: Self::extract_objective(&metrics), - duration_secs, - }; - - std::fs::create_dir_all(self.training_paths.hyperopt_dir()).ok(); - write_trial_result_dqn(&self.training_paths.hyperopt_dir(), &trial_result).ok(); - - Ok(metrics) - } - - fn extract_objective(metrics: &Self::Metrics) -> f64 { - // WAVE 4: Multi-objective optimization - // - // We optimize for avg_episode_reward, NOT validation loss, because: - // 1. Loss minimization rewards tiny batches (batch_size=32-43) that prevent learning - // 2. Low batch sizes → noisy gradients → Q-values stay near zero → low loss - // 3. Episode rewards measure actual trading performance (PnL) - // - // The optimizer minimizes this objective, so we negate rewards to maximize them. - - // Component 1: Reward (normalized, 40% weight) - // Normalize to [-1.0, 1.0] range and apply 40% weight - let reward_component = normalize_reward(metrics.avg_episode_reward); - let reward_weighted = 0.40 * reward_component; - - // Component 2: Diversity penalty (10,000× weight for catastrophic action bias) - // Extracts action distribution and calculates penalty for >80% bias - let action_distribution = [ - metrics.buy_action_pct, - metrics.sell_action_pct, - metrics.hold_action_pct, - ]; - let diversity_penalty = calculate_diversity_penalty(&action_distribution); - - // Component 4: Completion penalty (catastrophic if trial fails) - // Expected minimum epochs: 5 (matches validation epoch count) - let min_epochs = 5; - let completion_penalty = calculate_completion_penalty( - metrics.epochs_completed as u32, - min_epochs, - metrics.epochs_completed < (min_epochs as usize) - ); - - // Component 3: Stability penalty (20% weight) - // Penalizes gradient explosion (>50.0) and Q-value volatility (>100.0) - let stability_penalty_raw = calculate_stability_penalty( - metrics.gradient_norm, - metrics.q_value_std - ); - let stability_penalty = 0.20 * stability_penalty_raw; - - // TODO (Wave 4-A6): Add hard constraints (Q-value floor, loss ceiling, min epochs) - - // Log objective component breakdown for diagnostics - let objective_total = reward_weighted + diversity_penalty + stability_penalty + completion_penalty; - info!( - "Objective components: reward={:.6} | diversity_penalty={:.2} | stability_penalty={:.6} | completion_penalty={:.2} | TOTAL={:.6}", - reward_weighted, diversity_penalty, stability_penalty, completion_penalty, objective_total - ); - info!( - "Action distribution: BUY={:.1}% | SELL={:.1}% | HOLD={:.1}%", - metrics.buy_action_pct * 100.0, - metrics.sell_action_pct * 100.0, - metrics.hold_action_pct * 100.0 - ); - - // Final objective: reward + diversity_penalty + stability_penalty + completion_penalty - // (penalties are positive for bad trials, so we ADD them) - // - Diversity penalty: 0.0 for balanced, 10,000× (max_action_pct - 0.80)² for >80% bias - // - Stability penalty: 0.0 for stable, escalates for gradient_norm>50 or q_value_std>100 - // Weighted at 20% to balance against other components - // - Completion penalty: 0.0 for success, 500.0 for insufficient epochs, 1000.0 for catastrophic failure - // This ensures trials with action bias or catastrophic failures are heavily penalized - objective_total - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_dqn_params_roundtrip() { - let params = DQNParams { - learning_rate: 0.0001, - batch_size: 128, - gamma: 0.99, - epsilon_decay: 0.995, - buffer_size: 100_000, - movement_threshold: 0.02, - }; - - let continuous = params.to_continuous(); - let recovered = DQNParams::from_continuous(&continuous).unwrap(); - - assert!((recovered.learning_rate - params.learning_rate).abs() < 1e-10); - assert_eq!(recovered.batch_size, params.batch_size); - assert!((recovered.gamma - params.gamma).abs() < 1e-10); - assert!((recovered.epsilon_decay - params.epsilon_decay).abs() < 1e-6); - assert_eq!(recovered.buffer_size, params.buffer_size); - assert!((recovered.movement_threshold - params.movement_threshold).abs() < 1e-10); - } - - #[test] - fn test_dqn_params_bounds() { - let bounds = DQNParams::continuous_bounds(); - assert_eq!(bounds.len(), 6); - - // Check log-scale bounds are reasonable - assert!(bounds[0].0 < bounds[0].1); // learning_rate - assert!(bounds[3].0 < bounds[3].1); // epsilon_decay - assert!(bounds[4].0 < bounds[4].1); // buffer_size - - // Check linear bounds - assert_eq!(bounds[1], (32.0, 230.0)); // batch_size - assert_eq!(bounds[2], (0.95, 0.99)); // gamma - assert_eq!(bounds[5], (0.01, 0.05)); // movement_threshold - } - - #[test] - fn test_param_names() { - let names = DQNParams::param_names(); - assert_eq!(names.len(), 6); - assert_eq!(names[0], "learning_rate"); - assert_eq!(names[1], "batch_size"); - assert_eq!(names[2], "gamma"); - assert_eq!(names[3], "epsilon_decay"); - assert_eq!(names[4], "buffer_size"); - assert_eq!(names[5], "movement_threshold"); - } - - #[test] - fn test_objective_function_maximizes_reward() { - // Test that objective function uses normalization + clamping (Wave 4 multi-objective) - - // Scenario 1: Positive reward (clamped to 1.0) → 0.40 * 1.0 = 0.40 - let metrics_positive = DQNMetrics { - train_loss: 0.5, - val_loss: 0.4, - avg_q_value: 10.0, - final_epsilon: 0.01, - epochs_completed: 100, // >= min_epochs (10) → no completion penalty - avg_episode_reward: 100.0, // Clamped: (100/10).clamp(-1, 1) = 1.0 - buy_action_pct: 0.3, - sell_action_pct: 0.3, - hold_action_pct: 0.4, - gradient_norm: 2.0, - q_value_std: 1.5, - }; - let objective_positive = DQNTrainer::extract_objective(&metrics_positive); - // Expected: 0.40 * 1.0 + 0.0 (no completion penalty) = 0.40 - assert_eq!(objective_positive, 0.40, "Objective should be 0.40 * clamp(reward/10, -1, 1)"); - - // Scenario 2: Negative reward (clamped to -1.0) → 0.40 * -1.0 = -0.40 - let metrics_negative = DQNMetrics { - train_loss: 0.5, - val_loss: 0.4, - avg_q_value: 10.0, - final_epsilon: 0.01, - epochs_completed: 100, - avg_episode_reward: -50.0, // Clamped: (-50/10).clamp(-1, 1) = -1.0 - buy_action_pct: 0.3, - sell_action_pct: 0.3, - hold_action_pct: 0.4, - gradient_norm: 2.0, - q_value_std: 1.5, - }; - let objective_negative = DQNTrainer::extract_objective(&metrics_negative); - // Expected: 0.40 * -1.0 + 0.0 = -0.40 - assert_eq!(objective_negative, -0.40, "Negative reward should give -0.40"); - - // Scenario 3: Zero reward - let metrics_zero = DQNMetrics { - train_loss: 0.5, - val_loss: 0.4, - avg_q_value: 10.0, - final_epsilon: 0.01, - epochs_completed: 100, - avg_episode_reward: 0.0, - buy_action_pct: 0.3, - sell_action_pct: 0.3, - hold_action_pct: 0.4, - gradient_norm: 2.0, - q_value_std: 1.5, - }; - let objective_zero = DQNTrainer::extract_objective(&metrics_zero); - // Expected: 0.40 * 0.0 + 0.0 = 0.0 - assert_eq!(objective_zero, 0.0, "Zero reward should give zero objective"); - - // Scenario 4: Verify clamping works (reward > 10.0) - let high_reward = DQNMetrics { - train_loss: 0.5, - val_loss: 0.4, - avg_q_value: 10.0, - final_epsilon: 0.01, - epochs_completed: 100, - avg_episode_reward: 200.0, // Clamped: (200/10).clamp(-1, 1) = 1.0 - buy_action_pct: 0.3, - sell_action_pct: 0.3, - hold_action_pct: 0.4, - gradient_norm: 2.0, - q_value_std: 1.5, - }; - let low_reward = DQNMetrics { - train_loss: 0.5, - val_loss: 0.4, - avg_q_value: 10.0, - final_epsilon: 0.01, - epochs_completed: 100, - avg_episode_reward: 5.0, // (5/10).clamp(-1, 1) = 0.5 - buy_action_pct: 0.3, - sell_action_pct: 0.3, - hold_action_pct: 0.4, - gradient_norm: 2.0, - q_value_std: 1.5, - }; - let obj_high = DQNTrainer::extract_objective(&high_reward); - let obj_low = DQNTrainer::extract_objective(&low_reward); - - // Expected: obj_high = 0.40 * 1.0 = 0.40, obj_low = 0.40 * 0.5 = 0.20 - // Higher reward (after clamping) should give higher objective (since we normalize to positive range) - assert!( - obj_high > obj_low, - "Higher reward (1.0) should give higher objective than lower reward (0.5): {} > {}", - obj_high, - obj_low - ); - } -} diff --git a/ml/src/hyperopt/adapters/mamba2.rs.broken_backup b/ml/src/hyperopt/adapters/mamba2.rs.broken_backup deleted file mode 100644 index 148980020..000000000 --- a/ml/src/hyperopt/adapters/mamba2.rs.broken_backup +++ /dev/null @@ -1,747 +0,0 @@ -//! MAMBA-2 Hyperparameter Optimization Adapter -//! -//! This module provides a production-ready adapter for optimizing MAMBA-2 -//! hyperparameters using the generic optimization framework. It implements: -//! -//! - Parameter space with log-scale handling for learning rates -//! - Training wrapper that integrates with existing MAMBA-2 pipeline -//! - Metrics extraction for validation loss optimization -//! -//! ## Usage Example -//! -//! ```rust,no_run -//! use ml::hyperopt::EgoboxOptimizer; -//! use ml::hyperopt::adapters::mamba2::{Mamba2Trainer, Mamba2Params}; -//! -//! # async fn example() -> anyhow::Result<()> { -//! // Create trainer -//! let trainer = Mamba2Trainer::new( -//! "test_data/ES_FUT_180d.parquet", -//! 50, // epochs per trial -//! )?; -//! -//! // Run optimization -//! let optimizer = EgoboxOptimizer::with_trials(30, 5); -//! let result = optimizer.optimize(trainer)?; -//! -//! println!("Best learning rate: {}", result.best_params.learning_rate); -//! println!("Best batch size: {}", result.best_params.batch_size); -//! println!("Best validation loss: {:.6}", result.best_objective); -//! # Ok(()) -//! # } -//! ``` - -use anyhow::{Context, Result}; -use candle_core::{Device, Tensor}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use tracing::{info, warn}; - -use arrow::array::{Array, Float64Array, PrimitiveArray, UInt64Array}; -use arrow::datatypes::TimestampNanosecondType; -use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; -use std::fs::File; - -use crate::features::{extract_ml_features, FeatureConfig, OHLCVBar}; -use crate::hyperopt::traits::{HyperparameterOptimizable, ParameterSpace}; -use crate::mamba::{Mamba2Config, Mamba2SSM, OptimizerType}; -use crate::MLError; - -/// MAMBA-2 hyperparameter space -/// -/// Defines the hyperparameters to optimize for MAMBA-2 training: -/// - Learning rate (log-scale: 1e-5 to 1e-2) -/// - Batch size (linear scale: 16 to 256) -/// - Dropout rate (linear scale: 0.0 to 0.5) -/// - Weight decay (log-scale: 1e-6 to 1e-2) -/// - Gradient clipping (log-scale: 0.5 to 5.0) -/// - Warmup steps (linear scale: 100 to 2000) -/// - Adam beta1 (linear scale: 0.85 to 0.95) -/// - Gradient clipping (log-scale: 0.5 to 5.0) -/// - Warmup steps (linear scale: 100 to 2000) -/// - Adam beta1 (linear scale: 0.85 to 0.95) -/// -/// ## Parameter Scaling -/// -/// - **Log-scale**: Learning rate, weight decay (span multiple orders of magnitude) -/// - **Linear scale**: Batch size, dropout (span single order of magnitude) -/// -/// This scaling ensures efficient exploration by egobox's Gaussian Process. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Mamba2Params { - /// Learning rate for Adam optimizer (log-scale) - pub learning_rate: f64, - /// Batch size for training (linear scale, integer) - pub batch_size: usize, - /// Dropout rate for regularization (linear scale) - pub dropout: f64, - /// Weight decay for L2 regularization (log-scale) - pub weight_decay: f64, - /// P0: Gradient clipping threshold (log-scale) - pub grad_clip: f64, - /// P0: Warmup steps (linear scale, integer) - pub warmup_steps: usize, - /// P0: Adam beta1 parameter (linear scale) - pub adam_beta1: f64, - /// P1: Adam beta2 parameter (linear scale) - pub adam_beta2: f64, - /// P1: Adam epsilon (log-scale) - pub adam_epsilon: f64, - /// P1: Total decay steps for cosine schedule (linear scale, integer) - pub total_decay_steps: usize, - /// P2: Lookback window (sequence length) (linear scale, integer) - pub lookback_window: usize, - /// P2: Sequence stride for overlapping windows (linear scale, integer) - pub sequence_stride: usize, - /// P2: Normalization epsilon for layer norm (log-scale) - pub norm_eps: f64, -} - -impl Default for Mamba2Params { - fn default() -> Self { - Self { - learning_rate: 1e-4, - batch_size: 32, - dropout: 0.1, - weight_decay: 1e-4, - grad_clip: 1.0, - warmup_steps: 1000, - adam_beta1: 0.9, - grad_clip: 1.0, - warmup_steps: 1000, - adam_beta1: 0.9, - grad_clip: 1.0, - warmup_steps: 100, - adam_beta1: 0.9, - adam_beta2: 0.999, - adam_epsilon: 1e-8, - total_decay_steps: 10000, - lookback_window: 60, - sequence_stride: 1, - norm_eps: 1e-5, - } - } -} - -impl ParameterSpace for Mamba2Params { - fn continuous_bounds() -> Vec<(f64, f64)> { - vec![ - (1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log scale) - (16.0, 256.0), // batch_size (linear) - (0.0, 0.5), // dropout (linear) - (1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log scale) - (0.5_f64.ln(), 5.0_f64.ln()), // grad_clip (log scale) - (100.0, 2000.0), // warmup_steps (linear) - (0.85, 0.95), // adam_beta1 (linear) - ] - } - - fn from_continuous(x: &[f64]) -> Result { - if x.len() != 7 { - return Err(MLError::ConfigError { - reason: format!("Expected 10 parameters, got {}", x.len()) - }); - } - - Ok(Self { - learning_rate: x[0].exp(), - batch_size: x[1].round().max(1.0) as usize, // Ensure at least 1 - dropout: x[2].clamp(0.0, 0.5), - weight_decay: x[3].exp(), - grad_clip: x[4].exp(), - warmup_steps: x[5].round().max(1.0) as usize, // Ensure at least 1 - adam_beta1: x[6].clamp(0.85, 0.95), - }) - } - - fn to_continuous(&self) -> Vec { - vec![ - self.learning_rate.ln(), - self.batch_size as f64, - self.dropout, - self.weight_decay.ln(), - self.grad_clip.ln(), - self.warmup_steps as f64, - self.adam_beta1, - self.adam_beta2, - self.adam_epsilon.ln(), - self.total_decay_steps as f64, - ] - } - - fn param_names() -> Vec<&'static str> { - vec![ - "learning_rate", "batch_size", "dropout", "weight_decay", - "grad_clip", "warmup_steps", "adam_beta1", - "adam_beta2", "adam_epsilon", "total_decay_steps" - ] - } -} - -/// MAMBA-2 training metrics -/// -/// Contains all relevant metrics from a MAMBA-2 training run. -/// The primary optimization target is validation loss. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Mamba2Metrics { - /// Final validation loss (optimization target) - pub val_loss: f64, - /// Final training loss - pub train_loss: f64, - /// Validation perplexity (exp(val_loss)) - pub val_perplexity: f64, - /// Number of epochs completed - pub epochs_completed: usize, -} - -/// MAMBA-2 trainer for hyperparameter optimization -/// -/// This struct wraps the MAMBA-2 training pipeline and implements -/// `HyperparameterOptimizable` for use with `EgoboxOptimizer`. -/// -/// ## Configuration -/// -/// - **Parquet file**: Market data source (OHLCV bars) -/// - **Epochs**: Number of training epochs per trial -/// - **Device**: CUDA GPU (falls back to CPU if unavailable) -/// - **Features**: Wave D configuration (225 features) -/// -/// ## Fixed Architecture -/// -/// The following parameters are fixed for consistency: -/// - `d_model`: 225 (Wave D feature count) -/// - `d_state`: 16 -/// - `num_layers`: 6 -/// - `sequence_length`: 60 -/// -/// ## Optimized Hyperparameters -/// -/// The following are optimized by `Mamba2Params`: -/// - Learning rate -/// - Batch size -/// - Dropout -/// - Weight decay -pub struct Mamba2Trainer { - parquet_file: PathBuf, - epochs: usize, - device: Device, - feature_config: FeatureConfig, - d_model: usize, - train_split: f64, -} - -impl Mamba2Trainer { - /// Create a new MAMBA-2 trainer - /// - /// # Arguments - /// - /// * `parquet_file` - Path to Parquet file with market data - /// * `epochs` - Number of training epochs per trial - /// - /// # Returns - /// - /// Configured trainer ready for optimization - /// - /// # Errors - /// - /// Returns error if: - /// - Parquet file doesn't exist - /// - CUDA device initialization fails (falls back to CPU) - pub fn new(parquet_file: impl Into, epochs: usize) -> Result { - let parquet_file = parquet_file.into(); - - if !parquet_file.exists() { - return Err(MLError::ConfigError { - reason: format!("Parquet file not found: {}", parquet_file.display()) - } - .into()); - } - - // Initialize device (CUDA preferred, CPU fallback) - let device = Device::new_cuda(0).unwrap_or_else(|e| { - warn!("CUDA unavailable ({}), falling back to CPU", e); - Device::Cpu - }); - - // Use Wave D feature configuration - let feature_config = FeatureConfig::wave_d(); - let d_model = feature_config.feature_count(); - - info!("MAMBA-2 Trainer initialized:"); - info!(" Device: {:?}", device); - info!(" Features: {} (Wave D)", d_model); - info!(" Epochs per trial: {}", epochs); - - Ok(Self { - parquet_file, - epochs, - device, - feature_config, - d_model, - train_split: 0.8, - }) - } - - /// Set train/validation split ratio - pub fn with_train_split(mut self, split: f64) -> Self { - assert!(split > 0.0 && split < 1.0, "Split must be in (0, 1)"); - self.train_split = split; - self - } - - /// Load and prepare training data from Parquet - /// - /// Reads OHLCV bars, extracts features, creates sequences. - fn load_and_prepare_data( - &self, - seq_len: usize, - ) -> Result<(Vec<(Tensor, Tensor)>, Vec<(Tensor, Tensor)>)> { - // Open Parquet file - let file = File::open(&self.parquet_file).with_context(|| { - format!("Failed to open Parquet file: {}", self.parquet_file.display()) - })?; - - let builder = ParquetRecordBatchReaderBuilder::try_new(file) - .context("Failed to create Parquet reader")?; - - let reader = builder.build().context("Failed to build Parquet reader")?; - - // Read all OHLCV bars - let mut all_ohlcv_bars = Vec::new(); - - for batch_result in reader { - let batch = batch_result.context("Failed to read record batch")?; - - let timestamps = batch - .column(9) - .as_any() - .downcast_ref::>() - .context("Failed to downcast timestamp column")?; - - let opens = batch - .column(3) - .as_any() - .downcast_ref::() - .context("Failed to downcast open column")?; - - let highs = batch - .column(4) - .as_any() - .downcast_ref::() - .context("Failed to downcast high column")?; - - let lows = batch - .column(5) - .as_any() - .downcast_ref::() - .context("Failed to downcast low column")?; - - let closes = batch - .column(6) - .as_any() - .downcast_ref::() - .context("Failed to downcast close column")?; - - let volumes = batch - .column(7) - .as_any() - .downcast_ref::() - .context("Failed to downcast volume column")?; - - for i in 0..batch.num_rows() { - let timestamp_ns = timestamps.value(i); - let timestamp = chrono::DateTime::from_timestamp( - (timestamp_ns / 1_000_000_000) as i64, - (timestamp_ns % 1_000_000_000) as u32, - ) - .unwrap_or_else(|| chrono::Utc::now()); - - let bar = OHLCVBar { - timestamp, - open: opens.value(i), - high: highs.value(i), - low: lows.value(i), - close: closes.value(i), - volume: volumes.value(i) as f64, - }; - - all_ohlcv_bars.push(bar); - } - } - - // Extract features - let features = - extract_ml_features(&all_ohlcv_bars).context("Failed to extract features")?; - - if features.is_empty() { - return Err( - MLError::ModelError("No features extracted from Parquet data".to_string()).into(), - ); - } - - // Create sequences - let mut feature_sequences = Vec::new(); - - for window_idx in 0..features.len().saturating_sub(seq_len) { - let sequence: Vec = features[window_idx..window_idx + seq_len] - .iter() - .flat_map(|f| f.iter().copied()) - .collect(); - - let target_price = all_ohlcv_bars[window_idx + seq_len].close; - - let input_tensor = Tensor::new(sequence.as_slice(), &Device::Cpu)? - .reshape((1, seq_len, self.d_model))?; - let target_tensor = - Tensor::new(&[target_price], &Device::Cpu)?.reshape((1, 1, 1))?; - - feature_sequences.push((input_tensor, target_tensor)); - } - - // Split train/validation - let split_idx = (feature_sequences.len() as f64 * self.train_split) as usize; - let train_data = feature_sequences[..split_idx].to_vec(); - let val_data = feature_sequences[split_idx..].to_vec(); - - Ok((train_data, val_data)) - } -} - -impl HyperparameterOptimizable for Mamba2Trainer { - type Params = Mamba2Params; - type Metrics = Mamba2Metrics; - - fn train_with_params(&mut self, params: Self::Params) -> Result { - info!("Training MAMBA-2 with parameters:"); - info!(" Learning rate: {:.6}", params.learning_rate); - info!(" Batch size: {}", params.batch_size); - info!(" Dropout: {:.3}", params.dropout); - info!(" Weight decay: {:.6}", params.weight_decay); - info!(" Grad clip: {:.3}", params.grad_clip); - info!(" Warmup steps: {}", params.warmup_steps); - info!(" Adam beta1: {:.4}", params.adam_beta1); - info!(" Adam beta2: {:.4}", params.adam_beta2); - info!(" Adam epsilon: {:.2e}", params.adam_epsilon); - info!(" Total decay steps: {}", params.total_decay_steps); - - // Create MAMBA-2 config with trial hyperparameters - let mamba_config = Mamba2Config { - d_model: self.d_model, - d_state: 16, - d_head: self.d_model / 8, - num_heads: 8, - expand: 2, - num_layers: 6, - dropout: params.dropout, - use_ssd: true, - use_selective_state: true, - hardware_aware: true, - target_latency_us: 5, - max_seq_len: 120, - learning_rate: params.learning_rate, - weight_decay: params.weight_decay, - grad_clip: params.grad_clip, - warmup_steps: params.warmup_steps, - adam_beta1: params.adam_beta1, - adam_beta2: params.adam_beta2, - adam_epsilon: params.adam_epsilon, - total_decay_steps: params.total_decay_steps, - batch_size: params.batch_size, - seq_len: 60, - shuffle_batches: false, - optimizer_type: OptimizerType::Adam, - sgd_momentum: 0.9, - }; - - // Load and prepare data - let (train_data, val_data) = self - .load_and_prepare_data(mamba_config.seq_len) - .map_err(|e| MLError::ModelError(format!("Data loading failed: {}", e)))?; - - if train_data.is_empty() || val_data.is_empty() { - warn!("Empty training or validation data"); - return Ok(Mamba2Metrics { - val_loss: 1000.0, // Penalty - train_loss: 1000.0, - val_perplexity: f64::INFINITY, - epochs_completed: 0, - }); - } - - // Create and train model - let mut model = Mamba2SSM::new(mamba_config.clone(), &self.device) - .map_err(|e| MLError::ModelError(format!("Failed to create model: {}", e)))?; - - // Run training (synchronous) - let training_history = tokio::runtime::Runtime::new() - .unwrap() - .block_on(model.train(&train_data, &val_data, self.epochs)) - .map_err(|e| MLError::TrainingError(format!("Training failed: {}", e)))?; - - // Extract final metrics - let final_epoch = training_history - .last() - .ok_or_else(|| MLError::TrainingError("No training history".to_string()))?; - - let metrics = Mamba2Metrics { - val_loss: final_epoch.loss, - train_loss: final_epoch.loss, // Training loss would need separate tracking - val_perplexity: final_epoch.loss.exp(), - epochs_completed: training_history.len(), - }; - - info!("Training completed:"); - info!(" Validation loss: {:.6}", metrics.val_loss); - info!(" Perplexity: {:.4}", metrics.val_perplexity); - - Ok(metrics) - } - - fn extract_objective(metrics: &Self::Metrics) -> f64 { - metrics.val_loss - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_mamba2_params_roundtrip() { - let params = Mamba2Params { - learning_rate: 0.001, - batch_size: 64, - dropout: 0.2, - weight_decay: 0.0001, - }; - - let continuous = params.to_continuous(); - let recovered = Mamba2Params::from_continuous(&continuous).unwrap(); - - assert!((recovered.learning_rate - params.learning_rate).abs() < 1e-10); - assert_eq!(recovered.batch_size, params.batch_size); - assert!((recovered.dropout - params.dropout).abs() < 1e-10); - assert!((recovered.weight_decay - params.weight_decay).abs() < 1e-10); - } - - #[test] - fn test_mamba2_params_bounds() { - let bounds = Mamba2Params::continuous_bounds(); - assert_eq!(bounds.len(), 4); - - // Check log-scale bounds are reasonable - assert!(bounds[0].0 < bounds[0].1); // learning_rate - assert!(bounds[3].0 < bounds[3].1); // weight_decay - - // Check linear bounds - assert_eq!(bounds[1], (16.0, 256.0)); // batch_size - assert_eq!(bounds[2], (0.0, 0.5)); // dropout - } - - #[test] - fn test_param_names() { - let names = Mamba2Params::param_names(); - assert_eq!(names.len(), 4); - assert_eq!(names[0], "learning_rate"); - assert_eq!(names[1], "batch_size"); - assert_eq!(names[2], "dropout"); - assert_eq!(names[3], "weight_decay"); - } - - #[test] - fn test_p1_params_roundtrip() { - let params = Mamba2Params { - // Original 4 params - learning_rate: 1e-3, - batch_size: 64, - dropout: 0.2, - weight_decay: 1e-4, - // P0 params (Agent 1) - grad_clip: 2.5, - warmup_steps: 500, - adam_beta1: 0.9, - // P1 params (Agent 2) - adam_beta2: 0.995, - adam_epsilon: 5e-8, - total_decay_steps: 8000, - }; - - let continuous = params.to_continuous(); - let recovered = Mamba2Params::from_continuous(&continuous).unwrap(); - - // Test P1 params - assert!((recovered.adam_beta2 - params.adam_beta2).abs() < 1e-10); - assert!((recovered.adam_epsilon - params.adam_epsilon).abs() < 1e-12); - assert_eq!(recovered.total_decay_steps, params.total_decay_steps); - } - - #[test] - fn test_p1_bounds_validation() { - let bounds = Mamba2Params::continuous_bounds(); - assert_eq!(bounds.len(), 10); // Was 7 after P0, now 10 - - // adam_beta2: linear 0.98 to 0.999 - assert_eq!(bounds[7], (0.98, 0.999)); - - // adam_epsilon: log scale 1e-9 to 1e-7 - assert!((bounds[8].0 - 1e-9_f64.ln()).abs() < 1e-10); - assert!((bounds[8].1 - 1e-7_f64.ln()).abs() < 1e-10); - - // total_decay_steps: linear 5000 to 20000 - assert_eq!(bounds[9], (5000.0, 20000.0)); - } - - #[test] - fn test_param_names_p1() { - let names = Mamba2Params::param_names(); - assert_eq!(names.len(), 10); - assert_eq!(names[7], "adam_beta2"); - assert_eq!(names[8], "adam_epsilon"); - assert_eq!(names[9], "total_decay_steps"); - } - - #[test] - fn test_p1_log_scale_conversion() { - // Test adam_epsilon log-scale conversion - let params = Mamba2Params { - learning_rate: 1e-4, - batch_size: 32, - dropout: 0.1, - weight_decay: 1e-4, - grad_clip: 1.0, - warmup_steps: 100, - adam_beta1: 0.9, - adam_beta2: 0.999, - adam_epsilon: 1e-8, - total_decay_steps: 10000, - }; - - let continuous = params.to_continuous(); - // adam_epsilon should be stored in log space - assert!((continuous[8] - 1e-8_f64.ln()).abs() < 1e-10); - } - - #[test] - fn test_p0_params_roundtrip() { - let params = Mamba2Params { - learning_rate: 1e-3, - batch_size: 64, - dropout: 0.2, - weight_decay: 1e-4, - grad_clip: 2.5, - warmup_steps: 500, - adam_beta1: 0.9, - }; - - let continuous = params.to_continuous(); - let recovered = Mamba2Params::from_continuous(&continuous).unwrap(); - - assert!((recovered.grad_clip - params.grad_clip).abs() < 1e-6); - assert_eq!(recovered.warmup_steps, params.warmup_steps); - assert!((recovered.adam_beta1 - params.adam_beta1).abs() < 1e-10); - } - - #[test] - fn test_p0_bounds_validation() { - let bounds = Mamba2Params::continuous_bounds(); - assert_eq!(bounds.len(), 7); // Was 4, now 7 - - // grad_clip: log scale 0.5 to 5.0 - assert!((bounds[4].0 - 0.5_f64.ln()).abs() < 1e-10); - assert!((bounds[4].1 - 5.0_f64.ln()).abs() < 1e-10); - - // warmup_steps: linear 100 to 2000 - assert_eq!(bounds[5], (100.0, 2000.0)); - - // adam_beta1: linear 0.85 to 0.95 - assert_eq!(bounds[6], (0.85, 0.95)); - } - - #[test] - fn test_param_names_p0() { - let names = Mamba2Params::param_names(); - assert_eq!(names.len(), 7); - assert_eq!(names[4], "grad_clip"); - assert_eq!(names[5], "warmup_steps"); - assert_eq!(names[6], "adam_beta1"); - } - - #[test] - fn test_log_scale_grad_clip() { - // Verify grad_clip uses log scale like learning_rate - let params = Mamba2Params { grad_clip: 1.0, ..Default::default() }; - let continuous = params.to_continuous(); - // ln(1.0) = 0.0 - assert!((continuous[4] - 0.0).abs() < 1e-10); - } - - #[test] - fn test_p2_params_roundtrip() { - let params = Mamba2Params { - // Original 4 params - learning_rate: 1e-3, - batch_size: 64, - dropout: 0.2, - weight_decay: 1e-4, - // P0 params - grad_clip: 2.5, - warmup_steps: 500, - adam_beta1: 0.9, - // P1 params - adam_beta2: 0.995, - adam_epsilon: 5e-8, - total_decay_steps: 8000, - // P2 params (YOU) - lookback_window: 90, - sequence_stride: 3, - norm_eps: 5e-5, - }; - - let continuous = params.to_continuous(); - let recovered = Mamba2Params::from_continuous(&continuous).unwrap(); - - // Test P2 params - assert_eq!(recovered.lookback_window, params.lookback_window); - assert_eq!(recovered.sequence_stride, params.sequence_stride); - assert!((recovered.norm_eps - params.norm_eps).abs() < 1e-12); - } - - #[test] - fn test_p2_bounds_validation() { - let bounds = Mamba2Params::continuous_bounds(); - assert_eq!(bounds.len(), 13); // Was 10 after P0+P1, now 13 - - // lookback_window: linear 30 to 120 - assert_eq!(bounds[10], (30.0, 120.0)); - - // sequence_stride: linear 1 to 5 - assert_eq!(bounds[11], (1.0, 5.0)); - - // norm_eps: log scale 1e-6 to 1e-4 - assert!((bounds[12].0 - 1e-6_f64.ln()).abs() < 1e-10); - assert!((bounds[12].1 - 1e-4_f64.ln()).abs() < 1e-10); - } - - #[test] - fn test_param_names_p2() { - let names = Mamba2Params::param_names(); - assert_eq!(names.len(), 13); - assert_eq!(names[10], "lookback_window"); - assert_eq!(names[11], "sequence_stride"); - assert_eq!(names[12], "norm_eps"); - } - - #[test] - fn test_full_13_param_space() { - // Final integration test - all 13 params - let params = Mamba2Params::default(); - let continuous = params.to_continuous(); - assert_eq!(continuous.len(), 13); - - let bounds = Mamba2Params::continuous_bounds(); - assert_eq!(bounds.len(), 13); - - let names = Mamba2Params::param_names(); - assert_eq!(names.len(), 13); - } -} diff --git a/ml/src/trainers/dqn.rs.backup b/ml/src/trainers/dqn.rs.backup deleted file mode 100644 index d1e4d2fb5..000000000 --- a/ml/src/trainers/dqn.rs.backup +++ /dev/null @@ -1,4975 +0,0 @@ -//! DQN Trainer with gRPC Integration -//! -//! Production-ready DQN training pipeline that: -//! - Loads real market data from DBN files -//! - Trains on GPU (RTX 3050 Ti, 4GB VRAM) -//! - Saves checkpoints to MinIO every 10 epochs -//! - Returns comprehensive training metrics -//! - Validates batch sizes for GPU memory limits - -use std::collections::VecDeque; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use candle_core::{Device, Tensor}; -use common::CommonError; -use risk::drawdown_monitor::DrawdownMonitor; -use risk::safety::position_limiter::HybridPositionLimiter; -use risk::safety::PositionLimiterConfig; -use rust_decimal::Decimal; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -use crate::dqn::action_space::FactoredAction; -use crate::dqn::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig}; -use crate::dqn::dqn::{WorkingDQN, WorkingDQNConfig}; -use crate::dqn::portfolio_tracker::PortfolioTracker; -use crate::dqn::regime_conditional::{RegimeConditionalDQN, RegimeMetrics, RegimeType}; -use crate::dqn::reward::{RewardConfig, RewardFunction}; -use crate::dqn::target_update::convergence_half_life; // WAVE 16 (Agent 36) -use crate::dqn::{Experience, TradingState}; -use crate::evaluation::metrics::calculate_var_cvar; -use crate::features::extraction::OHLCVBar; -use crate::preprocessing::{preprocess_prices, PreprocessConfig}; -use crate::trainers::TargetUpdateMode; // WAVE 16 (Agent 36) -use crate::training_pipeline::FinancialFeatures; -use crate::TrainingMetrics; -use crate::features::microstructure_features::*; - -// WAVE 1.1: Triple Barrier Integration -use crate::labeling::triple_barrier::{TripleBarrierEngine, PricePoint}; -use crate::labeling::types::BarrierConfig; - - -// P1 FIX: Episode boundary constant for proper temporal segmentation -// 200 bars ≈ 3.3 hours of trading (1-minute bars) -// Ensures Bellman equation doesn't bootstrap across unrelated time periods -const EPISODE_LENGTH: usize = 200; - -// WAVE 3.10: Full feature vector (140 features - 125 market + 3 portfolio + 12 microstructure) -// Was 128 (125 market + 3 portfolio), now 140 (+ 12 microstructure) -type FeatureVector = [f64; 54]; // Full feature vector: 54 features (WAVE 1 - AGENT 2: Updated from 225) -type FeatureVector51 = [f64; 51]; // Type alias for clarity (same as FeatureVector) - -/// Feature normalization statistics using Welford's algorithm -/// -/// WAVE 3 - FIX #2: Z-score normalization for 51-feature architecture -/// -/// Problem: Unnormalized features causing Q-value explosion (±10,000 instead of ±375) -/// Solution: Welford's online algorithm for numerically stable mean/std computation + z-score normalization -/// -/// Mathematical Properties: -/// - Mean: μ = Σx_i / n -/// - Variance: σ² = Σ(x_i - μ)² / n -/// - Welford update: δ = x - μ_old, μ_new = μ_old + δ/n, M2_new = M2_old + δ*(x - μ_new) -/// - Variance from M2: σ² = M2 / n -/// -/// Numerical Stability: -/// - Welford's algorithm avoids catastrophic cancellation (σ² = E[X²] - E[X]² breaks for large values) -/// - Single pass through data (no need to store all samples) -/// - Handles large values (1e9+) without precision loss -/// -/// Expected Impact: +55-94% Sharpe improvement (most impactful P1 fix) -#[derive(Clone, Debug)] -pub struct FeatureStatistics { - /// Number of samples seen - pub count: usize, - /// Running mean for each feature (f64 for precision) - pub mean: Vec, - /// Sum of squared differences from mean (Welford's M2) - pub m2: Vec, -} - -impl FeatureStatistics { - /// Create new feature statistics tracker - pub fn new(num_features: usize) -> Self { - Self { - count: 0, - mean: vec![0.0; num_features], - m2: vec![0.0; num_features], - } - } - - /// Update statistics with new sample using Welford's algorithm - /// - /// Welford's online algorithm (single pass, numerically stable): - /// ``` - /// δ = x - mean - /// mean += δ / count - /// δ2 = x - mean (new mean!) - /// M2 += δ * δ2 - /// ``` - pub fn update(&mut self, features: &[f32]) { - self.count += 1; - for (i, &value) in features.iter().enumerate() { - let delta = value as f64 - self.mean[i]; - self.mean[i] += delta / self.count as f64; - let delta2 = value as f64 - self.mean[i]; - self.m2[i] += delta * delta2; - } - } - - /// Compute standard deviation from M2 - pub fn std_dev(&self) -> Vec { - self.m2 - .iter() - .map(|&m2| (m2 / self.count as f64).sqrt()) - .collect() - } - - /// Normalize features to z-scores: z = (x - μ) / σ - pub fn normalize(&self, features: &[f32]) -> Vec { - let std_dev = self.std_dev(); - features - .iter() - .enumerate() - .map(|(i, &value)| { - let std = std_dev[i]; - if std < 1e-8 { 0.0 } else { ((value as f64 - self.mean[i]) / std) as f32 } - }) - .collect() - } - - /// Normalize features with placeholder skipping - /// - /// Skips normalization for specified indices (e.g., portfolio placeholders at 125-127) - /// Placeholders remain 0.0 to avoid breaking downstream logic - pub fn normalize_with_skip(&self, features: &[f32], skip_indices: &[usize]) -> Vec { - let std_dev = self.std_dev(); - features - .iter() - .enumerate() - .map(|(i, &value)| { - // Skip normalization for placeholders - if skip_indices.contains(&i) { - value - } else { - let std = std_dev[i]; - if std < 1e-8 { 0.0 } else { ((value as f64 - self.mean[i]) / std) as f32 } - } - }) - .collect() - } -} - -/// Agent type enum supporting both standard and regime-conditional DQN -/// -/// Provides unified API for agent operations regardless of underlying architecture. -/// Allows switching between single-head (standard) and multi-head (regime-conditional) -/// Q-networks via configuration without code duplication. -pub enum DQNAgentType { - /// Standard single-head Q-network - Standard(WorkingDQN), - /// Regime-conditional multi-head Q-network (3 heads: Trending, Ranging, Volatile) - RegimeConditional(RegimeConditionalDQN), -} - -impl std::fmt::Debug for DQNAgentType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Standard(_) => f.debug_tuple("DQNAgentType::Standard").finish(), - Self::RegimeConditional(_) => f.debug_tuple("DQNAgentType::RegimeConditional").finish(), - } - } -} - -/// Q-Value statistics for adaptive C51 bounds -#[derive(Clone, Debug)] -pub struct QValueStats { - pub min: f64, - pub max: f64, - pub mean: f64, - pub std: f64, - pub sample_count: usize, -} - -impl QValueStats { - pub fn range(&self) -> f64 { - self.max - self.min - } -} - -impl DQNAgentType { - /// Select action using appropriate agent type - pub fn select_action(&mut self, state: &[f32]) -> Result { - match self { - Self::Standard(agent) => agent.select_action(state), - Self::RegimeConditional(agent) => agent.select_action(state), - } - } - - /// Store experience in replay buffer - pub fn store_experience(&self, experience: Experience) -> Result<(), crate::MLError> { - match self { - Self::Standard(agent) => { - agent.memory.add(experience)?; - Ok(()) - } - Self::RegimeConditional(agent) => agent.store_experience(experience), - } - } - - /// Training step - returns (loss, grad_norm) - pub fn train_step(&mut self, batch: Option>) -> Result<(f32, f32), crate::MLError> { - match self { - Self::Standard(agent) => agent.train_step(batch), - Self::RegimeConditional(agent) => agent.train_step(batch), - } - } - - /// Update epsilon for exploration decay - pub fn update_epsilon(&mut self) { - match self { - Self::Standard(agent) => agent.update_epsilon(), - Self::RegimeConditional(agent) => { - // Update epsilon for all regime heads - agent.update_epsilon(RegimeType::Trending); - agent.update_epsilon(RegimeType::Ranging); - agent.update_epsilon(RegimeType::Volatile); - } - } - } - - /// Get current epsilon value - pub fn get_epsilon(&self) -> f32 { - match self { - Self::Standard(agent) => agent.get_epsilon(), - Self::RegimeConditional(agent) => { - // Return trending head epsilon as representative value - agent.get_epsilon(RegimeType::Trending) - } - } - } - - /// Set epsilon value for exploration - pub fn set_epsilon(&mut self, epsilon: f64) { - match self { - Self::Standard(agent) => agent.set_epsilon(epsilon), - Self::RegimeConditional(_agent) => { - // Regime-conditional doesn't support direct epsilon setting - // Epsilon is managed per-regime head via update_epsilon() - } - } - } - - /// Save checkpoint to disk - pub fn save_checkpoint(&self, path: &str) -> Result<(), crate::MLError> { - match self { - Self::Standard(agent) => { - agent.get_q_network_vars().save(path).map_err(|e| { - crate::MLError::CheckpointError(format!("Failed to save checkpoint: {}", e)) - })?; - Ok(()) - } - Self::RegimeConditional(agent) => agent.save_checkpoint(path), - } - } - - /// Get regime-specific metrics (None for standard agent) - pub fn get_regime_metrics(&self) -> Option<&std::collections::HashMap> { - match self { - Self::Standard(_) => None, - Self::RegimeConditional(agent) => Some(agent.get_regime_metrics()), - } - } - - /// Get replay buffer size - pub fn get_replay_buffer_size(&self) -> Result { - match self { - Self::Standard(agent) => agent.get_replay_buffer_size(), - Self::RegimeConditional(agent) => agent.get_replay_buffer_size(), - } - } - - /// Forward pass through Q-network - pub fn forward(&self, state: &Tensor) -> Result { - match self { - Self::Standard(agent) => agent.forward(state), - Self::RegimeConditional(agent) => { - // For regime-conditional, we need to extract the regime from state - // Since forward() takes a Tensor, we'll use the trending head by default - // The proper regime routing happens in select_action() which has access to f32 slice - agent.forward(state, RegimeType::Trending) - } - } - } - - /// Track action for diversity monitoring (only for standard agent) - pub fn track_action(&mut self, action: FactoredAction) { - match self { - Self::Standard(agent) => agent.track_action(action), - Self::RegimeConditional(_) => { - // Regime-conditional agent doesn't use track_action - // Action tracking happens via regime metrics instead - } - } - } - - /// Check if agent can train (has enough replay buffer samples) - pub fn can_train(&self) -> bool { - match self { - Self::Standard(agent) => agent.can_train(), - Self::RegimeConditional(agent) => { - // Check if replay buffer has minimum samples - // Use 100 as min_replay_size (same as in regime_conditional.rs train_step) - if let Ok(buffer) = agent.get_replay_buffer_size() { - buffer >= 100 - } else { - false - } - } - } - } - - /// Get reference to replay buffer memory - /// - /// For RegimeConditionalDQN, returns the trending head's buffer as representative sample. - pub fn memory(&self) -> &crate::dqn::replay_buffer_type::ReplayBufferType { - match self { - Self::Standard(agent) => &agent.memory, - Self::RegimeConditional(agent) => { - // Regime conditional DQN has separate buffers per head - // Return trending head's buffer as representative sample for Q-value monitoring - agent.get_trending_head_memory() - } - } - } - - /// WAVE 23 P0: Log diagnostics and check for gradient collapse (early stopping) - /// Returns Err if gradient collapse detected for consecutive epochs - pub fn log_diagnostics(&mut self, grad_norm: f32) -> Result<(), crate::MLError> { - match self { - Self::Standard(agent) => agent.log_diagnostics(grad_norm), - Self::RegimeConditional(_agent) => { - // Regime-conditional doesn't implement gradient collapse detection yet - // Skip for now (can add in future) - Ok(()) - } - } - } - - /// WAVE 23 P0: Log Q-values and check for Q-value divergence (early stopping) - /// Returns Err if Q-value divergence detected for consecutive checks - pub fn log_q_values(&mut self, states_tensor: &Tensor) -> Result<(), crate::MLError> { - match self { - Self::Standard(agent) => agent.log_q_values(states_tensor), - Self::RegimeConditional(_agent) => { - // Regime-conditional doesn't implement Q-value divergence detection yet - // Skip for now (can add in future) - Ok(()) - } - } - } - - /// Get device (CPU or CUDA) - pub fn device(&self) -> &Device { - match self { - Self::Standard(agent) => agent.device(), - Self::RegimeConditional(agent) => agent.get_device(), - } - } - - /// Get Q-network variables for checkpoint saving - pub fn get_q_network_vars(&self) -> candle_nn::VarMap { - match self { - Self::Standard(agent) => agent.get_q_network_vars().clone(), - Self::RegimeConditional(agent) => { - // For regime-conditional, return trending head vars as representative - agent.get_trending_head().unwrap().get_q_network_vars().clone() - } - } - } - - /// Get mutable reference to underlying standard agent (for methods not in unified API) - pub fn as_standard_mut(&mut self) -> Option<&mut WorkingDQN> { - match self { - Self::Standard(agent) => Some(agent), - Self::RegimeConditional(_) => None, - } - } - - /// Get state dimension from agent configuration - /// - /// WAVE 10.4: Added to fix hardcoded STATE_DIM bug - /// Returns the actual state dimension (57 for production: 54 market + 3 portfolio) - pub fn get_state_dim(&self) -> usize { - match self { - Self::Standard(agent) => agent.get_state_dim(), - Self::RegimeConditional(agent) => agent.get_state_dim(), - } - } - - /// BUG #38 FIX: Clear replay buffer(s) - pub fn clear_replay_buffer(&mut self) -> Result<(), crate::MLError> { - match self { - Self::Standard(agent) => agent.clear_replay_buffer(), - Self::RegimeConditional(agent) => agent.clear_replay_buffer(), - } - } - - /// BUG #38 FIX: Reset target network(s) to match main Q-network(s) - pub fn reset_target_network(&mut self) -> Result<(), crate::MLError> { - match self { - Self::Standard(agent) => agent.reset_target_network(), - Self::RegimeConditional(agent) => agent.reset_target_network(), - } - } -} - -/// DQN training hyperparameters from gRPC request -#[derive(Debug, Clone)] -pub struct DQNHyperparameters { - /// Learning rate (typically 1e-4 to 1e-3) - pub learning_rate: f64, - /// Batch size (must be ≤230 for RTX 3050 Ti 4GB) - pub batch_size: usize, - /// Discount factor (typically 0.95-0.99) - pub gamma: f64, - /// Initial exploration rate - pub epsilon_start: f64, - /// Final exploration rate - pub epsilon_end: f64, - /// Exploration decay rate - pub epsilon_decay: f64, - /// Replay buffer capacity - pub buffer_size: usize, - /// Minimum replay buffer size before training starts - pub min_replay_size: usize, - /// Number of training epochs - pub epochs: usize, - /// Checkpoint save frequency (epochs) - pub checkpoint_frequency: usize, - /// Enable early stopping based on convergence criteria - pub early_stopping_enabled: bool, - /// Minimum Q-value threshold before stopping (default: 0.5) - pub q_value_floor: f64, - /// Minimum loss improvement percentage over window (default: 2.0%) - pub min_loss_improvement_pct: f64, - /// Window size for plateau detection (default: 30 epochs) - pub plateau_window: usize, - /// Minimum epochs before early stopping can trigger (default: 50) - pub min_epochs_before_stopping: usize, - /// Small negative penalty encourages action diversity (Bug #3 fix) - pub hold_penalty: f64, - /// Use Huber loss instead of MSE (more robust to outliers) - pub use_huber_loss: bool, - /// Huber loss delta threshold (default: 1.0) - pub huber_delta: f64, - /// Use Double DQN to reduce overestimation bias - pub use_double_dqn: bool, - /// Gradient clipping max norm (None = disabled) - pub gradient_clip_norm: Option, - /// HOLD action penalty weight (penalizes holding during large price movements) - pub hold_penalty_weight: f64, - /// Price movement threshold for HOLD penalty (as fraction, e.g., 0.02 = 2%) - pub movement_threshold: f64, - /// Enable preprocessing (log returns + normalization + outlier clipping) - pub enable_preprocessing: bool, - /// Preprocessing window size (default: 50) - pub preprocessing_window: i64, - /// Preprocessing clip sigma (default: 5.0) - pub preprocessing_clip_sigma: f64, - - // WAVE 16 (Agent 36): Target update configuration - /// Polyak averaging coefficient for soft target updates (default: 0.001) - /// Rainbow DQN standard: τ=0.001 gives 693-step convergence half-life - pub tau: f64, - /// Target update mode: Soft (Polyak averaging) or Hard (periodic full copy) - pub target_update_mode: crate::trainers::TargetUpdateMode, - /// Target network hard update frequency in training steps (default: 10000) - /// Used when target_update_mode = Hard. Rainbow DQN: 32K frames, Stable Baselines3: 10K steps - pub target_update_frequency: usize, - - // Rainbow DQN warmup period - /// Warmup steps for random exploration (Rainbow DQN standard: 80K for 50M+ steps) - /// For short training (<200K steps), warmup=0 is recommended. - /// Adaptive CLI defaults: 0 (<200K), 5% (200K-500K), 8% (500K-1M), 80K (>1M) - pub warmup_steps: usize, - - // P2-A Enhancement: Initial capital for portfolio - /// Initial capital for portfolio trading (default: $100,000) - /// Minimum: $1,000 (validated at CLI layer) - pub initial_capital: f32, - - // P2-B Enhancement: Cash reserve requirement - /// Cash reserve requirement as percentage of portfolio value (0-100) - /// Default: 0.0 (no reserve, backward compatible) - pub cash_reserve_percent: f64, - - // WAVE 16S: Adaptive Risk Management Features - /// Enable Kelly criterion position sizing - pub enable_kelly_sizing: bool, - /// Enable volatility-adjusted epsilon exploration - pub enable_volatility_epsilon: bool, - /// Enable risk-adjusted rewards (Sharpe ratio) - pub enable_risk_adjusted_rewards: bool, - /// Kelly fractional multiplier (0.5 = half-Kelly, conservative) - pub kelly_fractional: f64, - /// Maximum Kelly fraction (cap at 0.25 = 25% of portfolio) - pub kelly_max_fraction: f64, - /// Minimum trades required for Kelly calculation - pub kelly_min_trades: usize, - /// Volatility rolling window size - pub volatility_window: usize, - - // WAVE 35: Advanced Features - Regime-Conditional Q-Networks + Compliance Engine - /// Enable regime-conditional Q-network (3 heads: Trending, Ranging, Volatile) - pub enable_regime_qnetwork: bool, - /// Enable compliance engine (real-time regulatory validation) - pub enable_compliance: bool, - - // WAVE 16: Core Risk Management Features - /// Enable drawdown monitoring (15% max drawdown early stop) - pub enable_drawdown_monitoring: bool, - /// Enable position limits (3-tier: absolute ±10.0, notional $1M, concentration 10%) - pub enable_position_limits: bool, - /// Enable circuit breaker (5-failure trip mechanism) - pub enable_circuit_breaker: bool, - - // Wave 16 Portfolio Features - /// Enable action masking (filters invalid actions based on position limits) - pub enable_action_masking: bool, - /// Enable entropy regularization (prevents policy collapse) - pub enable_entropy_regularization: bool, - /// Enable stress testing (robustness validation) - pub enable_stress_testing: bool, - /// Maximum absolute position size for action masking (1.0-10.0 contracts) - /// Default: 2.0 (matches current production behavior) - pub max_position_absolute: f64, - - // WAVE 17: Hyperopt-tuned parameters - /// Entropy regularization coefficient (optional) - pub entropy_coefficient: Option, - /// Transaction cost multiplier for reward calculation - pub transaction_cost_multiplier: f64, - - // Wave 3 (Phase 2): Triple Barrier Method - /// Enable triple barrier method for multi-step reward labeling - pub enable_triple_barrier: bool, - pub triple_barrier_profit_target_bps: u32, - pub triple_barrier_stop_loss_bps: u32, - pub triple_barrier_max_holding_seconds: u64, - - // P0: Prioritized Experience Replay - /// Enable Prioritized Experience Replay (PER) - pub use_per: bool, - pub per_alpha: f64, - pub per_beta_start: f64, - - // Wave 2.1: Dueling Networks - /// Enable Dueling DQN architecture (separate value/advantage streams) - pub use_dueling: bool, - /// Hidden dimension for dueling value/advantage streams - pub dueling_hidden_dim: usize, - - // Wave 2.2: Multi-Step Returns (N-step TD) - /// Number of steps for n-step returns (1-10, default: 3 for Rainbow DQN) - /// Recommended: 3-5 for balance between bias and variance - pub n_steps: usize, - - // Wave 2.3: Distributional RL (C51) - /// Enable distributional RL (C51 algorithm) - pub use_distributional: bool, - /// Number of atoms for value distribution (Rainbow DQN standard: 51) - pub num_atoms: usize, - /// Minimum value for distribution support - pub v_min: f64, - /// Maximum value for distribution support - pub v_max: f64, - - // Wave 2.4: Noisy Networks for Exploration - /// Enable Noisy Networks (replaces epsilon-greedy exploration) - /// CRITICAL: Mutually exclusive with epsilon decay (set epsilon to 0 when enabled) - pub use_noisy_nets: bool, - /// Initial noise std dev (Rainbow DQN standard: 0.5, scaled by 1/√in_features) - pub noisy_sigma_init: f64, - - // Two-Phase Feature Normalization Configuration - /// Ratio of total epochs to use for feature statistics collection (default: 0.3 = 30%) - /// Phase 1 collects statistics for this percentage of training - /// Example: 100 epochs * 0.3 = 30 epochs for stats collection - pub feature_stats_collection_ratio: f32, - /// Maximum number of epochs for stats collection (default: Some(10)) - /// Acts as a cap: min(epochs * ratio, max_epochs) - /// Example: 100 epochs → min(30, 10) = 10; 200 epochs → min(60, 10) = 10 - /// Set to None for no cap (pure percentage-based) - pub max_feature_stats_epochs: Option, - - // WAVE 23 P0: Early Stopping for Gradient Collapse - /// Adaptive gradient collapse threshold multiplier (default: 100.0) - /// Threshold = learning_rate × gradient_collapse_multiplier - /// WAVE 23: Replaces hardcoded 0.1 threshold with learning-rate aware detection - pub gradient_collapse_multiplier: f64, - /// Consecutive epoch patience before early stopping (default: 5) - /// Prevents false positives from single-epoch anomalies - pub gradient_collapse_patience: usize, -} - -// REMOVED: Default implementation removed to force explicit hyperparameter specification. -// Use best hyperparameters from hyperopt or specify explicitly in training config. - -impl DQNHyperparameters { - /// Create conservative hyperparameters suitable for testing and development. - /// WARNING: These are NOT optimized for production. Use hyperopt results instead. - /// After DQN hyperopt completes, update ml/hyperparams/dqn_best.toml with optimal values. - pub fn conservative() -> Self { - Self { - learning_rate: 0.0001, - batch_size: 128, - gamma: 0.99, - epsilon_start: 1.0, - epsilon_end: 0.01, - epsilon_decay: 0.995, - buffer_size: 100000, - min_replay_size: 1000, - epochs: 100, - checkpoint_frequency: 10, - early_stopping_enabled: true, - q_value_floor: -5.0, // Wave 3 fix: allow normal negative Q-values, catch explosions only - min_loss_improvement_pct: 2.0, - plateau_window: 30, - min_epochs_before_stopping: 50, - hold_penalty: -0.001, - use_huber_loss: true, // Default: Huber loss enabled (more robust) - huber_delta: 100.0, // BUG #12 FIX: Scale delta 100x for gradient explosion fix (was 1.0) - use_double_dqn: true, // Default: Double DQN enabled (prevents overestimation bias) - gradient_clip_norm: Some(10.0), // REVERTED: Back to 10.0 default (production standard) - hold_penalty_weight: 0.01, // Default: 1% penalty weight - movement_threshold: 0.02, // Default: 2% price movement threshold - enable_preprocessing: true, // Default: preprocessing enabled (Wave 14 Agent 32) - preprocessing_window: 50, // Default: 50-bar rolling window - preprocessing_clip_sigma: 5.0, // Default: clip at ±5σ - - // WAVE 16 (Agent 36): Target update defaults (SOFT UPDATES for gradient stability) - tau: 0.001, // Polyak averaging with 0.1% blend per step (prevents Q-value explosion) - target_update_mode: crate::trainers::TargetUpdateMode::Soft, // Soft updates (Rainbow DQN standard) - target_update_frequency: 500, // BUG #9 FIX: Hard update frequency: 500 steps (optimal Rainbow DQN, was 10K) - - // Rainbow DQN warmup - warmup_steps: 0, // Adaptive in CLI (0 for <200K, scaled 200K-1M, 80K for >1M) - - // P2-A Enhancement - initial_capital: 100_000.0, // $100K default - - // P2-B Enhancement - cash_reserve_percent: 0.0, // Default: no reserve (backward compatible) - - // WAVE 16S: Adaptive Risk Management - enable_kelly_sizing: true, // Default: Kelly position sizing enabled - enable_volatility_epsilon: true, // Default: volatility-adjusted exploration enabled - enable_risk_adjusted_rewards: true, // Default: Sharpe-based rewards enabled - kelly_fractional: 0.5, // Default: half-Kelly (conservative) - kelly_max_fraction: 0.25, // Default: max 25% of portfolio - kelly_min_trades: 20, // Default: 20 trades minimum for statistics - volatility_window: 20, // Default: 20-period rolling window - - // WAVE 35: Advanced Features (WAVE 16S: NOW ENABLED BY DEFAULT) - enable_regime_qnetwork: true, // Default: regime-conditional Q-networks enabled - enable_compliance: true, // Default: compliance engine enabled - - // WAVE 16: Core Risk Management (default: ALL ENABLED) - enable_drawdown_monitoring: true, // Default: drawdown monitoring enabled (15% max) - enable_position_limits: true, // Default: 3-tier position limits enabled - enable_circuit_breaker: true, // Default: circuit breaker enabled (5-failure trip) - - // Wave 16 Portfolio Features (default: ALL ENABLED) - enable_action_masking: true, // Default: action masking enabled - enable_entropy_regularization: true, // Default: entropy regularization enabled - enable_stress_testing: true, // Default: stress testing enabled - max_position_absolute: 2.0, // Default: ±2.0 position limit (matches production) - - // WAVE 17: Hyperopt-tuned parameters - entropy_coefficient: None, - transaction_cost_multiplier: 1.0, - - // Triple Barrier defaults - enable_triple_barrier: false, - triple_barrier_profit_target_bps: 100, - triple_barrier_stop_loss_bps: 50, - triple_barrier_max_holding_seconds: 3600, - - // P0: Prioritized Experience Replay (WAVE 6.4: ENABLED BY DEFAULT) - use_per: true, - per_alpha: 0.6, - per_beta_start: 0.4, - - // Wave 2.1: Dueling Networks (WAVE 6.4: ENABLED BY DEFAULT) - use_dueling: true, // Default: enabled (Rainbow DQN standard) - dueling_hidden_dim: 128, // Default: 128 hidden units - - // Wave 2.2: Multi-Step Returns (WAVE 6.4: ENABLED BY DEFAULT) - n_steps: 3, // Default: 3 (Rainbow DQN standard) - - // Wave 2.3: Distributional RL (WAVE 6.4: ENABLED BY DEFAULT) - use_distributional: true, // Default: enabled (C51 distributional RL) - num_atoms: 51, // Rainbow DQN standard: 51 atoms - v_min: -2.0, // BUG #5 FIX: Align with reward range ±2 (was -1000.0, 500x too large!) - v_max: 2.0, // BUG #5 FIX: Align with reward range ±2 (was +1000.0, 500x too large!) - - // Wave 2.4: Noisy Networks (WAVE 6.4: ENABLED BY DEFAULT) - use_noisy_nets: true, // Default: enabled (replaces epsilon-greedy) - noisy_sigma_init: 0.5, // Rainbow DQN standard: 0.5 - - // Two-Phase Feature Normalization Configuration - feature_stats_collection_ratio: 0.3, // Default: 30% of epochs for stats collection - max_feature_stats_epochs: Some(10), // Default: cap at 10 epochs - - // WAVE 23 P0: Early Stopping for Gradient Collapse - gradient_collapse_multiplier: 100.0, // Adaptive threshold (LR × 100) - gradient_collapse_patience: 5, // 5 consecutive epochs before early stop - } - } -} - -/// Training monitor to prevent constant-reward bugs -#[derive(Debug, Clone)] -struct TrainingMonitor { - epoch: usize, - reward_history: Vec, - action_counts: [usize; 45], // 5 exposure × 3 order × 3 urgency (FactoredAction) - q_value_sums: [f64; 45], // Sum of Q-values per action - q_value_counts: [usize; 45], // Count of Q-values per action - consecutive_constant_epochs: usize, - // Q-value range tracking (WAVE 9-11 production monitoring) - q_value_min: f64, - q_value_max: f64, - q_value_history: Vec, // Per-step Q-values for mean calculation - - // WAVE P2: Episode length tracking - episode_lengths: Vec, - episode_start_step: usize, - barrier_exit_counts: [usize; 4], // [profit, stop, time, boundary] -} - -impl TrainingMonitor { - fn new(epoch: usize) -> Self { - Self { - epoch, - reward_history: Vec::new(), - action_counts: [0; 45], - q_value_sums: [0.0; 45], - q_value_counts: [0; 45], - consecutive_constant_epochs: 0, - q_value_min: f64::INFINITY, - q_value_max: f64::NEG_INFINITY, - q_value_history: Vec::new(), - - // WAVE P2: Episode tracking - episode_lengths: Vec::new(), - episode_start_step: 0, - barrier_exit_counts: [0; 4], // [profit=0, stop=1, time=2, boundary=3] - } - } - - /// Add reward to tracking (with bounded history) - fn track_reward(&mut self, reward: f32) { - self.reward_history.push(reward); - // MEMORY LEAK FIX: Limit reward history to last 1000 entries per epoch - // Each trial has ~10-50 epochs, so this limits to ~10-50K entries total - // vs unbounded growth causing OOM at 10-30 trials - if self.reward_history.len() > 1000 { - self.reward_history.drain(0..500); // Remove oldest 500, keep newest 500 - } - } - - /// Add action to tracking - fn track_action(&mut self, action: &FactoredAction) { - let idx = action.to_index() as usize; // Returns 0-44 - self.action_counts[idx] += 1; - } - - /// Add Q-value to tracking - fn track_q_value(&mut self, action: &FactoredAction, q_value: f64) { - let idx = action.to_index() as usize; // Returns 0-44 - self.q_value_sums[idx] += q_value; - self.q_value_counts[idx] += 1; - } - - /// Track Q-value range for monitoring (WAVE 9-11 production) - fn track_q_value_range(&mut self, q_value: f64) { - if q_value < self.q_value_min { - self.q_value_min = q_value; - } - if q_value > self.q_value_max { - self.q_value_max = q_value; - } - self.q_value_history.push(q_value); - // MEMORY LEAK FIX: Limit Q-value history to last 1000 entries per epoch - if self.q_value_history.len() > 1000 { - self.q_value_history.drain(0..500); // Remove oldest 500, keep newest 500 - } - } - - /// Get Q-value statistics (min, max, mean) - fn get_q_value_stats(&self) -> (f64, f64, f64) { - if self.q_value_history.is_empty() { - return (0.0, 0.0, 0.0); - } - let mean = self.q_value_history.iter().sum::() / self.q_value_history.len() as f64; - (self.q_value_min, self.q_value_max, mean) - } - - /// Validate rewards are not constant - fn validate_rewards(&mut self) -> Result<()> { - if self.reward_history.is_empty() { - return Ok(()); - } - - let mean = self.reward_history.iter().sum::() / self.reward_history.len() as f32; - let variance = self - .reward_history - .iter() - .map(|r| (r - mean).powi(2)) - .sum::() - / self.reward_history.len() as f32; - let std = variance.sqrt(); - - // Check if all rewards are identical (std == 0) or nearly constant (std < 0.01) - if std < 0.01 { - self.consecutive_constant_epochs += 1; - - warn!( - "⚠️ CONSTANT REWARDS DETECTED at epoch {}! std={:.6}, mean={:.4}, consecutive_epochs={}", - self.epoch, std, mean, self.consecutive_constant_epochs - ); - - // Panic if constant for 5+ consecutive epochs (critical bug) - if self.consecutive_constant_epochs >= 5 { - return Err(anyhow::anyhow!( - "❌ CRITICAL: Constant rewards for {} consecutive epochs! std={:.6}, mean={:.4}\n\ - This indicates a reward calculation bug. Training aborted.", - self.consecutive_constant_epochs, std, mean - )); - } - } else { - // Reset counter if variance is healthy - self.consecutive_constant_epochs = 0; - } - - Ok(()) - } - - /// Validate action diversity - fn validate_action_diversity(&self) -> Result<()> { - let total_actions: usize = self.action_counts.iter().sum(); - - if total_actions == 0 { - return Ok(()); // No actions yet, skip validation - } - - // Check if any action is below diversity threshold - // Uniform distribution for 45 actions = 100/45 = 2.22% - // During exploration (ε=0.3): Expected ~0.7% per action - // Warn if action < 0.5% (truly neglected actions only) - for (i, &count) in self.action_counts.iter().enumerate() { - let percentage = (count as f64 / total_actions as f64) * 100.0; - - // Convert index to FactoredAction for proper display - if let Ok(action) = FactoredAction::from_index(i) { - let action_str = format!("{:?}", action); - - if percentage < 0.5 { - warn!( - "⚠️ LOW ACTION DIVERSITY at epoch {}: {} only {:.1}% ({}/{})", - self.epoch, action_str, percentage, count, total_actions - ); - } - } - } - - Ok(()) - } - - /// Validate Q-value balance across actions - fn validate_q_value_balance(&self) -> Result<()> { - // Calculate average Q-value per action - let mut avg_q_values = [0.0f64; 3]; - for i in 0..3 { - if self.q_value_counts[i] > 0 { - avg_q_values[i] = self.q_value_sums[i] / self.q_value_counts[i] as f64; - } - } - - // Check if BUY Q-values diverge > 1000 from SELL/HOLD - let buy_q = avg_q_values[0]; - let sell_q = avg_q_values[1]; - let hold_q = avg_q_values[2]; - - if (buy_q - sell_q).abs() > 1000.0 || (buy_q - hold_q).abs() > 1000.0 { - warn!( - "⚠️ Q-VALUE DIVERGENCE at epoch {}: BUY={:.2}, SELL={:.2}, HOLD={:.2}", - self.epoch, buy_q, sell_q, hold_q - ); - } - - Ok(()) - } - - /// Log action distribution every 10 epochs - fn log_action_distribution(&self) { - if self.epoch % 10 == 0 { - let total_actions: usize = self.action_counts.iter().sum(); - if total_actions > 0 { - // Sort action_counts by frequency (descending) - let mut sorted_actions: Vec<(usize, usize)> = self - .action_counts - .iter() - .enumerate() - .map(|(idx, &count)| (idx, count)) - .collect(); - sorted_actions.sort_by(|a, b| b.1.cmp(&a.1)); - - // Log top 5 most frequent actions (DEBUG level) - debug!( - "Action Distribution [Epoch {}] - Top 5 Actions:", - self.epoch - ); - for (idx, count) in sorted_actions.iter().take(5) { - if *count > 0 { - if let Ok(action) = FactoredAction::from_index(*idx) { - let pct = (*count as f64 / total_actions as f64) * 100.0; - debug!(" [{:2}] {:?}: {} ({:.1}%)", idx, action, count, pct); - } - } - } - - // Log average Q-values per action (top 5) (DEBUG level) - let mut avg_q = [0.0f64; 45]; - for i in 0..45 { - if self.q_value_counts[i] > 0 { - avg_q[i] = self.q_value_sums[i] / self.q_value_counts[i] as f64; - } - } - - debug!("Average Q-values [Epoch {}] - Top 5 Actions:", self.epoch); - for (idx, _count) in sorted_actions.iter().take(5) { - if self.q_value_counts[*idx] > 0 { - if let Ok(action) = FactoredAction::from_index(*idx) { - debug!(" [{:2}] {:?}: Q={:.4}", idx, action, avg_q[*idx]); - } - } - } - } - } - } - - /// Run all validations - fn validate_all(&mut self) -> Result<()> { - self.validate_rewards()?; - self.validate_action_diversity()?; - self.validate_q_value_balance()?; - self.log_action_distribution(); - Ok(()) - } - - // WAVE P2: Episode tracking methods - - /// Track episode end and record length/exit reason - fn track_episode_end(&mut self, current_step: usize, barrier_label: Option) { - let episode_length = current_step - self.episode_start_step; - self.episode_lengths.push(episode_length); - - // Count exit reason - match barrier_label { - Some(1) => self.barrier_exit_counts[0] += 1, // Profit target - Some(-1) => self.barrier_exit_counts[1] += 1, // Stop loss - Some(0) => self.barrier_exit_counts[2] += 1, // Time expiry - _ => self.barrier_exit_counts[3] += 1, // Data/time boundary - } - - // Reset for next episode - self.episode_start_step = current_step + 1; - } - - /// Get episode statistics - fn get_episode_stats(&self) -> (f64, f64, usize, usize, [usize; 4]) { - if self.episode_lengths.is_empty() { - return (0.0, 0.0, 0, 0, [0; 4]); - } - - let total = self.episode_lengths.len(); - let mean = self.episode_lengths.iter().sum::() as f64 / total as f64; - let min = *self.episode_lengths.iter().min().unwrap(); - let max = *self.episode_lengths.iter().max().unwrap(); - - // Calculate std dev - let variance = self.episode_lengths.iter() - .map(|&len| { - let diff = len as f64 - mean; - diff * diff - }) - .sum::() / total as f64; - let std_dev = variance.sqrt(); - - (mean, std_dev, min, max, self.barrier_exit_counts) - } -} - -/// DQN Trainer with gRPC integration -pub struct DQNTrainer { - /// DQN agent - agent: Arc>, - /// Training hyperparameters - hyperparams: DQNHyperparameters, - /// Device (GPU or CPU) - device: Device, - /// Training metrics - metrics: Arc>, - /// Loss history for plateau detection - loss_history: Vec, - /// Q-value history for floor detection - q_value_history: Vec, - /// Best validation loss achieved so far - best_val_loss: f64, - /// Validation data for computing validation loss - val_data: Vec<(FeatureVector51, Vec)>, - /// Validation loss history for early stopping - val_loss_history: Vec, - /// Epoch with best validation loss - best_epoch: usize, - /// Step counter for gradient logging (logs every 10 steps) - gradient_logging_step: usize, - /// Portfolio state tracker for P&L-based rewards (Bug #2 fix) - pub portfolio_tracker: PortfolioTracker, - /// Feature normalization statistics (WAVE 3 FIX #2) - /// None during stats collection phase (epochs 0-10), Some during normalization phase (epochs 11+) - pub feature_stats: Option, - /// Sliding window of recent actions for reward calculation (max 100) - recent_actions: VecDeque, - /// Reward function for calculating rewards with recent actions - reward_fn: RewardFunction, - - // WAVE 16S: Adaptive Risk Management Components - /// Kelly criterion optimizer for position sizing (None if disabled) - kelly_optimizer: Option>, - /// Trade history for Kelly calculation (wins/losses) - trade_history: VecDeque, - /// Volatility tracker for epsilon adjustment (None if disabled) - volatility_returns: VecDeque, - /// PnL history for Sharpe calculation (max 1000 entries) - pnl_history: VecDeque, - - // Wave 16 Portfolio Features - /// Enable action masking (filters invalid actions before Q-value computation) - pub enable_action_masking: bool, - /// Maximum position size for action masking (default: 2.0) - pub max_position: f64, - /// Entropy regularizer for preventing policy collapse (None if disabled) - pub entropy_regularizer: Option>, - /// Multi-asset portfolio tracker (None if single-asset mode) - pub multi_asset_portfolio: Option>, - /// Stress tester for robustness validation (None if disabled) - pub stress_tester: Option>, - - // Wave 16 Core Risk Features Integration - /// Drawdown monitor for tracking portfolio drawdowns (15% max drawdown) - pub drawdown_monitor: Option>, - /// Position limiter with 3-tier limits (±10.0 absolute, 1M notional, 10% concentration) - pub position_limiter: Option>, - /// Circuit breaker for stopping training on consecutive failures - pub circuit_breaker: Option>, - - // WAVE 3.10: Microstructure Feature Calculators (12 features) - micro_high_low_spread: HighLowSpread, - micro_vw_spread: VolumeWeightedSpread, - micro_tick_count: TickCount, - micro_inter_arrival: InterArrivalTime, - micro_buy_sell_imbalance: BuySellImbalance, - micro_kyle_lambda: KyleLambda, - micro_price_impact: PriceImpact, - micro_variance_ratio: VarianceRatio, - // Note: Roll Measure, Corwin-Schultz, Amihud, VPIN already exist in ml/src/microstructure/ - // We'll integrate those in the update logic - /// Track last timestamp for inter-arrival time calculation - last_timestamp_ns: u64, - /// Track last close price for microstructure calculations - last_close: f64, - - // WAVE 1.1: Triple Barrier Integration - /// Triple barrier engine for position exit labeling - triple_barrier: Arc>, - /// Active position tracker ID (None = no active position) - active_position_tracker: Option, - /// WAVE P3: Track previous simulated position for barrier tracking continuity - previous_simulated_position: f32, - - // WAVE 1.2: Safety Infrastructure Integration (8 Systems) - /// Loss history window for spike detection (size: 30) - safety_loss_history: VecDeque, - /// Loss plateau counter for anomaly detection - safety_loss_plateau_counter: usize, - /// Action counts for diversity monitoring (45 actions) - safety_action_counts: std::collections::HashMap, - /// Memory manager for GPU OOM risk monitoring - safety_memory_manager: Arc>, - /// Safety enforcement level (Strict/Normal/Permissive) - safety_level: crate::safety::SafetyLevel, - /// Step counter for periodic safety checks - safety_step_counter: usize, - - /// Optional path to feature cache directory for faster hyperopt - feature_cache_dir: Option, -} - -impl std::fmt::Debug for DQNTrainer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DQNTrainer") - .field("hyperparams", &self.hyperparams) - .finish_non_exhaustive() - } -} - -impl DQNTrainer { - /// Create new DQN trainer with hyperparameters and debug logging disabled - pub fn new(hyperparams: DQNHyperparameters) -> Result { - Self::new_with_debug(hyperparams, false) - } - - /// Create new DQN trainer with hyperparameters and configurable debug logging - /// - /// # Arguments - /// * `hyperparams` - DQN training hyperparameters - /// * `debug_logging` - Enable debug logging (REWARD_DEBUG, gradient norms, etc.) - pub fn new_with_debug(hyperparams: DQNHyperparameters, debug_logging: bool) -> Result { - // Validate batch size is non-zero - if hyperparams.batch_size == 0 { - return Err(anyhow::anyhow!( - "Batch size must be greater than 0, got: {}", - hyperparams.batch_size - )); - } - - // Validate batch size for GPU memory (RTX 3050 Ti 4GB) - const MAX_BATCH_SIZE: usize = 230; - if hyperparams.batch_size > MAX_BATCH_SIZE { - warn!( - "Batch size {} exceeds GPU limit ({}), reducing to safe value", - hyperparams.batch_size, MAX_BATCH_SIZE - ); - return Err(anyhow::anyhow!( - "Batch size {} exceeds GPU memory limit (max: {}). Please reduce batch_size in hyperparameters.", - hyperparams.batch_size, - MAX_BATCH_SIZE - )); - } - - // Use GPU if available (RTX 3050 Ti) - let device = Device::cuda_if_available(0) - .map_err(|e| anyhow::anyhow!("Failed to initialize device: {}", e))?; - - info!( - "Initializing DQN trainer on device: {:?}, using {} actions (FactoredAction: 5×3×3 = 45)", - if device.is_cuda() { "CUDA GPU" } else { "CPU" }, - 45 // num_actions configured at line 413 - ); - - // Create DQN configuration - // 51-feature architecture: Technical indicators, time, statistical features (Proxy OFI removed in WAVE 10) - // Portfolio features are populated via PortfolioTracker (Bug #2 fix) - let config = WorkingDQNConfig { - state_dim: 54, // 54-feature vectors: 51 market features + 3 portfolio - num_actions: 45, // 5 exposure × 3 order × 3 urgency (FactoredAction) - hidden_dims: vec![256, 128, 64], // Larger 3-layer network (Wave 10-A1: 4x capacity to prevent gradient collapse) - learning_rate: hyperparams.learning_rate, - gamma: hyperparams.gamma as f32, - epsilon_start: hyperparams.epsilon_start as f32, - epsilon_end: hyperparams.epsilon_end as f32, - epsilon_decay: hyperparams.epsilon_decay as f32, - replay_buffer_capacity: hyperparams.buffer_size, - batch_size: hyperparams.batch_size, - min_replay_size: hyperparams.min_replay_size, // Configurable min replay size - target_update_freq: hyperparams.target_update_frequency, // Use hyperparameter instead of hardcoded 1000 - use_double_dqn: true, - use_huber_loss: hyperparams.use_huber_loss, - huber_delta: hyperparams.huber_delta as f32, - leaky_relu_alpha: 0.01, // Standard LeakyReLU alpha (prevents dead neurons) - gradient_clip_norm: hyperparams.gradient_clip_norm.unwrap_or(10.0), // Wave 11 Bug #1 fix: Dynamic clipping - - // WAVE 16 (Agent 36): Target update configuration - tau: hyperparams.tau, - use_soft_updates: matches!(hyperparams.target_update_mode, TargetUpdateMode::Soft), - - // Rainbow DQN warmup period - warmup_steps: hyperparams.warmup_steps, - - // PER configuration - initial_capital: hyperparams.initial_capital as f64, - use_per: hyperparams.use_per, - per_alpha: hyperparams.per_alpha, - per_beta_start: hyperparams.per_beta_start, - per_beta_max: 1.0, - per_beta_annealing_steps: hyperparams.epochs * 70, // ~70 steps/epoch estimate - - // Wave 2.1: Dueling Networks (ENABLED BY DEFAULT - Wave 6.4) - use_dueling: hyperparams.use_dueling, - dueling_hidden_dim: hyperparams.dueling_hidden_dim, - - // Wave 2.2: Multi-Step Returns (N-step TD) (ENABLED BY DEFAULT - Wave 6.4) - n_steps: hyperparams.n_steps, // Default: 3 (Rainbow DQN standard) - - // Wave 2.3: Distributional RL (C51) (ENABLED BY DEFAULT - Wave 6.4) - use_distributional: hyperparams.use_distributional, // Default: enabled (C51 distributional RL) - num_atoms: hyperparams.num_atoms, // Rainbow DQN standard: 51 atoms - v_min: hyperparams.v_min as f32, // Minimum value for distribution support - v_max: hyperparams.v_max as f32, // Maximum value for distribution support - - // Wave 2.4: Noisy Networks for Exploration (ENABLED BY DEFAULT - Wave 6.4) - use_noisy_nets: hyperparams.use_noisy_nets, // Default: enabled (replaces epsilon-greedy) - noisy_sigma_init: hyperparams.noisy_sigma_init, // Rainbow DQN standard: 0.5 - - // BUG #37 FIX: Q-value clipping (prevents step-level explosions) - enable_q_value_clipping: true, - q_value_clip_min: -500.0, - q_value_clip_max: 500.0, - - // WAVE 23 P0 Fix #1: Adaptive gradient collapse threshold (from hyperparams) - gradient_collapse_multiplier: hyperparams.gradient_collapse_multiplier, - gradient_collapse_patience: hyperparams.gradient_collapse_patience, - }; - - // Create DQN agent - let agent = if hyperparams.enable_regime_qnetwork { - info!("Creating regime-conditional DQN with 3 heads (Trending, Ranging, Volatile)"); - info!(" - Regime detection: ADX (index 211) + Entropy (index 219)"); - info!(" - Classification: Trending (ADX>25), Volatile (ADX≤25 & Entropy>0.7), Ranging (ADX≤25 & Entropy≤0.7)"); - let regime_agent = RegimeConditionalDQN::new(config) - .map_err(|e| anyhow::anyhow!("Failed to create regime-conditional DQN: {}", e))?; - DQNAgentType::RegimeConditional(regime_agent) - } else { - info!("Creating standard DQN with single Q-network head"); - let standard_agent = WorkingDQN::new(config) - .map_err(|e| anyhow::anyhow!("Failed to create DQN agent: {}", e))?; - DQNAgentType::Standard(standard_agent) - }; - - - // Initialize portfolio tracker with $100k starting capital and 1 basis point spread - // Bug #2 fix: Portfolio features were hardcoded as [0.0, 0.0, 0.0] at line 1528 - let portfolio_tracker = PortfolioTracker::new( - hyperparams.initial_capital, // P2-A: Configurable capital - 0.0001, // 1 basis point spread (0.01%) - hyperparams.cash_reserve_percent, // Cash reserve requirement - ); - - // Initialize reward function with hyperparameter-driven configuration - // WAVE 10-A9 FIX: Wire hold_penalty_weight from hyperparameters to RewardConfig - // BUG #17 FIX: Add normalization and percentage-based P&L (enabled by default) - let reward_config = RewardConfig { - pnl_weight: Decimal::ONE, - risk_weight: Decimal::try_from(0.1).unwrap_or(Decimal::ZERO), - cost_weight: Decimal::ONE, // Bug #2 fix: 100% transaction cost weight (was 0.05, 20x too low) - hold_reward: Decimal::try_from(0.001).unwrap_or(Decimal::ZERO), - movement_threshold: Decimal::try_from(hyperparams.movement_threshold) - .unwrap_or(Decimal::ZERO), - hold_penalty_weight: Decimal::try_from(hyperparams.hold_penalty_weight) - .unwrap_or(Decimal::ZERO), // CRITICAL FIX - diversity_weight: Decimal::try_from(-0.1).unwrap_or(Decimal::ZERO), - enable_normalization: true, // Bug #17: Normalize rewards to ~N(0,1) - use_percentage_pnl: true, // Bug #17: Use percentage returns for scale-invariance - circuit_breaker_config: CircuitBreakerConfig::default(), - triple_barrier_profit_bonus: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO), - triple_barrier_stop_penalty: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO), - }; - let reward_fn = RewardFunction::new_with_debug(reward_config, debug_logging); - - // WAVE 1.1: Initialize triple barrier engine (max 1000 active trackers) - let triple_barrier = Arc::new(RwLock::new(TripleBarrierEngine::new(1000))); - info!("Triple barrier engine initialized with 1000 max trackers"); - - // WAVE 16S: Initialize Kelly optimizer if enabled - let kelly_optimizer = if hyperparams.enable_kelly_sizing { - use crate::risk::kelly_optimizer::{KellyCriterionOptimizer, KellyOptimizerConfig}; - let kelly_config = KellyOptimizerConfig { - max_fraction: hyperparams.kelly_max_fraction, - min_fraction: 0.01, - lookback_period: 252, - confidence_threshold: 0.6, - volatility_adjustment: true, - drawdown_protection: true, - }; - let optimizer = KellyCriterionOptimizer::new(kelly_config) - .map_err(|e| anyhow::anyhow!("Failed to create Kelly optimizer: {}", e))?; - info!("Kelly optimizer enabled (fractional={}, max={})", - hyperparams.kelly_fractional, hyperparams.kelly_max_fraction); - Some(Arc::new(optimizer)) - } else { - None - }; - - // Wave 16 Portfolio Features: Initialize action masking, entropy regularization, and stress testing - let enable_action_masking = hyperparams.enable_action_masking; - let max_position = hyperparams.max_position_absolute; // BLOCKER #2: Use hyperopt-tunable position limit - - // Entropy regularization for preventing policy collapse - let entropy_regularizer: Option> = if hyperparams.enable_entropy_regularization { - use crate::dqn::entropy_regularization::EntropyRegularizer; - info!("Entropy regularization enabled (coefficient=0.01)"); - Some(Arc::new(EntropyRegularizer::new())) - } else { - None - }; - - // Multi-asset portfolio tracking (disabled by default - single-asset mode) - let multi_asset_portfolio: Option> = None; // TODO: Add enable_multi_asset flag when needed - - // Stress testing for robustness validation - let stress_tester: Option> = if hyperparams.enable_stress_testing { - // Note: We need to create a dummy DQNTrainer first for stress testing - // For now, we'll initialize this as None and populate it after Self is created - // This is a circular dependency issue that will be resolved in the wiring phase - info!("Stress testing enabled (8 scenarios)"); - None // Will be initialized after DQNTrainer construction - } else { - None - }; - - if enable_action_masking { - info!( - "Action masking enabled (max_position=±{:.1}, 30-50% filtering expected)", - max_position - ); - } else { - info!("Action masking disabled (all 45 actions available)"); - } - - // Wave 16 Core Risk Features: Initialize drawdown monitor, position limiter, circuit breaker - // These are ALWAYS enabled by default for production safety - - // 1. Drawdown Monitor (15% max drawdown, alerts at 10%, 12.5%, 15%) - let drawdown_monitor = { - // DrawdownMonitor will be configured in first training step - // Config will be applied via async configure_alerts() in train_epoch - info!("Drawdown monitor enabled (thresholds: 10%, 12.5%, 15%)"); - Some(Arc::new(DrawdownMonitor::new())) - }; - - // 2. Position Limiter (3-tier limits: ±10.0 absolute, 1M notional, 10% concentration) - let position_limiter = { - let config = PositionLimiterConfig { - enabled: true, - cache_ttl: Duration::from_secs(60), - rpc_check_threshold_percent: 0.8, - max_position_per_symbol: 10.0, // ±10.0 absolute position limit - max_order_value: 1_000_000.0, // $1M notional limit - max_daily_loss: 0.10, // 10% concentration limit - }; - let limiter = HybridPositionLimiter::new(config); - info!("Position limiter enabled (abs=±10.0, notional=$1M, concentration=10%)"); - Some(Arc::new(limiter)) - }; - - // 3. Circuit Breaker (5 consecutive failures, 60s cooldown) - let circuit_breaker = { - let config = CircuitBreakerConfig { - failure_threshold: 5, - success_threshold: 3, - timeout_duration: Duration::from_secs(60), - half_open_max_calls: 2, - }; - let breaker = CircuitBreaker::new(config); - info!("Circuit breaker enabled (threshold=5 failures, cooldown=60s)"); - Some(Arc::new(breaker)) - }; - - Ok(Self { - agent: Arc::new(RwLock::new(agent)), - hyperparams, - device, - metrics: Arc::new(RwLock::new(TrainingMetrics::new())), - loss_history: Vec::new(), - q_value_history: Vec::new(), - best_val_loss: f64::INFINITY, // Start with worst possible loss - val_data: Vec::new(), - val_loss_history: Vec::new(), - best_epoch: 0, - gradient_logging_step: 0, - portfolio_tracker, - feature_stats: None, // WAVE 3 FIX #2: Start with None, collect stats in epochs 0-10 - recent_actions: VecDeque::with_capacity(100), - reward_fn, - - // WAVE 16S: Adaptive risk management - kelly_optimizer, - trade_history: VecDeque::with_capacity(500), - volatility_returns: VecDeque::with_capacity(20), // Use default instead of moved hyperparams - pnl_history: VecDeque::with_capacity(1000), - - // Wave 16 Portfolio Features - enable_action_masking, - max_position, - entropy_regularizer, - multi_asset_portfolio, - stress_tester, - - // Wave 16 Core Risk Features - drawdown_monitor, - position_limiter, - circuit_breaker, - - // WAVE 3.10: Microstructure feature calculators - micro_high_low_spread: HighLowSpread::default(), - micro_vw_spread: VolumeWeightedSpread::default(), - micro_tick_count: TickCount::default(), - micro_inter_arrival: InterArrivalTime::default(), - micro_buy_sell_imbalance: BuySellImbalance::default(), - micro_kyle_lambda: KyleLambda::default(), - micro_price_impact: PriceImpact::default(), - micro_variance_ratio: VarianceRatio::default(), - last_timestamp_ns: 0, - last_close: 0.0, - - // WAVE 1.1: Triple barrier integration - triple_barrier, - active_position_tracker: None, - previous_simulated_position: 0.0, // WAVE P3: Start with flat position - - // WAVE 1.2: Safety Infrastructure Integration (8 Systems) - safety_loss_history: VecDeque::with_capacity(30), - safety_loss_plateau_counter: 0, - safety_action_counts: std::collections::HashMap::new(), - safety_memory_manager: Arc::new(RwLock::new( - crate::safety::memory_manager::SafeMemoryManager::new( - &crate::safety::MLSafetyConfig::default() - ) - )), - safety_level: crate::safety::SafetyLevel::Normal, // Default to Normal mode - safety_step_counter: 0, - - feature_cache_dir: None, - }) - } - - /// Set feature cache directory for faster hyperopt - /// - /// Enables loading pre-computed features from disk instead of recomputing them - pub fn with_feature_cache(mut self, cache_dir: PathBuf) -> Self { - self.feature_cache_dir = Some(cache_dir); - self - } - - /// Train DQN on market data from DBN files - /// - /// # Arguments - /// - /// * `dbn_data_dir` - Directory containing DBN files (e.g., "test_data/real/databento/ml_training/") - /// * `checkpoint_callback` - Callback for saving checkpoints (epoch, model_data, is_final) -> `Result` - /// - /// # Returns - /// - /// Training metrics (loss, accuracy, gradient norms, Q-values) - pub async fn train( - &mut self, - dbn_data_dir: &str, - checkpoint_callback: F, - ) -> Result - where - F: FnMut(usize, Vec, bool) -> Result + Send, - { - info!( - "Starting DQN training for {} epochs with batch size {}", - self.hyperparams.epochs, self.hyperparams.batch_size - ); - - // Load market data from DBN files - let (training_data, val_data) = self.load_training_data(dbn_data_dir).await?; - - info!( - "Loaded {} training samples, {} validation samples", - training_data.len(), - val_data.len() - ); - - // Store validation data for loss computation - self.val_data = val_data; - - // Use the common training loop (Wave 12 Group 3 refactor) - self.train_with_data_full_loop(training_data, checkpoint_callback) - .await - } - - /// Full training loop with existing logic (Wave 12 Group 3) - /// Calculate average metrics for an epoch - fn calculate_epoch_metrics( - epoch_loss: f64, - epoch_q_value: f64, - epoch_gradient_norm: f64, - samples_processed: usize, - ) -> (f64, f64, f64) { - if samples_processed > 0 { - let count = samples_processed as f64; - ( - epoch_loss / count, - epoch_q_value / count, - epoch_gradient_norm / count, - ) - } else { - (0.0, 0.0, 0.0) - } - } - - /// Compute validation loss on held-out data - /// WAVE 10.6: Batched validation for 5-10x speedup - - /// Collect Q-value statistics from replay buffer - /// - /// Samples experiences from the replay buffer and computes Q-value statistics - /// (min, max, mean, std) for adaptive C51 bounds calculation. - /// - /// # Returns - /// - /// QValueStats with min/max/mean/std of Q-values - async fn collect_qvalue_statistics(&self) -> Result { - let agent = self.agent.read().await; - - // Determine sample size (min of buffer size or 1000) - let buffer_size = agent.get_replay_buffer_size()?; - let sample_size = buffer_size.min(1000); - - if sample_size == 0 { - return Err(crate::MLError::TrainingError( - "Replay buffer is empty, cannot collect Q-value statistics".to_string() - )); - } - - // Sample experiences from replay buffer - let batch_sample = agent.memory().sample(sample_size)?; - let experiences = batch_sample.experiences; - - // Extract states and create batch tensor - let states: Vec = experiences - .iter() - .flat_map(|exp| exp.state.iter().copied()) - .collect(); - - let state_dim = agent.get_state_dim(); - let batch_tensor = Tensor::from_vec( - states, - (sample_size, state_dim), - agent.device() - ).map_err(|e| crate::MLError::ModelError(format!("Failed to create batch tensor: {}", e)))?; - - // Forward pass to get Q-values [batch_size, num_actions] - let q_values = agent.forward(&batch_tensor)?; - - // Flatten to get all Q-values - let q_vec: Vec = q_values - .to_vec2::() - .map_err(|e| crate::MLError::ModelError(format!("Failed to extract Q-values: {}", e)))? - .into_iter() - .flatten() - .collect(); - - // Calculate statistics - let min = q_vec.iter().cloned().fold(f64::INFINITY, |a, b| a.min(b as f64)); - let max = q_vec.iter().cloned().fold(f64::NEG_INFINITY, |a, b| a.max(b as f64)); - let sum: f64 = q_vec.iter().map(|&v| v as f64).sum(); - let count = q_vec.len(); - let mean = sum / count as f64; - - // Calculate standard deviation - let variance: f64 = q_vec.iter() - .map(|&v| { - let diff = v as f64 - mean; - diff * diff - }) - .sum::() / count as f64; - let std = variance.sqrt(); - - Ok(QValueStats { - min, - max, - mean, - std, - sample_count: count, - }) - } - - /// Calculate adaptive bounds with margin - /// - /// # Arguments - /// - /// * `stats` - Q-value statistics from Phase 1 - /// * `margin` - Safety margin as fraction (e.g., 0.3 = 30%) - /// - /// # Returns - /// - /// Tuple of (v_min, v_max) with safety margin applied - fn calculate_adaptive_bounds(stats: &QValueStats, margin: f64) -> (f64, f64) { - let range = stats.max - stats.min; - let v_min = stats.min - range * margin; - let v_max = stats.max + range * margin; - // Cap at ±10,000 to prevent explosion - (v_min.max(-10000.0), v_max.min(10000.0)) - } - - /// Reinitialize categorical distribution with new bounds - async fn reinit_categorical_distribution(&mut self, v_min: f64, v_max: f64) -> Result<(), crate::MLError> { - let mut agent = self.agent.write().await; - match &mut *agent { - DQNAgentType::Standard(agent) => agent.reinit_categorical_distribution(v_min, v_max)?, - DQNAgentType::RegimeConditional(agent) => agent.reinit_categorical_distribution(v_min, v_max)?, - } - Ok(()) - } - - async fn compute_validation_loss(&mut self) -> Result { - if self.val_data.is_empty() { - return Ok(0.0); - } - - // Save current epsilon and force to 0 for deterministic evaluation - let original_epsilon = self.get_epsilon().await?; - self.set_epsilon(0.0).await?; // Pure greedy selection - - let sample_size = self.val_data.len().min(1000); // Sample up to 1000 for speed - - // WAVE 10.6: Batched processing - collect all state vectors first - let mut state_vecs = Vec::with_capacity(sample_size); - let mut states = Vec::with_capacity(sample_size); - let mut next_states = Vec::with_capacity(sample_size); - let mut actions_for_rewards = Vec::with_capacity(sample_size); - - for (feature_vec, target) in self.val_data.iter().take(sample_size) { - let current_close = if target.len() >= 2 { - target[0] - } else { - feature_vec[3] - }; - let next_close = if target.len() >= 2 { - target[1] - } else { - current_close - }; - let close_price = rust_decimal::Decimal::try_from(current_close) - .unwrap_or(rust_decimal::Decimal::ZERO); - let state = self.feature_vector_to_state(feature_vec, Some(close_price))?; - - let next_close_price = - rust_decimal::Decimal::try_from(next_close).unwrap_or(rust_decimal::Decimal::ZERO); - let next_state = self.feature_vector_to_state(feature_vec, Some(next_close_price))?; - - state_vecs.push(state.to_vector()); - states.push(state); - next_states.push(next_state); - } - - // WAVE 10.6: Single batched forward pass for all validation samples - let agent = self.agent.read().await; - let state_dim = state_vecs[0].len(); - let batched_states: Vec = state_vecs.iter().flat_map(|v| v.iter().copied()).collect(); - let batch_tensor = Tensor::from_vec(batched_states, (sample_size, state_dim), &self.device) - .map_err(|e| anyhow::anyhow!("Failed to create batched validation tensor: {}", e))?; - - let batch_q_values = agent.forward(&batch_tensor) - .map_err(|e| anyhow::anyhow!("Batched validation forward pass failed: {}", e))?; - - drop(agent); // Release lock early - - // WAVE 10.6: GPU-optimized argmax for action selection - let greedy_action_indices = batch_q_values - .argmax(1) - .map_err(|e| anyhow::anyhow!("Failed to compute validation argmax: {}", e))? - .to_vec1::() - .map_err(|e| anyhow::anyhow!("Failed to transfer validation argmax to CPU: {}", e))?; - - // Extract max Q-values using vectorized operations - let max_q_values = batch_q_values - .max(1) - .map_err(|e| anyhow::anyhow!("Failed to compute max Q-values: {}", e))? - .to_vec1::() - .map_err(|e| anyhow::anyhow!("Failed to transfer max Q-values to CPU: {}", e))?; - - // Convert action indices to FactoredAction for reward calculation - for &idx in &greedy_action_indices { - let action = FactoredAction::from_index(idx as usize) - .map_err(|e| anyhow::anyhow!("Invalid validation action index {}: {}", idx, e))?; - actions_for_rewards.push(action); - } - - // Calculate rewards and losses (this part still needs to be sequential due to RewardFunction) - let mut total_loss = 0.0; - let recent_actions_vec: Vec = - self.recent_actions.iter().copied().collect(); - - for i in 0..sample_size { - let reward_decimal = self.reward_fn.calculate_reward( - actions_for_rewards[i], - &states[i], - &next_states[i], - &recent_actions_vec, - )?; - let reward = reward_decimal.to_string().parse::().unwrap_or(0.0); - let max_q = max_q_values[i] as f64; - - // Loss = (predicted_q - reward)^2 - let loss = (max_q - reward as f64).powi(2); - total_loss += loss; - } - - // Restore original epsilon after evaluation - self.set_epsilon(original_epsilon).await?; - - Ok(total_loss / sample_size as f64) - } - - /// Get Q-values for a given state - async fn get_q_values(&self, state: &TradingState) -> Result> { - let agent = self.agent.read().await; - let state_vec = state.to_vector(); - let state_tensor = Tensor::new(&state_vec[..], &self.device)?.unsqueeze(0)?; // Add batch dimension - - let q_values_tensor = agent.forward(&state_tensor)?; - let q_values_vec = q_values_tensor.squeeze(0)?.to_vec1::()?; - - Ok(q_values_vec.iter().map(|&v| v as f64).collect()) - } - - /// Check if early stopping criteria are met - fn check_early_stopping(&self, avg_q_value: f64, epoch: usize) -> Option { - if !self.hyperparams.early_stopping_enabled - || epoch + 1 < self.hyperparams.min_epochs_before_stopping - { - return None; - } - - // Criterion 1: Q-value floor check - if avg_q_value < self.hyperparams.q_value_floor { - return Some(format!( - "Q-value {:.4} below floor threshold {:.4}", - avg_q_value, self.hyperparams.q_value_floor - )); - } - - // Criterion 2: Validation loss plateau check - if self.val_loss_history.len() >= self.hyperparams.plateau_window { - let window = self.hyperparams.plateau_window; - let recent_losses: Vec = self - .val_loss_history - .iter() - .rev() - .take(window) - .copied() - .collect(); - - if let (Some(&first), Some(&last)) = (recent_losses.first(), recent_losses.last()) { - let improvement = last - first; - - if improvement < 0.001 { - return Some(format!( - "Validation loss plateau detected (improvement: {:.6})", - improvement - )); - } - } - } - - None - } - - /// Create final training metrics - async fn create_final_metrics( - &self, - total_loss: f64, - total_q_value: f64, - total_gradient_norm: f64, - total_reward: f64, - num_epochs: usize, - training_duration: std::time::Duration, - early_stopped: bool, - total_action_counts: [usize; 45], // WAVE 3 AGENT A3: 5 exposure × 3 order × 3 urgency (FactoredAction) - ) -> Result { - let final_loss = total_loss / num_epochs as f64; - let avg_q_value_final = total_q_value / num_epochs as f64; - let avg_grad_norm_final = total_gradient_norm / num_epochs as f64; - let avg_episode_reward = total_reward / num_epochs as f64; - - let mut metrics = TrainingMetrics { - loss: final_loss, - accuracy: 0.0, - precision: 0.0, - recall: 0.0, - f1_score: 0.0, - training_time_seconds: training_duration.as_secs_f64(), - epochs_trained: num_epochs as u32, - convergence_achieved: final_loss < 1.0, - additional_metrics: std::collections::HashMap::new(), - }; - - metrics.add_metric("avg_q_value", avg_q_value_final); - metrics.add_metric("avg_gradient_norm", avg_grad_norm_final); - metrics.add_metric("final_epsilon", self.get_epsilon().await.unwrap_or(0.1)); - metrics.add_metric("avg_episode_reward", avg_episode_reward); - - // WAVE 15 AGENT A12: Add 45-action metrics - let total_actions: usize = total_action_counts.iter().sum(); - if total_actions > 0 { - // Calculate action diversity (unique actions used / 45) - let unique_actions = total_action_counts - .iter() - .filter(|&&count| count > 0) - .count(); - let action_diversity = (unique_actions as f64 / 45.0) * 100.0; - metrics.add_metric("action_diversity", action_diversity); - - // WAVE 9-11 PRODUCTION: Calculate active actions (used >0.5% of the time) - let active_threshold = (total_actions as f64 * 0.005).max(1.0); // 0.5% threshold - let active_actions_count = total_action_counts - .iter() - .filter(|&&count| count as f64 >= active_threshold) - .count(); - let active_diversity_pct = (active_actions_count as f64 / 45.0) * 100.0; - metrics.add_metric("active_actions_count", active_actions_count as f64); - metrics.add_metric("active_diversity_pct", active_diversity_pct); - - // Sort actions by frequency to find top actions - let mut sorted_actions: Vec<(usize, usize)> = total_action_counts - .iter() - .enumerate() - .map(|(idx, &count)| (idx, count)) - .collect(); - sorted_actions.sort_by(|a, b| b.1.cmp(&a.1)); - - // Add top 1 action metrics - if let Some((top1_idx, top1_count)) = sorted_actions.get(0) { - let top1_pct = (*top1_count as f64 / total_actions as f64) * 100.0; - metrics.add_metric("top1_action_idx", *top1_idx as f64); - metrics.add_metric("top1_action_count", *top1_count as f64); - metrics.add_metric("top1_action_pct", top1_pct); - } - - // Calculate top 5 coverage percentage - let top5_count: usize = sorted_actions.iter().take(5).map(|(_, count)| count).sum(); - let top5_coverage_pct = (top5_count as f64 / total_actions as f64) * 100.0; - metrics.add_metric("top5_coverage_pct", top5_coverage_pct); - - metrics.add_metric("total_actions", total_actions as f64); - } - - if early_stopped { - metrics.add_metric("early_stopped", 1.0); - } - - Ok(metrics) - } - - async fn train_with_data_full_loop( - &mut self, - training_data: Vec<(FeatureVector51, Vec)>, - mut checkpoint_callback: F, - ) -> Result - where - F: FnMut(usize, Vec, bool) -> Result + Send, - { - let start_time = std::time::Instant::now(); - let mut total_loss = 0.0; - let mut total_q_value = 0.0; - let mut total_gradient_norm = 0.0; - let mut total_reward = 0.0; // Track cumulative rewards across all epochs - let mut total_action_counts = [0_usize; 45]; // 5 exposure × 3 order × 3 urgency - WAVE 3 AGENT A3 - - // WAVE 16 (Agent 36): Log target update strategy (one-time at training start) - match self.hyperparams.target_update_mode { - TargetUpdateMode::Soft => { - let half_life = convergence_half_life(self.hyperparams.tau); - info!("🎯 WAVE 16: Using soft target updates (Polyak averaging)"); - info!(" • Tau: {}", self.hyperparams.tau); - info!(" • Convergence half-life: {} steps", half_life as usize); - info!(" • Strategy: Smooth Q-value tracking (50-70% variance reduction)"); - }, - TargetUpdateMode::Hard => { - info!("⚠️ WAVE 16: Using hard target updates (legacy mode)"); - info!(" • Update frequency: every 1000 steps"); - info!(" • Warning: Sudden Q-value shifts may cause instability"); - }, - } - - // Rainbow DQN Component Status (one-time at training start) - info!("🌈 Rainbow DQN Components:"); - info!(" ✅ Double DQN (always enabled)"); - - if self.hyperparams.use_dueling { - info!(" ✅ Dueling Networks (value/advantage streams, hidden_dim={})", self.hyperparams.dueling_hidden_dim); - } else { - info!(" ❌ Dueling Networks"); - } - - if self.hyperparams.use_per { - info!(" ✅ Prioritized Experience Replay (α={}, β={}→1.0)", - self.hyperparams.per_alpha, self.hyperparams.per_beta_start); - } else { - info!(" ❌ Prioritized Experience Replay"); - } - - if self.hyperparams.n_steps > 1 { - info!(" ✅ N-Step Returns (n={})", self.hyperparams.n_steps); - } else { - info!(" ❌ N-Step Returns (n=1, standard TD)"); - } - - if self.hyperparams.use_distributional { - info!(" ✅ Categorical DQN (atoms={}, V=[{}, {}])", - self.hyperparams.num_atoms, self.hyperparams.v_min, self.hyperparams.v_max); - } else { - info!(" ❌ Categorical DQN / C51"); - } - - if self.hyperparams.use_noisy_nets { - info!(" ✅ Noisy Networks (σ_init={})", self.hyperparams.noisy_sigma_init); - } else { - info!(" ❌ Noisy Networks"); - } - - // Training loop - for epoch in 0..self.hyperparams.epochs { - // Create monitor for this epoch - let mut monitor = TrainingMonitor::new(epoch + 1); - - // BUG #15 FIX (Wave 16S-V15): Portfolio compounding across epochs - // - // REMOVED: self.portfolio_tracker.reset(); - // - // Root Cause: Resetting portfolio to initial capital ($100k default) at the START - // of every epoch caused catastrophic learning signal collapse: - // - // BEFORE (BROKEN - Bug #15): - // Epoch 1: $100k → $105k (reward = +5.0%, variance = 0.0001) - // Epoch 2: $100k → $104k (reward = +4.0%, variance = 0.0001) ← RESET! - // Epoch 3: $100k → $106k (reward = +6.0%, variance = 0.0001) ← RESET! - // Result: Constant rewards ~0.004 ± 0.0001 (ZERO learning signal) - // - // AFTER (FIXED - Compounding): - // Epoch 1: $100k → $105k (reward = +5.0%, reward_std = 0.02) - // Epoch 2: $105k → $110k (reward = +5.0%, reward_std = 0.03) - // Epoch 3: $110k → $116k (reward = +5.5%, reward_std = 0.04) - // Epoch 100: $500k → $550k (reward = +10.0%, reward_std = 0.50) - // Result: Increasing reward variance (10x-100x improvement in learning signal!) - // - // Why This Matters: - // - DQN learns by observing reward differences across states/actions - // - Constant rewards (0.004 ± 0.0001) provide ZERO differentiation - // - Compounding creates natural variance: profitable strategies compound faster - // - Higher portfolio values amplify good/bad decisions (better signal-to-noise) - // - Epoch 100 reward = 10x Epoch 1 reward (massive learning signal improvement) - // - // Portfolio is initialized ONCE at trainer creation (line 600-604): - // let portfolio_tracker = PortfolioTracker::new( - // hyperparams.initial_capital, // Default: $100k - // 0.0001, // 1 basis point spread - // hyperparams.cash_reserve_percent, - // ); - // - // Portfolio only resets when starting a NEW training run (new DQNTrainer instance). - // Within a single training run, portfolio compounds across ALL epochs. - - let epoch_start = std::time::Instant::now(); - let mut epoch_loss = 0.0; - let mut epoch_q_value = 0.0; - let mut epoch_gradient_norm = 0.0; - - // **WAVE 3 FIX #2: Two-Phase Feature Normalization** (Enhanced with configurable ratio) - // - // Phase 1 (epochs 0-N): Collect feature statistics - // - Build mean/std using Welford's algorithm (numerically stable) - // - N = min(epochs * ratio, max_epochs) - // - Default: min(epochs * 0.3, 10) → 10 epochs for <34 total epochs - // - No normalization applied yet - // - // Phase 2 (epochs N+1 onwards): Apply z-score normalization - // - Normalize all 54 market features to mean=0, std=1 - // - Portfolio features (indices 54-56) added separately via PortfolioTracker - // - Expected impact: Q-values reduced from ±10,000 to ±375 (27x improvement) - - // Calculate stats collection epochs using flexible formula - let stats_collection_epochs = { - let ratio_based = (self.hyperparams.epochs as f32 * self.hyperparams.feature_stats_collection_ratio) as usize; - let capped = match self.hyperparams.max_feature_stats_epochs { - Some(max_epochs) => ratio_based.min(max_epochs), - None => ratio_based, // No cap if None - }; - capped.max(1) // Always collect at least 1 epoch - }; - - // **PHASE 1: GPU-Optimized Experience Collection with Batched Action Selection** - // Fill replay buffer with batched action selection (125× fewer GPU kernel launches) - const ACTION_BATCH_SIZE: usize = 128; - let total_samples = training_data.len(); - let num_batches = (total_samples + ACTION_BATCH_SIZE - 1) / ACTION_BATCH_SIZE; - - for batch_idx in 0..num_batches { - let batch_start = batch_idx * ACTION_BATCH_SIZE; - let batch_end = ((batch_idx + 1) * ACTION_BATCH_SIZE).min(total_samples); - let batch_indices: Vec = (batch_start..batch_end).collect(); - - // Convert batch to states for batched action selection - let states: Result> = batch_indices - .iter() - .map(|&i| { - let target = &training_data[i].1; - let current_close = if target.len() >= 2 { - target[0] - } else { - training_data[i].0[3] - }; - let close_price = rust_decimal::Decimal::try_from(current_close) - .unwrap_or(rust_decimal::Decimal::ZERO); - self.feature_vector_to_state(&training_data[i].0, Some(close_price)) - }) - .collect(); - let states = states?; - - // Batched action selection (single GPU kernel launch) ✅ - let actions = self.select_actions_batch(&states).await?; - - // Store experiences with batched actions - for (idx_in_batch, &i) in batch_indices.iter().enumerate() { - let state = &states[idx_in_batch]; - let action = actions[idx_in_batch]; - let target = &training_data[i].1; - - // Extract close prices for next state calculation - // WAVE 3 BUG FIX: Use raw prices (indices 2,3) for barrier tracker, preprocessed (indices 0,1) for rewards - let current_close_raw = if target.len() >= 4 { - target[2] // Raw price for barrier tracker - } else if target.len() >= 2 { - target[0] // Fallback to preprocessed for old data - } else { - training_data[i].0[3] - }; - let next_close_raw = if target.len() >= 4 { - target[3] // Raw price for barrier tracker - } else if target.len() >= 2 { - target[1] // Fallback to preprocessed for old data - } else { - current_close_raw - }; - let current_close = if target.len() >= 2 { - target[0] // Preprocessed for reward calculation - } else { - training_data[i].0[3] - }; - let next_close = if target.len() >= 2 { - target[1] // Preprocessed for reward calculation - } else { - current_close - }; - - // Get next state (BUG #42: made mutable to update portfolio features after trade execution) - let mut next_state = if i + 1 < training_data.len() { - let next_close_price = rust_decimal::Decimal::try_from(next_close) - .unwrap_or(rust_decimal::Decimal::ZERO); - self.feature_vector_to_state( - &training_data[i + 1].0, - Some(next_close_price), - )? - } else { - state.clone() - }; - - // WAVE 3 AGENT 2: Simulated Position Tracking for Barrier Episodes - // Root Cause: BUG #8 prevents portfolio execution during experience collection, - // so positions never change and barriers never trigger (0% barrier exits). - // Solution: Map action exposure to simulated position for barrier tracking. - let simulated_position = match action.exposure { - crate::dqn::action_space::ExposureLevel::Long100 => 1.0, - crate::dqn::action_space::ExposureLevel::Long50 => 0.5, - crate::dqn::action_space::ExposureLevel::Flat => 0.0, - crate::dqn::action_space::ExposureLevel::Short50 => -0.5, - crate::dqn::action_space::ExposureLevel::Short100 => -1.0, - }; - - // Override next_state portfolio features with simulated position - // Note: This ONLY affects barrier tracking, NOT the actual state stored in experience - let mut next_state_with_sim_position = next_state.clone(); - if next_state_with_sim_position.portfolio_features.len() >= 2 { - next_state_with_sim_position.portfolio_features[1] = simulated_position; - } - - // WAVE 1.1 + WAVE 3: Triple Barrier Position Tracking with Simulated Positions - // WAVE P3 FIX: Use previous_simulated_position instead of state.portfolio_features[1] - // Bug: state.portfolio_features[1] is always 0.0 during experience collection (BUG #8 fix) - // This caused position_changed=true on EVERY step, creating new tracker each iteration - let current_position = self.previous_simulated_position; - // WAVE 3: Use simulated position from action intent (not portfolio tracker) - let next_position = next_state_with_sim_position.portfolio_features.get(1).unwrap_or(&0.0); - let position_changed = (next_position - current_position).abs() > 0.01; - - // Update previous position for next iteration (WAVE P3 FIX) - self.previous_simulated_position = simulated_position; - - // Start tracking if position changed and no active tracker - if position_changed && self.active_position_tracker.is_none() && next_position.abs() > 0.01 { - // Use conservative barrier configuration - let config = BarrierConfig::conservative(); - - // Convert price to cents (multiply by 100) - // WAVE 3 BUG FIX: Use raw price (not preprocessed) to avoid divide-by-zero - let entry_price_cents = (current_close_raw * 100.0) as u64; - - // Use step index as timestamp (nanoseconds) - // Each step = 1 second for simplicity - let entry_timestamp_ns = (i as u64) * 1_000_000_000; - - // Start tracking the position - match self.triple_barrier.write().await.start_tracking( - config, - entry_price_cents, - entry_timestamp_ns, - ) { - Ok(tracker_id) => { - self.active_position_tracker = Some(tracker_id); - debug!( - "WAVE 1.1: Started triple barrier tracking at step {}, price=${:.2}, position={:.2}", - i, current_close, next_position - ); - }, - Err(e) => { - warn!("WAVE 1.1: Failed to start triple barrier tracking: {}", e); - } - } - } - - // WAVE P2: Check for barrier exits on each step - // Changed to Option to distinguish "no barrier" (None) from "time expiry" (Some(0)) - let mut barrier_label: Option = None; // None = no barrier, Some(0/1/-1) = barrier hit - if let Some(tracker_id) = self.active_position_tracker { - // Create price point for current step - // WAVE 3 BUG FIX: Use raw price (not preprocessed) for barrier calculations - let price_cents = (next_close_raw * 100.0) as u64; - let timestamp_ns = ((i + 1) as u64) * 1_000_000_000; - let price_point = PricePoint::new(price_cents, timestamp_ns); - - // Check if any barrier was hit - if let Some(event_label) = self.triple_barrier.write().await.update_tracker( - tracker_id, - price_point, - ) { - // WAVE P2: Use Option to distinguish barrier events from no-barrier - barrier_label = Some(event_label.label_value); // Some(1/0/-1) indicates barrier hit - self.active_position_tracker = None; // Clear tracker on exit - - // WAVE P2: Reset portfolio on barrier exit (position closes) - self.portfolio_tracker.reset(); - - debug!( - "WAVE P2: Barrier-driven episode end at step {}: {:?}, label={}, return_bps={}, portfolio reset", - i, event_label.barrier_result, barrier_label.unwrap(), event_label.return_bps - ); - } - } - - // BUG #42 FIX: Execute portfolio action to enable reward calculation - // Root Cause: Portfolio tracker never updated during experience collection, - // causing state.portfolio_features[0] to always equal initial_capital, - // which results in 100% zero rewards since (V - V) / V = 0. - // Solution: Execute trade on portfolio tracker BEFORE reward calculation - // to update portfolio value, enabling P&L-based rewards. - // - // Flow: - // 1. state.portfolio_features[0] = current portfolio value (from tracker) - // 2. Execute action A at current_price → portfolio updates - // 3. Market moves to next_price → position marked-to-market - // 4. next_state.portfolio_features[0] = new portfolio value (after price move) - // 5. reward = (next_value - current_value) / current_value (percentage P&L) - - // ========== DIAGNOSTIC: Capture position BEFORE action execution ========== - let position_before = self.portfolio_tracker.current_position(); - let target_exposure = action.target_exposure() as f32; - let expected_position = target_exposure * self.max_position as f32; - - // Execute trade on portfolio tracker - let current_price_f32 = current_close as f32; - let max_position_f32 = self.max_position as f32; - self.portfolio_tracker.execute_action(action, current_price_f32, max_position_f32); - - // ========== DIAGNOSTIC: Capture position AFTER action execution ========== - let position_after = self.portfolio_tracker.current_position(); - - // Extract action components for detailed logging - use crate::dqn::action_space::{ExposureLevel, OrderType, Urgency}; - let action_idx = action.to_index(); - let exposure_name = match action.exposure { - ExposureLevel::Short100 => "Short100", - ExposureLevel::Short50 => "Short50", - ExposureLevel::Flat => "Flat", - ExposureLevel::Long50 => "Long50", - ExposureLevel::Long100 => "Long100", - }; - let order_name = match action.order { - OrderType::Market => "Market", - OrderType::LimitMaker => "LimitMaker", - OrderType::IoC => "IoC", - }; - let urgency_name = match action.urgency { - Urgency::Patient => "Patient", - Urgency::Normal => "Normal", - Urgency::Aggressive => "Aggressive", - }; - - // SIGN INVERSION DIAGNOSTIC: Log every action for first 100 steps - if i < 100 { - let sign_check = if target_exposure.abs() > 0.01 { - // Check if sign matches expectation - let expected_sign = expected_position.signum(); - let actual_sign = position_after.signum(); - if expected_sign != actual_sign && position_after.abs() > 0.01 { - "[SIGN_INVERTED]" - } else if (position_after - expected_position).abs() > 0.01 { - "[MAGNITUDE_MISMATCH]" - } else { - "[OK]" - } - } else { - "[FLAT_ACTION]" - }; - - println!( - "SIGN_DIAG step={:4} | Action#{:2}={}-{}-{} | target_exp={:+6.3} → expected_pos={:+8.4} | position: {:+8.4} → {:+8.4} | delta={:+8.4} | {}", - i, action_idx, exposure_name, order_name, urgency_name, - target_exposure, expected_position, - position_before, position_after, - position_after - position_before, - sign_check - ); - } - - // Update next_state portfolio features to reflect trade + price movement - // This is critical for reward calculation which compares current_state vs next_state - // CRITICAL: Use get_portfolio_features() (NORMALIZED) not total_value() (RAW) - // to match the normalization used in state.portfolio_features - let next_price_f32 = next_close as f32; - let updated_portfolio_features = self.portfolio_tracker.get_portfolio_features(next_price_f32); - - // Override next_state portfolio features with NORMALIZED post-trade values - if next_state.portfolio_features.len() >= 3 { - next_state.portfolio_features[0] = updated_portfolio_features[0]; // Normalized value - next_state.portfolio_features[1] = updated_portfolio_features[1]; // Normalized position - // portfolio_features[2] is spread, leave unchanged (already set) - } - - // Debug logging to verify trade execution and reward signal - if i % 100 == 0 || i < 10 { - let current_value = state.portfolio_features.get(0).unwrap_or(&1.0); - let new_value = updated_portfolio_features[0]; - let new_position = updated_portfolio_features[1]; - let reward_signal = if *current_value > 0.0 { - ((new_value - current_value) / current_value) * 100.0 - } else { - 0.0 - }; - debug!( - "BUG42_FIX: step={}, action={:?}, current_value={:.6}, new_value={:.6}, position={:.4}, reward_signal={:.4}%", - i, action, current_value, new_value, new_position, reward_signal - ); - } - - // Track action for diversity penalty - self.recent_actions.push_back(action); - if self.recent_actions.len() > 100 { - self.recent_actions.pop_front(); - } - - // WAVE 1.2 SAFETY #5: Action Diversity Monitor (every 100 steps) - self.safety_step_counter += 1; - *self.safety_action_counts.entry(action.to_index()).or_insert(0) += 1; - - if self.safety_step_counter % 100 == 0 { - let total_actions: usize = self.safety_action_counts.values().sum(); - let unique_actions = self.safety_action_counts.len(); - let diversity = unique_actions as f32 / 45.0; // 45 total actions - - if diversity < 0.5 { - let msg = format!( - "SAFETY: Action diversity below 50% at step {}: {:.1}% ({}/{} actions used in last {} steps)", - self.safety_step_counter, diversity * 100.0, unique_actions, 45, total_actions - ); - match self.safety_level { - crate::safety::SafetyLevel::Strict => { - return Err(anyhow::anyhow!("{} (stopping training)", msg)); - }, - crate::safety::SafetyLevel::Normal => { - warn!("{}", msg); - }, - crate::safety::SafetyLevel::Permissive => { - debug!("{}", msg); - }, - } - } - - // Reset counts every 1000 steps - if self.safety_step_counter % 1000 == 0 { - self.safety_action_counts.clear(); - } - } - - // WAVE 1.2 SAFETY #6: Memory Monitor (every 10 steps) - if self.safety_step_counter % 10 == 0 { - if let Device::Cuda(_) = &self.device { - let memory_manager = self.safety_memory_manager.read().await; - let current_usage = memory_manager.get_memory_usage(&self.device); - // Use 4GB as limit for RTX 3050 Ti (memory_manager.get_memory_limit is private) - let limit = 4_000_000_000_usize; // 4GB in bytes - let usage_ratio = current_usage as f64 / limit as f64; - - if usage_ratio > 0.9 { - let usage_gb = current_usage as f64 / 1e9; - let limit_gb = limit as f64 / 1e9; - warn!( - "SAFETY: GPU memory usage high at step {}: {:.1}% ({:.2} GB / {:.2} GB)", - self.safety_step_counter, - usage_ratio * 100.0, - usage_gb, - limit_gb - ); - } - } - } - - // Calculate reward using RewardFunction (portfolio tracking, diversity penalty, movement threshold) - let recent_actions_vec: Vec = - self.recent_actions.iter().copied().collect(); - let reward_decimal = self.reward_fn.calculate_reward( - action, - state, - &next_state, - &recent_actions_vec, - )?; - let raw_reward = reward_decimal.to_string().parse::().unwrap_or(0.0) as f64; - - // WAVE P2: Apply triple barrier scaling if we have a label (updated for Option) - let barrier_scaled_reward = if let Some(label) = barrier_label { - let scaled = self.reward_fn.apply_triple_barrier_scaling(reward_decimal, label); - scaled.to_string().parse::().unwrap_or(0.0) as f64 - } else { - raw_reward - }; - - // WAVE 16S: Apply risk-adjusted rewards (Sharpe ratio) on barrier-scaled reward - let risk_adjusted_reward = self.calculate_risk_adjusted_reward(barrier_scaled_reward); - - // Calculate price return for volatility tracking - let price_return = (next_close - current_close) / current_close; - - // BUG #17 FIX (Wave 16S-V16): Break positive feedback loop - // CRITICAL: Use raw_reward (NOT risk_adjusted_reward) to update trackers - // Otherwise: amplified rewards → inflated Sharpe → even larger amplification → explosion - // Example: raw=1.0 → Sharpe=10 → adjusted=10.0 → stored=10.0 → next Sharpe=100 → ... - self.update_risk_trackers(raw_reward, price_return); - - // Convert back to f32 for experience storage - let reward = risk_adjusted_reward as f32; - - // WAVE 16S: Log adaptive features periodically - if i % 100 == 0 && i > 0 { - let kelly_frac = self.get_kelly_fraction(); - debug!( - "Step {}: Kelly={:.4}, Sharpe history={}, Vol returns={}", - i, kelly_frac, self.pnl_history.len(), self.volatility_returns.len() - ); - } - - // Track reward and action for monitoring - monitor.track_reward(reward); - monitor.track_action(&action); - // We'll track Q-values during training steps - - // BUG #8 FIX: DO NOT execute portfolio actions during experience collection - // Experience collection is for SIMULATION only (building replay buffer) - // Portfolio actions should ONLY be executed during: - // - Evaluation phase (compute_validation_loss) - // - Backtesting (separate EvaluationEngine) - // - NOT during training experience collection - // - // Portfolio features are already populated via feature_vector_to_state() - // which extracts them from FeatureVector51 (Bug #2 fix is separate) - - // Track action in DQN model for entropy penalty (Wave 7 fix) - self.agent.write().await.track_action(action); - - // WAVE P2: Barrier-Based Episode Termination - // Episodes end on THREE conditions: - // 1. Barrier hit (profit target, stop loss, or time expiry) - // 2. Fixed episode length boundary (fallback for compatibility) - // 3. Data boundary reached - let barrier_done = barrier_label.is_some(); // Any barrier event (Some(1/0/-1)) - let time_done = (i + 1) % EPISODE_LENGTH == 0; - let data_done = i + 1 >= training_data.len(); - let done = barrier_done || time_done || data_done; - - // Log barrier-driven terminations for analysis - if barrier_done { - let label_value = barrier_label.unwrap(); - debug!( - "WAVE P2: Episode ended via barrier at step {} ({}): label={}", - i + 1, - match label_value { - 1 => "Profit Target", - -1 => "Stop Loss", - 0 => "Time Expiry", - _ => "Unknown", - }, - label_value - ); - } - - // P1 FIX: Reset portfolio at episode boundaries (updated for barrier termination) - // Note: Portfolio already reset in barrier detection block (lines 1717-1718) - // This handles time/data boundary resets - if done && !barrier_done { - self.portfolio_tracker.reset(); - - let episode_num = ((i + 1) / EPISODE_LENGTH) + 1; - let total_episodes = (training_data.len() + EPISODE_LENGTH - 1) / EPISODE_LENGTH; - debug!( - "Episode boundary at sample {}/{} (time={}, data={}), portfolio reset (episode {}/{})", - i + 1, training_data.len(), time_done, data_done, episode_num, total_episodes - ); - } - - // WAVE P2: Track episode end for statistics - if done { - monitor.track_episode_end(i, barrier_label); - } - - // Store experience - let experience = Experience::new( - state.to_vector(), - action.to_index() as u8, - reward, - next_state.to_vector(), - done, - ); - - self.store_experience(experience).await?; - } - } - - // **PHASE 2: Batched Training from Replay Buffer** - // Now that buffer is populated, perform batched training - // This reduces train_step() calls from 1000×/epoch to ~8×/epoch (125× reduction) - let batch_size = self.hyperparams.batch_size; - let num_training_steps = if self.can_train().await? { - // Calculate number of training steps based on dataset size and batch size - // Use same total gradient updates as before, just in larger batches - (training_data.len() / batch_size).max(1) - } else { - // Buffer not ready yet (early epochs) - 0 - }; - - let mut train_step_count = 0; - for _ in 0..num_training_steps { - match self.train_step().await { - Ok((loss, q_value, grad_norm)) => { - // WAVE 1.2 SAFETY #1: NaN/Inf Detection (catch corrupted values EARLY) - if !loss.is_finite() || !q_value.is_finite() || !grad_norm.is_finite() { - let msg = format!( - "SAFETY: NaN/Inf detected at epoch {} - loss={:.6}, q_value={:.6}, grad_norm={:.6}", - epoch, loss, q_value, grad_norm - ); - match self.safety_level { - crate::safety::SafetyLevel::Strict => { - return Err(anyhow::anyhow!("{} (stopping training)", msg)); - }, - crate::safety::SafetyLevel::Normal => { - warn!("{} (continuing training)", msg); - continue; // Skip this step - }, - crate::safety::SafetyLevel::Permissive => { - debug!("{} (permissive mode)", msg); - }, - } - } - - // WAVE 1.2 SAFETY #2: Gradient Monitor (explosion >10K or vanishing <1e-6) - if grad_norm > 10000.0 { - let msg = format!( - "SAFETY: Gradient explosion detected at epoch {} - norm={:.2e} > 10K threshold", - epoch, grad_norm - ); - match self.safety_level { - crate::safety::SafetyLevel::Strict => { - return Err(anyhow::anyhow!("{} (stopping training)", msg)); - }, - crate::safety::SafetyLevel::Normal | crate::safety::SafetyLevel::Permissive => { - warn!("{}", msg); - }, - } - } else if grad_norm < 1e-6 && epoch > 10 { - warn!( - "SAFETY: Gradient vanishing detected at epoch {} - norm={:.2e} < 1e-6 threshold", - epoch, grad_norm - ); - } - - // WAVE 1.2 SAFETY #3: Q-Value Bounds Check (±1M limits) - if q_value < -1_000_000.0 || q_value > 1_000_000.0 { - warn!( - "SAFETY: Q-value out of bounds at epoch {}: {:.2e} (bounds: ±1M)", - epoch, q_value - ); - } - - // WAVE 1.2 SAFETY #4: Loss Spike Detector (>50% jump) - if !self.safety_loss_history.is_empty() { - let prev_loss = *self.safety_loss_history.back().unwrap() as f64; // Convert to f64 - // FIX: Use absolute difference instead of percentage (robust for negative/small values) - let loss_diff = (loss - prev_loss).abs(); - - if loss_diff > 0.5 { // Threshold: 0.5 absolute change - let msg = format!( - "SAFETY: Loss spike detected at epoch {}: {:.6} → {:.6} (Δ={:+.4})", - epoch, prev_loss, loss, loss_diff - ); - match self.safety_level { - crate::safety::SafetyLevel::Strict => { - return Err(anyhow::anyhow!("{} (stopping training)", msg)); - }, - crate::safety::SafetyLevel::Normal | crate::safety::SafetyLevel::Permissive => { - warn!("{}", msg); - }, - } - } - } - - // Update loss history - self.safety_loss_history.push_back(loss as f32); - if self.safety_loss_history.len() > 30 { - self.safety_loss_history.pop_front(); - } - - // Track Q-values per action (we use BUY as proxy since we don't track per sample) - // In practice, Q-values are already averaged across actions in train_step - // This is a simplified tracking - full per-action tracking would require more instrumentation - - epoch_loss += loss; - epoch_q_value += q_value; - epoch_gradient_norm += grad_norm; - train_step_count += 1; - - // WAVE 9-11: Track Q-value range for production monitoring - monitor.track_q_value_range(q_value); - - // BUG #29 FIX: Epsilon decay moved to per-epoch (see line ~1340) - // Previous per-batch decay caused premature exploration collapse in short hyperopt trials - // With batch_size=72, epsilon hit floor (0.05) after 2.1 epochs, freezing at 2.2% diversity - }, - Err(e) => { - // WAVE 23 P0 FIX: Propagate early stopping errors instead of suppressing them - // Check if this is an early stopping error (gradient collapse or Q-value divergence) - let error_msg = e.to_string(); - if error_msg.contains("Early stopping") || - error_msg.contains("Gradient collapse") || - error_msg.contains("Q-value divergence") { - tracing::error!("🛑 TERMINATING: Early stopping triggered - {}", e); - return Err(e.into()); // Propagate error, terminate training - } - - // For other errors (sampling issues), log and continue - warn!("Training step failed: {}, continuing...", e); - }, - } - } - - let epoch_duration = epoch_start.elapsed(); - - // Calculate epoch metrics (average over training steps, not samples) - let (avg_loss, avg_q_value, avg_grad_norm) = if train_step_count > 0 { - ( - epoch_loss / train_step_count as f64, - epoch_q_value / train_step_count as f64, - epoch_gradient_norm / train_step_count as f64, - ) - } else { - // Early epochs before replay buffer fills - (0.0, 0.0, 0.0) - }; - - total_loss += avg_loss; - total_q_value += avg_q_value; - total_gradient_norm += avg_grad_norm; - - // Calculate average reward for this epoch - let epoch_avg_reward = if !monitor.reward_history.is_empty() { - monitor.reward_history.iter().sum::() / monitor.reward_history.len() as f32 - } else { - 0.0 - }; - total_reward += epoch_avg_reward as f64; - - // VERBOSE: Log reward statistics every 10 epochs - if (epoch + 1) % 10 == 0 && !monitor.reward_history.is_empty() { - let rewards = &monitor.reward_history; - let reward_mean = rewards.iter().sum::() / rewards.len() as f32; - let reward_variance = rewards.iter() - .map(|r| (r - reward_mean).powi(2)) - .sum::() / rewards.len() as f32; - let reward_std = reward_variance.sqrt(); - let reward_min = rewards.iter().copied().fold(f32::INFINITY, f32::min); - let reward_max = rewards.iter().copied().fold(f32::NEG_INFINITY, f32::max); - let non_zero_count = rewards.iter().filter(|&&r| r.abs() > 1e-9).count(); - let non_zero_pct = (non_zero_count as f32 / rewards.len() as f32) * 100.0; - - info!( - "REWARD_STATS: epoch={}, mean={:.6}, std={:.6}, min={:.6}, max={:.6}, non_zero={}/{} ({:.1}%)", - epoch + 1, - reward_mean, - reward_std, - reward_min, - reward_max, - non_zero_count, - rewards.len(), - non_zero_pct - ); - } - - // WAVE 1.2 SAFETY #7: Training Anomaly Detector (plateau detection) - if self.safety_loss_history.len() >= 10 { - let recent_losses: Vec = self.safety_loss_history - .iter() - .rev() - .take(10) - .copied() - .collect(); - - let mean: f32 = recent_losses.iter().sum::() / 10.0; - let variance: f32 = recent_losses - .iter() - .map(|&x| (x - mean).powi(2)) - .sum::() / 10.0; - let std_dev = variance.sqrt(); - - // Alert if loss is stuck (variance < 1% of mean) - if std_dev < mean * 0.01 && mean > 1e-6 { - self.safety_loss_plateau_counter += 1; - - if self.safety_loss_plateau_counter >= 10 { - warn!( - "SAFETY: Training stuck for {} epochs (loss variance: {:.6}, mean: {:.6})", - self.safety_loss_plateau_counter, std_dev, mean - ); - } - } else { - self.safety_loss_plateau_counter = 0; - } - } - - // WAVE 3 AGENT A3: Accumulate action counts for constraint checking - for (i, count) in monitor.action_counts.iter().enumerate() { - total_action_counts[i] += count; - } - - // Run monitoring validation at end of epoch - if let Err(e) = monitor.validate_all() { - return Err(e); // Abort training if critical bug detected - } - - // Get current epsilon for logging - let current_epsilon = self.get_epsilon().await?; - - // Get Q-value range statistics - let (q_min, q_max, q_mean) = monitor.get_q_value_stats(); - - info!( - "Epoch {}/{}: train_loss={:.6}, Q-value={:.4}, grad_norm={:.6}, train_steps={}, epsilon={:.4}, duration={:.2}s", - epoch + 1, - self.hyperparams.epochs, - avg_loss, - avg_q_value, - avg_grad_norm, - train_step_count, - current_epsilon, - epoch_duration.as_secs_f64() - ); - - // BUG #29 FIX: Update epsilon once per epoch (not per batch) - // This ensures consistent exploration across different batch sizes - // With epsilon_decay=0.995, after 15 epochs: 0.3 × (0.995^15) = 0.2783 (27.8% exploration) - { - let mut agent = self.agent.write().await; - agent.update_epsilon(); - } - - // WAVE 9-11: Log Q-value range for production monitoring - if train_step_count > 0 { - info!( - "Epoch {}/{}: Q-value range=[{:.2}, {:.2}], mean={:.2}", - epoch + 1, - self.hyperparams.epochs, - q_min, - q_max, - q_mean - ); - - // WAVE 9-11: Warning threshold (500K as per production test report) - const Q_VALUE_WARNING_THRESHOLD: f64 = 500_000.0; - if q_max > Q_VALUE_WARNING_THRESHOLD { - warn!( - "⚠️ Q-value explosion detected at epoch {}: max Q-value {:.2e} exceeds threshold {:.2e}", - epoch + 1, - q_max, - Q_VALUE_WARNING_THRESHOLD - ); - warn!("Consider:"); - warn!(" • Reducing learning rate (current: {:.2e})", self.hyperparams.learning_rate); - warn!(" • Enabling target network soft updates (Polyak averaging, tau=0.005)"); - warn!(" • Adjusting reward scaling"); - } - } - - // Compute validation loss - let val_loss = self.compute_validation_loss().await?; - info!( - "Epoch {}/{}: val_loss={:.6}", - epoch + 1, - self.hyperparams.epochs, - val_loss - ); - - // WAVE 9-11 PRODUCTION: Track action diversity per epoch - // Calculate active actions (used >0.5% of the time) - let epoch_total_actions: usize = monitor.action_counts.iter().sum(); - let active_threshold = (epoch_total_actions as f64 * 0.005).max(1.0); // 0.5% threshold - let active_actions_count = monitor - .action_counts - .iter() - .filter(|&&count| count as f64 >= active_threshold) - .count(); - let diversity_percentage = (active_actions_count as f64 / 45.0) * 100.0; - - // Log action diversity - info!( - "Epoch {}/{}: Action diversity={}/{} ({:.1}%)", - epoch + 1, - self.hyperparams.epochs, - active_actions_count, - 45, - diversity_percentage - ); - - // Warning if diversity drops below 20% (9 actions) - const DIVERSITY_THRESHOLD: usize = 9; // 20% of 45 actions - if active_actions_count < DIVERSITY_THRESHOLD { - warn!( - "⚠️ LOW ACTION DIVERSITY: {}/45 actions (<20%), consider increasing epsilon floor", - active_actions_count - ); - info!(" Recommendation: Increase epsilon_end from 0.05 to 0.10"); - info!(" Alternative: Add entropy regularization bonus"); - } - - // WAVE P2: Log episode statistics - let (mean_len, std_len, min_len, max_len, exit_counts) = monitor.get_episode_stats(); - if !monitor.episode_lengths.is_empty() { - let total_episodes = monitor.episode_lengths.len(); - - info!( - "WAVE P2 Episode Stats [Epoch {}]: {} episodes, length: mean={:.1}±{:.1}, min={}, max={}", - epoch + 1, - total_episodes, - mean_len, - std_len, - min_len, - max_len - ); - - info!( - " Exit breakdown: profit={}({:.1}%), stop={}({:.1}%), time={}({:.1}%), boundary={}({:.1}%)", - exit_counts[0], (exit_counts[0] as f64 / total_episodes as f64) * 100.0, - exit_counts[1], (exit_counts[1] as f64 / total_episodes as f64) * 100.0, - exit_counts[2], (exit_counts[2] as f64 / total_episodes as f64) * 100.0, - exit_counts[3], (exit_counts[3] as f64 / total_episodes as f64) * 100.0, - ); - } - - // WAVE 3.11: Calculate and log VaR/CVaR from PnL history - if self.pnl_history.len() > 20 { - let returns: Vec = self.pnl_history.iter().copied().collect(); - - // Calculate VaR/CVaR at 95% and 99% confidence levels - // confidence_level=0.05 means we're looking at the worst 5% of returns (95% VaR) - let (var_95, cvar_95) = calculate_var_cvar(&returns, 0.05); - let (var_99, cvar_99) = calculate_var_cvar(&returns, 0.01); - - info!( - "Epoch {}/{}: Risk Metrics - VaR(95%)={:.4}%, CVaR(95%)={:.4}%, VaR(99%)={:.4}%, CVaR(99%)={:.4}% (from {} PnL samples)", - epoch + 1, - self.hyperparams.epochs, - var_95 * 100.0, // Convert to percentage - cvar_95 * 100.0, // Convert to percentage - var_99 * 100.0, // Convert to percentage - cvar_99 * 100.0, // Convert to percentage - returns.len() - ); - } - - // Track metrics for early stopping - self.loss_history.push(avg_loss); - self.q_value_history.push(avg_q_value); - self.val_loss_history.push(val_loss); - - // MEMORY LEAK FIX: Limit history vectors to prevent unbounded growth - // Keep last 100 epochs (sufficient for early stopping window of 5) - const MAX_HISTORY_LEN: usize = 100; - if self.loss_history.len() > MAX_HISTORY_LEN { - self.loss_history.drain(0..50); // Remove oldest 50, keep newest 50 - } - if self.q_value_history.len() > MAX_HISTORY_LEN { - self.q_value_history.drain(0..50); - } - if self.val_loss_history.len() > MAX_HISTORY_LEN { - self.val_loss_history.drain(0..50); - } - - // Save best model checkpoint if validation loss improved - if train_step_count > 0 && val_loss < self.best_val_loss { - self.best_val_loss = val_loss; - self.best_epoch = epoch + 1; - - info!( - "🎉 New best validation loss: {:.6} at epoch {}", - val_loss, - epoch + 1 - ); - - // WAVE 1.2 SAFETY #8: Checkpoint Verification (before save) - let checkpoint_data = self.serialize_model().await?; - - // Verify checkpoint integrity (check for NaN/Inf in serialized data) - if self.safety_level != crate::safety::SafetyLevel::Permissive { - // Simple check: ensure checkpoint data is not empty and doesn't contain obvious corruption markers - if checkpoint_data.is_empty() { - let msg = "SAFETY: Checkpoint verification failed - empty checkpoint data"; - match self.safety_level { - crate::safety::SafetyLevel::Strict => { - return Err(anyhow::anyhow!("{}", msg)); - }, - crate::safety::SafetyLevel::Normal => { - warn!("{} (continuing anyway)", msg); - }, - _ => {}, - } - } else { - debug!("SAFETY: Checkpoint verification passed ({} bytes)", checkpoint_data.len()); - } - } - - let best_checkpoint_path = checkpoint_callback( - epoch + 1, - checkpoint_data, - true, // is_best flag - ) - .context("Failed to save best checkpoint")?; - - info!("Best model saved to: {}", best_checkpoint_path); - } - - // Early stopping checks (skip if no training occurred) - if train_step_count > 0 { - if let Some(stop_reason) = self.check_early_stopping(avg_q_value, epoch) { - warn!( - "Early stopping triggered at epoch {}/{}: {}", - epoch + 1, - self.hyperparams.epochs, - stop_reason - ); - info!( - "Final metrics: loss={:.6}, Q-value={:.4}", - avg_loss, avg_q_value - ); - - // WAVE 13-A2: Save checkpoint for early stopping (use is_best=false for proper naming) - let checkpoint_data = self - .serialize_model() - .await - .context("Failed to serialize model for early stopping checkpoint")?; - let checkpoint_size = checkpoint_data.len(); - let checkpoint_path = checkpoint_callback(epoch + 1, checkpoint_data, false) - .context("Failed to save early stopping checkpoint")?; - info!( - "Early stopping checkpoint saved to: {} ({} bytes)", - checkpoint_path, checkpoint_size - ); - - // WAVE 23 P0 FIX: Return error instead of Ok(metrics) to terminate with non-zero exit code - // This ensures hyperopt can properly detect and kill failing trials early - // The checkpoint has already been saved above, so the model state is preserved - return Err(anyhow::anyhow!( - "Training terminated by early stopping at epoch {}/{}: {}", - epoch + 1, - self.hyperparams.epochs, - stop_reason - )); - } - } - - // WAVE 13-A2: Save periodic checkpoint every N epochs - if (epoch + 1) % self.hyperparams.checkpoint_frequency == 0 { - info!( - "💾 Saving periodic checkpoint at epoch {}/{}", - epoch + 1, - self.hyperparams.epochs - ); - - let checkpoint_data = self.serialize_model().await?; - let checkpoint_size = checkpoint_data.len(); - let checkpoint_path = checkpoint_callback(epoch + 1, checkpoint_data, false) - .context("Failed to save periodic checkpoint")?; - - info!( - "✅ Periodic checkpoint saved: {} ({} bytes)", - checkpoint_path, checkpoint_size - ); - } - } - - let training_duration = start_time.elapsed(); - - // Calculate final metrics - let metrics = self - .create_final_metrics( - total_loss, - total_q_value, - total_gradient_norm, - total_reward, - self.hyperparams.epochs, - training_duration, - false, - total_action_counts, // WAVE 3 AGENT A3 - ) - .await?; - - // Update stored metrics - { - let mut stored_metrics = self.metrics.write().await; - *stored_metrics = metrics.clone(); - } - - info!( - "Training completed in {:.2}s: final_loss={:.6}, avg_q_value={:.4}", - training_duration.as_secs_f64(), - metrics.loss, - metrics - .additional_metrics - .get("avg_q_value") - .unwrap_or(&0.0) - ); - - info!("Best model summary:"); - info!( - " Best validation loss: {:.6} at epoch {}", - self.best_val_loss, self.best_epoch - ); - info!(" Best model checkpoint: best_model.safetensors"); - - Ok(metrics) - } - - /// Train DQN on market data from Parquet file (Wave 12 Group 3) - /// - /// # Arguments - /// - /// * `parquet_path` - Path to Parquet file containing OHLCV bars - /// * `checkpoint_callback` - Callback for saving checkpoints (epoch, model_data) -> `Result` - /// - /// # Returns - /// - /// Training metrics (loss, accuracy, gradient norms, Q-values) - pub async fn train_from_parquet( - &mut self, - parquet_path: &str, - checkpoint_callback: F, - ) -> Result - where - F: FnMut(usize, Vec, bool) -> Result + Send, - { - info!("Starting DQN training from Parquet file: {}", parquet_path); - - // Load market data from Parquet file (returns train/val split) - let (mut training_data, mut validation_data) = - self.load_training_data_from_parquet(parquet_path).await?; - - info!( - "Loaded {} training samples, {} validation samples", - training_data.len(), - validation_data.len() - ); - - - // Calculate feature statistics from all training samples - info!("📊 Calculating feature statistics from {} training samples...", training_data.len()); - let feature_stats = self.calculate_feature_statistics(&training_data)?; - self.feature_stats = Some(feature_stats.clone()); - info!("✅ Feature statistics calculated: {} features normalized", feature_stats.mean.len()); - - // Normalize all training and validation samples BEFORE training starts - info!("📊 Normalizing all samples with z-score normalization..."); - self.normalize_dataset(&mut training_data)?; - self.normalize_dataset(&mut validation_data)?; - info!("✅ Dataset normalization complete"); - - // Store normalized validation data for validation loss computation - self.val_data = validation_data; - - // Use the same training loop as DBN-based training - self.train_with_data_full_loop(training_data, checkpoint_callback) - .await - } - - /// Load training data from Parquet file (Wave 12 Group 3) - /// Returns (train_data, val_data) with 80/20 split - pub async fn load_training_data_from_parquet( - &mut self, - parquet_path: &str, - ) -> Result<( - Vec<(FeatureVector51, Vec)>, - Vec<(FeatureVector51, Vec)>, - )> { - use arrow::array::{Array, Float64Array, PrimitiveArray, UInt64Array}; - use arrow::datatypes::TimestampNanosecondType; - use arrow::record_batch::RecordBatch; - use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; - use std::fs::File; - - info!("Loading Parquet file: {}", parquet_path); - - // TRY CACHE FIRST - if let Some(cache_dir) = &self.feature_cache_dir { - let parquet_path_obj = Path::new(parquet_path); - let mbp10 = Path::new("test_data/mbp10"); - - info!("🔍 Checking feature cache..."); - - // Calculate cache key - match crate::feature_cache::calculate_cache_key( - parquet_path_obj, - mbp10, - 50, // warmup period - ) { - Ok(cache_key) => { - // Try to load from cache - match crate::feature_cache::load_features_from_cache( - cache_dir, - &cache_key, - ).await { - Ok(Some(features)) => { - info!("🚀 Loaded {} feature vectors from cache", features.len()); - info!(" ⚡ Savings vs compute: ~2m 25s"); - - // Split into train/val (80/20) - let split_idx = (features.len() as f64 * 0.8) as usize; - let train_data: Vec<(FeatureVector51, Vec)> = features[..split_idx] - .iter() - .map(|f| (*f, vec![])) - .collect(); - let val_data: Vec<(FeatureVector51, Vec)> = features[split_idx..] - .iter() - .map(|f| (*f, vec![])) - .collect(); - - return Ok((train_data, val_data)); - } - Ok(None) => { - info!("⚠️ Cache miss, computing features from scratch..."); - } - Err(e) => { - warn!("⚠️ Cache load failed: {}, computing features...", e); - } - } - } - Err(e) => { - warn!("⚠️ Failed to calculate cache key: {}, skipping cache", e); - } - } - } - - // FALLBACK: Original feature extraction - info!("📊 Computing features from scratch..."); - - // Open Parquet file - let file = File::open(parquet_path) - .with_context(|| format!("Failed to open Parquet file: {}", parquet_path))?; - - // Create Parquet reader - let builder = ParquetRecordBatchReaderBuilder::try_new(file) - .with_context(|| "Failed to create Parquet reader")?; - - let reader = builder - .build() - .with_context(|| "Failed to build Parquet reader")?; - - // Read all batches - let mut all_ohlcv_bars = Vec::new(); - - for batch_result in reader { - let batch: RecordBatch = batch_result.with_context(|| "Failed to read record batch")?; - - // Extract columns by name (schema-agnostic approach) - // Required columns: timestamp_ns (or ts_event), open, high, low, close, volume - - // Try timestamp_ns first (our schema), fallback to ts_event (Databento schema) - let timestamp_col = batch - .column_by_name("timestamp_ns") - .or_else(|| batch.column_by_name("ts_event")) - .ok_or_else(|| { - anyhow::anyhow!( - "Missing timestamp column. Expected 'timestamp_ns' or 'ts_event'" - ) - })?; - - let timestamps = timestamp_col - .as_any() - .downcast_ref::>() - .ok_or_else(|| { - anyhow::anyhow!( - "Failed to downcast timestamp column. Expected Timestamp(Nanosecond), got: {:?}", - timestamp_col.data_type() - ) - })?; - - // Extract OHLCV columns by name - let opens = batch - .column_by_name("open") - .ok_or_else(|| anyhow::anyhow!("Missing 'open' column in Parquet schema"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'open' column type. Expected Float64"))?; - - let highs = batch - .column_by_name("high") - .ok_or_else(|| anyhow::anyhow!("Missing 'high' column in Parquet schema"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'high' column type. Expected Float64"))?; - - let lows = batch - .column_by_name("low") - .ok_or_else(|| anyhow::anyhow!("Missing 'low' column in Parquet schema"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'low' column type. Expected Float64"))?; - - let closes = batch - .column_by_name("close") - .ok_or_else(|| anyhow::anyhow!("Missing 'close' column in Parquet schema"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'close' column type. Expected Float64"))?; - - let volumes = batch - .column_by_name("volume") - .ok_or_else(|| anyhow::anyhow!("Missing 'volume' column in Parquet schema"))? - .as_any() - .downcast_ref::() - .ok_or_else(|| anyhow::anyhow!("Invalid 'volume' column type. Expected UInt64"))?; - - // Convert to OHLCVBar structs - for i in 0..batch.num_rows() { - let timestamp_ns = timestamps.value(i); - let timestamp = chrono::DateTime::from_timestamp_nanos(timestamp_ns); - - let bar = OHLCVBar { - timestamp, - open: opens.value(i), - high: highs.value(i), - low: lows.value(i), - close: closes.value(i), - volume: volumes.value(i) as f64, // Convert u64 to f64 - }; - all_ohlcv_bars.push(bar); - } - } - - info!( - "Successfully loaded {} OHLCV bars from Parquet file", - all_ohlcv_bars.len() - ); - - // Sort bars by timestamp (critical for rolling window feature extraction) - debug!("Sorting bars chronologically by timestamp..."); - all_ohlcv_bars.sort_by_key(|bar| bar.timestamp); - debug!("Bars sorted successfully"); - - // Wave 14 Agent 32: Preprocess close prices for stationarity - let preprocessed_closes = if self.hyperparams.enable_preprocessing { - info!("🔬 Preprocessing enabled: Applying log returns + windowed normalization + outlier clipping"); - - // Extract close prices - // WAVE 16E: Convert f64 to f32 for preprocessing (preprocessing expects f32 tensors) - let close_prices_f64: Vec = all_ohlcv_bars.iter().map(|b| b.close).collect(); - let close_prices_f32: Vec = close_prices_f64.iter().map(|&x| x as f32).collect(); - let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu); - let close_tensor = - Tensor::from_slice(&close_prices_f32, (close_prices_f32.len(),), &device) - .context("Failed to create close price tensor")?; - - // Configure preprocessing - let preprocess_config = PreprocessConfig { - window_size: self.hyperparams.preprocessing_window, - clip_sigma: self.hyperparams.preprocessing_clip_sigma, - use_log_returns: true, - }; - - info!(" • Window size: {}", preprocess_config.window_size); - info!(" • Clip sigma: ±{:.1}σ", preprocess_config.clip_sigma); - - // Apply preprocessing - let preprocessed_tensor = preprocess_prices(&close_tensor, preprocess_config) - .context("Failed to preprocess prices")?; - - let preprocessed_vec: Vec = preprocessed_tensor - .to_vec1() - .context("Failed to convert preprocessed tensor to vec")?; - - // Convert f32 to f64 for consistency with existing pipeline - let preprocessed_f64: Vec = preprocessed_vec.iter().map(|&x| x as f64).collect(); - - // Compute statistics for validation - let warmup = preprocess_config.window_size as usize; - let post_warmup: Vec = preprocessed_f64[warmup..].to_vec(); - let mean = post_warmup.iter().sum::() / post_warmup.len() as f64; - let variance = post_warmup.iter().map(|&x| (x - mean).powi(2)).sum::() - / post_warmup.len() as f64; - let std = variance.sqrt(); - let max_abs = post_warmup.iter().map(|&x| x.abs()).fold(0.0f64, f64::max); - - debug!("✅ Preprocessing complete:"); - debug!(" • Mean: {:.6} (expected ~0 for normalized data)", mean); - debug!(" • Std: {:.4} (expected ~1 for normalized data)", std); - debug!( - " • Max absolute value: {:.4} (clipped at ±{:.1}σ)", - max_abs, preprocess_config.clip_sigma - ); - - Some(preprocessed_f64) - } else { - info!("⚠️ Preprocessing disabled: Using raw close prices (NON-STATIONARY)"); - None - }; - - // WAVE 2-A2: Load MBP-10 snapshots for OFI calculation - use data::providers::databento::dbn_parser::DbnParser; - let mbp10_dir = Path::new("test_data/mbp10"); - let mbp10_snapshots = if mbp10_dir.exists() { - info!("📊 Loading MBP-10 order book snapshots for OFI calculation..."); - // Load all .dbn files in the directory - let mut all_snapshots = Vec::new(); - if let Ok(entries) = std::fs::read_dir(mbp10_dir) { - let parser = DbnParser::new() - .context("Failed to create DBN parser for MBP-10 data")?; - - for entry in entries.flatten() { - let path = entry.path(); - // Only load .dbn files (not .zst compressed files) - if path.extension().and_then(|s| s.to_str()) == Some("dbn") { - match parser.parse_mbp10_file(&path).await { - Ok(mut snaps) => { - info!(" ✅ Loaded {} snapshots from {:?}", snaps.len(), path.file_name()); - all_snapshots.append(&mut snaps); - } - Err(e) => { - warn!(" ⚠️ Failed to load {:?}: {}", path.file_name(), e); - } - } - } - } - } - - if all_snapshots.is_empty() { - warn!("⚠️ No MBP-10 snapshots loaded. OFI features will be zeros."); - None - } else { - // Sort snapshots by timestamp for efficient lookup - all_snapshots.sort_by_key(|s| s.timestamp); - info!("✅ Total MBP-10 snapshots loaded: {} (sorted by timestamp)", all_snapshots.len()); - Some(all_snapshots) - } - } else { - warn!("⚠️ MBP-10 directory not found at {:?}. OFI features will be zeros.", mbp10_dir); - None - }; - - // Extract 54-feature vectors (technical indicators, OFI, time, statistical features) - info!("Extracting 51-feature vectors from OHLCV bars (51-feature architecture)..."); - let feature_vectors = self.extract_full_features(&all_ohlcv_bars, mbp10_snapshots.as_deref())?; - - info!( - "Extracted {} feature vectors (51 dimensions: technical indicators, time, statistical features (Proxy OFI removed))", - feature_vectors.len() - ); - - // Create training data pairs (features, target) - // Target: [preprocessed_current, preprocessed_next, raw_current, raw_next] - // WAVE 3 BUG FIX: Include raw prices for triple barrier tracker (needs actual market prices in cents) - // Wave 14 Agent 32: Use preprocessed closes if enabled - let mut training_data = Vec::new(); - for i in 0..feature_vectors.len().saturating_sub(1) { - let (preprocessed_current, preprocessed_next, raw_current, raw_next) = if let Some(ref preprocessed) = preprocessed_closes { - // Use preprocessed values for reward calculation + raw for barrier tracker - ( - preprocessed[i + 50], - preprocessed[i + 1 + 50], - all_ohlcv_bars[i + 50].close, - all_ohlcv_bars[i + 1 + 50].close, - ) - } else { - // Use raw prices for both (original behavior) - let raw_curr = all_ohlcv_bars[i + 50].close; - let raw_next = all_ohlcv_bars[i + 1 + 50].close; - (raw_curr, raw_next, raw_curr, raw_next) - }; - training_data.push((feature_vectors[i], vec![preprocessed_current, preprocessed_next, raw_current, raw_next])); - } - // Last sample targets itself - if !feature_vectors.is_empty() { - let idx = all_ohlcv_bars.len() - 1; - let (preprocessed_close, raw_close) = if let Some(ref preprocessed) = preprocessed_closes { - (preprocessed[idx], all_ohlcv_bars[idx].close) - } else { - let raw = all_ohlcv_bars[idx].close; - (raw, raw) - }; - training_data.push(( - feature_vectors[feature_vectors.len() - 1], - vec![preprocessed_close, preprocessed_close, raw_close, raw_close], - )); - } - - info!( - "Created {} total samples with 54-dim features", - training_data.len() - ); - - // Split training data 80/20 for train/validation - let split_idx = (training_data.len() * 80) / 100; - let train_data = training_data[..split_idx].to_vec(); - let val_data = training_data[split_idx..].to_vec(); - - info!( - "Split data - Training samples: {}, Validation samples: {}", - train_data.len(), - val_data.len() - ); - - Ok((train_data, val_data)) - } - - /// Load training data from DBN files using official dbn crate decoder - /// Returns (train_data, val_data) with 80/20 split - async fn load_training_data( - &mut self, - dbn_data_dir: &str, - ) -> Result<( - Vec<(FeatureVector51, Vec)>, - Vec<(FeatureVector51, Vec)>, - )> { - // Find all DBN files in directory - let dir_path = Path::new(dbn_data_dir); - if !dir_path.exists() { - return Err(anyhow::anyhow!( - "Data directory not found: {}", - dbn_data_dir - )); - } - - let dbn_files: Vec<_> = std::fs::read_dir(dir_path)? - .filter_map(|entry| entry.ok()) - .filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("dbn")) - .map(|entry| entry.path()) - .collect(); - - if dbn_files.is_empty() { - return Err(anyhow::anyhow!("No DBN files found in: {}", dbn_data_dir)); - } - - info!("Found {} DBN files to load", dbn_files.len()); - - let mut all_ohlcv_bars = Vec::new(); - - // Load and decode each DBN file to collect OHLCV bars - for (file_idx, file_path) in dbn_files.iter().enumerate() { - debug!( - "Loading DBN file {}/{}: {}", - file_idx + 1, - dbn_files.len(), - file_path.display() - ); - - // Extract raw OHLCV bars from file - let file_bars = self.extract_ohlcv_bars_from_dbn(file_path)?; - - debug!( - "Extracted {} OHLCV bars from {}", - file_bars.len(), - file_path.file_name().unwrap_or_default().to_string_lossy() - ); - - all_ohlcv_bars.extend(file_bars); - } - - if all_ohlcv_bars.is_empty() { - return Err(anyhow::anyhow!( - "No OHLCV bars extracted from DBN files. Check if files contain OHLCV messages." - )); - } - - info!( - "Successfully loaded {} OHLCV bars from {} DBN files", - all_ohlcv_bars.len(), - dbn_files.len() - ); - - // Sort bars by timestamp (critical for rolling window feature extraction) - debug!("Sorting bars chronologically by timestamp..."); - all_ohlcv_bars.sort_by_key(|bar| bar.timestamp); - debug!("Bars sorted successfully"); - - // Extract 54-feature vectors (technical indicators, Proxy OFI, time, statistical features) - // Note: DBN loader does not load MBP-10 data, so OFI features will be zeros - info!("Extracting 51-feature vectors from OHLCV bars (51-feature architecture)..."); - let feature_vectors = self.extract_full_features(&all_ohlcv_bars, None)?; - - info!( - "Extracted {} feature vectors (51 dimensions: technical indicators, time, statistical features (Proxy OFI removed))", - feature_vectors.len() - ); - - // Create training data pairs (features, target) - // Target: [current_close, next_close] for proper reward calculation - let mut training_data = Vec::new(); - for i in 0..feature_vectors.len().saturating_sub(1) { - let current_close = all_ohlcv_bars[i + 50].close; // +50 to account for warmup period - let next_close = all_ohlcv_bars[i + 1 + 50].close; - training_data.push((feature_vectors[i], vec![current_close, next_close])); - } - // Last sample targets itself - if !feature_vectors.is_empty() { - let idx = all_ohlcv_bars.len() - 1; - let current_close = all_ohlcv_bars[idx].close; - training_data.push(( - feature_vectors[feature_vectors.len() - 1], - vec![current_close, current_close], - )); - } - - info!( - "Created {} total samples with 54-dim features", - training_data.len() - ); - - // Split training data 80/20 for train/validation - let split_idx = (training_data.len() * 80) / 100; - let train_data = training_data[..split_idx].to_vec(); - let val_data = training_data[split_idx..].to_vec(); - - info!( - "Split data - Training samples: {}, Validation samples: {}", - train_data.len(), - val_data.len() - ); - - Ok((train_data, val_data)) - } - - /// Extract raw OHLCV bars from DBN file using official dbn crate decoder - /// - /// This replaces the custom parser that only extracted 2 messages (header metadata). - /// Now extracts all OHLCV bars (400-500+ records per file). - /// - /// Public for testing purposes. - pub fn extract_ohlcv_bars_from_dbn(&self, file_path: &Path) -> Result> { - use dbn::decode::dbn::Decoder; - use dbn::decode::{DbnMetadata, DecodeRecordRef}; - use std::fs::File; - use std::io::BufReader; - - let mut ohlcv_bars = Vec::new(); - - // Open file and create official DBN decoder - let file = File::open(file_path) - .with_context(|| format!("Failed to open DBN file: {:?}", file_path))?; - let reader = BufReader::new(file); - - let mut decoder = Decoder::new(reader) - .map_err(|e| anyhow::anyhow!("Failed to create DBN decoder: {}", e))?; - - // Read metadata (for logging) - let metadata = decoder.metadata(); - debug!( - "DBN file metadata: dataset={:?}, schema={:?}, symbols={:?}", - metadata.dataset, metadata.schema, metadata.symbols - ); - - // Decode all OHLCV records - let mut ohlcv_count = 0; - let mut other_count = 0; - let mut idx = 0; - - loop { - match decoder.decode_record_ref() { - Ok(Some(record)) => { - idx += 1; - - // Convert RecordRef to RecordRefEnum for pattern matching - let record_enum = record - .as_enum() - .map_err(|e| anyhow::anyhow!("Failed to convert record to enum: {}", e))?; - - match record_enum { - dbn::RecordRefEnum::Ohlcv(ohlcv) => { - ohlcv_count += 1; - - // Extract OHLCV values (prices are i64 scaled by 1e-9 per DBN spec, volume is u64) - let open_f64 = ohlcv.open as f64 * 1e-9; - let high_f64 = ohlcv.high as f64 * 1e-9; - let low_f64 = ohlcv.low as f64 * 1e-9; - let close_f64 = ohlcv.close as f64 * 1e-9; - let volume_u64 = ohlcv.volume; - - // WAVE 8 AGENT 36: Validate all price values are finite (not NaN/Inf) - // Skip bars with invalid data to prevent NaN propagation - if !open_f64.is_finite() - || !high_f64.is_finite() - || !low_f64.is_finite() - || !close_f64.is_finite() - { - debug!( - "Skipping OHLCV bar {} with non-finite values: open={}, high={}, low={}, close={}", - ohlcv_count, open_f64, high_f64, low_f64, close_f64 - ); - continue; - } - - // Log first few records for validation - if ohlcv_count <= 5 { - debug!( - "Raw OHLCV #{}: open={}, high={}, low={}, close={}", - ohlcv_count, ohlcv.open, ohlcv.high, ohlcv.low, ohlcv.close - ); - debug!( - "Scaled OHLCV #{}: open={:.6}, high={:.6}, low={:.6}, close={:.6}", - ohlcv_count, open_f64, high_f64, low_f64, close_f64 - ); - } - - // Convert timestamp from nanoseconds since epoch to DateTime - let timestamp_nanos = ohlcv.hd.ts_event as i64; - let timestamp_secs = timestamp_nanos / 1_000_000_000; - let timestamp_nanos_remainder = - (timestamp_nanos % 1_000_000_000) as u32; - let timestamp = chrono::DateTime::::from_timestamp( - timestamp_secs, - timestamp_nanos_remainder, - ) - .unwrap_or_else(|| chrono::Utc::now()); - - // Create OHLCVBar for feature extraction pipeline - let bar = OHLCVBar { - timestamp, - open: open_f64, - high: high_f64, - low: low_f64, - close: close_f64, - volume: volume_u64 as f64, - }; - - ohlcv_bars.push(bar); - }, - _ => { - other_count += 1; - if other_count <= 5 { - debug!("Skipping non-OHLCV record at index {}", idx); - } - }, - } - }, - Ok(None) => { - // End of stream - break; - }, - Err(e) => { - return Err(anyhow::anyhow!("Failed to decode record {}: {}", idx, e)); - }, - } - } - - info!( - "Extracted {} OHLCV bars from {:?} ({} other records skipped)", - ohlcv_count, - file_path.file_name().unwrap_or_default(), - other_count - ); - - Ok(ohlcv_bars) - } - - /// Create features from OHLCV data - fn create_ohlcv_features( - &self, - open: f64, - high: f64, - low: f64, - close: f64, - volume: u64, - ) -> Result { - use std::collections::HashMap; - - // Use absolute values for Price type (futures data can have negative values) - // For ML training, the absolute magnitude is what matters for feature extraction - let close_price = - common::Price::from_f64(close.abs()).unwrap_or_else(|_| common::Price::ZERO); - let open_price = - common::Price::from_f64(open.abs()).unwrap_or_else(|_| common::Price::ZERO); - let high_price = - common::Price::from_f64(high.abs()).unwrap_or_else(|_| common::Price::ZERO); - let low_price = common::Price::from_f64(low.abs()).unwrap_or_else(|_| common::Price::ZERO); - - // Calculate technical indicators - let mut indicators = HashMap::new(); - - // Price-based features - let price_range = high - low; - let body_size = (close - open).abs(); - let upper_shadow = high - close.max(open); - let lower_shadow = close.min(open) - low; - - indicators.insert("price_range".to_string(), price_range); - indicators.insert("body_size".to_string(), body_size); - indicators.insert("upper_shadow".to_string(), upper_shadow); - indicators.insert("lower_shadow".to_string(), lower_shadow); - indicators.insert("close_to_high".to_string(), (close - high).abs()); - indicators.insert("close_to_low".to_string(), (close - low).abs()); - - // Microstructure features - let spread_bps = ((high - low) / close * 10000.0) as i32; - let trade_intensity = volume as f64; - - Ok(FinancialFeatures { - prices: vec![open_price, high_price, low_price, close_price], - volumes: vec![volume as i64], - technical_indicators: indicators, - microstructure: crate::training_pipeline::MicrostructureFeatures { - spread_bps, - imbalance: 0.0, // Not available from OHLCV - trade_intensity, - vwap: close_price, // Approximate VWAP as close - }, - risk_metrics: crate::training_pipeline::RiskFeatures { - var_5pct: -0.02, // Placeholder - expected_shortfall: -0.03, - max_drawdown: -0.05, - sharpe_ratio: 1.0, - }, - timestamp: chrono::Utc::now(), - }) - } - - /// Convert 54-dim feature vector to TradingState - /// - /// CRITICAL BUG FIX: Features 0-3 are LOG RETURNS (signed), not raw prices. - /// Using .abs() destroys directional information (bullish vs bearish). - /// We now use TradingState::from_normalized() to preserve sign information. - /// - /// Feature mapping: - /// - Features 0-3: OHLC log returns → price_features (signed, normalized) - /// - Features 4-224: All other features → technical_indicators (221 features including Wave D) - /// - /// # Arguments - /// - /// * `feature_vec` - 54-dimensional feature vector (46 base + 8 OFI placeholders) - /// * `close_price` - Current close price for portfolio feature calculation (optional) - /// - /// # Bug #4 Fix - /// - /// Added close_price parameter to enable portfolio feature population from PortfolioTracker. - fn feature_vector_to_state( - &self, - feature_vec: &FeatureVector51, - close_price: Option, // Used for portfolio feature population - ) -> Result { - // States are pre-normalized during data loading - let normalized_features: Vec = feature_vec.iter().map(|&v| v as f32).collect(); - - // Features 0-3 are LOG RETURNS - preserve sign information for price direction - let price_features: Vec = vec![ - normalized_features[0], // open log return (can be negative) - normalized_features[1], // high log return (can be negative) - normalized_features[2], // low log return (can be negative) - normalized_features[3], // close log return (can be negative) - ]; - - // 51-FEATURE ARCHITECTURE: Extract market features (indices 4-50) - // Features 0-3: OHLCV log returns (preserved above as price_features) - // Features 4-50: Technical indicators, time, statistical features (Proxy OFI removed in WAVE 10) - // Portfolio features added separately via PortfolioTracker below - assert_eq!( - normalized_features.len(), - 51, - "Expected 51 market features (got {})", - normalized_features.len() - ); - let market_features: Vec = normalized_features[4..51] - .iter() - .map(|&x| x as f32) - .collect(); - - // Legacy technical_indicators (empty for 51-feature architecture) - let technical_indicators = vec![]; - - // BUG #36 FIX: Use NORMALIZED portfolio features to prevent Q-value explosion - // - // Root Cause (Bug #36): RAW portfolio features cause Q-values to be 100x too large - // - Portfolio value = $100,000 (raw) → Q-values converge to ~10,000 - // - Expected: Portfolio value = 1.0 (normalized) → Q-values converge to ±100 - // - // REVERTS Bug #16 fix which incorrectly used raw values: - // - Bug #16 reasoning was flawed: normalized features work perfectly with percentage rewards - // - Reward calculation uses absolute P&L changes, not portfolio feature values - // - Normalization only affects network input, not reward calculation - // - // CORRECT BEHAVIOR (Bug #36 fix): - // - Portfolio value normalized to 1.0 (initial capital) - // - Position size normalized to [-1.0, 1.0] range - // - Rewards still based on absolute P&L (calculated from actual portfolio value) - // - Q-values converge to ±100 range (not ±10,000) - // - // WAVE 3.10: Model expects 140-dim input (125 market + 3 portfolio + 12 microstructure) - let portfolio_features = if let Some(price) = close_price { - let price_f32 = price.to_string().parse::().unwrap_or(0.0); - self.portfolio_tracker - .get_portfolio_features(price_f32) // BUG #36 FIX: Use NORMALIZED values - .to_vec() - } else { - vec![0.0, 0.0, 0.0] // Fallback if no price provided - }; - - // 54-FEATURE ARCHITECTURE: No regime features (removed for feature reduction) - // 54 features = 4 (OHLCV) + 50 (market/technical/OFI/statistical) - // Portfolio features added separately via PortfolioTracker (3 features) - let regime_features: Vec = vec![]; - - // Use from_normalized() to preserve sign information - Ok(TradingState::from_normalized( - price_features, - technical_indicators, - market_features, - portfolio_features, - regime_features, - )) - } - - /// Select action using epsilon-greedy - async fn select_action(&self, state: &TradingState) -> Result { - let _agent = self.agent.read().await; - - // Convert state to tensor - let state_vec = state.to_vector(); - let state_tensor = Tensor::new(&state_vec[..], &self.device) - .map_err(|e| anyhow::anyhow!("Failed to create state tensor: {}", e))? - .unsqueeze(0)?; // Add batch dimension - - // Get Q-values (epsilon-greedy handled by agent internally) - let action_idx = self.epsilon_greedy_action(&state_tensor).await?; - - FactoredAction::from_index(action_idx) - .map_err(|e| anyhow::anyhow!("Invalid action index {}: {}", action_idx, e)) - } - - /// Select actions for a batch of states (GPU-optimized) - /// - /// This method reduces GPU kernel launches by batching all action selections - /// into a single forward pass. Provides 125× reduction in kernel launches - /// compared to sequential select_action() calls. - /// - /// # Performance Impact - /// - Single GPU kernel launch for entire batch (vs. one per sample) - /// - Reduced CPU-GPU synchronization overhead - /// - Better GPU utilization through larger batch sizes - /// - /// # Arguments - /// * `states` - Slice of TradingState objects to process - /// - /// # Returns - /// Vector of TradingAction decisions (same order as input states) - async fn select_actions_batch(&self, states: &[TradingState]) -> Result> { - if states.is_empty() { - return Ok(Vec::new()); - } - - let agent = self.agent.read().await; - - // Convert all states to vectors - let state_vecs: Vec> = states.iter().map(|s| s.to_vector()).collect(); - - // Validate all states have consistent dimensions (first state sets the dimension) - let batch_size = states.len(); - if batch_size == 0 { - return Ok(Vec::new()); - } - - let state_dim = state_vecs[0].len(); - for (i, vec) in state_vecs.iter().enumerate().skip(1) { - if vec.len() != state_dim { - return Err(anyhow::anyhow!( - "State {} dimension mismatch: expected {}, got {}", - i, - state_dim, - vec.len() - )); - } - } - - // Flatten all states into single tensor [batch_size, state_dim] - let batched_states: Vec = state_vecs.into_iter().flat_map(|v| v.into_iter()).collect(); - - // Create batched tensor - let batch_tensor = Tensor::from_vec(batched_states, (batch_size, state_dim), &self.device) - .map_err(|e| anyhow::anyhow!("Failed to create batched state tensor: {}", e))?; - - // WAVE 16S: Get volatility-adjusted epsilon for exploration - let base_epsilon = agent.get_epsilon() as f64; - let adjusted_epsilon = self.calculate_volatility_adjusted_epsilon(base_epsilon); - let epsilon = adjusted_epsilon as f32; - - debug!("Epsilon: base={:.4}, volatility-adjusted={:.4}", base_epsilon, adjusted_epsilon); - - // Single forward pass for all samples (GPU-optimized) - let batch_q_values = agent - .forward(&batch_tensor) - .map_err(|e| anyhow::anyhow!("Batched forward pass failed: {}", e))?; - - drop(agent); // Release lock early - - // Extract Q-values and select actions (epsilon-greedy) - let mut actions = Vec::with_capacity(batch_size); - let mut rng = rand::thread_rng(); - - // WAVE 10.6: GPU-optimized argmax - compute argmax on GPU, only pull indices to CPU - let greedy_action_indices = batch_q_values - .argmax(1) - .map_err(|e| anyhow::anyhow!("Failed to compute argmax on GPU: {}", e))? - .to_vec1::() - .map_err(|e| anyhow::anyhow!("Failed to transfer argmax results to CPU: {}", e))?; - - for i in 0..batch_size { - use rand::Rng; - - let action_idx = if rng.gen::() < epsilon { - // Random exploration - rng.gen_range(0..45) - } else { - // Greedy exploitation: use precomputed argmax from GPU - greedy_action_indices[i] as usize - }; - - let action = FactoredAction::from_index(action_idx) - .map_err(|e| anyhow::anyhow!("Invalid action index {}: {}", action_idx, e))?; - - actions.push(action); - } - - Ok(actions) - } - - /// Epsilon-greedy action selection - async fn epsilon_greedy_action(&self, state: &Tensor) -> Result { - use rand::Rng; - - let epsilon = self.get_epsilon().await? as f32; - let mut rng = rand::thread_rng(); - - if rng.gen::() < epsilon { - // Random action (exploration) - Ok(rng.gen_range(0..45)) - } else { - // Greedy action (exploitation) - use actual Q-network - let agent = self.agent.read().await; - let q_values = agent.forward(state)?; - - // Find action with maximum Q-value (argmax) - let q_vec = q_values.squeeze(0)?.to_vec1::()?; - let best_action = q_vec - .iter() - .enumerate() - .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(idx, _)| idx) - .unwrap_or(2); // Default to HOLD (index 2) on tie/error - - Ok(best_action) - } - } - - /// Calculate reward based on price movement - /// - /// # Arguments - /// * `current_close` - Current bar's close price - /// * `next_close` - Next bar's close price (target) - /// - /// # Returns - /// Normalized reward in [-1.0, 1.0] based on price change - fn calculate_reward(&self, current_close: f64, next_close: f64) -> f32 { - let price_change = next_close - current_close; - // Normalize by 10.0 for ES futures typical moves (±10 points) - // Clamp to [-1.0, 1.0] to prevent extreme rewards - (price_change / 10.0).clamp(-1.0, 1.0) as f32 - } - - /// Store experience in replay buffer - async fn store_experience(&self, experience: Experience) -> Result<()> { - let agent = self.agent.read().await; - agent - .store_experience(experience) - .map_err(|e| anyhow::anyhow!("Failed to store experience: {}", e))?; - Ok(()) - } - - /// Check if we can train (buffer has enough samples) - async fn can_train(&self) -> Result { - let agent = self.agent.read().await; - Ok(agent.can_train()) - } - - /// Perform one training step using real DQN algorithm - /// - /// This method implements the core Deep Q-Learning algorithm: - /// 1. Sample batch from experience replay buffer - /// 2. Compute current Q-values: Q(s, a) - /// 3. Compute target Q-values: r + γ * max_a' Q_target(s', a') - /// 4. Calculate TD-error and MSE loss - /// 5. Backpropagate gradients and update Q-network - /// 6. Periodically update target network - /// - /// Returns: (loss, avg_q_value, grad_norm) - async fn train_step(&mut self) -> Result<(f64, f64, f64)> { - let mut agent = self.agent.write().await; - - // Call the agent's train_step which implements real Q-learning - // Now returns (loss, grad_norm) tuple with actual gradient norm from optimizer - let (loss_f32, grad_norm_f32) = agent - .train_step(None) - .map_err(|e| anyhow::anyhow!("Training step failed: {}", e))?; - - // WAVE 23 P0 Fix: Check for gradient collapse (early stopping) - // This calls log_diagnostics() which returns Err if collapse detected for consecutive epochs - agent.log_diagnostics(grad_norm_f32) - .map_err(|e| { - tracing::info!("🛑 Early stopping triggered (gradient collapse): {}", e); - anyhow::anyhow!("Early stopping: {}", e) - })?; - - // WAVE 6-A2: Emergency brake - clip extreme losses to prevent TD error explosions - // Max loss: 1 million (1e6) - prevents GPU memory spikes and numerical instability - // Normal losses: 0.01-1.0. Losses > 1e6 indicate catastrophic TD errors (77,000+) - // Reference: Wave 5-A3 analysis - highest loss observed: 659.5 billion (Trial 11, Epoch 1) - let loss_clipped = if loss_f32 > 1e6 { - warn!( - "Loss clipped from {:.2e} to 1.0e6 (TD error explosion detected, epoch {})", - loss_f32, - self.loss_history.len() + 1 - ); - 1e6 - } else { - loss_f32 - }; - - // Get Q-values from a sample state for monitoring (also performs Q-value divergence check) - let avg_q_value = self.estimate_avg_q_value_with_early_stopping(&mut agent).await?; - - // Convert to f64 for monitoring - let grad_norm = grad_norm_f32 as f64; - - // WAVE B Agent B3: Comprehensive gradient logging enhancement for monitoring - // Log actual gradient norm after clipping at debug level (detailed monitoring) - debug!("Gradient norm after clip (actual): {:.4}", grad_norm); - - // Interval logging every 10 steps at debug level (detailed metrics tracking) - self.gradient_logging_step += 1; - if self.gradient_logging_step % 10 == 0 { - debug!( - "Step {}: grad={:.4}, loss={:.4}", - self.gradient_logging_step, grad_norm, loss_clipped - ); - } - - Ok((loss_clipped as f64, avg_q_value, grad_norm)) - } - - /// Estimate average Q-value from replay buffer samples for monitoring - /// - /// WAVE 23 P0: Now includes Q-value divergence check (early stopping) - /// OPTIMIZATION: Batched Q-value estimation for 10× speedup via GPU parallelization - async fn estimate_avg_q_value_with_early_stopping(&self, agent: &mut DQNAgentType) -> Result { - // Get a few samples from the replay buffer to estimate Q-values - let buffer = agent.memory(); - - if buffer.len() == 0 { - return Ok(0.0); - } - - // Sample up to 10 experiences for Q-value estimation - let sample_size = buffer.len().min(10); - let batch_sample = buffer - .sample(sample_size) - .map_err(|e| anyhow::anyhow!("Failed to sample experiences: {}", e))?; - let samples = batch_sample.experiences; - - // OPTIMIZATION: Batch all states into single tensor for parallel GPU processing - // WAVE 10.4: Get state dimension from agent configuration (fixes hardcoded STATE_DIM bug) - let state_dim = agent.get_state_dim(); - - // samples is already destructured above (line 2520) - let batched_states: Vec = samples.iter().flat_map(|exp| exp.state.clone()).collect(); - - // Create batched tensor [batch_size, state_dim] - let batch_tensor = - Tensor::from_vec(batched_states, (sample_size, state_dim), agent.device()) - .map_err(|e| anyhow::anyhow!("Failed to create batched state tensor: {}", e))?; - - // WAVE 23 P0 Fix: Check for Q-value divergence (early stopping) - // This calls log_q_values() which returns Err if divergence detected for consecutive checks - agent.log_q_values(&batch_tensor) - .map_err(|e| { - tracing::info!("🛑 Early stopping triggered (Q-value divergence): {}", e); - anyhow::anyhow!("Early stopping: {}", e) - })?; - - // Single forward pass for all samples (10× faster than sequential) - let batch_q_values = agent - .forward(&batch_tensor) - .map_err(|e| anyhow::anyhow!("Batched forward pass failed: {}", e))?; - - // Get max Q-value per sample across action dimension - let max_q_values = batch_q_values - .max(1) - .map_err(|e| anyhow::anyhow!("Failed to compute max Q-values: {}", e))?; - - // Compute average across batch - let avg_q = max_q_values - .mean_all() - .map_err(|e| anyhow::anyhow!("Failed to compute mean Q-value: {}", e))? - .to_scalar::() - .map_err(|e| anyhow::anyhow!("Failed to extract average Q-value: {}", e))? - as f64; - - Ok(avg_q) - } - - /// Get current epsilon value - async fn get_epsilon(&self) -> Result { - let agent = self.agent.read().await; - Ok(agent.get_epsilon() as f64) - } - - /// Set epsilon value (used for deterministic evaluation) - async fn set_epsilon(&self, epsilon: f64) -> Result<()> { - let mut agent = self.agent.write().await; - agent.set_epsilon(epsilon); - Ok(()) - } - - /// Get best validation loss achieved during training - /// - /// Returns the lowest validation loss seen across all epochs. - /// Used by hyperopt adapter to optimize for generalization. - pub fn get_best_val_loss(&self) -> f64 { - self.best_val_loss - } - - /// Get epoch number where best validation loss was achieved - /// - /// Returns the 1-indexed epoch number with the best validation loss. - pub fn get_best_epoch(&self) -> usize { - self.best_epoch - } - - /// Get validation data for backtest integration - /// - /// Returns a reference to the validation dataset for hyperopt backtest evaluation. - /// Each entry contains a 54-dimensional feature vector and the corresponding target values. - /// Used by hyperopt adapter to run backtests on unseen data after training. - pub fn get_val_data(&self) -> &[(FeatureVector51, Vec)] { - &self.val_data - } - - /// Convert feature vector to state tensor for action selection - /// - /// Public wrapper around internal state conversion for hyperopt backtest integration. - /// Converts a 51-dimensional feature vector to a 54-dimensional state tensor - /// suitable for DQN agent's select_action method. - /// - /// # Arguments - /// - /// * `feature_vec` - 51-dimensional feature vector (43 base + 8 OFI placeholders) - /// * `close_price` - Current close price for portfolio feature calculation - /// - /// # Returns - /// - /// Result containing the 54-dimensional state tensor ready for model inference. - /// Portfolio features (last 3 dimensions) are populated via PortfolioTracker. - pub fn convert_to_state( - &self, - feature_vec: &FeatureVector51, - close_price: f64, - ) -> Result { - let close = rust_decimal::Decimal::try_from(close_price) - .map_err(|e| CommonError::validation(&format!("Invalid close price: {}", e)))?; - - // Use internal conversion method (returns TradingState) - let trading_state = self.feature_vector_to_state(feature_vec, Some(close))?; - - // Convert TradingState to flat vector - let state_vec = trading_state.to_vector(); - - // Convert to Tensor using trainer's device (GPU or CPU) - Tensor::new(state_vec.as_slice(), &self.device) - .context("Failed to create state tensor from TradingState") - } - - /// Get access to the DQN agent - /// - /// Returns a reference to the Arc> for checkpoint saving. - /// Used by hyperopt adapter to save model weights after training. - pub fn get_agent(&self) -> &Arc> { - &self.agent - } - - /// Serialize model to bytes - pub async fn serialize_model(&self) -> Result> { - let agent = self.agent.read().await; - - // Create temp file for SafeTensors serialization - let temp_path = std::env::temp_dir().join(format!("dqn_{}.safetensors", Uuid::new_v4())); - - // Save Q-network to SafeTensors - agent - .get_q_network_vars() - .save(&temp_path) - .map_err(|e| anyhow::anyhow!("Failed to save Q-network: {}", e))?; - - // Read serialized data - let data = std::fs::read(&temp_path) - .map_err(|e| anyhow::anyhow!("Failed to read checkpoint: {}", e))?; - - // Clean up temp file - let _ = std::fs::remove_file(&temp_path); - - Ok(data) - } - - /// BUG #38 FIX: Clear replay buffer of contaminated experiences - pub async fn clear_replay_buffer(&mut self) -> Result<()> { - let mut agent = self.agent.write().await; - agent.clear_replay_buffer().map_err(|e| { - anyhow::anyhow!("Failed to clear replay buffer: {}", e) - })?; - let buffer_size = agent.get_replay_buffer_size().unwrap_or(0); - info!("Replay buffer cleared successfully. Current size: {}", buffer_size); - Ok(()) - } - - /// BUG #38 FIX: Reset target network to match current network - pub async fn reset_target_network(&mut self) -> Result<()> { - let mut agent = self.agent.write().await; - agent.reset_target_network().map_err(|e| { - anyhow::anyhow!("Failed to reset target network: {}", e) - })?; - info!("Target network reset successfully"); - Ok(()) - } - - // WAVE 16S: Adaptive Risk Management Helper Methods - - /// Calculate volatility-adjusted epsilon for exploration - /// - /// Adjusts epsilon based on recent return volatility: - /// - Low volatility (<1%): reduce epsilon (exploit more) - /// - High volatility (>5%): increase epsilon (explore more) - /// - Moderate volatility: linear interpolation - fn calculate_volatility_adjusted_epsilon(&self, base_epsilon: f64) -> f64 { - if !self.hyperparams.enable_volatility_epsilon || self.volatility_returns.len() < 10 { - return base_epsilon; - } - - // Calculate volatility (standard deviation of returns) - let mean: f64 = self.volatility_returns.iter().sum::() / self.volatility_returns.len() as f64; - let variance: f64 = self.volatility_returns.iter() - .map(|x| (x - mean).powi(2)) - .sum::() / self.volatility_returns.len() as f64; - let volatility = variance.sqrt(); - - // Adjust epsilon based on volatility regime - let multiplier = if volatility < 0.01 { - 0.5 // Low volatility: exploit more (reduce epsilon) - } else if volatility > 0.05 { - 2.0 // High volatility: explore more (increase epsilon) - } else { - // Linear interpolation between 0.01 and 0.05 - 0.5 + (volatility - 0.01) / 0.04 * 1.5 - }; - - (base_epsilon * multiplier).clamp(0.05, 0.95) - } - - /// Calculate risk-adjusted reward using Sharpe ratio - /// - /// Amplifies rewards for consistent profitable strategies, - /// reduces rewards for volatile strategies. - fn calculate_risk_adjusted_reward(&self, raw_reward: f64) -> f64 { - if !self.hyperparams.enable_risk_adjusted_rewards || self.pnl_history.len() < 20 { - return raw_reward; - } - - // Calculate Sharpe ratio from PnL history - let mean: f64 = self.pnl_history.iter().sum::() / self.pnl_history.len() as f64; - let variance: f64 = self.pnl_history.iter() - .map(|x| (x - mean).powi(2)) - .sum::() / self.pnl_history.len() as f64; - let std_dev = variance.sqrt().max(1e-8); - let sharpe = mean / std_dev; - - // Apply Sharpe multiplier to reward - // Positive Sharpe amplifies reward, negative reduces it - raw_reward * sharpe - } - - /// Get Kelly fraction for position sizing - /// - /// Returns Kelly criterion position size (0.0-0.25) based on trade history. - /// Requires minimum trade history for statistical significance. - pub fn get_kelly_fraction(&self) -> f64 { - if !self.hyperparams.enable_kelly_sizing { - return 1.0; // Full position sizing (disabled) - } - - let kelly_opt = match &self.kelly_optimizer { - Some(opt) => opt, - None => return 1.0, - }; - - // Need minimum trades for Kelly calculation - if self.trade_history.len() < self.hyperparams.kelly_min_trades { - return 0.1; // Conservative default until enough history - } - - // Calculate win/loss statistics - let wins: Vec = self.trade_history.iter() - .filter(|&&r| r > 0.0) - .copied() - .collect(); - let losses: Vec = self.trade_history.iter() - .filter(|&&r| r < 0.0) - .map(|r| -r) - .collect(); - - let win_prob = wins.len() as f64 / self.trade_history.len() as f64; - let avg_win = if wins.is_empty() { - 0.01 - } else { - wins.iter().sum::() / wins.len() as f64 - }; - let avg_loss = if losses.is_empty() { - 0.01 - } else { - losses.iter().sum::() / losses.len() as f64 - }; - - // Calculate Kelly fraction - let kelly_result = kelly_opt.calculate_basic_kelly(win_prob, avg_win, avg_loss); - let kelly_fraction = kelly_result.unwrap_or(0.1); - - // Apply fractional Kelly (conservative) - let fractional_kelly = kelly_fraction * self.hyperparams.kelly_fractional; - - // Clamp to safety bounds - fractional_kelly.clamp(0.01, self.hyperparams.kelly_max_fraction) - } - - /// Update adaptive risk trackers with new market data - fn update_risk_trackers(&mut self, reward: f64, price_return: f64) { - // Update PnL history - self.pnl_history.push_back(reward); - if self.pnl_history.len() > 1000 { - self.pnl_history.pop_front(); - } - - // Update volatility tracker - self.volatility_returns.push_back(price_return); - if self.volatility_returns.len() > self.hyperparams.volatility_window { - self.volatility_returns.pop_front(); - } - - // Update trade history for Kelly - if reward.abs() > 1e-6 { // Only track non-zero rewards - self.trade_history.push_back(reward); - if self.trade_history.len() > 500 { - self.trade_history.pop_front(); - } - } - } - - /// Create synthetic features (placeholder for testing) - fn create_synthetic_features(&self, price: f64) -> Result { - use std::collections::HashMap; - - let price_obj = - common::Price::from_f64(price).unwrap_or_else(|_| common::Price::new(price).unwrap()); - - let mut indicators = HashMap::new(); - indicators.insert("rsi_14".to_string(), 50.0); - indicators.insert("sma_20".to_string(), price); - indicators.insert("ema_12".to_string(), price); - - Ok(FinancialFeatures { - prices: vec![price_obj; 4], - volumes: vec![1000], - technical_indicators: indicators, - microstructure: crate::training_pipeline::MicrostructureFeatures { - spread_bps: 10, - imbalance: 0.0, - trade_intensity: 100.0, - vwap: price_obj, - }, - risk_metrics: crate::training_pipeline::RiskFeatures { - var_5pct: -0.02, - expected_shortfall: -0.03, - max_drawdown: -0.05, - sharpe_ratio: 1.0, - }, - timestamp: chrono::Utc::now(), - }) - } - - /// Get current training metrics - pub async fn get_metrics(&self) -> TrainingMetrics { - self.metrics.read().await.clone() - } - - /// WAVE 3.10: Extract 140 features (125 market + 3 portfolio + 12 microstructure) - /// - /// This method extracts 140 features for DQN state representation: - /// - 125 market features (price, technical indicators, volatility, etc.) - /// - 3 portfolio features (populated later via PortfolioTracker) - /// - 12 microstructure features (spread estimators, liquidity, order flow, market impact) - /// - /// The microstructure features are calculated on-the-fly from OHLCV data using - /// the calculators initialized in DQNTrainer::new(). - /// - /// # Arguments - /// - /// * `bars` - OHLCV bars for feature extraction - /// * `mbp10_snapshots` - Optional MBP-10 order book snapshots for OFI calculation - fn extract_full_features( - &mut self, - bars: &[OHLCVBar], - mbp10_snapshots: Option<&[data::providers::databento::mbp10::Mbp10Snapshot]>, - ) -> Result> { - use crate::features::extraction::FeatureExtractor; - - if bars.is_empty() { - anyhow::bail!("Cannot extract features from empty bar sequence"); - } - - const WARMUP_PERIOD: usize = 50; - if bars.len() < WARMUP_PERIOD { - anyhow::bail!( - "Insufficient data: {} bars provided, {} required for warmup", - bars.len(), - WARMUP_PERIOD - ); - } - - let mut extractor = FeatureExtractor::new(); - let mut feature_vectors = Vec::with_capacity(bars.len() - WARMUP_PERIOD); - - // Feed bars sequentially to build rolling windows - for (i, bar) in bars.iter().enumerate() { - extractor.update(bar)?; - - // WAVE 3.10: Update microstructure features with current bar - // Calculate timestamp in nanoseconds - let timestamp_ns = bar.timestamp.timestamp_nanos_opt().unwrap_or(0) as u64; - - // Update all 8 microstructure calculators (4 more already exist in microstructure.rs) - let hl_spread = self.micro_high_low_spread.update(bar.high, bar.low); - let _vw_spread = self.micro_vw_spread.update(hl_spread, bar.volume); - let _tick_count = self.micro_tick_count.update(bar.close); - let _inter_arrival = self.micro_inter_arrival.update(timestamp_ns); - let _buy_sell_imb = self.micro_buy_sell_imbalance.update(bar.close, bar.volume); - - // Kyle's Lambda: slow-updating (only updates every 5 minutes) - let return_pct = if self.last_close > 0.0 { - (bar.close - self.last_close) / self.last_close - } else { - 0.0 - }; - let signed_volume = (bar.close - bar.open).signum() * (bar.close * bar.volume).sqrt(); - let _kyle_lambda = self.micro_kyle_lambda.maybe_update(timestamp_ns, return_pct, signed_volume); - - let _price_impact = self.micro_price_impact.update(bar.high, bar.low, bar.close); - let _variance_ratio = self.micro_variance_ratio.update(return_pct); - - // Track last close for next iteration - self.last_close = bar.close; - - // Start extracting features after warmup - if i >= WARMUP_PERIOD { - // WAVE 10: Extract 43 base features + 8 OFI features (51 total, Proxy OFI removed) - // Features breakdown: - // - 0-4: OHLCV (5) - // - 5-9: Technical indicators (5) - // - 10-15: Price patterns (6) - // - 16-21: Volume features (6) - // - 22-26: Time-based (5) - // - 27-39: Statistical (13) - // - 40-42: Regime detection (3) - // - 43-50: OFI features (8) - // TOTAL: 51 features - - // Extract features with OFI if MBP-10 data available - let features_51 = if let Some(mbp10_data) = mbp10_snapshots { - // Calculate OFI features from MBP-10 order book data - use crate::features::mbp10_loader::get_snapshots_for_timestamp; - - let bar_timestamp_ns = bar.timestamp.timestamp_nanos_opt().unwrap_or(0) as u64; - let window = get_snapshots_for_timestamp(mbp10_data, bar_timestamp_ns, 100); - - if !window.is_empty() { - match extractor.extract_current_features_with_ofi(window) { - Ok(feats) => feats, - Err(e) => { - warn!("Failed to calculate OFI for bar {}: {}. Using zeros for OFI.", i, e); - let base_features_43 = extractor.extract_current_features_v2()?; - let mut feats = [0.0; 51]; - feats[..43].copy_from_slice(&base_features_43); - feats - } - } - } else { - // No MBP-10 snapshots for this timestamp - let base_features_43 = extractor.extract_current_features_v2()?; - let mut feats = [0.0; 51]; - feats[..43].copy_from_slice(&base_features_43); - feats - } - } else { - // Fallback: Use base 43 features + zero-padded OFI - let base_features_43 = extractor.extract_current_features_v2()?; - let mut feats = [0.0; 51]; - feats[..43].copy_from_slice(&base_features_43); - feats - }; - - feature_vectors.push(features_51); - } - } - - Ok(feature_vectors) - } - - /// Calculate feature statistics from training samples using Welford's algorithm - fn calculate_feature_statistics( - &self, - samples: &[(FeatureVector51, Vec)], - ) -> Result { - let mut stats = FeatureStatistics::new(54); - - for (feature_vec, _) in samples { - let features: Vec = feature_vec.iter().map(|&v| v as f32).collect(); - stats.update(&features); - } - - Ok(stats) - } - - /// Normalize all samples in a dataset using z-score normalization - fn normalize_dataset( - &mut self, - samples: &mut [(FeatureVector51, Vec)], - ) -> Result<()> { - if let Some(ref stats) = self.feature_stats { - for (feature_vec, _) in samples.iter_mut() { - // Convert to f32 for normalization - let features_f32: Vec = feature_vec.iter().map(|&v| v as f32).collect(); - - // Normalize with skip (indices 125-127 are portfolio placeholders) - let normalized = stats.normalize_with_skip(&features_f32, &[125, 126, 127]); - - // Convert back to f64 and update - for (i, &val) in normalized.iter().enumerate() { - feature_vec[i] = val as f64; - } - } - } else { - return Err(anyhow::anyhow!("Feature statistics not initialized")); - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Helper function to create test hyperparameters - // Uses conservative defaults suitable for testing - fn create_test_params() -> DQNHyperparameters { - let params = DQNHyperparameters::conservative(); - // WAVE 9.1 FIX: Re-enable distributional dueling (CUDA device mismatch fixed) - // Root cause fixed in ml/src/dqn/distributional.rs (removed cfg!(test) check) - // Tests now use distributional dueling like production - params - } - - #[tokio::test] - async fn test_dqn_trainer_creation() { - let hyperparams = create_test_params(); - let trainer = DQNTrainer::new(hyperparams); - - assert!( - trainer.is_ok(), - "Failed to create DQN trainer: {:?}", - trainer.err() - ); - } - - #[tokio::test] - async fn test_batch_size_validation() { - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 500; // Exceeds GPU limit - - let trainer = DQNTrainer::new(hyperparams); - assert!(trainer.is_err(), "Should reject batch size > 230"); - } - - #[tokio::test] - async fn test_feature_vector_to_state() { - let hyperparams = create_test_params(); - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - // Create a synthetic 51-dim feature vector (51 features: 43 base + 8 OFI placeholders) - let mut feature_vec = [0.0; 51]; - feature_vec[0] = 4000.0; // open - feature_vec[1] = 4010.0; // high - feature_vec[2] = 3990.0; // low - feature_vec[3] = 4005.0; // close - feature_vec[4] = 1000.0; // volume - // Fill remaining features with synthetic data - for i in 5..51 { - feature_vec[i] = (i as f64) * 0.1; - } - - let close_price = - rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO); - let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)); - - assert!( - state.is_ok(), - "Failed to convert feature vector: {:?}", - state.err() - ); - - let state = state.unwrap(); - // WAVE 10: State dimension is 54 (51 market + 3 portfolio + 0 regime) - // - Market features: 0-50 (51 features: Proxy OFI removed) - // - Portfolio features: 51-53 (3 features, populated by PortfolioTracker) - // - Regime features: none (removed in WAVE 10) - assert_eq!( - state.dimension(), - 54, - "State dimension should be 54 (WAVE 10: 51+3+0 features)" - ); - } - - #[tokio::test] - async fn test_batched_action_selection() { - let hyperparams = create_test_params(); - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - // Create multiple synthetic states for batched action selection - let batch_size = 10; - let mut states = Vec::with_capacity(batch_size); - - for i in 0..batch_size { - let mut feature_vec = [0.0; 51]; // 51 features: 43 base + 8 OFI placeholders - // Create varied states for testing - feature_vec[0] = 4000.0 + (i as f64 * 10.0); // open - feature_vec[1] = 4010.0 + (i as f64 * 10.0); // high - feature_vec[2] = 3990.0 + (i as f64 * 10.0); // low - feature_vec[3] = 4005.0 + (i as f64 * 10.0); // close - feature_vec[4] = 1000.0 + (i as f64 * 100.0); // volume - - // Fill remaining features - for j in 5..51 { - // 51 features: 43 base + 8 OFI placeholders - feature_vec[j] = (j as f64 + i as f64) * 0.1; - } - - let close_price = rust_decimal::Decimal::try_from(feature_vec[3]) - .unwrap_or(rust_decimal::Decimal::ZERO); - let state = trainer - .feature_vector_to_state(&feature_vec, Some(close_price)) - .unwrap(); - states.push(state); - } - - // Test batched action selection - let actions_result = trainer.select_actions_batch(&states).await; - - assert!( - actions_result.is_ok(), - "Batched action selection failed: {:?}", - actions_result.err() - ); - - let actions = actions_result.unwrap(); - assert_eq!( - actions.len(), - batch_size, - "Expected {} actions, got {}", - batch_size, - actions.len() - ); - - // Verify all actions are valid FactoredActions - for (i, action) in actions.iter().enumerate() { - // Valid action: index 0-44 - let idx = action.to_index(); - assert!( - idx < 45, - "Action {} has invalid index {}: {:?}", - i, - idx, - action - ); - } - } - - #[tokio::test] - async fn test_batched_vs_sequential_action_selection_consistency() { - let hyperparams = create_test_params(); - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - // Create test states - let batch_size = 5; - let mut states = Vec::with_capacity(batch_size); - - for i in 0..batch_size { - let mut feature_vec = [0.0; 51]; // 51 features: 43 base + 8 OFI placeholders - feature_vec[0] = 4000.0 + (i as f64 * 50.0); - feature_vec[1] = 4050.0 + (i as f64 * 50.0); - feature_vec[2] = 3950.0 + (i as f64 * 50.0); - feature_vec[3] = 4025.0 + (i as f64 * 50.0); - feature_vec[4] = 5000.0 + (i as f64 * 500.0); - - for j in 5..51 { - // 51 features: 43 base + 8 OFI placeholders - feature_vec[j] = (j as f64) * 0.5 + (i as f64); - } - - let close_price = rust_decimal::Decimal::try_from(feature_vec[3]) - .unwrap_or(rust_decimal::Decimal::ZERO); - let state = trainer - .feature_vector_to_state(&feature_vec, Some(close_price)) - .unwrap(); - states.push(state); - } - - // Get batched actions (GPU-optimized) - let batched_actions = trainer.select_actions_batch(&states).await.unwrap(); - - // Both should return valid actions - assert_eq!( - batched_actions.len(), - batch_size, - "Batched action count mismatch" - ); - - // Verify all actions are valid FactoredActions (can't compare exact values due to epsilon-greedy randomness) - for action in &batched_actions { - // Valid action: index 0-44 - let idx = action.to_index(); - assert!(idx < 45, "Invalid action index {}: {:?}", idx, action); - } - } - - #[tokio::test] - async fn test_empty_batch_handling() { - let hyperparams = create_test_params(); - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - let empty_states: Vec = Vec::new(); - let result = trainer.select_actions_batch(&empty_states).await; - - assert!(result.is_ok(), "Empty batch should be handled gracefully"); - assert_eq!( - result.unwrap().len(), - 0, - "Empty batch should return empty actions" - ); - } - - #[tokio::test] - async fn test_zero_batch_size_handling() { - // Test DQN rejects zero batch size - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 0; - - let result = DQNTrainer::new(hyperparams); - - // Should fail with descriptive error - assert!( - result.is_err(), - "DQN should reject zero batch size, but got: {:?}", - result - ); - - // Error message should mention batch size - let error_msg = result.unwrap_err().to_string(); - assert!( - error_msg.to_lowercase().contains("batch"), - "Error message should mention batch size, got: {}", - error_msg - ); - } - - // ===== Agent 23 Test #6: Batch Size Mismatch Validation Tests ===== - - /// Production-critical test: Verify trainer handles batch smaller than configured - #[tokio::test] - async fn test_batch_size_mismatch_smaller_than_configured() { - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 32; - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - // Create batch with 16 states (half of configured 32) - let mut feature_vec = [0.0; 51]; // 51 features: 43 base + 8 OFI placeholders - for i in 0..4 { - feature_vec[i] = 4000.0 + (i as f64 * 10.0); - } - for i in 5..51 { - feature_vec[i] = (i as f64) * 0.1; - } - - let close_price = - rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO); - let state = trainer - .feature_vector_to_state(&feature_vec, Some(close_price)) - .unwrap(); - let smaller_batch = vec![state.clone(); 16]; - - let result = trainer.select_actions_batch(&smaller_batch).await; - assert!( - result.is_ok(), - "DQN should handle smaller batches: {:?}", - result.err() - ); - assert_eq!( - result.unwrap().len(), - 16, - "Should return action for each state" - ); - } - - /// Production-critical test: Verify trainer handles batch larger than configured - #[tokio::test] - async fn test_batch_size_mismatch_larger_than_configured() { - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 16; - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - // Create batch with 64 states (4x configured 16) - let mut feature_vec = [0.0; 51]; // 51 features: 43 base + 8 OFI placeholders - for i in 0..4 { - feature_vec[i] = 4000.0 + (i as f64 * 10.0); - } - for i in 5..51 { - feature_vec[i] = (i as f64) * 0.1; - } - - let close_price = - rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO); - let state = trainer - .feature_vector_to_state(&feature_vec, Some(close_price)) - .unwrap(); - let larger_batch = vec![state.clone(); 64]; - - let result = trainer.select_actions_batch(&larger_batch).await; - assert!( - result.is_ok(), - "DQN should handle larger batches: {:?}", - result.err() - ); - assert_eq!( - result.unwrap().len(), - 64, - "Should return action for each state" - ); - } - - /// Production-critical test: Verify empty batch handling - #[tokio::test] - async fn test_empty_batch_returns_empty_actions() { - let trainer = DQNTrainer::new(create_test_params()).unwrap(); - let empty_batch: Vec = vec![]; - - let result = trainer.select_actions_batch(&empty_batch).await; - assert!(result.is_ok(), "Should handle empty batch gracefully"); - assert_eq!( - result.unwrap().len(), - 0, - "Empty batch should return empty actions" - ); - } - - /// Production-critical test: Verify single-sample batch handling - #[tokio::test] - async fn test_single_sample_batch() { - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 32; - let trainer = DQNTrainer::new(hyperparams).unwrap(); - - let mut feature_vec = [0.0; 51]; // 51 features: 43 base + 8 OFI placeholders - for i in 0..4 { - feature_vec[i] = 4000.0; - } - for i in 5..51 { - feature_vec[i] = (i as f64) * 0.1; - } - - let close_price = - rust_decimal::Decimal::try_from(feature_vec[3]).unwrap_or(rust_decimal::Decimal::ZERO); - let state = trainer - .feature_vector_to_state(&feature_vec, Some(close_price)) - .unwrap(); - let single_batch = vec![state]; - - let result = trainer.select_actions_batch(&single_batch).await; - assert!( - result.is_ok(), - "Should handle single-sample batch: {:?}", - result.err() - ); - assert_eq!(result.unwrap().len(), 1, "Should return exactly one action"); - } - - /// Production-critical test: GPU memory limit enforcement - #[test] - fn test_gpu_batch_limit_230_enforced() { - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 300; - - let result = DQNTrainer::new(hyperparams); - assert!( - result.is_err(), - "Should reject batch_size=300 (>230 GPU limit)" - ); - - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("230"), - "Error should mention GPU limit: {}", - err_msg - ); - assert!( - err_msg.contains("batch"), - "Error should mention batch size: {}", - err_msg - ); - } - - /// Production-critical test: Non-power-of-2 batch sizes - #[tokio::test] - async fn test_non_power_of_two_batch_size() { - let mut hyperparams = create_test_params(); - hyperparams.batch_size = 13; // Not a power of 2 - - let result = DQNTrainer::new(hyperparams); - assert!( - result.is_ok(), - "Should accept non-power-of-2 batch sizes: {:?}", - result.err() - ); - } - - /// Production-critical test: Train with empty dataset - #[tokio::test] - async fn test_train_with_empty_data_completes_gracefully() { - let mut trainer = DQNTrainer::new(create_test_params()).unwrap(); - let empty_data: Vec<(FeatureVector51, Vec)> = vec![]; - let checkpoint_callback = |_, _, _| Ok(String::new()); - - let result = trainer - .train_with_data_full_loop(empty_data, checkpoint_callback) - .await; - - assert!( - result.is_ok(), - "Training with empty data should complete: {:?}", - result.err() - ); - let metrics = result.unwrap(); - assert_eq!( - metrics.epochs_trained, 100, - "Should complete all epochs even with no data" - ); - assert_eq!(metrics.loss, 0.0, "Loss should be 0 for empty data"); - } - - /// Test reward function calculates actual price changes correctly - #[test] - fn test_reward_function_price_changes() { - let trainer = DQNTrainer::new(create_test_params()).unwrap(); - - // Test upward price move (+14.25 points, should clamp to +1.0) - let reward_up = trainer.calculate_reward(5900.0, 5914.25); - assert!( - (reward_up - 1.0).abs() < 1e-6, - "Upward move should return +1.0 (clamped), got: {}", - reward_up - ); - - // Test downward price move (-14.25 points, should clamp to -1.0) - let reward_down = trainer.calculate_reward(5914.25, 5900.0); - assert!( - (reward_down - (-1.0)).abs() < 1e-6, - "Downward move should return -1.0 (clamped), got: {}", - reward_down - ); - - // Test flat market (0 points, should return 0.0) - let reward_flat = trainer.calculate_reward(5900.0, 5900.0); - assert!( - reward_flat.abs() < 1e-6, - "Flat market should return 0.0, got: {}", - reward_flat - ); - - // Test small upward move (+5 points, should return +0.5) - let reward_small_up = trainer.calculate_reward(5900.0, 5905.0); - assert!( - (reward_small_up - 0.5).abs() < 1e-6, - "Small upward move (+5) should return +0.5, got: {}", - reward_small_up - ); - - // Test small downward move (-5 points, should return -0.5) - let reward_small_down = trainer.calculate_reward(5905.0, 5900.0); - assert!( - (reward_small_down - (-0.5)).abs() < 1e-6, - "Small downward move (-5) should return -0.5, got: {}", - reward_small_down - ); - - // Test unclamped move (+3 points, should return +0.3) - let reward_unclamped = trainer.calculate_reward(5900.0, 5903.0); - assert!( - (reward_unclamped - 0.3).abs() < 1e-6, - "Move of +3 points should return +0.3, got: {}", - reward_unclamped - ); - } -} diff --git a/ml/src/trainers/tft.rs.backup b/ml/src/trainers/tft.rs.backup deleted file mode 100644 index 6857953c2..000000000 --- a/ml/src/trainers/tft.rs.backup +++ /dev/null @@ -1,2915 +0,0 @@ -//! Temporal Fusion Transformer Trainer with gRPC Interface -//! -//! Production-grade TFT trainer optimized for GPU (4GB VRAM) with checkpoint -//! management, real-time metrics reporting, and MinIO/S3 storage integration. -//! -//! ## Features -//! -//! - GPU acceleration with memory-efficient attention -//! - Quantile loss for probabilistic forecasting -//! - Attention weights analysis -//! - Real-time training progress streaming -//! - Checkpoint persistence to MinIO/S3 -//! - RMSE and quantile loss metrics - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime}; - -use candle_core::{Device, IndexOp, Tensor}; -use candle_nn::VarMap; -use ndarray::Dimension; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; -use tracing::{debug, error, info, instrument, warn}; - -use crate::checkpoint::{ - CheckpointConfig, CheckpointManager, CheckpointMetadata, CheckpointStorage, -}; -use crate::memory_optimization::{AutoBatchSizer, BatchSizeConfig, ModelPrecision, OptimizerType}; -use crate::tft::training::{TFTBatch, TFTDataLoader, TFTTrainingConfig}; -use crate::tft::{TFTConfig, TemporalFusionTransformer}; -use crate::{MLError, MLResult}; - -// ============================================================================ -// QAT Metrics Export Types (Prometheus-compatible) -// ============================================================================ - -/// Comprehensive QAT metrics for Prometheus/Grafana export -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct QATMetrics { - /// Number of FakeQuantize observers - pub observer_count: usize, - - /// Statistics for quantization scales across all layers - pub scale_statistics: ScaleStatistics, - - /// Statistics for quantization zero points across all layers - pub zero_point_statistics: ZeroPointStatistics, - - /// Observer activation range statistics - pub observer_ranges: ObserverRangeStatistics, - - /// Per-layer quantization metrics - pub layer_metrics: Vec, - - /// Calibration convergence (0.0 to 1.0) - pub calibration_convergence: f64, - - /// Overall quantization error (FP32 vs INT8 difference) - pub quantization_error: f64, -} - -/// Statistics for quantization scale factors -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScaleStatistics { - pub min: f64, - pub max: f64, - pub mean: f64, - pub std: f64, -} - -/// Statistics for quantization zero points -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ZeroPointStatistics { - pub min: i32, - pub max: i32, - pub mean: i32, - pub mode: i32, // Most common zero point (typically 127 for symmetric) -} - -/// Observer activation range statistics -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ObserverRangeStatistics { - pub min_range: f64, - pub max_range: f64, - pub mean_range: f64, - pub convergence_rate: f64, // EMA convergence (0.0 to 1.0) -} - -/// Per-layer quantization metrics -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LayerQuantizationMetrics { - pub layer_name: String, - pub scale: f64, - pub zero_point: i32, - pub min_val: f64, - pub max_val: f64, - pub num_observations: usize, -} - -/// Trait for polymorphic TFT model (FP32 or QAT) -/// -/// Allows TFTTrainer to work with both standard FP32 models and QAT models -/// without code duplication or type-specific logic. -pub trait TFTModel: Send + Sync { - /// Forward pass with optional gradient checkpointing - /// - /// # Arguments - /// * `static_features` - Static features [batch, num_static_features] - /// * `historical_ts` - Historical time series [batch, seq_len, num_unknown_features] - /// * `future_ts` - Future time series [batch, horizon, num_known_features] - /// * `use_checkpointing` - Enable gradient checkpointing (trades compute for memory) - /// - /// # Returns - /// * Quantile predictions [batch, horizon, num_quantiles] - fn forward( - &mut self, - static_features: &Tensor, - historical_ts: &Tensor, - future_ts: &Tensor, - use_checkpointing: bool, - ) -> Result; - - /// Get device for tensor operations - fn get_device(&self) -> &Device; - - /// Get configuration - fn get_config(&self) -> &TFTConfig; - - /// Get variable map (for checkpoint saving) - fn get_varmap(&self) -> Arc; - - /// Clear attention cache to free memory - /// Call this after training/inference batch to prevent memory accumulation - fn clear_cache(&mut self); -} - -/// Implement TFTModel for standard FP32 TemporalFusionTransformer -impl TFTModel for TemporalFusionTransformer { - fn forward( - &mut self, - static_features: &Tensor, - historical_ts: &Tensor, - future_ts: &Tensor, - use_checkpointing: bool, - ) -> Result { - self.forward_with_checkpointing( - static_features, - historical_ts, - future_ts, - use_checkpointing, - ) - } - - fn get_device(&self) -> &Device { - self.device() - } - - fn get_config(&self) -> &TFTConfig { - &self.config - } - - fn get_varmap(&self) -> Arc { - self.get_varmap().clone() - } - - fn clear_cache(&mut self) { - // No-op: TemporalFusionTransformer doesn't expose a public clear_cache method - // The attention cache is managed internally by TemporalSelfAttention - // CUDA cache clearing is handled separately by sync_cuda_device() - } -} - -// Implement TFTModel for QAT TemporalFusionTransformer - DISABLED: P0 compilation errors -/* QAT IMPLEMENTATION DISABLED DUE TO P0 COMPILATION ERRORS -impl TFTModel for QATTemporalFusionTransformer { - fn forward( - &mut self, - static_features: &Tensor, - historical_ts: &Tensor, - future_ts: &Tensor, - _use_checkpointing: bool, - ) -> Result { - // QAT forward pass (no checkpointing support yet) - // Note: Checkpointing would require hooks into FakeQuantize layers - self.forward(static_features, historical_ts, future_ts) - } - - fn get_device(&self) -> &Device { - self.fp32_model().device() - } - - fn get_config(&self) -> &TFTConfig { - &self.fp32_model().config - } - - fn get_varmap(&self) -> Arc { - self.fp32_model().get_varmap().clone() - } -} -*/ - -/// TFT trainer with gRPC interface integration -/// -/// This trainer is designed to work seamlessly with the ML Training Service -/// gRPC interface, providing real-time progress updates, checkpoint management, -/// and comprehensive metrics reporting. -pub struct TFTTrainer { - /// Model configuration - model_config: TFTConfig, - - /// Training configuration - training_config: TFTTrainingConfig, - - /// TFT model instance (polymorphic: FP32 or QAT) - model: Box, - - /// AdamW optimizer - optimizer: Option, - - /// Checkpoint manager for persistence - checkpoint_manager: Arc, - - /// Checkpoint directory path - checkpoint_dir: String, - - /// Device (CPU/GPU) - device: Device, - - /// Training state - state: TrainingState, - - /// Progress callback channel - progress_tx: Option>, - - /// Whether to use INT8 quantization - use_int8: bool, - - /// QAT configuration - use_qat: bool, - qat_calibration_batches: usize, - qat_calibrated: bool, - - /// QAT learning rate schedule configuration - qat_warmup_epochs: usize, - qat_cooldown_factor: f64, - - /// Minimum batch size for QAT calibration OOM recovery - qat_min_batch_size: usize, - - /// Gradient checkpointing enabled - use_gradient_checkpointing: bool, - - /// Target normalization parameters (for denormalizing predictions) - pub target_mean: Option, - pub target_std: Option, -} - -impl std::fmt::Debug for TFTTrainer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TFTTrainer") - .field("model_config", &self.model_config) - .field("training_config", &self.training_config) - .field("model", &"") - .field("optimizer", &self.optimizer.as_ref().map(|_| "")) - .field("checkpoint_manager", &"") - .field("checkpoint_dir", &self.checkpoint_dir) - .field("device", &self.device) - .field("state", &self.state) - .field( - "progress_tx", - &self.progress_tx.as_ref().map(|_| ""), - ) - .finish() - } -} - -/// Training state tracking -#[derive(Debug, Clone)] -struct TrainingState { - /// Current epoch - current_epoch: usize, - - /// Global step counter - global_step: usize, - - /// Best validation loss - best_val_loss: f64, - - /// Training start time - started_at: Option, - - /// Current learning rate - learning_rate: f64, - - /// Early stopping patience counter - patience_counter: usize, - - /// Last valid validation loss (for cached display) - last_val_loss: Option, - - /// Last valid validation metrics (for cached display) - last_val_metrics: ValidationMetrics, - - /// QAT calibration metrics - qat_calibration_progress: f64, - qat_observer_range: f64, - qat_fake_quant_error: f64, - - /// QAT metrics export (Prometheus-compatible) - qat_metrics: Option, -} - -impl Default for TrainingState { - fn default() -> Self { - Self { - current_epoch: 0, - global_step: 0, - best_val_loss: f64::INFINITY, - started_at: None, - learning_rate: 0.0, - patience_counter: 0, - last_val_loss: None, - last_val_metrics: ValidationMetrics::default(), - qat_calibration_progress: 0.0, - qat_observer_range: 0.0, - qat_fake_quant_error: 0.0, - qat_metrics: None, - } - } -} - -/// Training progress update for gRPC streaming -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TrainingProgress { - /// Current epoch (1-indexed for display) - pub current_epoch: u32, - - /// Total epochs - pub total_epochs: u32, - - /// Progress percentage (0.0 to 100.0) - pub progress_percentage: f32, - - /// Current metrics - pub metrics: HashMap, - - /// Status message - pub message: String, - - /// Timestamp (Unix seconds) - pub timestamp: i64, - - /// Resource usage - pub resource_usage: ResourceUsage, -} - -/// Resource usage statistics -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourceUsage { - /// CPU usage percentage - pub cpu_usage_percent: f32, - - /// Memory usage in GB - pub memory_usage_gb: f32, - - /// GPU usage percentage - pub gpu_usage_percent: f32, - - /// GPU memory usage in GB - pub gpu_memory_usage_gb: f32, -} - -impl Default for ResourceUsage { - fn default() -> Self { - Self { - cpu_usage_percent: 0.0, - memory_usage_gb: 0.0, - gpu_usage_percent: 0.0, - gpu_memory_usage_gb: 0.0, - } - } -} - -/// TFT trainer configuration from gRPC proto -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TFTTrainerConfig { - /// Number of epochs - pub epochs: usize, - - /// Learning rate - pub learning_rate: f64, - - /// Batch size (overridden if auto_batch_size is true) - pub batch_size: usize, - - /// Auto-detect optimal batch size based on available GPU memory - pub auto_batch_size: bool, - - /// Hidden dimension (128, 256, 512) - pub hidden_dim: usize, - - /// Number of attention heads (4, 8, 16) - pub num_attention_heads: usize, - - /// Dropout rate (0.0-0.3) - pub dropout_rate: f64, - - /// Number of LSTM layers - pub lstm_layers: usize, - - /// Quantiles for quantile regression [0.1, 0.5, 0.9] - pub quantiles: Vec, - - /// Lookback window length - pub lookback_window: usize, - - /// Forecast horizon - pub forecast_horizon: usize, - - /// Use GPU - pub use_gpu: bool, - - /// Use INT8 quantization for memory efficiency (3-8x reduction) - pub use_int8_quantization: bool, - - /// Use Quantization-Aware Training (QAT) - trains with fake quantization for better INT8 accuracy - pub use_qat: bool, - - /// Number of calibration batches for QAT (observer statistics collection before training) - /// Default: 100 batches (~3% of typical training data) - pub qat_calibration_batches: usize, - - /// QAT warmup epochs - gradual LR warmup after calibration (default: 10) - /// During warmup, LR starts at 10% of normal LR and gradually increases to full LR - pub qat_warmup_epochs: usize, - - /// QAT cooldown factor - LR reduction in final 10% of training (default: 0.1) - /// Fine-tunes quantization parameters with reduced LR for stability - pub qat_cooldown_factor: f64, - - /// Minimum batch size for QAT calibration OOM recovery (default: 2) - /// If OOM occurs during calibration, batch size is halved automatically. - /// Training aborts if batch size drops below this threshold. - pub qat_min_batch_size: usize, - - /// Enable gradient checkpointing (trades compute for memory, 30-40% reduction) - pub use_gradient_checkpointing: bool, - - /// Validation batch size - pub validation_batch_size: usize, - - /// Maximum validation batches to run (None = unlimited) - /// Limits validation to N batches to reduce memory usage on constrained GPUs. - /// Example: 50 batches = ~500MB vs 1760MB for full validation (176 batches) - pub max_validation_batches: Option, - - /// Validation frequency (run validation every N epochs, default: 1) - /// Used in hyperparameter optimization to control validation overhead. - /// Set to 1 for every epoch (normal), higher values for faster training. - pub validation_frequency: usize, - - /// Checkpoint directory - pub checkpoint_dir: String, -} - -impl Default for TFTTrainerConfig { - fn default() -> Self { - let batch_size = 32; // Reduced for 4GB VRAM (overridden if auto_batch_size=true) - Self { - epochs: 100, - learning_rate: 1e-3, - batch_size, - auto_batch_size: false, // Default: manual batch size - hidden_dim: 256, - num_attention_heads: 8, - dropout_rate: 0.1, - lstm_layers: 2, - quantiles: vec![0.1, 0.5, 0.9], - lookback_window: 60, - forecast_horizon: 10, - use_gpu: true, - use_int8_quantization: false, // Default to FP32 for accuracy - use_qat: false, // Default to standard training (FP32 or post-training quantization) - qat_calibration_batches: 100, // ~3% of typical 3000-batch training - qat_warmup_epochs: 10, // Default: 10 epochs warmup - qat_cooldown_factor: 0.1, // Default: 10x LR reduction in cooldown - qat_min_batch_size: 2, // Default: minimum 2 samples per batch - use_gradient_checkpointing: false, // Default: off (prioritize speed over memory) - validation_batch_size: batch_size, // Match training batch_size to avoid memory spikes - max_validation_batches: None, // Default: unlimited (use all validation data) - validation_frequency: 1, // Default: validate every epoch - checkpoint_dir: "/tmp/tft_checkpoints".to_string(), - } - } -} - -impl TFTTrainerConfig { - /// Create TFT model config from trainer config - pub fn to_model_config(&self) -> TFTConfig { - TFTConfig { - input_dim: 225, // 5 + 10 + 210 = 225 (static + known + unknown) - hidden_dim: self.hidden_dim, - num_heads: self.num_attention_heads, - num_layers: self.lstm_layers, - prediction_horizon: self.forecast_horizon, - sequence_length: self.lookback_window, - num_quantiles: 3, // [0.1, 0.5, 0.9] - num_static_features: 5, - num_known_features: 10, - num_unknown_features: 210, // Wave C (201) + Wave D (24) = 225 total features - learning_rate: self.learning_rate, - batch_size: self.batch_size, - dropout_rate: self.dropout_rate, - l2_regularization: 1e-4, - use_flash_attention: true, - mixed_precision: false, - memory_efficient: true, - max_inference_latency_us: 50, - target_throughput_pps: 100_000, - } - } - - /// Create TFT training config from trainer config - pub fn to_training_config(&self) -> TFTTrainingConfig { - TFTTrainingConfig { - epochs: self.epochs, - batch_size: self.batch_size, - learning_rate: self.learning_rate, - dropout_rate: self.dropout_rate, - gradient_checkpointing: self.use_gradient_checkpointing, - validation_batch_size: self.validation_batch_size, - max_validation_batches: self.max_validation_batches, - validation_frequency: self.validation_frequency, - ..Default::default() - } - } -} - -impl TFTTrainer { - /// Create new TFT trainer instance - pub fn new( - mut config: TFTTrainerConfig, - _checkpoint_storage: Arc, - ) -> MLResult { - info!("Initializing TFT trainer with config: {:?}", config); - - // Validate batch size is non-zero - if config.batch_size == 0 { - return Err(MLError::ValidationError { - message: format!( - "Batch size must be greater than 0, got: {}", - config.batch_size - ), - }); - } - - // Select device (GPU if available and requested) - let device = if config.use_gpu { - Device::cuda_if_available(0).map_err(|e| MLError::ConfigError { - reason: format!("GPU requested but not available: {}", e), - })? - } else { - Device::Cpu - }; - - info!("Using device: {:?}", device); - - // Auto batch size tuning (if enabled and using GPU) - if config.auto_batch_size && config.use_gpu { - info!("Auto batch size tuning enabled, detecting optimal batch size..."); - - match AutoBatchSizer::new() { - Ok(sizer) => { - // Display GPU memory info - let mem_info = sizer.memory_info(); - info!( - "GPU Memory: {:.1} MB total, {:.1} MB free ({:.1}% utilization)", - mem_info.total_memory_mb, - mem_info.free_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - - // Estimate model memory (TFT with 225 features, 256 hidden_dim) - // Formula: (input_dim * hidden_dim + hidden_dim^2 * num_layers) * 4 bytes * 2 (weights + biases) - // Base estimate for TFT-225: ~125 MB (INT8) for 256 hidden_dim - // FP32 models are 4x larger: ~500 MB - let base_model_memory_mb = (config.hidden_dim as f64 / 256.0) * 125.0; - - // Determine model precision based on quantization settings - // CRITICAL: QAT requires special memory handling due to FakeQuantize overhead - // - QAT: FP32 training + 8 intermediate tensors per FakeQuantize (60% safety margin) - // - PTQ (Post-Training Quantization): Trains in FP32, quantizes AFTER training completes (25% margin) - // - Normal: Trains in FP32 (25% margin) - // INT8 memory estimates are ONLY for inference with a pretrained quantized model - let model_precision = if config.use_qat { - ModelPrecision::QAT // QAT mode: FP32 + FakeQuantize overhead (60% safety margin) - } else { - ModelPrecision::FP32 // Normal/PTQ training (25% safety margin) - }; - - let batch_config = BatchSizeConfig { - model_memory_mb: base_model_memory_mb, // Legacy field (for backward compatibility) - model_precision, - base_model_memory_mb, - sequence_length: config.lookback_window, - feature_dim: 225, // Wave C (201) + Wave D (24) - gradient_checkpointing: config.use_gradient_checkpointing, - optimizer_type: OptimizerType::Adam, // TFT uses AdamW (same memory as Adam) - safety_margin: 0.20, // 20% safety margin - min_batch_size: 1, - max_batch_size: 256, - }; - - match sizer.calculate_optimal_batch_size(&batch_config) { - Ok(optimal_batch_size) => { - info!( - "Auto batch size tuning: {} (overriding configured batch_size={})", - optimal_batch_size, config.batch_size - ); - config.batch_size = optimal_batch_size; - config.validation_batch_size = optimal_batch_size; // Use same for validation - }, - Err(e) => { - // QAT-specific fallback: use batch_size=1-4 if auto-detection fails - if config.use_qat { - let qat_fallback_batch_size = 4; // Conservative fallback for QAT (tested on RTX 3050 Ti) - warn!( - "Failed to calculate optimal batch size for QAT: {}. Using QAT fallback batch_size={} (tested on 4GB GPU)", - e, qat_fallback_batch_size - ); - config.batch_size = qat_fallback_batch_size; - config.validation_batch_size = qat_fallback_batch_size; - } else { - warn!( - "Failed to calculate optimal batch size: {}. Using configured batch_size={}", - e, config.batch_size - ); - } - }, - } - }, - Err(e) => { - warn!( - "Failed to initialize AutoBatchSizer: {}. Using configured batch_size={}", - e, config.batch_size - ); - }, - } - } - - // Create model config - let model_config = config.to_model_config(); - - // Create training config - let training_config = config.to_training_config(); - - // Initialize model (FP32 or QAT based on config) - // QAT TEMPORARILY DISABLED DUE TO P0 COMPILATION ERRORS - let model: Box = if config.use_qat { - warn!("⚠️ QAT requested but disabled due to P0 compilation errors - falling back to FP32"); - info!("🔧 Initializing standard FP32 model (QAT unavailable)"); - let fp32_model = - TemporalFusionTransformer::new_with_device(model_config.clone(), device.clone())?; - Box::new(fp32_model) - /* QAT CODE DISABLED - info!("🎯 Initializing QAT model (Quantization-Aware Training enabled)"); - - // Step 1: Create FP32 base model - let fp32_model = TemporalFusionTransformer::new_with_device( - model_config.clone(), - device.clone() - )?; - - // Step 2: Wrap with QAT for fake quantization - let qat_model = QATTemporalFusionTransformer::new_from_fp32(fp32_model)?; - - info!("✅ QAT model initialized with {} FakeQuantize observers", qat_model.num_observers()); - Box::new(qat_model) - */ - } else { - info!("🔧 Initializing standard FP32 model"); - let fp32_model = - TemporalFusionTransformer::new_with_device(model_config.clone(), device.clone())?; - Box::new(fp32_model) - }; - - // Create checkpoint manager with proper CheckpointConfig - let checkpoint_config = CheckpointConfig { - base_dir: config.checkpoint_dir.clone().into(), - ..Default::default() - }; - let checkpoint_manager = Arc::new(CheckpointManager::new(checkpoint_config)?); - - // Initialize training state - let state = TrainingState { - learning_rate: config.learning_rate, - ..Default::default() - }; - - let trainer = Self { - model_config, - training_config, - model, - optimizer: None, - checkpoint_manager, - checkpoint_dir: config.checkpoint_dir.clone(), - device, - state, - progress_tx: None, - use_int8: config.use_int8_quantization, - use_qat: config.use_qat, - qat_calibration_batches: config.qat_calibration_batches, - qat_calibrated: false, - qat_warmup_epochs: config.qat_warmup_epochs, - qat_cooldown_factor: config.qat_cooldown_factor, - qat_min_batch_size: config.qat_min_batch_size, - use_gradient_checkpointing: config.use_gradient_checkpointing, - target_mean: None, - target_std: None, - }; - - if config.use_gradient_checkpointing { - info!("💾 Gradient checkpointing ENABLED"); - info!(" → Expected: 30-40% memory reduction"); - info!(" → Trade-off: ~20% slower training (recomputes activations during backprop)"); - } - - Ok(trainer) - } - - /// Set progress callback channel for real-time updates - pub fn set_progress_callback(&mut self, tx: mpsc::UnboundedSender) { - self.progress_tx = Some(tx); - } - - /// Initialize optimizer with model parameters - fn initialize_optimizer(&mut self) -> MLResult<()> { - // Collect all trainable variables from the model - let vars = self.model.get_varmap().all_vars(); - - // Create AdamW optimizer parameters - let params = candle_optimisers::adam::ParamsAdam { - lr: self.training_config.learning_rate, - beta_1: 0.9, - beta_2: 0.999, - eps: 1e-8, - weight_decay: None, - amsgrad: false, - }; - - // Initialize optimizer - self.optimizer = Some(crate::Adam::new(vars, params)?); - - info!( - "Initialized AdamW optimizer with lr={:.2e}", - self.training_config.learning_rate - ); - - Ok(()) - } - - /// Check if an error is an OOM (Out of Memory) error - /// - /// # Arguments - /// * `error` - The MLError to check - /// - /// # Returns - /// * true if the error is an OOM error, false otherwise - /// - /// # Detects - /// - CUDA OOM errors (error code 2) - /// - Explicit "out of memory" strings - /// - "OOM" strings - /// - Memory allocation failures - pub(crate) fn is_oom_error(error: &MLError) -> bool { - let msg = format!("{:?}", error).to_lowercase(); - msg.contains("out of memory") - || msg.contains("oom") - || msg.contains("cuda error 2") - || msg.contains("cuda error: out of memory") - || msg.contains("failed to allocate") - || msg.contains("allocation failed") - } - - /// Synchronize CUDA device and attempt to free unused memory - /// - /// # Note - /// Candle's Device API doesn't expose direct cache clearing, so this - /// function performs device synchronization which may help trigger - /// automatic memory cleanup by the CUDA runtime. - /// - /// # Arguments - /// * `device` - The Device to synchronize - /// - /// # Returns - /// * Ok(()) if synchronization succeeded - /// * Err if synchronization failed - fn sync_cuda_device(device: &Device) -> MLResult<()> { - if device.is_cuda() { - // Force synchronization to ensure all pending operations complete - // This may allow CUDA runtime to reclaim unused memory - // Note: Candle doesn't expose direct synchronization API, so we - // create and immediately drop a small tensor to trigger sync - let _sync_tensor = Tensor::zeros((1,), candle_core::DType::F32, device) - .map_err(|e| MLError::ModelError(format!("CUDA sync failed: {}", e)))?; - - info!("CUDA device synchronized (may have freed unused memory)"); - } - Ok(()) - } - - /// Recreate data loader with a new batch size - /// - /// NOTE: This method requires the underlying data to recreate the loader. - /// Currently, TFTDataLoader doesn't support dynamic batch size updates. - /// For production OOM retry, use Parquet training with --parquet-file flag. - fn recreate_data_loader_with_batch_size( - &self, - _loader: TFTDataLoader, - _new_batch_size: usize, - ) -> MLResult { - Err(MLError::TrainingError( - "Data loader batch size cannot be updated dynamically. \ - Use Parquet training (--parquet-file) for OOM retry support." - .to_string(), - )) - } - - /// Main training loop with progress reporting - #[instrument(skip(self, train_loader, val_loader))] - pub async fn train( - &mut self, - mut train_loader: TFTDataLoader, - mut val_loader: TFTDataLoader, - ) -> MLResult { - info!( - "Starting TFT training for {} epochs", - self.training_config.epochs - ); - - // Initialize optimizer - self.initialize_optimizer()?; - - // Mark training start - self.state.started_at = Some(Instant::now()); - - // QAT Calibration Phase (if enabled) - if self.use_qat && !self.qat_calibrated { - // OOM recovery: Retry calibration with exponentially smaller batch sizes - let mut calibration_batch_size = self.training_config.batch_size; - let mut calibration_attempts = 0; - const MAX_CALIBRATION_RETRIES: usize = 3; - - info!( - "🎯 QAT Calibration Phase: Running {} batches for observer statistics (initial batch_size={})", - self.qat_calibration_batches, calibration_batch_size - ); - - loop { - match self.run_qat_calibration(&mut train_loader).await { - Ok(_) => { - if calibration_attempts > 0 { - info!( - "✅ QAT calibration complete after {} OOM retries - final batch_size={}, observers frozen", - calibration_attempts, calibration_batch_size - ); - } else { - info!("✅ QAT calibration complete - observers frozen, fake quantization enabled"); - } - break; - }, - Err(e) => { - // Check if error is OOM-related - if Self::is_oom_error(&e) - && calibration_attempts < MAX_CALIBRATION_RETRIES - && calibration_batch_size > self.qat_min_batch_size - { - calibration_attempts += 1; - let old_batch_size = calibration_batch_size; - calibration_batch_size = calibration_batch_size / 2; - - // Enforce minimum batch size - if calibration_batch_size < self.qat_min_batch_size { - calibration_batch_size = self.qat_min_batch_size; - } - - warn!( - "⚠️ QAT calibration OOM detected (attempt {}/{}), reducing batch_size: {} → {}", - calibration_attempts, MAX_CALIBRATION_RETRIES, - old_batch_size, calibration_batch_size - ); - - // Clear GPU cache to free fragmented memory - if self.device.is_cuda() { - info!(" 🧹 Clearing CUDA cache..."); - // Note: Candle doesn't expose cuda::synchronize() or clear_cache() yet - // This would be: candle_core::cuda::clear_cache()?; - // For now, we rely on Rust's Drop trait to free tensors - } - - // Update training config with reduced batch size - self.training_config.batch_size = calibration_batch_size; - self.training_config.validation_batch_size = calibration_batch_size; - - // LIMITATION: Cannot recreate data loader dynamically in train() method - // The train_loader is passed as a parameter, not created here. - // OOM retry requires access to the underlying dataset, which is not available. - // Workaround: Use train_tft_parquet.rs which has access to the dataset. - return Err(MLError::TrainingError(format!( - "QAT calibration OOM: batch_size={} is too large. \ - Cannot retry dynamically from train() method. \ - Workaround: Use train_tft_parquet.rs with --batch-size {} or lower.", - old_batch_size, calibration_batch_size - ))); - } else { - // Non-OOM error OR retries exhausted OR batch size at minimum - if Self::is_oom_error(&e) { - if calibration_batch_size <= self.qat_min_batch_size { - return Err(MLError::TrainingError(format!( - "QAT calibration OOM: batch_size={} (minimum={}) is too large for available GPU memory. \ - Consider: (1) using a GPU with more VRAM, (2) reducing model size, or (3) using CPU", - calibration_batch_size, self.qat_min_batch_size - ))); - } else { - return Err(MLError::TrainingError(format!( - "QAT calibration OOM after {} retries (final batch_size={}). Original error: {}", - calibration_attempts, calibration_batch_size, e - ))); - } - } - return Err(e); - } - }, - } - } - } - - // Training metrics accumulator - let mut final_metrics = TrainingMetrics::default(); - - // OOM retry tracking - let mut current_batch_size = self.training_config.batch_size; - let mut oom_retry_count = 0; - const MAX_OOM_RETRIES: usize = 3; - - for epoch in 0..self.training_config.epochs { - self.state.current_epoch = epoch; - let epoch_start = Instant::now(); - - // Memory profiling: Log GPU memory at start of epoch - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Epoch {} START: {:.1}MB / {:.1}MB ({:.1}% utilization)", - epoch, - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - - // Apply QAT-specific learning rate schedule (if enabled) - if self.use_qat { - self.apply_qat_lr_schedule(epoch)?; - } - - // Training phase with OOM retry logic (note: data loader recreation not yet supported) - let train_loss = loop { - match self.train_epoch(&mut train_loader, epoch).await { - Ok(loss) => { - // Success - proceed to next epoch - if oom_retry_count > 0 { - info!( - "✅ Epoch {} completed successfully after {} OOM retries (batch_size: {} → {})", - epoch, - oom_retry_count, - self.training_config.batch_size, - current_batch_size - ); - } - break loss; - }, - Err(e) if Self::is_oom_error(&e) && oom_retry_count < MAX_OOM_RETRIES => { - oom_retry_count += 1; - - // Use AutoBatchSizer to reduce batch size (exponential backoff) - current_batch_size = AutoBatchSizer::reduce_batch_size(current_batch_size); - - warn!( - "🔥 OOM detected (retry {}/{}): reducing batch_size {} → {}", - oom_retry_count, - MAX_OOM_RETRIES, - self.training_config.batch_size, - current_batch_size - ); - - // Check if batch size is too small (abort condition) - if AutoBatchSizer::is_batch_size_too_small(current_batch_size) { - return Err(MLError::TrainingError(format!( - "OOM even with batch_size={} (original: {}). GPU memory insufficient for this model. \ - Recommendations: \ - (1) Enable gradient checkpointing (--use-gradient-checkpointing, 30-40% memory reduction), \ - (2) Reduce hidden_dim (--hidden-dim 128 or 64), \ - (3) Use cloud GPU (AWS p3.2xlarge: 16GB, GCP T4: 16GB, Azure NC6: 12GB)", - current_batch_size, - self.training_config.batch_size - ))); - } - - // Synchronize CUDA device to free unused memory - if let Err(sync_err) = Self::sync_cuda_device(&self.device) { - warn!( - "Failed to sync CUDA device during OOM recovery: {}", - sync_err - ); - } - - // Log memory stats if CUDA is available - #[cfg(feature = "cuda")] - { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "GPU Memory after sync: {:.1}MB / {:.1}MB ({:.1}% utilization)", - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - - // Update training config for next epoch - let original_batch_size = self.training_config.batch_size; - self.training_config.batch_size = current_batch_size; - self.training_config.validation_batch_size = current_batch_size; - - warn!( - "⚠️ Data loader batch size cannot be updated dynamically. \ - Training will continue with original batch size ({}) but may OOM again. \ - To enable OOM retry, use Parquet data loader with --parquet-file flag.", - original_batch_size - ); - - info!( - "🔄 Retrying epoch {} with batch_size={} after CUDA sync (retry {}/{})", - epoch, current_batch_size, oom_retry_count, MAX_OOM_RETRIES - ); - - // Send progress update with OOM retry metrics - if let Some(ref tx) = self.progress_tx { - let mut metrics = HashMap::new(); - metrics.insert("oom_retry_count".to_string(), oom_retry_count as f32); - metrics.insert( - "current_batch_size".to_string(), - current_batch_size as f32, - ); - metrics.insert( - "original_batch_size".to_string(), - original_batch_size as f32, - ); - - let update = TrainingProgress { - current_epoch: (epoch + 1) as u32, - total_epochs: self.training_config.epochs as u32, - progress_percentage: (epoch as f32 - / self.training_config.epochs as f32) - * 100.0, - metrics, - message: format!( - "OOM recovery: retry {}/{}, batch_size {} → {}", - oom_retry_count, - MAX_OOM_RETRIES, - original_batch_size, - current_batch_size - ), - timestamp: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - resource_usage: self.get_resource_usage(), - }; - - if let Err(e) = tx.send(update) { - warn!("Failed to send OOM recovery progress: {}", e); - } - } - }, - Err(e) => { - // Non-OOM error or max retries exceeded - if Self::is_oom_error(&e) { - warn!( - "❌ Max OOM retries ({}) exceeded. Final batch_size: {} (original: {}). \ - GPU memory insufficient. Recommendations: \ - (1) Enable --use-gradient-checkpointing (30-40% memory reduction), \ - (2) Reduce --hidden-dim to 128 or 64, \ - (3) Use cloud GPU (AWS p3.2xlarge: 16GB, GCP T4: 16GB, Azure NC6: 12GB)", - MAX_OOM_RETRIES, - current_batch_size, - self.training_config.batch_size - ); - } - return Err(e); - }, - } - }; - - // Reset OOM retry counter on successful epoch - oom_retry_count = 0; - - // Memory profiling: Log GPU memory after training batches - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Epoch {} AFTER_TRAINING: {:.1}MB / {:.1}MB ({:.1}% utilization)", - epoch, - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - - // Validation phase (every N epochs) - let (val_loss, val_metrics) = if epoch % self.training_config.validation_frequency == 0 - { - // Memory profiling: Log GPU memory before validation - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Epoch {} BEFORE_VALIDATION: {:.1}MB / {:.1}MB ({:.1}% utilization)", - epoch, - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - - // Actually drop optimizer to free 1100MB GPU memory - drop(self.optimizer.take()); - if self.device.is_cuda() { - Self::sync_cuda_device(&self.device).ok(); - } - info!("[MEMORY] Dropped optimizer, freed ~1100MB AdamW state"); - - let result = self.validate_epoch(&mut val_loader, epoch).await?; - - // Recreate optimizer after validation with same learning rate - self.initialize_optimizer()?; - info!("[MEMORY] Recreated optimizer after validation"); - - // Memory profiling: Log GPU memory after validation - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Epoch {} AFTER_VALIDATION: {:.1}MB / {:.1}MB ({:.1}% utilization)", - epoch, - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - - result - } else { - (0.0, ValidationMetrics::default()) - }; - - let epoch_duration = epoch_start.elapsed(); - - // Update metrics - final_metrics.train_loss = train_loss; - final_metrics.val_loss = val_loss; - final_metrics.quantile_loss = val_metrics.quantile_loss; - final_metrics.rmse = val_metrics.rmse; - final_metrics.attention_entropy = val_metrics.attention_entropy; - - // Send progress update - self.send_progress_update(epoch, train_loss, val_loss, &val_metrics) - .await; - - info!( - "Epoch {}/{}: Train Loss: {:.6}, Val Loss: {:.6}, RMSE: {:.6}, Duration: {:.1}s", - epoch + 1, - self.training_config.epochs, - train_loss, - val_loss, - val_metrics.rmse, - epoch_duration.as_secs_f64() - ); - - // Save checkpoint - if epoch % self.training_config.checkpoint_frequency == 0 { - self.save_checkpoint(epoch, train_loss, val_loss).await?; - - // Memory profiling: Log GPU memory after checkpoint saving - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Epoch {} AFTER_CHECKPOINT: {:.1}MB / {:.1}MB ({:.1}% utilization)", - epoch, - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - } - - // Memory profiling: Log GPU memory at end of epoch - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Epoch {} END: {:.1}MB / {:.1}MB ({:.1}% utilization)", - epoch, - mem_info.used_memory_mb, - mem_info.total_memory_mb, - (mem_info.used_memory_mb / mem_info.total_memory_mb) * 100.0 - ); - } - } - - // Clear CUDA cache to prevent fragmentation - if self.device.is_cuda() { - // Synchronize device to ensure all pending operations complete - if let Err(sync_err) = Self::sync_cuda_device(&self.device) { - warn!( - "Failed to sync CUDA device after epoch {}: {}", - epoch, sync_err - ); - } - } - // Clear model's attention cache - self.model.clear_cache(); - - // Early stopping check - if val_loss > 0.0 && self.check_early_stopping(val_loss) { - info!("Early stopping triggered at epoch {}", epoch); - break; - } - } - - // Save final checkpoint - self.save_checkpoint( - self.state.current_epoch, - final_metrics.train_loss, - final_metrics.val_loss, - ) - .await?; - - let total_duration = self - .state - .started_at - .map(|start| start.elapsed()) - .unwrap_or(Duration::from_secs(0)); - - final_metrics.training_time_seconds = total_duration.as_secs_f64(); - - // Populate QAT metrics if QAT was used - if self.use_qat { - final_metrics.qat_calibration_progress = Some(self.state.qat_calibration_progress); - final_metrics.qat_observer_range = Some(self.state.qat_observer_range); - final_metrics.qat_fake_quant_error = Some(self.state.qat_fake_quant_error); - - // Export comprehensive QAT metrics for Prometheus - if let Some(qat_metrics) = self.export_qat_metrics() { - final_metrics.qat_metrics = Some(qat_metrics.clone()); - self.state.qat_metrics = Some(qat_metrics); - - info!( - "QAT Metrics Exported: {} observers, {:.1}% calibration, {:.4} avg scale, {:.4} quant error", - self.state.qat_metrics.as_ref().unwrap().observer_count, - self.state.qat_calibration_progress, - self.state.qat_metrics.as_ref().unwrap().scale_statistics.mean, - self.state.qat_fake_quant_error - ); - } - - // Estimate INT8 accuracy: assume 1% accuracy loss per 0.1 quantization error - let estimated_int8_accuracy = 100.0 - (self.state.qat_fake_quant_error * 10.0); - final_metrics.qat_estimated_int8_accuracy = Some(estimated_int8_accuracy); - - info!( - "QAT Metrics - Calibration: {:.1}%, Observer Range: {:.4}, Fake Quant Error: {:.4}, Estimated INT8 Accuracy: {:.1}%", - self.state.qat_calibration_progress, - self.state.qat_observer_range, - self.state.qat_fake_quant_error, - estimated_int8_accuracy - ); - } - - info!("Training completed in {:.1}s", total_duration.as_secs_f64()); - - // Step 2: Quantize to INT8 if requested (after FP32 training or QAT) - if self.use_int8 { - if self.use_qat { - info!("⚡ Converting QAT model to INT8 (observers already calibrated)..."); - // QAT model already has fake quantization - just convert to real INT8 - let num_tensors = self - .qat_to_quantized_checkpoint( - self.state.current_epoch, - final_metrics.train_loss, - final_metrics.val_loss, - ) - .await?; - info!("✅ QAT→INT8 conversion complete: {} tensors, 75% memory savings, minimal accuracy loss", num_tensors); - } else { - info!("⚡ Post-training quantization: Converting FP32 model to INT8..."); - // Standard post-training quantization (higher accuracy loss) - let num_tensors = self - .quantize_and_save_int8_checkpoint( - self.state.current_epoch, - final_metrics.train_loss, - final_metrics.val_loss, - ) - .await?; - info!( - "✅ Post-training INT8 quantization complete: {} tensors, 75% memory savings", - num_tensors - ); - } - } - - Ok(final_metrics) - } - - /// Train single epoch - async fn train_epoch( - &mut self, - train_loader: &mut TFTDataLoader, - epoch: usize, - ) -> MLResult { - // ✅ ADD: Memory profiling at epoch start - #[cfg(feature = "cuda")] - let mut memory_profiler = crate::benchmark::MemoryProfiler::new(0); - - #[cfg(feature = "cuda")] - let epoch_start_memory = memory_profiler.take_snapshot().ok(); - - let mut epoch_loss = 0.0; - let mut batch_count = 0; - let mut qat_error_accumulator = 0.0; - - // 🔥 NOTE: Gradient accumulation REMOVED - // Candle doesn't support PyTorch-style gradient accumulation because: - // 1. backward_step() creates a fresh GradStore each call - // 2. Gradients are not persistent across batches - // 3. Attempting to delay backward() causes computation graph to grow → OOM - // Solution: Call backward_step() on EVERY batch to free computation graph immediately - - for (_batch_idx, batch) in train_loader.iter().enumerate() { - // Convert batch to tensors (GPU-direct allocation, see batch_to_tensors) - let (static_tensor, hist_tensor, fut_tensor, target_tensor) = - self.batch_to_tensors(batch)?; - - // Forward pass with optional gradient checkpointing (polymorphic: FP32 or QAT) - let predictions = self.model.forward( - &static_tensor, - &hist_tensor, - &fut_tensor, - self.use_gradient_checkpointing, - )?; - - // QAT: Compute fake quantization error (if enabled) - if self.use_qat && self.qat_calibrated { - // Simulate INT8 quantization by scaling to [-128, 127] range - // Predictions shape: [batch_size, horizon, num_quantiles] - let pred_min = predictions.flatten_all()?.min(0)?.to_vec0::()? as f64; - let pred_max = predictions.flatten_all()?.max(0)?.to_vec0::()? as f64; - let scale = (pred_max - pred_min) / 255.0; - - // Quantization error: L2 norm between original and quantized predictions - // This simulates the accuracy loss from INT8 conversion - if scale > 1e-8 { - let quant_error = (scale / pred_max.abs().max(pred_min.abs().max(1e-8))).abs(); - qat_error_accumulator += quant_error; - } - } - - // Compute quantile loss (manual implementation) - let loss = self.compute_quantile_loss(&predictions, &target_tensor)?; - - let loss_value = loss.to_vec0::()? as f64; - - // Track epoch loss (unscaled) - epoch_loss += loss_value; - - batch_count += 1; - self.state.global_step += 1; - - // 🔥 FIX: Call backward_step() on EVERY batch to prevent OOM - // Candle's backward_step() does BOTH: - // 1. loss.backward() - computes gradients and creates GradStore - // 2. step(&grads) - applies gradients to model weights - // - // CRITICAL: The GradStore is created fresh each time, so there's NO gradient - // persistence across batches. If we skip backward(), the computation graph - // accumulates in memory → OOM after 500-1000 batches. - // - // Calling backward_step() every batch: - // ✅ Frees computation graph immediately - // ✅ Prevents memory leaks - // ✅ Maintains stable memory usage throughout training - if let Some(ref mut opt) = self.optimizer { - opt.backward_step(&loss)?; - } - - // Log progress every 100 batches - if batch_count % 100 == 0 { - debug!( - "Epoch {}, Batch {}: Loss: {:.6}", - epoch + 1, - batch_count, - loss_value - ); - - // ✅ ADD: Log memory every 100 batches - #[cfg(feature = "cuda")] - if let Ok(current_memory) = memory_profiler.take_snapshot() { - let vram_mb = current_memory.vram_used_mb; - let vram_pct = (vram_mb / current_memory.vram_total_mb) * 100.0; - - debug!( - "Epoch {} Batch {}: GPU Memory {:.0}MB / {:.0}MB ({:.1}%)", - epoch, batch_count, vram_mb, current_memory.vram_total_mb, vram_pct - ); - - // Warn if memory usage growing - if let Some(ref start_mem) = epoch_start_memory { - let memory_growth_mb = vram_mb - start_mem.vram_used_mb; - if memory_growth_mb > 500.0 { - warn!( - "Memory leak detected: +{:.0}MB growth since epoch start", - memory_growth_mb - ); - } - } - } - } - } - - // Update QAT fake quantization error metric - if self.use_qat && self.qat_calibrated && batch_count > 0 { - self.state.qat_fake_quant_error = qat_error_accumulator / batch_count as f64; - } - - // ✅ ADD: Log memory at epoch end - #[cfg(feature = "cuda")] - if let (Some(start_mem), Ok(end_mem)) = - (epoch_start_memory, memory_profiler.take_snapshot()) - { - let memory_delta = end_mem.vram_used_mb - start_mem.vram_used_mb; - info!( - "Epoch {} memory delta: {:+.0}MB (start: {:.0}MB, end: {:.0}MB)", - epoch, memory_delta, start_mem.vram_used_mb, end_mem.vram_used_mb - ); - } - - Ok(epoch_loss / batch_count as f64) - } - - /// Validate single epoch - async fn validate_epoch( - &mut self, - val_loader: &mut TFTDataLoader, - epoch: usize, - ) -> MLResult<(f64, ValidationMetrics)> { - let mut total_loss = 0.0; - let mut total_quantile_loss = 0.0; - let mut total_rmse = 0.0; - let mut attention_entropies = Vec::new(); - let mut batch_count = 0; - - // Memory profiling: Track validation start - #[cfg(feature = "cuda")] - let validation_start_memory = if self.device.is_cuda() { - AutoBatchSizer::new().ok().and_then(|sizer| { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Validation START (Epoch {}): {:.1}MB / {:.1}MB", - epoch, mem_info.used_memory_mb, mem_info.total_memory_mb - ); - Some(mem_info.used_memory_mb) - }) - } else { - None - }; - - // Limit validation batches if max_validation_batches is set (memory optimization) - let max_batches = self - .training_config - .max_validation_batches - .unwrap_or(usize::MAX); - for (i, batch) in val_loader.iter().take(max_batches).enumerate() { - // Convert batch to tensors - let (static_tensor, hist_tensor, fut_tensor, target_tensor) = - self.batch_to_tensors(batch)?; - - // Forward pass with optional gradient checkpointing (no gradients stored during validation) - let predictions = self.model.forward( - &static_tensor, - &hist_tensor, - &fut_tensor, - self.use_gradient_checkpointing, - )?; - - // Compute quantile loss (manual implementation) - let loss = self.compute_quantile_loss(&predictions, &target_tensor)?; - - let loss_value = loss.to_vec0::()? as f64; - total_loss += loss_value; - total_quantile_loss += loss_value; - - // Compute RMSE - let rmse = self.compute_rmse(&predictions, &target_tensor)?; - total_rmse += rmse; - - // Extract attention statistics (if available) - if let Some(entropy) = self.extract_attention_entropy()? { - attention_entropies.push(entropy); - } - - batch_count += 1; - - // Clear CUDA cache EVERY batch to prevent accumulation (CRITICAL FIX) - // Changed from every 10 batches due to OOM with small batch sizes - if self.device.is_cuda() { - // Clear model's attention cache (prevents 2500MB leak during validation) - self.model.clear_cache(); - - if let Err(e) = Self::sync_cuda_device(&self.device) { - warn!("Failed to sync CUDA during validation batch {}: {}", i, e); - } - } - } - - // Defensive check: if no validation batches, return zero loss (skip validation) - if batch_count == 0 { - error!( - "❌ CRITICAL: No validation batches processed - validation SKIPPED for epoch {}! \ - This indicates insufficient validation data. \ - Check: (1) validation_batch_size={} vs val_data.len(), \ - (2) Parquet file size, (3) train/val split ratio", - epoch, self.training_config.validation_batch_size - ); - return Ok((0.0, ValidationMetrics::default())); - } - - // Log if validation was limited for memory optimization - if let Some(max) = self.training_config.max_validation_batches { - info!( - "[VALIDATION] Processed {} batches (limited to {} for memory optimization)", - batch_count, max - ); - } - - let avg_loss = total_loss / batch_count as f64; - let avg_attention_entropy = if attention_entropies.is_empty() { - 0.0 - } else { - attention_entropies.iter().sum::() / attention_entropies.len() as f64 - }; - - let metrics = ValidationMetrics { - quantile_loss: total_quantile_loss / batch_count as f64, - rmse: total_rmse / batch_count as f64, - attention_entropy: avg_attention_entropy, - }; - - // Memory profiling: Track validation end and memory delta - #[cfg(feature = "cuda")] - if self.device.is_cuda() { - if let Ok(sizer) = AutoBatchSizer::new() { - let mem_info = sizer.memory_info(); - info!( - "[MEMORY] Validation END (Epoch {}): {:.1}MB / {:.1}MB", - epoch, mem_info.used_memory_mb, mem_info.total_memory_mb - ); - - if let Some(start_memory) = validation_start_memory { - let memory_delta = mem_info.used_memory_mb - start_memory; - if memory_delta.abs() > 10.0 { - info!( - "[MEMORY] Validation memory delta: {:+.1}MB (potential leak indicator)", - memory_delta - ); - } - } - } - } - - Ok((avg_loss, metrics)) - } - - /// Convert batch to tensors (GPU-direct allocation) - /// - /// 🔥 OPTIMIZATION: Create tensors directly on GPU to eliminate CPU→GPU transfers - /// Before: CPU tensor → copy to GPU (2× memory allocation + PCIe transfer) - /// After: GPU tensor creation in one step (zero-copy) - fn batch_to_tensors(&self, batch: &TFTBatch) -> MLResult<(Tensor, Tensor, Tensor, Tensor)> { - // Convert ndarray to Vec (CPU memory, fast) - let static_data: Vec = batch.static_features.iter().map(|&x| x as f32).collect(); - let hist_data: Vec = batch - .historical_features - .iter() - .map(|&x| x as f32) - .collect(); - let fut_data: Vec = batch.future_features.iter().map(|&x| x as f32).collect(); - let target_data: Vec = batch.targets.iter().map(|&x| x as f32).collect(); - - // 🔥 Create tensors directly on GPU device (single allocation, no intermediate CPU tensor) - // This eliminates the CPU→GPU copy overhead (was 18s per epoch) - let static_tensor = Tensor::from_slice( - &static_data, - batch.static_features.raw_dim().into_pattern(), - &self.device, // ← Direct GPU allocation (zero-copy from CPU data) - )?; - - let hist_tensor = Tensor::from_slice( - &hist_data, - batch.historical_features.raw_dim().into_pattern(), - &self.device, // ← Direct GPU allocation - )?; - - let fut_tensor = Tensor::from_slice( - &fut_data, - batch.future_features.raw_dim().into_pattern(), - &self.device, // ← Direct GPU allocation - )?; - - let target_tensor = Tensor::from_slice( - &target_data, - batch.targets.raw_dim().into_pattern(), - &self.device, // ← Direct GPU allocation - )?; - - Ok((static_tensor, hist_tensor, fut_tensor, target_tensor)) - } - - /// Compute quantile loss for TFT predictions - /// - /// Implements pinball loss across multiple quantiles: - /// L(y, q_tau) = sum_i max(tau * (y_i - q_tau), (tau - 1) * (y_i - q_tau)) - fn compute_quantile_loss(&self, predictions: &Tensor, targets: &Tensor) -> MLResult { - // Use fixed quantiles [0.1, 0.5, 0.9] for TFT - let quantiles = vec![0.1, 0.5, 0.9]; - - // predictions shape: [batch_size, horizon, num_quantiles] - // targets shape: [batch_size, horizon] - - // Compute pinball loss for each quantile and sum - let mut losses = Vec::new(); - - for (i, &quantile) in quantiles.iter().enumerate() { - // Extract predictions for this quantile: [batch_size, horizon] - let pred_q = predictions.i((.., .., i))?.detach(); - - // Compute error: y - q_tau - let error = targets.sub(&pred_q)?.detach(); - - // Pinball loss: max(tau * error, (tau - 1) * error) - let tau = quantile as f64; // Cast to f64 for scalar multiplication - let positive_part = (&error * tau)?.detach(); - let negative_part = (&error * (tau - 1.0))?.detach(); - - // Take element-wise maximum: [batch_size, horizon] - let loss_q = positive_part.maximum(&negative_part)?.detach(); - - losses.push(loss_q); - } - - // Stack losses: [num_quantiles, batch_size, horizon] - let stacked = Tensor::stack(&losses, 0)?; - - // Mean over all dimensions to get scalar loss - let mean_loss = stacked.mean_all()?; - - Ok(mean_loss) - } - - /// Compute RMSE between predictions and targets - fn compute_rmse(&self, predictions: &Tensor, targets: &Tensor) -> MLResult { - // Extract median quantile (index 1 for [0.1, 0.5, 0.9]) - let median_pred = predictions.i((.., .., 1))?; - - // Compute squared error - let diff = median_pred.sub(targets)?; - let squared_error = diff.sqr()?; - - // Mean squared error - let mse = squared_error.mean_all()?; - let mse_value = mse.to_vec0::()? as f64; - - // RMSE - Ok(mse_value.sqrt()) - } - - /// Extract attention entropy for interpretability - fn extract_attention_entropy(&self) -> MLResult> { - // TODO: Extract attention weights from model - // For now, return None as attention weights extraction needs model API - Ok(None) - } - - /// Check early stopping condition with patience - fn check_early_stopping(&mut self, val_loss: f64) -> bool { - const EARLY_STOPPING_PATIENCE: usize = 20; - - if val_loss < self.state.best_val_loss - self.training_config.early_stopping_threshold { - // Validation loss improved - reset patience counter - self.state.best_val_loss = val_loss; - self.state.patience_counter = 0; - false - } else { - // No improvement - increment patience counter - self.state.patience_counter += 1; - - if self.state.patience_counter >= EARLY_STOPPING_PATIENCE { - info!( - "Early stopping triggered: no improvement for {} epochs (best val loss: {:.6})", - EARLY_STOPPING_PATIENCE, self.state.best_val_loss - ); - true - } else { - debug!( - "Patience: {}/{} (best val loss: {:.6}, current: {:.6})", - self.state.patience_counter, - EARLY_STOPPING_PATIENCE, - self.state.best_val_loss, - val_loss - ); - false - } - } - } - - /// Save model checkpoint - async fn save_checkpoint(&self, epoch: usize, train_loss: f64, val_loss: f64) -> MLResult<()> { - let checkpoint_name = format!("tft_225_epoch_{}.safetensors", epoch); - - let metadata = CheckpointMetadata { - checkpoint_id: uuid::Uuid::new_v4().to_string(), - model_type: crate::ModelType::TFT, - model_name: "TFT".to_string(), - version: format!("epoch_{}", epoch), - created_at: chrono::Utc::now(), - epoch: Some(epoch as u64), - step: None, - loss: Some(train_loss), - accuracy: None, - hyperparameters: HashMap::new(), - metrics: { - let mut m = HashMap::new(); - m.insert("train_loss".to_string(), train_loss); - m.insert("val_loss".to_string(), val_loss); - m - }, - architecture: HashMap::new(), - format: crate::checkpoint::CheckpointFormat::Binary, - compression: crate::checkpoint::CompressionType::None, - file_size: 0, - compressed_size: None, - checksum: String::new(), - tags: Vec::new(), - custom_metadata: HashMap::new(), - signature: None, - signature_algorithm: String::from("none"), - signing_key_id: String::from("none"), - signed_at: None, - }; - - // Serialize model weights to SafeTensors - use std::path::PathBuf; - - let checkpoint_path = PathBuf::from(&self.checkpoint_dir).join(&checkpoint_name); - - // Create checkpoint directory if it doesn't exist - std::fs::create_dir_all(&self.checkpoint_dir).map_err(|e| { - MLError::ModelError(format!("Failed to create checkpoint directory: {}", e)) - })?; - - // Save all model weights to SafeTensors format - self.model - .get_varmap() - .save(&checkpoint_path) - .map_err(|e| { - MLError::ModelError(format!("Failed to save checkpoint to SafeTensors: {}", e)) - })?; - - // Get file size for verification - let file_size = std::fs::metadata(&checkpoint_path) - .map(|m| m.len()) - .unwrap_or(0); - - info!( - "Checkpoint saved: {} (epoch: {}, train_loss: {:.6}, val_loss: {:.6}, size: {} bytes)", - checkpoint_name, epoch, train_loss, val_loss, file_size - ); - - // Save metadata to JSON sidecar file - let metadata_path = checkpoint_path.with_extension("json"); - let metadata_json = - serde_json::to_string_pretty(&metadata).map_err(|e| MLError::SerializationError { - reason: format!("Failed to serialize metadata: {}", e), - })?; - std::fs::write(&metadata_path, metadata_json) - .map_err(|e| MLError::ModelError(format!("Failed to write metadata: {}", e)))?; - - Ok(()) - } - - /// Send progress update to gRPC stream - async fn send_progress_update( - &self, - epoch: usize, - train_loss: f64, - val_loss: f64, - val_metrics: &ValidationMetrics, - ) { - if let Some(ref tx) = self.progress_tx { - let progress = (epoch as f32 + 1.0) / self.training_config.epochs as f32 * 100.0; - - let mut metrics = HashMap::new(); - metrics.insert("train_loss".to_string(), train_loss as f32); - metrics.insert("val_loss".to_string(), val_loss as f32); - metrics.insert( - "quantile_loss".to_string(), - val_metrics.quantile_loss as f32, - ); - metrics.insert("rmse".to_string(), val_metrics.rmse as f32); - metrics.insert( - "attention_entropy".to_string(), - val_metrics.attention_entropy as f32, - ); - - // Add QAT metrics if available - if self.use_qat { - metrics.insert( - "qat_fake_quant_error".to_string(), - self.state.qat_fake_quant_error as f32, - ); - metrics.insert( - "qat_observer_range".to_string(), - self.state.qat_observer_range as f32, - ); - // Estimate INT8 accuracy: assume 1% accuracy loss per 0.1 quantization error - let estimated_int8_accuracy = 100.0 - (self.state.qat_fake_quant_error * 10.0); - metrics.insert( - "qat_estimated_int8_accuracy".to_string(), - estimated_int8_accuracy as f32, - ); - } - - let update = TrainingProgress { - current_epoch: (epoch + 1) as u32, - total_epochs: self.training_config.epochs as u32, - progress_percentage: progress, - metrics, - message: format!( - "Epoch {}/{}: Train Loss: {:.6}, Val Loss: {:.6}", - epoch + 1, - self.training_config.epochs, - train_loss, - val_loss - ), - timestamp: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - resource_usage: self.get_resource_usage(), - }; - - if let Err(e) = tx.send(update) { - warn!("Failed to send progress update: {}", e); - } - } - } - - /// Send QAT calibration progress update - async fn send_qat_calibration_progress(&self) { - if let Some(ref tx) = self.progress_tx { - let mut metrics = HashMap::new(); - metrics.insert( - "qat_calibration_progress".to_string(), - self.state.qat_calibration_progress as f32, - ); - metrics.insert( - "qat_observer_range".to_string(), - self.state.qat_observer_range as f32, - ); - - let update = TrainingProgress { - current_epoch: 0, - total_epochs: self.training_config.epochs as u32, - progress_percentage: self.state.qat_calibration_progress as f32, - metrics, - message: format!( - "QAT Calibration: {:.1}% complete", - self.state.qat_calibration_progress - ), - timestamp: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - resource_usage: self.get_resource_usage(), - }; - - if let Err(e) = tx.send(update) { - warn!("Failed to send QAT calibration progress: {}", e); - } - } - } - - /// Get current resource usage - fn get_resource_usage(&self) -> ResourceUsage { - // TODO: Implement actual resource monitoring - // Would use system metrics crates for CPU/memory - // And CUDA APIs for GPU metrics - ResourceUsage::default() - } - - /// Get reference to the TFT model (polymorphic trait object) - /// - /// Note: Returns a trait object, so you can't downcast to concrete types. - /// Use get_varmap() instead to access model weights directly. - pub fn get_model(&self) -> &dyn TFTModel { - self.model.as_ref() - } - - /// Get reference to the VarMap (for weight extraction) - pub fn get_varmap(&self) -> Arc { - self.model.get_varmap() - } - - /// Get reference to the training configuration (for Parquet loader) - pub fn get_training_config(&self) -> &TFTTrainingConfig { - &self.training_config - } - - /// Get QAT minimum batch size (for OOM recovery) - pub fn get_qat_min_batch_size(&self) -> usize { - self.qat_min_batch_size - } - - /// Update training batch size (for OOM recovery) - pub fn update_batch_size(&mut self, new_batch_size: usize) { - self.training_config.batch_size = new_batch_size; - self.training_config.validation_batch_size = new_batch_size; - info!( - "Updated training batch_size and validation_batch_size to: {}", - new_batch_size - ); - } - - /// Quantize FP32 model to INT8 and save checkpoint (called after training if use_int8=true) - /// - /// # Returns - /// * Ok(num_tensors) - Number of tensors quantized and saved - /// * Err if quantization fails - /// - /// # Process - /// 1. Extract FP32 VarMap from trained model - /// 2. Quantize all weights to INT8 using varmap_quantization module - /// 3. Save INT8 weights to SafeTensors file - /// 4. Save metadata JSON with training metrics - async fn quantize_and_save_int8_checkpoint( - &self, - epoch: usize, - train_loss: f64, - val_loss: f64, - ) -> MLResult { - use crate::memory_optimization::quantization::{ - QuantizationConfig, QuantizationType, Quantizer, - }; - use crate::tft::varmap_quantization::{quantize_varmap, save_quantized_weights}; - use std::path::PathBuf; - - let var_map = self.model.get_varmap(); - info!( - "🔄 Quantizing {} FP32 parameters to INT8...", - var_map.all_vars().len() - ); - - // Create quantizer for INT8 symmetric quantization - let quant_config = QuantizationConfig { - quant_type: QuantizationType::Int8, - per_channel: false, - symmetric: true, - calibration_samples: None, - }; - let mut quantizer = Quantizer::new(quant_config, self.device.clone()); - - // Quantize all weights in VarMap (uses bulk quantization with progress tracking) - let quantized_weights = quantize_varmap(var_map.clone(), &mut quantizer)?; - let num_tensors = quantized_weights.len(); - - info!("✅ Quantized {} tensors to INT8", num_tensors); - - // Build checkpoint path (INT8 variant) - let checkpoint_name = format!("tft_225_int8_epoch_{}", epoch); - let checkpoint_path = PathBuf::from(&self.checkpoint_dir).join(&checkpoint_name); - - // Save quantized weights to SafeTensors format - info!("💾 Saving INT8 checkpoint: {}.safetensors", checkpoint_name); - save_quantized_weights(&quantized_weights, checkpoint_path.to_str().unwrap())?; - - // Save metadata JSON sidecar - let metadata = CheckpointMetadata { - checkpoint_id: uuid::Uuid::new_v4().to_string(), - model_type: crate::ModelType::TFT, - model_name: "TFT-INT8".to_string(), - version: format!("epoch_{}", epoch), - created_at: chrono::Utc::now(), - epoch: Some(epoch as u64), - step: None, - loss: Some(train_loss), - accuracy: None, - hyperparameters: { - let mut h = HashMap::new(); - h.insert( - "quantization".to_string(), - serde_json::Value::String("int8".to_string()), - ); - h.insert( - "memory_reduction".to_string(), - serde_json::Value::String("75%".to_string()), - ); - h - }, - metrics: { - let mut m = HashMap::new(); - m.insert("train_loss".to_string(), train_loss); - m.insert("val_loss".to_string(), val_loss); - m - }, - architecture: HashMap::new(), - format: crate::checkpoint::CheckpointFormat::Binary, - compression: crate::checkpoint::CompressionType::None, - file_size: 0, - compressed_size: None, - checksum: String::new(), - tags: vec!["int8".to_string(), "quantized".to_string()], - custom_metadata: { - let mut c = HashMap::new(); - c.insert( - "model_type".to_string(), - serde_json::Value::String("int8".to_string()), - ); - c.insert( - "num_tensors".to_string(), - serde_json::Value::Number(num_tensors.into()), - ); - c - }, - signature: None, - signature_algorithm: String::from("none"), - signing_key_id: String::from("none"), - signed_at: None, - }; - - let metadata_path = checkpoint_path.with_extension("json"); - let metadata_json = - serde_json::to_string_pretty(&metadata).map_err(|e| MLError::SerializationError { - reason: format!("Failed to serialize INT8 metadata: {}", e), - })?; - std::fs::write(&metadata_path, metadata_json) - .map_err(|e| MLError::ModelError(format!("Failed to write INT8 metadata: {}", e)))?; - - info!("✅ INT8 checkpoint saved: {}.safetensors", checkpoint_name); - - Ok(num_tensors) - } - - /// QAT calibration phase: Run forward passes to collect observer statistics - /// - /// This phase: - /// 1. Runs N batches forward-only (no backprop) - /// 2. Observers track min/max/mean/std of activations - /// 3. After calibration, observers are frozen - /// 4. Subsequent training uses fake quantization (quantize→dequantize in forward pass) - async fn run_qat_calibration(&mut self, train_loader: &mut TFTDataLoader) -> MLResult<()> { - info!("🔍 QAT Calibration: Collecting observer statistics..."); - - let mut batch_count = 0; - let mut activation_stats = Vec::new(); - - for batch in train_loader.iter() { - if batch_count >= self.qat_calibration_batches { - break; - } - - // Convert batch to tensors - let (static_tensor, hist_tensor, fut_tensor, _target_tensor) = - self.batch_to_tensors(batch)?; - - // Forward pass ONLY (no backprop) to update observers, with optional checkpointing - let predictions = self.model.forward( - &static_tensor, - &hist_tensor, - &fut_tensor, - self.use_gradient_checkpointing, - )?; - - // Track activation statistics for logging - // Predictions shape: [batch_size, horizon, num_quantiles] - // Flatten to get global min/max across all dimensions - let pred_min = predictions.flatten_all()?.min(0)?.to_vec0::()? as f64; - let pred_max = predictions.flatten_all()?.max(0)?.to_vec0::()? as f64; - let pred_mean = predictions.mean_all()?.to_vec0::()? as f64; - activation_stats.push((pred_min, pred_max, pred_mean)); - - batch_count += 1; - - // Update calibration progress (0-100%) - self.state.qat_calibration_progress = - (batch_count as f64 / self.qat_calibration_batches as f64) * 100.0; - - if batch_count % 20 == 0 { - debug!( - "QAT Calibration: {}/{} batches ({:.1}% complete, range: {:.4} to {:.4}, mean: {:.4})", - batch_count, self.qat_calibration_batches, - self.state.qat_calibration_progress, - pred_min, pred_max, pred_mean - ); - - // Send progress update with calibration metrics - self.send_qat_calibration_progress().await; - } - } - - // Log observer statistics summary - if !activation_stats.is_empty() { - let avg_min = activation_stats.iter().map(|(min, _, _)| min).sum::() - / activation_stats.len() as f64; - let avg_max = activation_stats.iter().map(|(_, max, _)| max).sum::() - / activation_stats.len() as f64; - let avg_mean = activation_stats - .iter() - .map(|(_, _, mean)| mean) - .sum::() - / activation_stats.len() as f64; - - // Store observer range for metrics reporting - self.state.qat_observer_range = avg_max - avg_min; - - info!( - "📊 Observer Statistics: min={:.4}, max={:.4}, mean={:.4}, range={:.4} (over {} batches)", - avg_min, avg_max, avg_mean, self.state.qat_observer_range, batch_count - ); - } - - // Mark calibration complete - self.qat_calibrated = true; - self.state.qat_calibration_progress = 100.0; - - info!("🔒 Observers frozen - fake quantization now active for training"); - Ok(()) - } - - /// Convert QAT model to INT8 checkpoint - /// - /// QAT models have fake quantization baked in (quantize→dequantize in forward pass). - /// This method: - /// 1. Extracts FP32 weights from VarMap - /// 2. Applies observer-calibrated quantization (using min/max from calibration) - /// 3. Saves INT8 weights to SafeTensors - /// - /// Expected accuracy loss: <1% (vs. 3-5% for post-training quantization) - async fn qat_to_quantized_checkpoint( - &self, - epoch: usize, - train_loss: f64, - val_loss: f64, - ) -> MLResult { - use crate::memory_optimization::quantization::{ - QuantizationConfig, QuantizationType, Quantizer, - }; - use crate::tft::varmap_quantization::{quantize_varmap, save_quantized_weights}; - use std::path::PathBuf; - - info!("🔄 Converting QAT-trained model to INT8 (observer-calibrated quantization)..."); - - // Create quantizer for INT8 symmetric quantization (using calibrated ranges) - let quant_config = QuantizationConfig { - quant_type: QuantizationType::Int8, - per_channel: false, - symmetric: true, - calibration_samples: None, // Already calibrated via QAT observers - }; - let mut quantizer = Quantizer::new(quant_config, self.device.clone()); - - // Quantize all weights in VarMap (uses calibrated min/max from observers) - let var_map = self.model.get_varmap(); - let quantized_weights = quantize_varmap(var_map.clone(), &mut quantizer)?; - let num_tensors = quantized_weights.len(); - - info!( - "✅ Quantized {} tensors to INT8 using QAT observers", - num_tensors - ); - - // Build checkpoint path (QAT-INT8 variant) - let checkpoint_name = format!("tft_225_qat_int8_epoch_{}", epoch); - let checkpoint_path = PathBuf::from(&self.checkpoint_dir).join(&checkpoint_name); - - // Save quantized weights to SafeTensors format - info!( - "💾 Saving QAT-INT8 checkpoint: {}.safetensors", - checkpoint_name - ); - save_quantized_weights(&quantized_weights, checkpoint_path.to_str().unwrap())?; - - // Save metadata JSON sidecar - let metadata = CheckpointMetadata { - checkpoint_id: uuid::Uuid::new_v4().to_string(), - model_type: crate::ModelType::TFT, - model_name: "TFT-QAT-INT8".to_string(), - version: format!("epoch_{}", epoch), - created_at: chrono::Utc::now(), - epoch: Some(epoch as u64), - step: None, - loss: Some(train_loss), - accuracy: None, - hyperparameters: { - let mut h = HashMap::new(); - h.insert( - "quantization".to_string(), - serde_json::Value::String("qat-int8".to_string()), - ); - h.insert( - "memory_reduction".to_string(), - serde_json::Value::String("75%".to_string()), - ); - h.insert( - "qat_calibration_batches".to_string(), - serde_json::Value::Number(self.qat_calibration_batches.into()), - ); - h.insert( - "expected_accuracy_loss".to_string(), - serde_json::Value::String("<1%".to_string()), - ); - h - }, - metrics: { - let mut m = HashMap::new(); - m.insert("train_loss".to_string(), train_loss); - m.insert("val_loss".to_string(), val_loss); - m - }, - architecture: HashMap::new(), - format: crate::checkpoint::CheckpointFormat::Binary, - compression: crate::checkpoint::CompressionType::None, - file_size: 0, - compressed_size: None, - checksum: String::new(), - tags: vec![ - "qat".to_string(), - "int8".to_string(), - "quantized".to_string(), - ], - custom_metadata: { - let mut c = HashMap::new(); - c.insert( - "model_type".to_string(), - serde_json::Value::String("qat-int8".to_string()), - ); - c.insert( - "num_tensors".to_string(), - serde_json::Value::Number(num_tensors.into()), - ); - c.insert("qat_enabled".to_string(), serde_json::Value::Bool(true)); - c - }, - signature: None, - signature_algorithm: String::from("none"), - signing_key_id: String::from("none"), - signed_at: None, - }; - - let metadata_path = checkpoint_path.with_extension("json"); - let metadata_json = - serde_json::to_string_pretty(&metadata).map_err(|e| MLError::SerializationError { - reason: format!("Failed to serialize QAT-INT8 metadata: {}", e), - })?; - std::fs::write(&metadata_path, metadata_json).map_err(|e| { - MLError::ModelError(format!("Failed to write QAT-INT8 metadata: {}", e)) - })?; - - info!( - "✅ QAT-INT8 checkpoint saved: {}.safetensors (expected accuracy loss: <1%)", - checkpoint_name - ); - - Ok(num_tensors) - } - - /// Export comprehensive QAT metrics for Prometheus/Grafana monitoring - /// - /// Extracts per-layer quantization statistics (scale, zero_point, observer ranges) - /// from the QAT model's FakeQuantize observers. - /// - /// # Returns - /// * `Some(QATMetrics)` - Comprehensive QAT metrics if QAT is enabled - /// * `None` - If QAT is not enabled or model is not QAT - /// - /// # Metrics Exported - /// - Per-layer scale factors (min, max, mean, std) - /// - Per-layer zero points - /// - Observer min/max ranges - /// - Calibration convergence metrics - /// - Quantization error estimates - /// - /// # Usage - /// ```ignore - /// let qat_metrics = trainer.export_qat_metrics(); - /// if let Some(metrics) = qat_metrics { - /// // Export to Prometheus - /// prometheus_exporter.export_qat_metrics(&metrics); - /// - /// // Log to Grafana dashboard - /// grafana_client.log_qat_metrics(&metrics); - /// } - /// ``` - fn export_qat_metrics(&self) -> Option { - if !self.use_qat { - return None; - } - - // Downcast trait object to QATTemporalFusionTransformer to access observers - // Note: This requires type_id or alternative approach since trait objects - // don't support downcasting by default. For now, return placeholder metrics. - // In production, this would extract actual observer statistics. - - // Placeholder QAT metrics (production would extract from actual observers) - let scale_statistics = ScaleStatistics { - min: 0.001, - max: 0.1, - mean: 0.02, - std: 0.015, - }; - - let zero_point_statistics = ZeroPointStatistics { - min: -128, - max: 127, - mean: 0, - mode: 127, // Symmetric quantization - }; - - let observer_ranges = ObserverRangeStatistics { - min_range: 0.5, - max_range: 10.0, - mean_range: 3.5, - convergence_rate: 0.95, - }; - - let layer_metrics = vec![LayerQuantizationMetrics { - layer_name: "quantile_outputs.output_layer".to_string(), - scale: 0.02, - zero_point: 127, - min_val: -2.0, - max_val: 2.0, - num_observations: self.qat_calibration_batches, - }]; - - Some(QATMetrics { - observer_count: 10, // Placeholder: would be actual observer count - scale_statistics, - zero_point_statistics, - observer_ranges, - layer_metrics, - calibration_convergence: self.state.qat_calibration_progress / 100.0, - quantization_error: self.state.qat_fake_quant_error, - }) - } - - /// Apply QAT-specific learning rate schedule - /// - /// # QAT Learning Rate Schedule - /// - /// 1. **Warmup Phase** (epochs 0 to qat_warmup_epochs): - /// - Start at 10% of normal LR (0.1 * base_lr) - /// - Gradually increase to full LR over warmup epochs - /// - Allows observers to stabilize during initial training - /// - /// 2. **Normal Training Phase** (warmup to cooldown): - /// - Use full learning rate (base_lr) - /// - Standard training with fake quantization - /// - /// 3. **Cooldown Phase** (final 10% of training): - /// - Reduce LR by qat_cooldown_factor (default: 0.1x = 10x reduction) - /// - Fine-tune quantization parameters for stability - /// - Reduces oscillations in quantized model - /// - /// # Arguments - /// * `epoch` - Current training epoch (0-indexed) - /// - /// # Example - /// ```text - /// Total epochs: 100 - /// Warmup: 10 epochs (0-9) - /// Normal: 80 epochs (10-89) - /// Cooldown: 10 epochs (90-99) - /// - /// LR schedule: - /// Epoch 0: 0.1 * base_lr (warmup start) - /// Epoch 5: 0.55 * base_lr (warmup mid) - /// Epoch 10: 1.0 * base_lr (warmup end, normal start) - /// Epoch 89: 1.0 * base_lr (normal end) - /// Epoch 90: 0.1 * base_lr (cooldown start) - /// Epoch 99: 0.1 * base_lr (cooldown end) - /// ``` - fn apply_qat_lr_schedule(&mut self, epoch: usize) -> MLResult<()> { - let total_epochs = self.training_config.epochs; - let base_lr = self.training_config.learning_rate; - - // Calculate cooldown start epoch (last 10% of training) - let cooldown_start_epoch = (total_epochs as f64 * 0.9) as usize; - - let new_lr = if epoch < self.qat_warmup_epochs { - // Warmup Phase: Linear warmup from 10% to 100% of base_lr - let warmup_progress = epoch as f64 / self.qat_warmup_epochs as f64; - let warmup_multiplier = 0.1 + (0.9 * warmup_progress); // 0.1 → 1.0 - base_lr * warmup_multiplier - } else if epoch >= cooldown_start_epoch { - // Cooldown Phase: Reduce LR by cooldown factor - base_lr * self.qat_cooldown_factor - } else { - // Normal Training Phase: Use full base_lr - base_lr - }; - - // Update learning rate - self.state.learning_rate = new_lr; - - // Apply to optimizer (if initialized) - // Check if LR actually changed before recreating optimizer - if let Some(ref opt) = self.optimizer { - let current_lr = opt.learning_rate(); - - // Only recreate optimizer if LR changed by more than epsilon (1e-10) - if (current_lr - new_lr).abs() > 1e-10 { - info!( - "🔄 QAT LR Schedule - Recreating optimizer: {:.2e} → {:.2e} (epoch {})", - current_lr, new_lr, epoch - ); - - // Drop old optimizer to free memory (~1100MB) - drop(self.optimizer.take()); - - // Update config with new LR - self.training_config.learning_rate = new_lr; - - // Recreate optimizer with new LR (allocates ~1100MB) - self.initialize_optimizer()?; - } else { - debug!( - "QAT LR Schedule - Epoch {}: {:.2e} (unchanged, warmup: {}, cooldown: {})", - epoch, - new_lr, - epoch < self.qat_warmup_epochs, - epoch >= cooldown_start_epoch - ); - } - } - - // Log major phase transitions - if epoch == 0 { - info!("🎯 QAT Warmup Phase: Starting at {:.2e} (10% of base LR), will reach {:.2e} at epoch {}", new_lr, base_lr, self.qat_warmup_epochs); - } else if epoch == self.qat_warmup_epochs { - info!( - "✅ QAT Warmup Complete: Full LR {:.2e} reached at epoch {}", - new_lr, epoch - ); - } else if epoch == cooldown_start_epoch { - info!( - "🔽 QAT Cooldown Phase: Reducing LR to {:.2e} ({:.1}x reduction) at epoch {}", - new_lr, self.qat_cooldown_factor, epoch - ); - } - - Ok(()) - } -} - -/// Validation metrics -#[derive(Debug, Clone, Default)] -struct ValidationMetrics { - quantile_loss: f64, - rmse: f64, - attention_entropy: f64, -} - -/// Training metrics result -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TrainingMetrics { - /// Final training loss - pub train_loss: f64, - - /// Final validation loss - pub val_loss: f64, - - /// Quantile loss - pub quantile_loss: f64, - - /// RMSE - pub rmse: f64, - - /// Attention entropy (interpretability metric) - pub attention_entropy: f64, - - /// Total training time in seconds - pub training_time_seconds: f64, - - /// QAT calibration progress (0.0-100.0) - /// Percentage of calibration batches completed during QAT observer setup phase - pub qat_calibration_progress: Option, - - /// QAT fake quantization error (L2 norm between FP32 and quantized activations) - /// Measures accuracy loss from quantization-aware training - pub qat_fake_quant_error: Option, - - /// QAT observer min/max range statistics - /// Average activation range (max - min) across all layers during calibration - pub qat_observer_range: Option, - - /// Comprehensive QAT metrics for Prometheus/Grafana monitoring - /// Exported during training if QAT is enabled - pub qat_metrics: Option, - - /// Estimated INT8 accuracy (predicted final accuracy after quantization) - /// Based on fake quantization error during training - pub qat_estimated_int8_accuracy: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::checkpoint::FileSystemStorage; - use std::path::PathBuf; - - #[tokio::test] - async fn test_tft_trainer_creation() { - let config = TFTTrainerConfig::default(); - let storage = Arc::new(FileSystemStorage::new(PathBuf::from( - "/tmp/test_checkpoints", - ))); - - let trainer = TFTTrainer::new(config, storage); - assert!(trainer.is_ok()); - } - - #[tokio::test] - async fn test_training_config_conversion() { - let config = TFTTrainerConfig { - hidden_dim: 128, - num_attention_heads: 4, - lstm_layers: 2, - ..Default::default() - }; - - let model_config = config.to_model_config(); - assert_eq!(model_config.hidden_dim, 128); - assert_eq!(model_config.num_heads, 4); - assert_eq!(model_config.num_layers, 2); - } - - #[tokio::test] - async fn test_checkpoint_save_load() { - use tempfile::TempDir; - - // Create temporary directory for checkpoints - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let checkpoint_dir = temp_dir.path().to_str().unwrap().to_string(); - - // Create trainer with custom checkpoint directory - let config = TFTTrainerConfig { - epochs: 5, - batch_size: 2, - hidden_dim: 32, - num_attention_heads: 2, - checkpoint_dir: checkpoint_dir.clone(), - ..Default::default() - }; - - let storage = Arc::new(FileSystemStorage::new(PathBuf::from(&checkpoint_dir))); - let trainer = TFTTrainer::new(config, storage).expect("Failed to create trainer"); - - // Save checkpoint - let result = trainer.save_checkpoint(1, 0.5, 0.6).await; - assert!( - result.is_ok(), - "Failed to save checkpoint: {:?}", - result.err() - ); - - // Verify checkpoint file exists and has non-zero size - let checkpoint_path = PathBuf::from(&checkpoint_dir).join("tft_225_epoch_1.safetensors"); - assert!(checkpoint_path.exists(), "Checkpoint file does not exist"); - - let file_size = std::fs::metadata(&checkpoint_path) - .expect("Failed to get file metadata") - .len(); - assert!( - file_size > 0, - "Checkpoint file is empty (size: {} bytes)", - file_size - ); - - // Note: File size will be small (16-32 bytes) for untrained model with empty VarMap - // In actual training, weights would be present and file size would be >1MB - // Here we just verify the SafeTensors format is being saved correctly - - // Verify metadata file exists - let metadata_path = checkpoint_path.with_extension("json"); - assert!(metadata_path.exists(), "Metadata file does not exist"); - - // Read and validate metadata - let metadata_content = - std::fs::read_to_string(&metadata_path).expect("Failed to read metadata"); - let metadata: serde_json::Value = - serde_json::from_str(&metadata_content).expect("Failed to parse metadata JSON"); - - assert_eq!(metadata["epoch"], 1); - assert_eq!(metadata["model_type"], "TFT"); - assert!(metadata["metrics"]["train_loss"].as_f64().unwrap() - 0.5 < 0.0001); - assert!(metadata["metrics"]["val_loss"].as_f64().unwrap() - 0.6 < 0.0001); - - println!("✅ Checkpoint saved successfully: {} bytes", file_size); - println!("✅ Metadata file created: {}", metadata_path.display()); - } - - #[tokio::test] - async fn test_zero_batch_size_handling() { - use tempfile::TempDir; - - // Create temporary directory - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let checkpoint_dir = temp_dir.path().to_str().unwrap().to_string(); - - // Test TFT rejects zero batch size - let config = TFTTrainerConfig { - batch_size: 0, - checkpoint_dir, - ..Default::default() - }; - - let storage = Arc::new(FileSystemStorage::new(PathBuf::from( - "/tmp/test_checkpoints", - ))); - let result = TFTTrainer::new(config, storage); - - // Should fail with descriptive error - assert!( - result.is_err(), - "TFT should reject zero batch size, but got: {:?}", - result - ); - - // Error message should mention batch size or validation - let error_msg = result.unwrap_err().to_string(); - assert!( - error_msg.to_lowercase().contains("batch") - || error_msg.to_lowercase().contains("valid"), - "Error message should mention batch size or validation, got: {}", - error_msg - ); - } - - #[tokio::test] - async fn test_qat_lr_schedule() { - use tempfile::TempDir; - - // Create temporary directory for checkpoints - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let checkpoint_dir = temp_dir.path().to_str().unwrap().to_string(); - - // Create trainer with QAT enabled - let config = TFTTrainerConfig { - epochs: 100, - learning_rate: 1e-3, - use_qat: true, - qat_warmup_epochs: 10, - qat_cooldown_factor: 0.1, - checkpoint_dir: checkpoint_dir.clone(), - ..Default::default() - }; - - let storage = Arc::new(FileSystemStorage::new(PathBuf::from(&checkpoint_dir))); - let mut trainer = TFTTrainer::new(config, storage).expect("Failed to create trainer"); - - // Test warmup phase - trainer - .apply_qat_lr_schedule(0) - .expect("Failed to apply LR schedule"); - assert!( - (trainer.state.learning_rate - 1e-4).abs() < 1e-9, - "Epoch 0: Expected 1e-4 (10% of 1e-3), got {}", - trainer.state.learning_rate - ); - - trainer - .apply_qat_lr_schedule(5) - .expect("Failed to apply LR schedule"); - let expected_mid_warmup = 1e-3 * 0.55; // 55% progress - assert!( - (trainer.state.learning_rate - expected_mid_warmup).abs() < 1e-9, - "Epoch 5: Expected {} (55% of 1e-3), got {}", - expected_mid_warmup, - trainer.state.learning_rate - ); - - trainer - .apply_qat_lr_schedule(10) - .expect("Failed to apply LR schedule"); - assert!( - (trainer.state.learning_rate - 1e-3).abs() < 1e-9, - "Epoch 10: Expected 1e-3 (full LR), got {}", - trainer.state.learning_rate - ); - - // Test normal training phase - trainer - .apply_qat_lr_schedule(50) - .expect("Failed to apply LR schedule"); - assert!( - (trainer.state.learning_rate - 1e-3).abs() < 1e-9, - "Epoch 50: Expected 1e-3 (full LR), got {}", - trainer.state.learning_rate - ); - - // Test cooldown phase (starts at epoch 90 for 100 total epochs) - trainer - .apply_qat_lr_schedule(90) - .expect("Failed to apply LR schedule"); - assert!( - (trainer.state.learning_rate - 1e-4).abs() < 1e-9, - "Epoch 90: Expected 1e-4 (10% of 1e-3), got {}", - trainer.state.learning_rate - ); - } - - #[tokio::test] - async fn test_is_oom_error() { - // Test standard OOM error strings - let oom1 = MLError::ModelError("CUDA error: out of memory".to_string()); - assert!( - TFTTrainer::is_oom_error(&oom1), - "Should detect 'out of memory'" - ); - - let oom2 = MLError::TrainingError("OOM detected during forward pass".to_string()); - assert!(TFTTrainer::is_oom_error(&oom2), "Should detect 'OOM'"); - - let oom3 = MLError::ModelError("cuda error 2: allocation failed".to_string()); - assert!( - TFTTrainer::is_oom_error(&oom3), - "Should detect 'cuda error 2'" - ); - - let oom4 = MLError::ModelError("Failed to allocate 500MB on GPU".to_string()); - assert!( - TFTTrainer::is_oom_error(&oom4), - "Should detect 'failed to allocate'" - ); - - // Test non-OOM errors - let not_oom1 = MLError::ModelError("Invalid tensor shape".to_string()); - assert!( - !TFTTrainer::is_oom_error(¬_oom1), - "Should not detect regular errors" - ); - - let not_oom2 = MLError::ConfigError { - reason: "Missing parameter".to_string(), - }; - assert!( - !TFTTrainer::is_oom_error(¬_oom2), - "Should not detect config errors" - ); - } - - #[tokio::test] - async fn test_sync_cuda_device_cpu() { - // Test CUDA sync on CPU device (should be no-op) - let device = Device::Cpu; - let result = TFTTrainer::sync_cuda_device(&device); - assert!(result.is_ok(), "CPU sync should succeed as no-op"); - } - - #[tokio::test] - #[cfg(feature = "cuda")] - #[ignore] // Only run when GPU available - async fn test_sync_cuda_device_gpu() { - // Test CUDA sync on GPU device - let device = Device::cuda_if_available(0).expect("CUDA not available"); - if !device.is_cuda() { - println!("Skipping CUDA sync test - GPU not available"); - return; - } - - let result = TFTTrainer::sync_cuda_device(&device); - assert!( - result.is_ok(), - "GPU sync should succeed: {:?}", - result.err() - ); - } - - #[tokio::test] - async fn test_oom_retry_batch_size_reduction() { - use tempfile::TempDir; - - // Create temporary directory for checkpoints - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let checkpoint_dir = temp_dir.path().to_str().unwrap().to_string(); - - // Create trainer with initial batch size - let config = TFTTrainerConfig { - epochs: 5, - batch_size: 64, // Start with large batch size - hidden_dim: 32, - checkpoint_dir: checkpoint_dir.clone(), - ..Default::default() - }; - - let storage = Arc::new(FileSystemStorage::new(PathBuf::from(&checkpoint_dir))); - let mut trainer = TFTTrainer::new(config, storage).expect("Failed to create trainer"); - - // Simulate OOM retry logic - let mut current_batch_size = trainer.training_config.batch_size; - let mut oom_retry_count = 0; - const MAX_OOM_RETRIES: usize = 3; - - // Simulate 3 OOM events - while oom_retry_count < MAX_OOM_RETRIES { - oom_retry_count += 1; - current_batch_size /= 2; - - // Test exponential backoff: 64 → 32 → 16 → 8 - match oom_retry_count { - 1 => assert_eq!( - current_batch_size, 32, - "First retry should halve batch size to 32" - ), - 2 => assert_eq!( - current_batch_size, 16, - "Second retry should halve batch size to 16" - ), - 3 => assert_eq!( - current_batch_size, 8, - "Third retry should halve batch size to 8" - ), - _ => panic!("Should not exceed MAX_OOM_RETRIES"), - } - - // Update trainer config (simulating actual retry logic) - trainer.training_config.batch_size = current_batch_size; - trainer.training_config.validation_batch_size = current_batch_size; - } - - // Verify final state - assert_eq!(oom_retry_count, 3, "Should have retried exactly 3 times"); - assert_eq!(current_batch_size, 8, "Final batch size should be 8"); - assert_eq!( - trainer.training_config.batch_size, 8, - "Trainer config should be updated" - ); - } - - #[tokio::test] - async fn test_oom_retry_minimum_batch_size() { - use tempfile::TempDir; - - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let checkpoint_dir = temp_dir.path().to_str().unwrap().to_string(); - - // Start with batch size that will go below minimum - let config = TFTTrainerConfig { - epochs: 5, - batch_size: 4, // Minimum batch size - hidden_dim: 32, - checkpoint_dir: checkpoint_dir.clone(), - ..Default::default() - }; - - let storage = Arc::new(FileSystemStorage::new(PathBuf::from(&checkpoint_dir))); - let trainer = TFTTrainer::new(config, storage).expect("Failed to create trainer"); - - // Simulate OOM at minimum batch size - let mut current_batch_size = trainer.training_config.batch_size; - current_batch_size /= 2; // 4 → 2 - - // Should be below minimum (4) - assert!( - current_batch_size < 4, - "Reduced batch size should be below minimum (got {})", - current_batch_size - ); - } -} diff --git a/ml/tests/dqn_realistic_constraints_integration.rs.disabled b/ml/tests/dqn_realistic_constraints_integration.rs.disabled deleted file mode 100644 index ad98bba4a..000000000 --- a/ml/tests/dqn_realistic_constraints_integration.rs.disabled +++ /dev/null @@ -1,542 +0,0 @@ -//! DQN Realistic Constraints Integration Tests -//! -//! Comprehensive integration tests verifying: -//! - TradeExecutor rejection flow (position limits, risk controls) -//! - Partial fill handling and P&L calculation -//! - Slippage impact on rewards -//! - Backtest metrics integration in hyperopt -//! - End-to-end training with all features -//! -//! These tests validate production-ready constraints that prevent -//! unrealistic trading behavior and ensure proper risk management. - -#![allow(unused_crate_dependencies)] - -use anyhow::Result; -use chrono::Utc; -use ml::dqn::portfolio_tracker::PortfolioTracker; -use ml::dqn::reward::{RewardConfig, RewardFunction}; -use ml::dqn::TradingAction; -use ml::features::extraction::OHLCVBar; -use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer}; - -// ================================================================================================ -// TEST UTILITIES MODULE -// ================================================================================================ - -mod test_utils { - use super::*; - - /// Create synthetic trending market data for testing - pub fn create_synthetic_data(bars: usize, trend: f64) -> Vec { - let mut data = Vec::with_capacity(bars); - let base_price = 100.0; - let base_volume = 1000.0; - - for i in 0..bars { - let price_delta = (i as f64) * trend; - let price = base_price + price_delta; - - let bar = OHLCVBar { - timestamp: Utc::now(), - open: price - 0.05, - high: price + 0.1, - low: price - 0.1, - close: price, - volume: base_volume * (1.0 + (i % 10) as f64 * 0.1), - }; - - data.push(bar); - } - - data - } - - /// Create test hyperparameters with conservative settings - pub fn create_test_hyperparams(epochs: usize) -> DQNHyperparameters { - DQNHyperparameters { - learning_rate: 0.001, - batch_size: 32, - gamma: 0.95, - epsilon_start: 0.3, - epsilon_end: 0.05, - epsilon_decay: 0.995, - buffer_size: 5000, - min_replay_size: 100, - epochs, - checkpoint_frequency: 10, - early_stopping_enabled: false, - q_value_floor: 0.5, - min_loss_improvement_pct: 2.0, - plateau_window: 5, - min_epochs_before_stopping: 10, - hold_penalty: 0.01, - use_huber_loss: true, - huber_delta: 1.0, - use_double_dqn: true, - gradient_clip_norm: Some(10.0), - hold_penalty_weight: 2.0, - movement_threshold: 0.02, - enable_preprocessing: true, - preprocessing_window: 50, - preprocessing_clip_sigma: 5.0, - tau: 0.001, - target_update_mode: ml::trainers::TargetUpdateMode::Soft, - target_update_frequency: 10000, - warmup_steps: 0, - } - } - - /// Create restrictive hyperparameters for testing rejection flow - pub fn create_restrictive_hyperparams() -> DQNHyperparameters { - let mut params = create_test_hyperparams(5); - // High hold penalty to force rejections - params.hold_penalty_weight = 5.0; - params.movement_threshold = 0.01; // Very sensitive - params - } - - /// Simulate trade executor rejection (no actual executor needed for unit test) - pub fn simulate_trade_rejection( - action: TradingAction, - position_size: f32, - position_limit: f32, - ) -> (TradingAction, f64) { - let rejected = match action { - TradingAction::Buy if position_size >= position_limit => true, - TradingAction::Sell if position_size <= -position_limit => true, - _ => false, - }; - - if rejected { - // Convert to HOLD and apply rejection penalty - (TradingAction::Hold, -0.5) - } else { - (action, 0.0) - } - } - - /// Calculate slippage impact (basis points) - pub fn apply_slippage(price: f64, slippage_bps: f64, is_buy: bool) -> f64 { - let slippage_fraction = slippage_bps / 10000.0; - if is_buy { - price * (1.0 + slippage_fraction) - } else { - price * (1.0 - slippage_fraction) - } - } - - /// Calculate Sharpe ratio from returns - pub fn calculate_sharpe_ratio(returns: &[f64]) -> f64 { - if returns.len() < 2 { - return 0.0; - } - - let mean = returns.iter().sum::() / returns.len() as f64; - let variance = - returns.iter().map(|r| (r - mean).powi(2)).sum::() / (returns.len() - 1) as f64; - let std_dev = variance.sqrt(); - - if std_dev > 0.0 { - mean / std_dev - } else { - 0.0 - } - } - - /// Calculate maximum drawdown percentage - pub fn calculate_max_drawdown(portfolio_values: &[f64]) -> f64 { - if portfolio_values.is_empty() { - return 0.0; - } - - let mut max_value = portfolio_values[0]; - let mut max_drawdown = 0.0; - - for &value in portfolio_values { - if value > max_value { - max_value = value; - } - let drawdown = (max_value - value) / max_value * 100.0; - if drawdown > max_drawdown { - max_drawdown = drawdown; - } - } - - -max_drawdown // Return as negative percentage - } - - /// Calculate win rate from trades - pub fn calculate_win_rate(pnl_values: &[f64]) -> f64 { - if pnl_values.is_empty() { - return 0.0; - } - - let winning_trades = pnl_values.iter().filter(|&&pnl| pnl > 0.0).count(); - (winning_trades as f64 / pnl_values.len() as f64) * 100.0 - } -} - -// ================================================================================================ -// TEST 1: TRADE EXECUTOR REJECTION FLOW -// ================================================================================================ - -#[test] -fn test_trade_executor_rejection_flow() -> Result<()> { - println!("\n=== Test 1: Trade Executor Rejection Flow ==="); - - // Setup: Position limit of 10.0 contracts - let position_limit = 10.0; - let mut portfolio = PortfolioTracker::new(10_000.0, 0.0001); - - // Step 1: Execute BUY to reach position limit - portfolio.execute_action(TradingAction::Buy, 100.0, 10.0); - assert_eq!(portfolio.get_portfolio_features(100.0)[1], 10.0); - println!("✓ Step 1: Opened maximum long position (10.0 contracts)"); - - // Step 2: Attempt another BUY (should be rejected) - let (executed_action, rejection_penalty) = - test_utils::simulate_trade_rejection(TradingAction::Buy, 10.0, position_limit); - - assert_eq!( - executed_action, - TradingAction::Hold, - "Rejected trade should convert to HOLD" - ); - assert_eq!(rejection_penalty, -0.5, "Rejection penalty should be -0.5"); - println!("✓ Step 2: BUY rejected at position limit → HOLD with -0.5 penalty"); - - // Step 3: Verify portfolio unchanged after rejection - portfolio.execute_action(executed_action, 101.0, 10.0); - assert_eq!( - portfolio.get_portfolio_features(101.0)[1], - 10.0, - "Position should remain at limit" - ); - println!("✓ Step 3: Portfolio state unchanged after rejected trade"); - - // Step 4: Test short position rejection - let mut short_portfolio = PortfolioTracker::new(10_000.0, 0.0001); - short_portfolio.execute_action(TradingAction::Sell, 100.0, 10.0); - assert_eq!(short_portfolio.get_portfolio_features(100.0)[1], -10.0); - - let (rejected_sell, penalty) = - test_utils::simulate_trade_rejection(TradingAction::Sell, -10.0, position_limit); - assert_eq!(rejected_sell, TradingAction::Hold); - assert_eq!(penalty, -0.5); - println!("✓ Step 4: SELL rejected at short limit → HOLD with -0.5 penalty"); - - println!("✅ Test 1 PASSED: Rejection flow works correctly\n"); - Ok(()) -} - -// ================================================================================================ -// TEST 2: PARTIAL FILL P&L CALCULATION -// ================================================================================================ - -#[test] -fn test_trade_executor_partial_fill_pnl() -> Result<()> { - println!("\n=== Test 2: Partial Fill P&L Calculation ==="); - - // Setup: 60% fill ratio (request 10 contracts, get 6) - let fill_ratio = 0.6; - let requested_size = 10.0; - let actual_size = requested_size * fill_ratio; - - let mut portfolio = PortfolioTracker::new(10_000.0, 0.0001); - - // Step 1: Execute partial fill BUY - portfolio.execute_action(TradingAction::Buy, 100.0, actual_size); - let position = portfolio.get_portfolio_features(100.0)[1]; - assert_eq!(position, 6.0, "Position should be 6.0 (partial fill)"); - println!("✓ Step 1: Partial fill executed (6.0 / 10.0 requested)"); - - // Step 2: Price rises to 110, calculate P&L - let initial_value = portfolio.get_portfolio_features(100.0)[0]; - let final_value = portfolio.get_portfolio_features(110.0)[0]; - let pnl = final_value - initial_value; - - // Expected P&L: 6 contracts × $10 gain = $60 - let expected_pnl = 6.0 * (110.0 - 100.0); - assert!( - (pnl - expected_pnl).abs() < 0.01, - "P&L should be ${:.2} (got ${:.2})", - expected_pnl, - pnl - ); - println!( - "✓ Step 2: P&L correctly reflects partial position (${:.2})", - pnl - ); - - // Step 3: Verify P&L differs from full fill - let full_fill_pnl = 10.0 * (110.0 - 100.0); - assert!( - (pnl - full_fill_pnl).abs() > 0.1, - "Partial fill P&L should differ from full fill" - ); - println!( - "✓ Step 3: Partial P&L (${:.2}) < Full P&L (${:.2})", - pnl, full_fill_pnl - ); - - // Step 4: Close position with partial fill SELL - portfolio.execute_action(TradingAction::Sell, 110.0, actual_size); - let final_position = portfolio.get_portfolio_features(110.0)[1]; - assert_eq!(final_position, 0.0, "Position should be closed"); - - let realized_pnl = portfolio.get_portfolio_features(110.0)[0] - 10_000.0; - assert!( - (realized_pnl - expected_pnl).abs() < 0.01, - "Realized P&L should match expected" - ); - println!( - "✓ Step 4: Position closed, realized P&L: ${:.2}", - realized_pnl - ); - - println!("✅ Test 2 PASSED: Partial fill P&L calculation correct\n"); - Ok(()) -} - -// ================================================================================================ -// TEST 3: SLIPPAGE IMPACT ON REWARDS -// ================================================================================================ - -#[test] -fn test_slippage_impact_on_rewards() -> Result<()> { - println!("\n=== Test 3: Slippage Impact on Rewards ==="); - - let slippage_bps = 5.0; // 5 basis points (0.05%) - let entry_price = 100.0_f64; - let exit_price = 105.0_f64; - - // Scenario 1: No slippage - let mut portfolio_no_slip = PortfolioTracker::new(10_000.0, 0.0001); - portfolio_no_slip.execute_action(TradingAction::Buy, entry_price as f32, 10.0); - portfolio_no_slip.execute_action(TradingAction::Sell, exit_price as f32, 10.0); - let pnl_no_slip = portfolio_no_slip.get_portfolio_features(exit_price as f32)[0] - 10_000.0; - println!("✓ Scenario 1: No slippage P&L = ${:.2}", pnl_no_slip); - - // Scenario 2: With slippage - let mut portfolio_with_slip = PortfolioTracker::new(10_000.0, 0.0001); - let buy_price_slipped = test_utils::apply_slippage(entry_price, slippage_bps, true); - let sell_price_slipped = test_utils::apply_slippage(exit_price, slippage_bps, false); - - portfolio_with_slip.execute_action(TradingAction::Buy, buy_price_slipped as f32, 10.0); - portfolio_with_slip.execute_action(TradingAction::Sell, sell_price_slipped as f32, 10.0); - let pnl_with_slip = portfolio_with_slip.get_portfolio_features(exit_price as f32)[0] - 10_000.0; - println!( - "✓ Scenario 2: With slippage (5 bps) P&L = ${:.2}", - pnl_with_slip - ); - - // Step 1: Verify slippage reduces profit - assert!(pnl_with_slip < pnl_no_slip, "Slippage should reduce profit"); - let slippage_cost = pnl_no_slip - pnl_with_slip; - println!( - "✓ Step 1: Slippage cost = ${:.2} ({:.2}% reduction)", - slippage_cost, - (slippage_cost / pnl_no_slip * 100.0) - ); - - // Step 2: Calculate expected slippage impact - // Buy slippage: +5 bps on $100 × 10 = $0.05 × 10 = $0.50 - // Sell slippage: -5 bps on $105 × 10 = $0.0525 × 10 = $0.525 - // Total: ~$1.025 - let expected_slippage = ((buy_price_slipped - entry_price) * 10.0 - + (exit_price - sell_price_slipped) * 10.0) as f32; - assert!( - (slippage_cost - expected_slippage).abs() < 0.01, - "Slippage cost should match expected (${:.2} vs ${:.2})", - slippage_cost, - expected_slippage - ); - println!( - "✓ Step 2: Slippage cost matches expected ${:.2}", - expected_slippage - ); - - // Step 3: Verify impact on reward function - let reward_config = RewardConfig::default(); - let _reward_fn = RewardFunction::new(reward_config); - - // Rewards should reflect lower P&L with slippage - assert!( - pnl_with_slip < pnl_no_slip, - "Reward calculation should account for slippage" - ); - println!("✓ Step 3: Reward function correctly penalizes slippage"); - - println!("✅ Test 3 PASSED: Slippage impact verified\n"); - Ok(()) -} - -// ================================================================================================ -// TEST 4: BACKTEST METRICS IN HYPEROPT -// ================================================================================================ - -#[test] -fn test_backtest_metrics_in_hyperopt() -> Result<()> { - println!("\n=== Test 4: Backtest Metrics in Hyperopt ==="); - - // Note: This is a mock test since we don't have actual TradeExecutor - // In production, these metrics would come from real backtesting - - // Step 1: Simulate trading returns - let returns = vec![0.02, -0.01, 0.03, -0.005, 0.015, 0.01, -0.02, 0.025]; - let sharpe = test_utils::calculate_sharpe_ratio(&returns); - println!("✓ Step 1: Calculated Sharpe ratio = {:.4}", sharpe); - assert!( - sharpe > 0.0, - "Sharpe should be positive for profitable strategy" - ); - - // Step 2: Calculate portfolio values and drawdown - let mut portfolio_values = vec![10_000.0]; - for ret in &returns { - let new_value = portfolio_values.last().unwrap() * (1.0 + ret); - portfolio_values.push(new_value); - } - let max_dd = test_utils::calculate_max_drawdown(&portfolio_values); - println!("✓ Step 2: Maximum drawdown = {:.2}%", max_dd); - assert!(max_dd < 0.0, "Drawdown should be negative"); - - // Step 3: Calculate win rate - let win_rate = test_utils::calculate_win_rate(&returns); - println!("✓ Step 3: Win rate = {:.2}%", win_rate); - assert!( - win_rate >= 0.0 && win_rate <= 100.0, - "Win rate should be in [0, 100]" - ); - - // Step 4: Verify metrics would influence objective - // In actual hyperopt, these would be part of multi-objective optimization: - // objective = 0.4*pnl_score + 0.3*sharpe_score + 0.2*drawdown_score + 0.1*winrate_score - - let pnl_score = 0.5; // Mock normalized P&L - let sharpe_score = sharpe.abs().min(5.0) / 5.0; // Normalize to [0,1] - let drawdown_score = 1.0 - (max_dd.abs() / 100.0).min(1.0); // Better with lower DD - let winrate_score = win_rate / 100.0; - - let composite_objective = - 0.4 * pnl_score + 0.3 * sharpe_score + 0.2 * drawdown_score + 0.1 * winrate_score; - - println!( - "✓ Step 4: Composite objective = {:.4} (P&L: {:.3}, Sharpe: {:.3}, DD: {:.3}, WR: {:.3})", - composite_objective, pnl_score, sharpe_score, drawdown_score, winrate_score - ); - assert!( - composite_objective >= 0.0 && composite_objective <= 1.0, - "Objective should be normalized" - ); - - println!("✅ Test 4 PASSED: Backtest metrics integration verified\n"); - Ok(()) -} - -// ================================================================================================ -// TEST 5: END-TO-END TRAINING WITH ALL FEATURES -// ================================================================================================ - -#[tokio::test] -async fn test_end_to_end_training_with_all_features() -> Result<()> { - println!("\n=== Test 5: End-to-End Training (5 epochs) ==="); - - // Setup: Create trainer with all production features enabled - let hyperparams = test_utils::create_test_hyperparams(5); - let mut trainer = DQNTrainer::new(hyperparams)?; - - // Verify all features are enabled - assert!(trainer.get_best_epoch() == 0); - println!("✓ Step 1: Trainer initialized with production features"); - println!(" - Gradient clipping: 10.0 max norm"); - println!(" - Double DQN: enabled"); - println!(" - Huber loss: enabled (delta=1.0)"); - println!(" - HOLD penalty: 2.0 weight"); - println!(" - Preprocessing: enabled (window=50, clip=5σ)"); - - // Create synthetic data - let data = test_utils::create_synthetic_data(500, 0.1); - println!("✓ Step 2: Generated {} bars of synthetic data", data.len()); - - // Note: Full training would require DBN parquet data - // This test verifies configuration and initialization only - - // Step 3: Verify state dimensionality (128 features) - // 125 market features + 3 portfolio features = 128 total - println!("✓ Step 3: State space verified (128-dimensional)"); - println!(" - Market features: 125"); - println!(" - Portfolio features: 3 [value, position, spread]"); - - // Step 4: Simulate portfolio tracking across episode - let mut portfolio = PortfolioTracker::new(10_000.0, 0.0001); - let mut rejections = 0; - let position_limit = 10.0; - - for (i, bar) in data.iter().take(50).enumerate() { - // Simulate random actions - let action = match i % 3 { - 0 => TradingAction::Buy, - 1 => TradingAction::Sell, - _ => TradingAction::Hold, - }; - - let current_position = portfolio.get_portfolio_features(bar.close as f32)[1]; - let (executed_action, _penalty) = - test_utils::simulate_trade_rejection(action, current_position, position_limit); - - if executed_action != action { - rejections += 1; - } - - portfolio.execute_action(executed_action, bar.close as f32, 1.0); - } - - let final_value = portfolio.get_portfolio_features(data[49].close as f32)[0]; - println!( - "✓ Step 4: Portfolio tracking operational (final value: ${:.2})", - final_value - ); - println!( - " - Rejections: {} / 50 actions ({:.1}%)", - rejections, - (rejections as f64 / 50.0) * 100.0 - ); - - // Step 5: Verify constraints - assert!(final_value > 0.0, "Portfolio value should be positive"); - assert!( - rejections > 0, - "Some rejections should occur with random actions" - ); - println!("✓ Step 5: Risk constraints enforced during execution"); - - println!("✅ Test 5 PASSED: End-to-end integration verified\n"); - Ok(()) -} - -// ================================================================================================ -// SUMMARY TESTS -// ================================================================================================ - -#[test] -fn test_all_integration_features_summary() { - println!("\n========================================"); - println!("DQN REALISTIC CONSTRAINTS TEST SUITE"); - println!("========================================\n"); - - println!("Test Coverage:"); - println!(" ✓ Test 1: Trade rejection flow (position limits)"); - println!(" ✓ Test 2: Partial fill P&L calculation"); - println!(" ✓ Test 3: Slippage impact on rewards"); - println!(" ✓ Test 4: Backtest metrics in hyperopt"); - println!(" ✓ Test 5: End-to-end training (128-dim state)"); - println!("\nProduction Features:"); - println!(" ✓ Risk controls: Position limits enforced"); - println!(" ✓ Realistic execution: Partial fills, slippage"); - println!(" ✓ Portfolio tracking: 3-feature state [value, position, spread]"); - println!(" ✓ Backtest metrics: Sharpe, drawdown, win rate"); - println!(" ✓ Multi-objective: Composite optimization"); - println!("\nStatus: 🟢 PRODUCTION READY\n"); -} diff --git a/ml/tests/dqn_training_loop_integration_test.rs.disabled b/ml/tests/dqn_training_loop_integration_test.rs.disabled deleted file mode 100644 index 38a55a87f..000000000 --- a/ml/tests/dqn_training_loop_integration_test.rs.disabled +++ /dev/null @@ -1,444 +0,0 @@ -//! DQN Training Loop Integration Tests -//! -//! Wave 10-A18: Tests to expose the dual reward system bug and validate the fix. -//! -//! **Bug Description**: -//! The main training loop `train_with_data_full_loop()` uses simple match-based rewards -//! (HOLD = -0.0001 fixed) instead of the sophisticated RewardFunction with portfolio -//! tracking, movement thresholds, and diversity penalties. This causes 100% HOLD bias. -//! -//! **These tests**: -//! 1. Expose the bug by showing HOLD is learned preferentially -//! 2. Validate that RewardFunction (when used) produces proper diversity -//! 3. Verify target network updates don't interfere with learning -//! 4. Test epsilon decay doesn't force premature exploitation - -use anyhow::Result; -use ml::dqn::{Experience, TradingAction, TradingState}; -use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer}; -use rust_decimal::Decimal; -use std::collections::HashMap; - -/// Helper: Create minimal test hyperparameters -fn create_test_hyperparams() -> DQNHyperparameters { - DQNHyperparameters { - learning_rate: 0.001, - batch_size: 4, - gamma: 0.99, - epsilon_start: 0.1, // Low epsilon for deterministic testing - epsilon_end: 0.01, - epsilon_decay: 0.99, - buffer_size: 1000, - min_replay_size: 10, - epochs: 1, - checkpoint_frequency: 100, - early_stopping_enabled: false, - q_value_floor: 0.5, - min_loss_improvement_pct: 2.0, - plateau_window: 5, - min_epochs_before_stopping: 50, - hold_penalty: -0.001, - use_huber_loss: true, - huber_delta: 1.0, - use_double_dqn: true, - gradient_clip_norm: Some(10.0), - hold_penalty_weight: 0.01, - movement_threshold: 0.02, - } -} - -/// Helper: Create synthetic training data with clear patterns -/// -/// Pattern: Price increases by 5 points per step (5900 → 5905 → 5910 → ...) -/// Optimal policy: BUY when price going up, SELL when going down, HOLD when flat -fn create_synthetic_uptrend_data() -> Vec<([f64; 225], Vec)> { - let mut data = Vec::new(); - let base_price = 5900.0; - - for i in 0..100 { - let current_price = base_price + (i as f64 * 5.0); - let next_price = current_price + 5.0; - - // Create 225-dim feature vector (Wave C + Wave D) - let mut features = [0.0; 225]; - features[0] = current_price; // open - features[1] = current_price + 2.0; // high - features[2] = current_price - 1.0; // low - features[3] = current_price; // close (most important for reward) - - // Fill remaining features with small random values - for j in 4..225 { - features[j] = (i as f64 * 0.01) + (j as f64 * 0.001); - } - - // Target: [current_close, next_close] - let target = vec![current_price, next_price]; - - data.push((features, target)); - } - - data -} - -/// Helper: Create synthetic downtrend data -fn create_synthetic_downtrend_data() -> Vec<([f64; 225], Vec)> { - let mut data = Vec::new(); - let base_price = 6000.0; - - for i in 0..100 { - let current_price = base_price - (i as f64 * 5.0); - let next_price = current_price - 5.0; - - let mut features = [0.0; 225]; - features[0] = current_price; - features[1] = current_price + 1.0; - features[2] = current_price - 2.0; - features[3] = current_price; - - for j in 4..225 { - features[j] = (i as f64 * 0.01) + (j as f64 * 0.001); - } - - let target = vec![current_price, next_price]; - data.push((features, target)); - } - - data -} - -/// Helper: Create synthetic flat market data -fn create_synthetic_flat_data() -> Vec<([f64; 225], Vec)> { - let mut data = Vec::new(); - let base_price = 5950.0; - - for i in 0..100 { - let current_price = base_price; // No price movement - let next_price = base_price; - - let mut features = [0.0; 225]; - features[0] = current_price; - features[1] = current_price; - features[2] = current_price; - features[3] = current_price; - - for j in 4..225 { - features[j] = (i as f64 * 0.01) + (j as f64 * 0.001); - } - - let target = vec![current_price, next_price]; - data.push((features, target)); - } - - data -} - -#[tokio::test] -async fn test_full_training_loop_learns_uptrend_policy() -> Result<()> { - // **TEST OBJECTIVE**: Verify that after training on uptrend data, the agent - // learns to prefer BUY actions over HOLD. - // - // **EXPECTED (with correct RewardFunction)**: - // - BUY actions should be > 30% (agent learns to buy in uptrends) - // - HOLD actions should be < 70% (agent avoids holding when profitable to buy) - // - // **CURRENT BUG (with simple match rewards)**: - // - HOLD actions ~100% (agent learns HOLD is safest due to tiny -0.0001 penalty) - - let hyperparams = create_test_hyperparams(); - let mut trainer = DQNTrainer::new(hyperparams)?; - - // Generate 100 samples of uptrend data - let training_data = create_synthetic_uptrend_data(); - - // Train for 10 steps - let mut action_counts = HashMap::new(); - action_counts.insert(TradingAction::Buy, 0); - action_counts.insert(TradingAction::Sell, 0); - action_counts.insert(TradingAction::Hold, 0); - - // Simulate 10 training steps - for (features, _target) in training_data.iter().take(10) { - // Convert to trading state - let close_price = Decimal::try_from(features[3]).unwrap_or(Decimal::ZERO); - let state = trainer.feature_vector_to_state(features, Some(close_price))?; - - // Select action - let action = trainer.select_action(&state).await?; - *action_counts.entry(action).or_insert(0) += 1; - } - - let total_actions: usize = action_counts.values().sum(); - let buy_pct = (*action_counts.get(&TradingAction::Buy).unwrap_or(&0) as f64 - / total_actions as f64) - * 100.0; - let hold_pct = (*action_counts.get(&TradingAction::Hold).unwrap_or(&0) as f64 - / total_actions as f64) - * 100.0; - - println!("Uptrend Policy Test:"); - println!(" BUY: {:.1}%", buy_pct); - println!( - " SELL: {:.1}%", - (*action_counts.get(&TradingAction::Sell).unwrap_or(&0) as f64 / total_actions as f64) - * 100.0 - ); - println!(" HOLD: {:.1}%", hold_pct); - - // **ASSERTION REVEALS BUG**: - // With simple rewards: This test will FAIL (HOLD ~100%) - // With RewardFunction: This test will PASS (BUY > 30%, HOLD < 70%) - assert!( - hold_pct < 90.0, - "HOLD bias detected: {:.1}% HOLD actions (expected < 90%). Bug: Simple match rewards favor HOLD.", - hold_pct - ); - - Ok(()) -} - -#[tokio::test] -async fn test_target_network_stabilizes_learning() -> Result<()> { - // **TEST OBJECTIVE**: Verify that target network updates don't interfere with learning. - // - // **METHOD**: Train for 20 steps and track Q-value stability. Target network should - // reduce oscillations compared to no target network. - - let mut hyperparams = create_test_hyperparams(); - hyperparams.min_replay_size = 5; // Allow training after 5 experiences - - let mut trainer = DQNTrainer::new(hyperparams)?; - - let training_data = create_synthetic_uptrend_data(); - let mut q_value_history = Vec::new(); - - // Populate replay buffer with 10 experiences - for (features, target) in training_data.iter().take(10) { - let close_price = Decimal::try_from(features[3]).unwrap_or(Decimal::ZERO); - let state = trainer.feature_vector_to_state(features, Some(close_price))?; - let action = TradingAction::Buy; // Fixed action for consistency - - let next_close = if target.len() >= 2 { - target[1] - } else { - features[3] - }; - let next_close_price = Decimal::try_from(next_close).unwrap_or(Decimal::ZERO); - let next_state = trainer.feature_vector_to_state(features, Some(next_close_price))?; - - let experience = Experience::new( - state.to_vector(), - action.to_int(), - 0.5, // Fixed reward - next_state.to_vector(), - false, - ); - - trainer.store_experience(experience).await?; - } - - // Perform 10 training steps and track Q-values - for _ in 0..10 { - if trainer.can_train().await? { - let (_loss, q_value, _grad_norm) = trainer.train_step().await?; - q_value_history.push(q_value); - } - } - - println!("Target Network Stability Test:"); - println!(" Q-value history: {:?}", q_value_history); - - // Calculate Q-value variance (should be low if target network stabilizes) - if q_value_history.len() > 1 { - let mean = q_value_history.iter().sum::() / q_value_history.len() as f64; - let variance = q_value_history - .iter() - .map(|q| (q - mean).powi(2)) - .sum::() - / q_value_history.len() as f64; - let std = variance.sqrt(); - - println!(" Q-value std: {:.4}", std); - - // Target network should keep std reasonable (< 10.0) - assert!( - std < 10.0, - "Q-value oscillation too high: std={:.4} (expected < 10.0). Target network may not be stabilizing.", - std - ); - } - - Ok(()) -} - -#[tokio::test] -async fn test_epsilon_decay_allows_exploration() -> Result<()> { - // **TEST OBJECTIVE**: Verify that epsilon decay rate allows sufficient exploration. - // - // **METHOD**: Track epsilon values over 100 steps. With decay=0.995, epsilon should - // decay slowly enough to explore for at least 50 steps. - - let mut hyperparams = create_test_hyperparams(); - hyperparams.epsilon_start = 1.0; - hyperparams.epsilon_decay = 0.995; - hyperparams.epsilon_end = 0.01; - - let trainer = DQNTrainer::new(hyperparams)?; - - // Simulate epsilon decay over 100 steps - let initial_epsilon = trainer.get_epsilon().await?; - println!("Epsilon Decay Test:"); - println!(" Initial epsilon: {:.4}", initial_epsilon); - - // Check epsilon after 50 steps (simulate by calculating) - let epsilon_after_50 = initial_epsilon * 0.995_f32.powi(50); - println!(" Epsilon after 50 steps: {:.4}", epsilon_after_50); - - // Epsilon should still be > 0.5 after 50 steps for good exploration - assert!( - epsilon_after_50 > 0.5, - "Epsilon decays too fast: {:.4} after 50 steps (expected > 0.5). Increase epsilon_decay closer to 1.0.", - epsilon_after_50 - ); - - Ok(()) -} - -#[tokio::test] -async fn test_reward_function_diversity_penalty() -> Result<()> { - // **TEST OBJECTIVE**: Verify that RewardFunction applies diversity penalty correctly. - // - // **METHOD**: Create a scenario where agent repeatedly selects HOLD. RewardFunction - // should apply increasing diversity penalties. - // - // **NOTE**: This test directly uses RewardFunction, NOT the training loop, to verify - // the correct implementation exists (even if unused in production). - - use ml::dqn::reward::{RewardConfig, RewardFunction}; - - let reward_config = RewardConfig { - pnl_weight: Decimal::ONE, - risk_weight: Decimal::try_from(0.1).unwrap_or(Decimal::ZERO), - cost_weight: Decimal::try_from(0.05).unwrap_or(Decimal::ZERO), - hold_reward: Decimal::try_from(0.001).unwrap_or(Decimal::ZERO), - movement_threshold: Decimal::try_from(0.02).unwrap_or(Decimal::ZERO), - hold_penalty_weight: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO), - diversity_weight: Decimal::try_from(-0.1).unwrap_or(Decimal::ZERO), - }; - - let reward_fn = RewardFunction::new(reward_config); - - // Create state with flat prices (no movement) - let state = TradingState { - open: 5900.0, - high: 5900.0, - low: 5900.0, - close: 5900.0, - volume: 1000.0, - technical_indicators: vec![0.0; 16], - microstructure_features: vec![0.0; 16], - portfolio_features: vec![0.0; 16], - tick_imbalance: 0.0, - order_flow_imbalance: 0.0, - bid_ask_spread: 0.01, - }; - - let next_state = TradingState { - close: 5900.0, // No price change - ..state.clone() - }; - - // Test: Repeated HOLD actions should accumulate diversity penalty - let recent_actions_uniform = vec![TradingAction::Buy, TradingAction::Sell, TradingAction::Hold]; - let recent_actions_biased = vec![TradingAction::Hold; 10]; // 10x HOLD - - let reward_uniform = reward_fn.calculate_reward( - TradingAction::Hold, - &state, - &next_state, - &recent_actions_uniform, - )?; - - let reward_biased = reward_fn.calculate_reward( - TradingAction::Hold, - &state, - &next_state, - &recent_actions_biased, - )?; - - println!("Diversity Penalty Test:"); - println!(" Reward (uniform actions): {}", reward_uniform); - println!(" Reward (biased HOLD): {}", reward_biased); - - // Biased HOLD should have lower reward due to diversity penalty - assert!( - reward_biased < reward_uniform, - "Diversity penalty not working: biased={}, uniform={}. Expected biased < uniform.", - reward_biased, - reward_uniform - ); - - Ok(()) -} - -#[tokio::test] -async fn test_batch_action_selection_consistency() -> Result<()> { - // **TEST OBJECTIVE**: Verify that batched action selection produces same results - // as sequential action selection (within randomness tolerance). - // - // **METHOD**: Select actions for same states in both modes, compare distributions. - - let hyperparams = create_test_hyperparams(); - let mut trainer = DQNTrainer::new(hyperparams)?; - - let training_data = create_synthetic_uptrend_data(); - - // Extract 10 states - let states: Result> = training_data - .iter() - .take(10) - .map(|(features, _)| { - let close_price = Decimal::try_from(features[3]).unwrap_or(Decimal::ZERO); - trainer.feature_vector_to_state(features, Some(close_price)) - }) - .collect(); - let states = states?; - - // Batched action selection - let actions_batch = trainer.select_actions_batch(&states).await?; - - // Sequential action selection - let mut actions_sequential = Vec::new(); - for state in &states { - let action = trainer.select_action(state).await?; - actions_sequential.push(action); - } - - println!("Batch vs Sequential Action Selection:"); - println!(" Batch: {:?}", actions_batch); - println!(" Sequential: {:?}", actions_sequential); - - // Count distributions (should be similar, but not identical due to epsilon-greedy randomness) - let batch_hold_count = actions_batch - .iter() - .filter(|&&a| a == TradingAction::Hold) - .count(); - let seq_hold_count = actions_sequential - .iter() - .filter(|&&a| a == TradingAction::Hold) - .count(); - - println!(" Batch HOLD count: {}", batch_hold_count); - println!(" Sequential HOLD count: {}", seq_hold_count); - - // Both should have similar HOLD counts (within 20% tolerance) - let diff = (batch_hold_count as i32 - seq_hold_count as i32).abs(); - assert!( - diff <= 2, - "Batch and sequential action selection differ significantly: batch={}, seq={}, diff={}", - batch_hold_count, - seq_hold_count, - diff - ); - - Ok(()) -} diff --git a/ml/tests/dqn_zero_price_fix_test.rs.disabled b/ml/tests/dqn_zero_price_fix_test.rs.disabled deleted file mode 100644 index df8370067..000000000 --- a/ml/tests/dqn_zero_price_fix_test.rs.disabled +++ /dev/null @@ -1,238 +0,0 @@ -//! Test suite for zero price error fix in calculate_hold_reward -//! -//! Tests that HOLD reward calculation correctly handles log returns -//! instead of treating them as raw prices (which caused division by zero). - -use ml::dqn::agent::{TradingAction, TradingState}; -use ml::dqn::reward::{calculate_batch_rewards, RewardConfig, RewardFunction}; -use rust_decimal::Decimal; - -fn create_test_config() -> RewardConfig { - RewardConfig { - pnl_weight: Decimal::ONE, - risk_weight: Decimal::try_from(0.1).unwrap(), - cost_weight: Decimal::try_from(0.05).unwrap(), - hold_reward: Decimal::try_from(0.001).unwrap(), - movement_threshold: Decimal::try_from(0.02).unwrap(), - hold_penalty_weight: Decimal::try_from(0.5).unwrap(), - diversity_weight: Decimal::try_from(-0.1).unwrap(), - } -} - -#[test] -fn test_hold_reward_with_zero_log_return() { - // Test that zero log returns don't crash (stable prices) - // Velocity-based: volatility = |next_log_return| = 0.0 < 0.02 threshold - // Expected: hold_reward (0.001) granted - let config = create_test_config(); - let mut reward_fn = RewardFunction::new(config.clone()); - - let current_state = TradingState { - price_features: vec![0.0, 100.0, 100.0, 100.0], // Zero log return (not used in velocity calc) - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }; - - let next_state = TradingState { - price_features: vec![0.0, 100.0, 100.0, 100.0], // Zero log return (stable price) - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }; - - let recent_actions = vec![TradingAction::Buy, TradingAction::Sell, TradingAction::Hold]; - - // Should NOT crash with zero log return - let reward = reward_fn.calculate_reward( - TradingAction::Hold, - ¤t_state, - &next_state, - &recent_actions, - ); - - assert!( - reward.is_ok(), - "Should handle zero log returns without crashing, got: {:?}", - reward - ); - - // Should return hold_reward (low volatility) - let reward_value = reward.unwrap(); - println!("Zero log return reward: {}", reward_value); - // Velocity = |0.0| = 0.0 < 0.02, so should grant hold_reward (0.001) - // Note: diversity penalty (-0.1) may also be applied due to low entropy - assert!( - reward_value <= config.hold_reward, - "Low volatility should result in hold_reward or less (with diversity penalty), got: {}", - reward_value - ); -} - -#[test] -fn test_hold_reward_high_volatility() { - // Test high volatility triggers penalty - // Velocity-based: volatility = |0.05| = 0.05 > 0.02 threshold - // Expected: -hold_penalty_weight (-0.5) applied - let config = create_test_config(); - let mut reward_fn = RewardFunction::new(config.clone()); - - let current_state = TradingState { - price_features: vec![0.0, 100.0, 100.0, 100.0], // Not used in velocity calc - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }; - - let next_state = TradingState { - price_features: vec![0.05, 100.0, 100.0, 100.0], // 5% log return (> 0.02 threshold) - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }; - - let recent_actions = vec![TradingAction::Buy, TradingAction::Sell, TradingAction::Hold]; - - let reward = reward_fn.calculate_reward( - TradingAction::Hold, - ¤t_state, - &next_state, - &recent_actions, - ); - - assert!( - reward.is_ok(), - "Should handle high volatility, got: {:?}", - reward - ); - - let reward_value = reward.unwrap(); - println!("High volatility reward: {}", reward_value); - - // Velocity = |0.05| = 0.05 > 0.02, so should apply penalty (-0.5) - // With diversity penalty (-0.1), total = -0.5 - 0.1 = -0.6 - assert!( - reward_value < Decimal::ZERO, - "High volatility should trigger penalty, got: {}", - reward_value - ); -} - -#[test] -fn test_hold_reward_negative_log_return() { - // Test negative log returns (price decrease) - // Velocity-based: volatility = |-0.03| = 0.03 > 0.02 threshold - // Expected: penalty triggered (large downward move) - let config = create_test_config(); - let mut reward_fn = RewardFunction::new(config.clone()); - - let current_state = TradingState { - price_features: vec![0.01, 100.0, 100.0, 100.0], // Not used in velocity calc - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }; - - let next_state = TradingState { - price_features: vec![-0.03, 100.0, 100.0, 100.0], // -3% log return (downward move) - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }; - - let recent_actions = vec![TradingAction::Buy, TradingAction::Sell, TradingAction::Hold]; - - let reward = reward_fn.calculate_reward( - TradingAction::Hold, - ¤t_state, - &next_state, - &recent_actions, - ); - - assert!( - reward.is_ok(), - "Should handle negative log returns, got: {:?}", - reward - ); - - let reward_value = reward.unwrap(); - println!("Negative log return reward: {}", reward_value); - - // Velocity = |-0.03| = 0.03 > 0.02, so penalty should be applied - // Large downward move should NOT be rewarded - assert!( - reward_value < Decimal::ZERO, - "Large price decrease should trigger penalty, got: {}", - reward_value - ); -} - -#[test] -fn test_batch_rewards_with_mixed_log_returns() { - // Test batch processing with mixed log return scenarios - // Velocity-based logic: - // - Sample 1: volatility = |0.001| < 0.02 → hold_reward (0.001) - // - Sample 2: volatility = |0.05| > 0.02 → penalty (-0.5) - let config = create_test_config(); - let mut reward_fn = RewardFunction::new(config.clone()); - - let current_states = vec![ - TradingState { - price_features: vec![0.0, 100.0, 100.0, 100.0], // Not used in velocity calc - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }, - TradingState { - price_features: vec![0.0, 100.0, 100.0, 100.0], // Not used in velocity calc - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }, - ]; - - let next_states = vec![ - TradingState { - price_features: vec![0.001, 100.0, 100.0, 100.0], // Low volatility (0.1%) - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }, - TradingState { - price_features: vec![0.05, 100.0, 100.0, 100.0], // High volatility (5%) - technical_indicators: vec![0.5, 0.5, 0.5, 0.5], - market_features: vec![0.001, 100.0, 0.0, 0.0], - portfolio_features: vec![1.0, 0.0, 0.0, 0.0], - }, - ]; - - let actions = vec![TradingAction::Hold, TradingAction::Hold]; - let recent_actions = vec![TradingAction::Buy, TradingAction::Sell, TradingAction::Hold]; - - let rewards = calculate_batch_rewards( - &mut reward_fn, - &actions, - ¤t_states, - &next_states, - &recent_actions, - ); - - assert!( - rewards.is_ok(), - "Batch rewards should handle mixed log returns, got: {:?}", - rewards - ); - - let reward_values = rewards.unwrap(); - assert_eq!(reward_values.len(), 2); - println!("Batch rewards: {:?}", reward_values); - - // First HOLD (low volatility) should be less negative than second (high volatility) - // Sample 1: |0.001| < 0.02 → reward (0.001 or less with diversity penalty) - // Sample 2: |0.05| > 0.02 → penalty (-0.5 or less with diversity penalty) - assert!( - reward_values[0] > reward_values[1], - "Low volatility HOLD should have better reward than high volatility HOLD, got: {:?}", - reward_values - ); -} diff --git a/ml/tests/mamba2_hyperopt_edge_cases.rs.backup b/ml/tests/mamba2_hyperopt_edge_cases.rs.backup deleted file mode 100644 index fc691196a..000000000 --- a/ml/tests/mamba2_hyperopt_edge_cases.rs.backup +++ /dev/null @@ -1,597 +0,0 @@ -//! MAMBA2-Specific Edge Case Tests for Hyperparameter Optimization -//! -//! This test suite covers MAMBA2-specific edge cases: -//! 1. Async data loading edge cases -//! 2. Sequence length and stride edge cases -//! 3. Normalization parameter edge cases -//! 4. SSM-specific numerical stability -//! 5. Batch size clamping with GPU memory -//! -//! Purpose: Ensure MAMBA2 adapter handles all edge cases robustly - -use ml::hyperopt::adapters::mamba2::{Mamba2Params, Mamba2Trainer}; -use ml::hyperopt::traits::{HyperparameterOptimizable, ParameterSpace}; -use tempfile::TempDir; - -// ============================================================================ -// TEST UTILITIES -// ============================================================================ - -fn create_test_parquet(temp_dir: &TempDir, num_rows: usize, suffix: &str) -> String { - use arrow::array::{Float64Array, PrimitiveArray, UInt64Array}; - use arrow::datatypes::{DataType, Field, Schema, TimestampNanosecondType}; - use arrow::record_batch::RecordBatch; - use parquet::arrow::arrow_writer::ArrowWriter; - use parquet::file::properties::WriterProperties; - use std::fs::File; - use std::sync::Arc; - - let schema = Arc::new(Schema::new(vec![ - Field::new("ts_event", DataType::UInt64, false), - Field::new("rtype", DataType::UInt8, false), - Field::new("publisher_id", DataType::UInt16, false), - Field::new("open", DataType::Float64, false), - Field::new("high", DataType::Float64, false), - Field::new("low", DataType::Float64, false), - Field::new("close", DataType::Float64, false), - Field::new("volume", DataType::UInt64, false), - Field::new("symbol", DataType::Utf8, false), - Field::new("timestamp", DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, None), false), - ])); - - let file_path = temp_dir.path().join(format!("mamba2_test_{}.parquet", suffix)); - let file = File::create(&file_path).unwrap(); - let props = WriterProperties::builder().build(); - let mut writer = ArrowWriter::try_new(file, schema.clone(), Some(props)).unwrap(); - - let base_price = 5000.0; - let base_timestamp = 1700000000_000_000_000u64; - - let batch = RecordBatch::try_new( - schema, - vec![ - Arc::new(UInt64Array::from( - (0..num_rows).map(|i| base_timestamp + i as u64 * 60_000_000_000).collect::>() - )), - Arc::new(arrow::array::UInt8Array::from(vec![1u8; num_rows])), - Arc::new(arrow::array::UInt16Array::from(vec![1u16; num_rows])), - Arc::new(Float64Array::from( - (0..num_rows).map(|i| base_price + (i as f64 * 0.1)).collect::>() - )), - Arc::new(Float64Array::from( - (0..num_rows).map(|i| base_price + (i as f64 * 0.1) + 5.0).collect::>() - )), - Arc::new(Float64Array::from( - (0..num_rows).map(|i| base_price + (i as f64 * 0.1) - 5.0).collect::>() - )), - Arc::new(Float64Array::from( - (0..num_rows).map(|i| base_price + (i as f64 * 0.1) + 2.5).collect::>() - )), - Arc::new(UInt64Array::from(vec![1000u64; num_rows])), - Arc::new(arrow::array::StringArray::from(vec!["ES.FUT"; num_rows])), - Arc::new(PrimitiveArray::::from( - (0..num_rows).map(|i| base_timestamp as i64 + i as i64 * 60_000_000_000).collect::>() - )), - ], - ) - .unwrap(); - - writer.write(&batch).unwrap(); - writer.close().unwrap(); - - file_path.to_string_lossy().to_string() -} - -// ============================================================================ -// ASYNC DATA LOADING EDGE CASES -// ============================================================================ - -#[test] -fn test_async_loading_with_small_dataset() { - // Async loading with dataset smaller than prefetch_count - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 100, "small_async"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer") - .with_async_loading(true, 10); // Prefetch 10 batches, but dataset might be smaller - - let params = Mamba2Params::default(); - let result = trainer.train_with_params(params); - - // Should complete successfully - assert!( - result.is_ok(), - "Async loading should handle small datasets, got: {:?}", - result - ); -} - -#[test] -fn test_sync_vs_async_loading_consistency() { - // Verify sync and async loading produce consistent results - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 200, "sync_async"); - - // Train with sync loading - let mut sync_trainer = Mamba2Trainer::new(&parquet_file, 10) - .expect("Failed to create sync trainer") - .with_async_loading(false, 0); - - let params = Mamba2Params::default(); - let sync_result = sync_trainer.train_with_params(params.clone()); - - // Train with async loading - let mut async_trainer = Mamba2Trainer::new(&parquet_file, 10) - .expect("Failed to create async trainer") - .with_async_loading(true, 3); - - let async_result = async_trainer.train_with_params(params); - - // Both should succeed - assert!(sync_result.is_ok() && async_result.is_ok()); - - let sync_metrics = sync_result.unwrap(); - let async_metrics = async_result.unwrap(); - - // Metrics should be similar (within 10% tolerance due to different data ordering) - let loss_diff = (sync_metrics.val_loss - async_metrics.val_loss).abs(); - let max_loss = sync_metrics.val_loss.max(async_metrics.val_loss); - - assert!( - loss_diff / max_loss < 0.1, - "Sync and async losses should be similar: sync={}, async={}", - sync_metrics.val_loss, - async_metrics.val_loss - ); -} - -#[test] -#[should_panic(expected = "Prefetch count must be >= 2")] -fn test_async_loading_invalid_prefetch_count() { - // Prefetch count < 2 should panic - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 100, "invalid_prefetch"); - - let _trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer") - .with_async_loading(true, 1); // Should panic -} - -// ============================================================================ -// SEQUENCE LENGTH AND STRIDE EDGE CASES -// ============================================================================ - -#[test] -fn test_lookback_window_min_bound() { - // Test minimum lookback_window (30) - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 100, "min_lookback"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.lookback_window = 30; // Minimum bound - - let result = trainer.train_with_params(params); - - // Should succeed - assert!( - result.is_ok(), - "Minimum lookback_window should work, got: {:?}", - result - ); -} - -#[test] -fn test_lookback_window_max_bound() { - // Test maximum lookback_window (120) - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 200, "max_lookback"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.lookback_window = 120; // Maximum bound - - let result = trainer.train_with_params(params); - - // Should succeed - assert!( - result.is_ok(), - "Maximum lookback_window should work, got: {:?}", - result - ); -} - -#[test] -fn test_sequence_stride_min() { - // Test minimum sequence_stride (1) - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "min_stride"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.sequence_stride = 1; // Minimum (non-overlapping) - - let result = trainer.train_with_params(params); - - assert!( - result.is_ok(), - "sequence_stride=1 should work, got: {:?}", - result - ); -} - -#[test] -fn test_sequence_stride_max() { - // Test maximum sequence_stride (5) - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "max_stride"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.sequence_stride = 5; // Maximum (heavily overlapping) - - let result = trainer.train_with_params(params); - - assert!( - result.is_ok(), - "sequence_stride=5 should work, got: {:?}", - result - ); -} - -#[test] -fn test_lookback_exceeds_dataset_length() { - // lookback_window > dataset length - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 50, "lookback_exceeds"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.lookback_window = 100; // Exceeds 50 rows - - let result = trainer.train_with_params(params); - - // Should error or return penalty - match result { - Err(e) => { - let err_msg = format!("{:?}", e); - assert!( - err_msg.contains("insufficient") || err_msg.contains("empty"), - "Expected insufficient data error, got: {}", - err_msg - ); - } - Ok(metrics) => { - // Penalty loss - assert!( - metrics.val_loss >= 1000.0, - "Expected penalty for excessive lookback, got: {}", - metrics.val_loss - ); - } - } -} - -// ============================================================================ -// NORMALIZATION PARAMETER EDGE CASES -// ============================================================================ - -#[test] -fn test_norm_eps_min_bound() { - // Test minimum norm_eps (1e-6) - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "norm_eps_min"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.norm_eps = 1e-6; // Minimum bound - - let result = trainer.train_with_params(params); - - assert!( - result.is_ok(), - "Minimum norm_eps should work, got: {:?}", - result - ); -} - -#[test] -fn test_norm_eps_max_bound() { - // Test maximum norm_eps (1e-4) - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "norm_eps_max"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.norm_eps = 1e-4; // Maximum bound - - let result = trainer.train_with_params(params); - - assert!( - result.is_ok(), - "Maximum norm_eps should work, got: {:?}", - result - ); -} - -#[test] -fn test_denormalize_before_training() { - // Calling denormalize_prediction before training should panic - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "denorm_before"); - - let trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - // This should panic - let result = std::panic::catch_unwind(|| { - trainer.denormalize_prediction(0.5) - }); - - assert!( - result.is_err(), - "denormalize_prediction before training should panic" - ); -} - -#[test] -fn test_denormalize_after_training() { - // Calling denormalize_prediction after training should work - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "denorm_after"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let params = Mamba2Params::default(); - let result = trainer.train_with_params(params); - - assert!(result.is_ok(), "Training should succeed"); - - // Now denormalization should work - let denormalized = trainer.denormalize_prediction(0.5); - assert!(denormalized.is_finite(), "Denormalized value should be finite"); - assert!(denormalized > 0.0, "Denormalized price should be positive"); -} - -// ============================================================================ -// SSM-SPECIFIC NUMERICAL STABILITY -// ============================================================================ - -#[test] -fn test_adam_epsilon_bounds() { - // Test minimum and maximum adam_epsilon - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "adam_eps"); - - // Test minimum (1e-9) - let mut trainer_min = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params_min = Mamba2Params::default(); - params_min.adam_epsilon = 1e-9; - - let result_min = trainer_min.train_with_params(params_min); - assert!(result_min.is_ok(), "Minimum adam_epsilon should work"); - - // Test maximum (1e-7) - let mut trainer_max = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params_max = Mamba2Params::default(); - params_max.adam_epsilon = 1e-7; - - let result_max = trainer_max.train_with_params(params_max); - assert!(result_max.is_ok(), "Maximum adam_epsilon should work"); -} - -#[test] -fn test_grad_clip_bounds() { - // Test gradient clipping bounds - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "grad_clip"); - - // Test minimum (0.5) - let mut trainer_min = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params_min = Mamba2Params::default(); - params_min.grad_clip = 0.5; - - let result_min = trainer_min.train_with_params(params_min); - assert!(result_min.is_ok(), "Minimum grad_clip should work"); - - // Test maximum (5.0) - let mut trainer_max = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params_max = Mamba2Params::default(); - params_max.grad_clip = 5.0; - - let result_max = trainer_max.train_with_params(params_max); - assert!(result_max.is_ok(), "Maximum grad_clip should work"); -} - -#[test] -fn test_adam_beta_bounds() { - // Test Adam beta parameter bounds - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "adam_beta"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer"); - - let mut params = Mamba2Params::default(); - params.adam_beta1 = 0.85; // Minimum - params.adam_beta2 = 0.98; // Minimum - - let result = trainer.train_with_params(params); - assert!(result.is_ok(), "Minimum Adam betas should work"); -} - -// ============================================================================ -// BATCH SIZE CLAMPING WITH GPU MEMORY -// ============================================================================ - -#[test] -fn test_batch_size_clamping_min() { - // Test batch_size clamping to minimum bound - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "batch_clamp_min"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer") - .with_batch_size_bounds(16.0, 128.0); - - let mut params = Mamba2Params::default(); - params.batch_size = 4; // Below minimum (16) - - let result = trainer.train_with_params(params); - - // Should clamp to 16 and succeed - assert!( - result.is_ok(), - "Batch size clamping to minimum should work, got: {:?}", - result - ); -} - -#[test] -fn test_batch_size_clamping_max() { - // Test batch_size clamping to maximum bound - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "batch_clamp_max"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer") - .with_batch_size_bounds(4.0, 32.0); // RTX 3050 Ti constraints - - let mut params = Mamba2Params::default(); - params.batch_size = 256; // Above maximum (32) - - let result = trainer.train_with_params(params); - - // Should clamp to 32 and succeed - assert!( - result.is_ok(), - "Batch size clamping to maximum should work, got: {:?}", - result - ); -} - -#[test] -#[should_panic(expected = "Minimum batch size must be >= 1")] -fn test_batch_size_bounds_invalid_min() { - // Setting minimum batch size < 1 should panic - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "invalid_min"); - - let _trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer") - .with_batch_size_bounds(0.0, 32.0); // Should panic -} - -#[test] -#[should_panic(expected = "Maximum batch size must be > minimum")] -fn test_batch_size_bounds_invalid_max() { - // Setting maximum <= minimum should panic - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 150, "invalid_max"); - - let _trainer = Mamba2Trainer::new(&parquet_file, 5) - .expect("Failed to create trainer") - .with_batch_size_bounds(32.0, 16.0); // Should panic -} - -// ============================================================================ -// INTEGRATION TESTS -// ============================================================================ - -#[test] -fn test_all_13_params_roundtrip() { - // Verify all 13 MAMBA2 parameters survive roundtrip conversion - let params = Mamba2Params { - learning_rate: 5e-5, - batch_size: 64, - dropout: 0.15, - weight_decay: 5e-5, - grad_clip: 2.0, - warmup_steps: 500, - adam_beta1: 0.9, - adam_beta2: 0.999, - adam_epsilon: 1e-8, - total_decay_steps: 10000, - lookback_window: 90, - sequence_stride: 2, - norm_eps: 1e-5, - }; - - let continuous = params.to_continuous(); - assert_eq!(continuous.len(), 13, "Should have 13 continuous parameters"); - - let recovered = Mamba2Params::from_continuous(&continuous) - .expect("Failed to recover params"); - - // Verify all parameters - assert!((recovered.learning_rate - params.learning_rate).abs() < 1e-10); - assert_eq!(recovered.batch_size, params.batch_size); - assert!((recovered.dropout - params.dropout).abs() < 1e-10); - assert!((recovered.weight_decay - params.weight_decay).abs() < 1e-10); - assert!((recovered.grad_clip - params.grad_clip).abs() < 1e-6); - assert_eq!(recovered.warmup_steps, params.warmup_steps); - assert!((recovered.adam_beta1 - params.adam_beta1).abs() < 1e-10); - assert!((recovered.adam_beta2 - params.adam_beta2).abs() < 1e-10); - assert!((recovered.adam_epsilon - params.adam_epsilon).abs() < 1e-12); - assert_eq!(recovered.total_decay_steps, params.total_decay_steps); - assert_eq!(recovered.lookback_window, params.lookback_window); - assert_eq!(recovered.sequence_stride, params.sequence_stride); - assert!((recovered.norm_eps - params.norm_eps).abs() < 1e-12); -} - -#[test] -fn test_full_training_pipeline() { - // End-to-end test: create data, train, denormalize predictions - let temp_dir = TempDir::new().unwrap(); - let parquet_file = create_test_parquet(&temp_dir, 200, "full_pipeline"); - - let mut trainer = Mamba2Trainer::new(&parquet_file, 10) - .expect("Failed to create trainer") - .with_batch_size_bounds(4.0, 32.0) - .with_async_loading(true, 3) - .with_train_split(0.8); - - let params = Mamba2Params::default(); - let result = trainer.train_with_params(params); - - assert!(result.is_ok(), "Full training pipeline should succeed"); - - let metrics = result.unwrap(); - - // Verify metrics are reasonable - assert!(metrics.val_loss.is_finite(), "Validation loss should be finite"); - assert!(metrics.val_loss >= 0.0, "Validation loss should be non-negative"); - assert!(metrics.directional_accuracy >= 0.0 && metrics.directional_accuracy <= 1.0); - assert!(metrics.mae >= 0.0); - assert!(metrics.rmse >= 0.0); - assert!(metrics.r_squared >= -1.0 && metrics.r_squared <= 1.0); - assert_eq!(metrics.epochs_completed, 10); - - // Test denormalization - let pred = trainer.denormalize_prediction(0.5); - assert!(pred.is_finite() && pred > 0.0, "Denormalized prediction should be valid"); -} diff --git a/ml/tests/tlob_transformer_test.rs.disabled b/ml/tests/tlob_transformer_test.rs.disabled deleted file mode 100644 index 7de07805b..000000000 --- a/ml/tests/tlob_transformer_test.rs.disabled +++ /dev/null @@ -1,13 +0,0 @@ -// TLOB Transformer Integration Tests -// Wave 19 Phase 3: Minimal placeholder to eliminate 58 compilation errors -// -// Status: All tests disabled pending TLOB API stabilization -// Original test count: ~15 comprehensive integration tests -// Current test count: 1 placeholder test - -#[test] -fn tlob_transformer_placeholder() { - // Placeholder test to satisfy test infrastructure - // Full TLOB integration tests will be re-enabled after API stabilization - assert!(true, "TLOB transformer tests disabled - awaiting API stabilization"); -} diff --git a/ml/tests/wave16o_bug_reproduction_tests.rs.disabled b/ml/tests/wave16o_bug_reproduction_tests.rs.disabled deleted file mode 100644 index 195b19f6a..000000000 --- a/ml/tests/wave16o_bug_reproduction_tests.rs.disabled +++ /dev/null @@ -1,540 +0,0 @@ -// Wave 16O Agent O5: Bug Reproduction Tests -// -// These tests are designed to FAIL with the current implementation, -// proving that specific bugs exist. They use controlled synthetic data -// to isolate each bug in a minimal environment. -// -// Expected Test Results (BEFORE fixes): -// 1. test_pnl_realistic_range: FAIL - P&L will be $621T instead of ±$50K -// 2. test_transaction_costs_realistic: FAIL - Costs will be $621T instead of <$5K -// 3. test_gradient_nonzero: FAIL - Gradients will be 0.000000 instead of >0.01 -// 4. test_portfolio_epoch_reset: FAIL - Position will persist instead of resetting -// 5. test_val_data_raw_prices: FAIL - Validation data will be z-scored instead of raw -// -// Run with: cargo test --package ml --test wave16o_bug_reproduction_tests -- --nocapture - -use candle_core::{Device, Tensor}; -use ml::dqn::dqn::DQN; -use ml::dqn::reward::RewardParams; -use ml::trainers::dqn::DQNTrainer; -use std::path::PathBuf; - -/// Helper: Create synthetic price data -/// Returns (close_prices, features_tensor) where: -/// - close_prices: 100 timesteps, range $4000-$4100 -/// - features_tensor: [100, 128] with normalized features -fn create_synthetic_data(device: &Device) -> anyhow::Result<(Vec, Tensor)> { - let num_timesteps = 100; - let num_features = 128; - - // Synthetic close prices: $4000 + sine wave ±$100 - let mut close_prices = Vec::with_capacity(num_timesteps); - for i in 0..num_timesteps { - let t = i as f32 / num_timesteps as f32; - let price = 4000.0 + 100.0 * (t * 2.0 * std::f32::consts::PI).sin(); - close_prices.push(price); - } - - // Create features tensor [100, 128] - // Feature 0 = normalized close price (mean ~0, std ~1) - // Features 1-127 = random noise - let mut features_data = Vec::with_capacity(num_timesteps * num_features); - for (i, &price) in close_prices.iter().enumerate() { - // Feature 0: normalized close (mean=4000, std=100) - let norm_close = (price - 4000.0) / 100.0; - features_data.push(norm_close); - - // Features 1-127: small random noise - for j in 1..num_features { - let noise = (i as f32 * 0.01 + j as f32 * 0.001).sin() * 0.1; - features_data.push(noise); - } - } - - let features = Tensor::from_vec(features_data, (num_timesteps, num_features), device)?; - - Ok((close_prices, features)) -} - -/// Test 1: P&L Realistic Range -/// -/// Bug: P&L calculation uses z-scored validation data instead of raw prices, -/// causing astronomical P&L values ($621T). -/// -/// Expected Behavior: -/// - Max position: ±1.0 BTC -/// - Price range: $4000-$4100 -/// - Max P&L per trade: ~$100 -/// - Total P&L after 1 epoch: ±$50,000 (reasonable HFT range) -/// -/// Actual Behavior (BUG): -/// - P&L calculated on z-scored prices (mean ~0, std ~1) -/// - Results in P&L values in trillions of dollars -/// -/// This test will FAIL showing P&L >> $50K -#[test] -fn test_pnl_realistic_range() -> anyhow::Result<()> { - let device = Device::cuda_if_available(0)?; - - // 1. Create synthetic data - let (close_prices, features) = create_synthetic_data(&device)?; - - println!("Synthetic Data Stats:"); - println!(" Close prices: min={:.2}, max={:.2}, mean={:.2}", - close_prices.iter().cloned().fold(f32::INFINITY, f32::min), - close_prices.iter().cloned().fold(f32::NEG_INFINITY, f32::max), - close_prices.iter().sum::() / close_prices.len() as f32 - ); - - // 2. Initialize DQN - let num_actions = 45; - let state_dim = 128; - let hidden_dim = 128; - let dqn = DQN::new(state_dim, hidden_dim, num_actions, &device)?; - - // 3. Initialize trainer - let reward_params = RewardParams::default(); - let mut trainer = DQNTrainer::new( - dqn, - features.clone(), - features.clone(), // Use same data for train/val - reward_params, - 0.0001, // learning_rate - 0.99, // gamma - 128, // batch_size - 10000, // buffer_size - 1.0, // epsilon_start - 0.1, // epsilon_end - 0.995, // epsilon_decay - )?; - - // 4. Train for 1 epoch - println!("\nTraining 1 epoch..."); - trainer.train(1)?; - - // 5. Get validation data (this is where the bug occurs) - let val_data = trainer.get_val_data(); - - // CRITICAL: Check if validation data contains raw prices or z-scores - let val_mean = val_data.mean(0)?.mean_all()?.to_vec0::()?; - let val_std = val_data.std(0)?.mean_all()?.to_vec0::()?; - - println!("\nValidation Data Stats:"); - println!(" Mean: {:.6} (expected ~0 if z-scored, ~31 if raw)", val_mean); - println!(" Std: {:.6} (expected ~1 if z-scored, ~20 if raw)", val_std); - - // 6. Manually compute P&L using backtest logic - // (This replicates what hyperopt does in backtest integration) - let mut total_pnl = 0.0; - let mut position = 0.0; - let mut entry_price = 0.0; - - for i in 0..close_prices.len() - 1 { - // Get action from model - let state = val_data.get(i)?; - let action_idx = trainer.select_action_deterministic(&state)?; - - // Map action to position change (-1.0, 0.0, +1.0) - let target_position = match action_idx { - 0 => -1.0, // SHORT - 1 => 0.0, // FLAT - 2 => 1.0, // LONG - _ => 0.0, - }; - - let current_price = close_prices[i]; - - // Close existing position if any - if position != 0.0 && target_position != position { - let exit_price = current_price; - let trade_pnl = position * (exit_price - entry_price); - total_pnl += trade_pnl; - position = 0.0; - } - - // Open new position if needed - if target_position != 0.0 && position == 0.0 { - position = target_position; - entry_price = current_price; - } - } - - // Close final position at last price - if position != 0.0 { - let exit_price = close_prices[close_prices.len() - 1]; - let trade_pnl = position * (exit_price - entry_price); - total_pnl += trade_pnl; - } - - println!("\nP&L Calculation:"); - println!(" Total P&L: ${:.2}", total_pnl); - println!(" Expected range: ±$50,000 (reasonable HFT daily P&L)"); - - // ASSERTION: P&L should be in realistic range - // This will FAIL if validation data is z-scored (bug present) - assert!( - total_pnl.abs() < 50_000.0, - "P&L out of realistic range! Got ${:.2}, expected ±$50K. \ - This indicates validation data is z-scored instead of raw prices.", - total_pnl - ); - - Ok(()) -} - -/// Test 2: Transaction Costs Realistic -/// -/// Bug: Transaction costs calculated on z-scored data, resulting in -/// astronomical values. -/// -/// Expected Behavior: -/// - Fee: 0.0005 (0.05%) for limit orders -/// - ~10-20 trades per epoch (100 timesteps) -/// - Average trade size: $4000 * 1.0 BTC = $4000 -/// - Total costs: ~$2-$4 per trade = $20-$80 total -/// -/// Actual Behavior (BUG): -/// - Costs calculated on z-scored prices -/// - Results in costs in trillions of dollars -/// -/// This test will FAIL showing costs >> $5K -#[test] -fn test_transaction_costs_realistic() -> anyhow::Result<()> { - let device = Device::cuda_if_available(0)?; - - // 1. Create synthetic data - let (close_prices, features) = create_synthetic_data(&device)?; - - // 2. Initialize DQN - let num_actions = 45; - let state_dim = 128; - let hidden_dim = 128; - let dqn = DQN::new(state_dim, hidden_dim, num_actions, &device)?; - - // 3. Initialize trainer - let reward_params = RewardParams::default(); - let mut trainer = DQNTrainer::new( - dqn, - features.clone(), - features.clone(), - reward_params, - 0.0001, 0.99, 128, 10000, 1.0, 0.1, 0.995, - )?; - - // 4. Train for 1 epoch - trainer.train(1)?; - - // 5. Calculate transaction costs - let val_data = trainer.get_val_data(); - let mut total_costs = 0.0; - let mut num_trades = 0; - let mut prev_position = 0.0; - let fee_rate = 0.0005; // 0.05% - - for i in 0..close_prices.len() - 1 { - let state = val_data.get(i)?; - let action_idx = trainer.select_action_deterministic(&state)?; - - let target_position = match action_idx { - 0 => -1.0, - 1 => 0.0, - 2 => 1.0, - _ => 0.0, - }; - - // Calculate cost if position changes - if target_position != prev_position { - let current_price = close_prices[i]; - let position_delta = (target_position - prev_position).abs(); - let trade_value = current_price * position_delta; - let cost = trade_value * fee_rate; - total_costs += cost; - num_trades += 1; - prev_position = target_position; - } - } - - println!("\nTransaction Costs:"); - println!(" Number of trades: {}", num_trades); - println!(" Total costs: ${:.2}", total_costs); - println!(" Average cost per trade: ${:.2}", - if num_trades > 0 { total_costs / num_trades as f32 } else { 0.0 } - ); - println!(" Expected total: <$5,000 for 1 epoch"); - - // ASSERTION: Transaction costs should be reasonable - assert!( - total_costs < 5_000.0, - "Transaction costs unrealistic! Got ${:.2}, expected <$5K. \ - This indicates costs are being calculated on z-scored prices.", - total_costs - ); - - Ok(()) -} - -/// Test 3: Gradient Nonzero -/// -/// Bug: Gradients collapse to 0.000000 during training, preventing learning. -/// -/// Expected Behavior: -/// - After 5 training steps, gradients should be detectable -/// - Typical gradient norms: 0.01 - 10.0 -/// - Non-zero gradients indicate backprop is working -/// -/// Actual Behavior (BUG): -/// - Gradients are exactly 0.000000 -/// - Possible causes: reward clipping, TD error clipping, dead ReLUs -/// -/// This test will FAIL showing gradient_norm < 0.01 -#[test] -fn test_gradient_nonzero() -> anyhow::Result<()> { - let device = Device::cuda_if_available(0)?; - - // 1. Create synthetic data - let (_close_prices, features) = create_synthetic_data(&device)?; - - // 2. Initialize DQN - let num_actions = 45; - let state_dim = 128; - let hidden_dim = 128; - let dqn = DQN::new(state_dim, hidden_dim, num_actions, &device)?; - - // 3. Initialize trainer with high learning rate for visibility - let reward_params = RewardParams::default(); - let mut trainer = DQNTrainer::new( - dqn, - features.clone(), - features.clone(), - reward_params, - 0.001, // High LR to make gradients visible - 0.99, 128, 10000, 1.0, 0.1, 0.995, - )?; - - // 4. Populate replay buffer with 200 samples - println!("Populating replay buffer..."); - for i in 0..200 { - let state_idx = i % 90; // Use first 90 timesteps - let state = features.get(state_idx)?; - let action = (i % 45) as i32; // Cycle through all actions - let next_state = features.get(state_idx + 1)?; - let reward = (i as f32 * 0.1).sin(); // Varying rewards - let done = false; - - trainer.replay_buffer.push(state, action, reward, next_state, done)?; - } - - // 5. Run 5 training steps and capture gradient norms - println!("\nRunning 5 training steps..."); - let mut max_gradient_norm = 0.0_f32; - - for step in 0..5 { - // Perform one training step - let loss = trainer.train_step()?; - - // Get gradient norm from optimizer (this requires accessing internal state) - // For now, we'll use loss as a proxy - if loss changes, gradients are nonzero - println!(" Step {}: loss={:.6}", step, loss); - - // Track maximum gradient norm (simulated via loss change) - if step > 0 { - max_gradient_norm = max_gradient_norm.max(loss.abs()); - } - } - - println!("\nGradient Analysis:"); - println!(" Max gradient magnitude (via loss): {:.6}", max_gradient_norm); - println!(" Expected: >0.01 (detectable gradients)"); - - // ASSERTION: Gradients should be nonzero - // Note: This is a proxy test using loss magnitude - // A more direct test would require exposing gradient norms from the optimizer - assert!( - max_gradient_norm > 0.01, - "Gradients appear to be zero! Max magnitude={:.6}, expected >0.01. \ - This indicates gradient collapse (reward clipping or TD error saturation).", - max_gradient_norm - ); - - Ok(()) -} - -/// Test 4: Portfolio Epoch Reset -/// -/// Bug: PortfolioTracker may not reset between epochs, causing position -/// to persist across epoch boundaries. -/// -/// Expected Behavior: -/// - At start of epoch 1: position = 0.0, portfolio_value = 100000.0 -/// - After epoch 1: position = X, portfolio_value = Y -/// - At start of epoch 2: position = 0.0, portfolio_value = 100000.0 (RESET) -/// -/// Actual Behavior (BUG): -/// - Position and portfolio value persist across epochs -/// - This causes P&L to accumulate incorrectly -/// -/// This test will FAIL if position != 0.0 at start of epoch 2 -#[test] -fn test_portfolio_epoch_reset() -> anyhow::Result<()> { - let device = Device::cuda_if_available(0)?; - - // 1. Create synthetic data - let (_close_prices, features) = create_synthetic_data(&device)?; - - // 2. Initialize DQN - let num_actions = 45; - let state_dim = 128; - let hidden_dim = 128; - let dqn = DQN::new(state_dim, hidden_dim, num_actions, &device)?; - - // 3. Initialize trainer - let reward_params = RewardParams::default(); - let mut trainer = DQNTrainer::new( - dqn, - features.clone(), - features.clone(), - reward_params, - 0.0001, 0.99, 128, 10000, 1.0, 0.1, 0.995, - )?; - - // 4. Train epoch 1 - println!("Training epoch 1..."); - trainer.train(1)?; - - // 5. Check position at end of epoch 1 - // (This requires accessing PortfolioTracker state - may need to add getter) - // For now, we'll use a workaround: check if validation P&L is consistent - - // 6. Train epoch 2 - println!("Training epoch 2..."); - trainer.train(1)?; - - // 7. Check if results are consistent (would differ if state persists) - // This is a weak test - ideally we'd directly check PortfolioTracker.position - - println!("\nPortfolio Reset Check:"); - println!(" NOTE: This test is limited without direct PortfolioTracker access"); - println!(" To fully test, add: pub fn get_portfolio_state() to DQNTrainer"); - println!(" Expected: position=0.0, portfolio_value=100000.0 at epoch start"); - - // ASSERTION: For now, we just verify no panic - // TODO: Add PortfolioTracker state getter and check position=0.0 - assert!( - true, // Placeholder - replace with actual check when getter available - "PortfolioTracker state may persist across epochs. \ - Add get_portfolio_state() method to verify." - ); - - Ok(()) -} - -/// Test 5: Validation Data Raw Prices -/// -/// Bug: Validation data is z-scored instead of containing raw prices, -/// making P&L calculations meaningless. -/// -/// Expected Behavior: -/// - Validation data should contain raw prices in feature column 0 -/// - Mean of close prices: ~4000 -/// - Std of close prices: ~70-100 -/// - Feature 0 should match close_prices (not z-scored) -/// -/// Actual Behavior (BUG): -/// - Validation data is z-scored: mean ~0, std ~1 -/// - Feature 0 does not match close_prices -/// - P&L calculation uses normalized prices -/// -/// This test will FAIL showing val_mean != price_mean -#[test] -fn test_val_data_raw_prices() -> anyhow::Result<()> { - let device = Device::cuda_if_available(0)?; - - // 1. Create synthetic data - let (close_prices, features) = create_synthetic_data(&device)?; - - // Calculate expected stats from close prices - let price_mean = close_prices.iter().sum::() / close_prices.len() as f32; - let price_variance = close_prices.iter() - .map(|&x| (x - price_mean).powi(2)) - .sum::() / close_prices.len() as f32; - let price_std = price_variance.sqrt(); - - println!("Close Price Stats:"); - println!(" Mean: ${:.2}", price_mean); - println!(" Std: ${:.2}", price_std); - println!(" Min: ${:.2}", close_prices.iter().cloned().fold(f32::INFINITY, f32::min)); - println!(" Max: ${:.2}", close_prices.iter().cloned().fold(f32::NEG_INFINITY, f32::max)); - - // 2. Initialize DQN and trainer - let num_actions = 45; - let state_dim = 128; - let hidden_dim = 128; - let dqn = DQN::new(state_dim, hidden_dim, num_actions, &device)?; - - let reward_params = RewardParams::default(); - let trainer = DQNTrainer::new( - dqn, - features.clone(), - features.clone(), - reward_params, - 0.0001, 0.99, 128, 10000, 1.0, 0.1, 0.995, - )?; - - // 3. Get validation data - let val_data = trainer.get_val_data(); - - // 4. Extract feature 0 (should be close prices) - let val_feature_0 = val_data.i((.., 0))?; - let val_mean = val_feature_0.mean_all()?.to_vec0::()?; - let val_std = val_feature_0.std(0)?.mean_all()?.to_vec0::()?; - - println!("\nValidation Data Feature 0 Stats:"); - println!(" Mean: {:.2}", val_mean); - println!(" Std: {:.2}", val_std); - - // 5. Check if feature 0 matches close prices or is z-scored - let mean_diff = (val_mean - price_mean).abs(); - let is_zscore = val_mean.abs() < 1.0 && val_std > 0.5 && val_std < 2.0; - - println!("\nAnalysis:"); - println!(" Mean difference: {:.2} (close={:.2}, val={:.2})", - mean_diff, price_mean, val_mean); - println!(" Is z-scored? {} (mean~0, std~1)", is_zscore); - - // ASSERTION: Validation data should contain raw prices, not z-scores - assert!( - !is_zscore && mean_diff < 100.0, - "Validation data appears to be z-scored! \ - Expected mean~{:.2} (raw prices), got mean~{:.2} (z-score). \ - This causes P&L calculations to be meaningless.", - price_mean, val_mean - ); - - Ok(()) -} - -// ============================================================================ -// RUNNING THE TESTS -// ============================================================================ -// -// To run these reproduction tests: -// -// 1. Copy this file to ml/tests/wave16o_bug_reproduction_tests.rs -// 2. Run: cargo test --package ml --test wave16o_bug_reproduction_tests -- --nocapture -// -// Expected Results (BEFORE fixes): -// - test_pnl_realistic_range: FAIL -// - test_transaction_costs_realistic: FAIL -// - test_gradient_nonzero: FAIL (may pass if lucky with random init) -// - test_portfolio_epoch_reset: PASS (placeholder test) -// - test_val_data_raw_prices: FAIL -// -// After Bug Fixes: -// - All tests should PASS -// - P&L values should be in ±$50K range -// - Transaction costs should be <$5K -// - Gradients should be >0.01 -// - Portfolio should reset between epochs -// - Validation data should contain raw prices -// -// ============================================================================ diff --git a/risk/src/error_consolidated.rs b/risk/src/error_consolidated.rs deleted file mode 100644 index 0bd7bcfc2..000000000 --- a/risk/src/error_consolidated.rs +++ /dev/null @@ -1,473 +0,0 @@ -//! Consolidated error handling for the Risk module using CommonError -//! -//! This module demonstrates the consolidated error handling pattern -//! using the common error system across all Foxhunt Risk services. - -// ELIMINATED: Re-exports removed to force explicit imports - -/// Result type for risk operations using CommonError -pub type RiskResult = CommonResult; - -/// Risk module specific error extensions -/// For cases where we need domain-specific error information beyond CommonError -#[derive(Debug, thiserror::Error)] -pub enum RiskServiceError { - /// Common error with context - #[error("Risk service error: {0}")] - Common(#[from] CommonError), - - /// Position limit violation with specific context - #[error("Position limit exceeded: {instrument} position {current} exceeds limit {limit}")] - PositionLimitExceeded { - instrument: String, - current: f64, - limit: f64, - }, - - /// VaR limit violation with risk metrics - #[error("VaR limit exceeded: {var_value} exceeds limit {limit} (confidence: {confidence}%)")] - VarLimitExceeded { - var_value: f64, - limit: f64, - confidence: f64, - }, - - /// Drawdown limit violation - #[error("Drawdown limit exceeded: {drawdown}% exceeds limit {limit}%")] - DrawdownLimitExceeded { - drawdown: f64, - limit: f64, - }, - - /// Circuit breaker activation - #[error("Circuit breaker activated: {instrument} - {reason}")] - CircuitBreakerActive { - instrument: String, - reason: String, - }, - - /// Kill switch activation with scope - #[error("Kill switch activated: {scope} - {reason}")] - KillSwitchActive { - scope: String, - reason: String, - }, - - /// Market data unavailable for risk calculation - #[error("Market data unavailable: {instrument} required for risk calculation")] - MarketDataUnavailable { - instrument: String, - }, - - /// Compliance violation - #[error("Compliance violation: {rule} - {message}")] - ComplianceViolation { - rule: String, - message: String, - }, - - /// Risk calculation failure - #[error("Risk calculation failed: {calculation} - {message}")] - CalculationFailed { - calculation: String, - message: String, - }, - - /// Stress test failure - #[error("Stress test failed: {scenario} - {message}")] - StressTestFailed { - scenario: String, - message: String, - }, - - /// Performance violation - #[error("Performance violation: {metric} value {actual} exceeds threshold {threshold}")] - PerformanceViolation { - metric: String, - actual: f64, - threshold: f64, - }, -} - -impl RiskServiceError { - /// Convert to CommonError for metrics and monitoring - pub fn to_common_error(self) -> CommonError { - match self { - RiskServiceError::Common(err) => err, - RiskServiceError::PositionLimitExceeded { instrument, current, limit } => { - CommonError::risk( - "position_limit", - format!("{} position {} exceeds limit {}", instrument, current, limit) - ) - } - RiskServiceError::VarLimitExceeded { var_value, limit, confidence } => { - CommonError::risk( - "var_limit", - format!("VaR {} exceeds limit {} ({}% confidence)", var_value, limit, confidence) - ) - } - RiskServiceError::DrawdownLimitExceeded { drawdown, limit } => { - CommonError::risk( - "drawdown_limit", - format!("Drawdown {}% exceeds limit {}%", drawdown, limit) - ) - } - RiskServiceError::CircuitBreakerActive { instrument, reason } => { - CommonError::risk( - "circuit_breaker", - format!("Circuit breaker active for {}: {}", instrument, reason) - ) - } - RiskServiceError::KillSwitchActive { scope, reason } => { - CommonError::risk( - "kill_switch", - format!("Kill switch active for {}: {}", scope, reason) - ) - } - RiskServiceError::MarketDataUnavailable { instrument } => { - CommonError::service( - ErrorCategory::MarketData, - format!("Market data unavailable for {}", instrument) - ) - } - RiskServiceError::ComplianceViolation { rule, message } => { - CommonError::risk( - "compliance", - format!("Rule {} violated: {}", rule, message) - ) - } - RiskServiceError::CalculationFailed { calculation, message } => { - CommonError::risk( - "calculation", - format!("Calculation {} failed: {}", calculation, message) - ) - } - RiskServiceError::StressTestFailed { scenario, message } => { - CommonError::risk( - "stress_test", - format!("Stress test {} failed: {}", scenario, message) - ) - } - RiskServiceError::PerformanceViolation { metric, actual, threshold } => { - CommonError::risk( - "performance", - format!("Metric {} value {} exceeds threshold {}", metric, actual, threshold) - ) - } - } - } - - /// Get error category for metrics - pub fn category(&self) -> ErrorCategory { - match self { - RiskServiceError::Common(_) => self.to_common_error().category(), - RiskServiceError::MarketDataUnavailable { .. } => ErrorCategory::MarketData, - _ => ErrorCategory::Risk, - } - } - - /// Get error severity - Risk errors are generally critical - pub fn severity(&self) -> ErrorSeverity { - match self { - RiskServiceError::KillSwitchActive { .. } => ErrorSeverity::Critical, - RiskServiceError::DrawdownLimitExceeded { .. } => ErrorSeverity::Critical, - RiskServiceError::ComplianceViolation { .. } => ErrorSeverity::Critical, - RiskServiceError::PositionLimitExceeded { .. } => ErrorSeverity::Error, - RiskServiceError::VarLimitExceeded { .. } => ErrorSeverity::Error, - RiskServiceError::CircuitBreakerActive { .. } => ErrorSeverity::Error, - RiskServiceError::CalculationFailed { .. } => ErrorSeverity::Error, - RiskServiceError::StressTestFailed { .. } => ErrorSeverity::Warn, - RiskServiceError::PerformanceViolation { .. } => ErrorSeverity::Warn, - RiskServiceError::MarketDataUnavailable { .. } => ErrorSeverity::Warn, - RiskServiceError::Common(_) => self.to_common_error().severity(), - } - } - - /// Get retry strategy - Risk errors generally should not be retried - pub fn retry_strategy(&self) -> RetryStrategy { - match self { - // Critical risk violations should NEVER be retried - RiskServiceError::KillSwitchActive { .. } => RetryStrategy::NoRetry, - RiskServiceError::DrawdownLimitExceeded { .. } => RetryStrategy::NoRetry, - RiskServiceError::PositionLimitExceeded { .. } => RetryStrategy::NoRetry, - RiskServiceError::VarLimitExceeded { .. } => RetryStrategy::NoRetry, - RiskServiceError::ComplianceViolation { .. } => RetryStrategy::NoRetry, - - // System issues can be retried - RiskServiceError::MarketDataUnavailable { .. } => RetryStrategy::Exponential { - base_delay_ms: 1000, - max_delay_ms: 10000, - }, - RiskServiceError::CalculationFailed { .. } => RetryStrategy::Linear { - base_delay_ms: 500, - }, - - // Other errors use default logic - RiskServiceError::CircuitBreakerActive { .. } => RetryStrategy::CircuitBreaker, - RiskServiceError::StressTestFailed { .. } => RetryStrategy::Linear { - base_delay_ms: 2000, - }, - RiskServiceError::PerformanceViolation { .. } => RetryStrategy::NoRetry, - RiskServiceError::Common(_) => self.to_common_error().retry_strategy(), - } - } - - /// Check if error is retryable - pub fn is_retryable(&self) -> bool { - !matches!(self.retry_strategy(), RetryStrategy::NoRetry) - } - - /// Get error code for monitoring - pub fn error_code(&self) -> &'static str { - match self { - RiskServiceError::Common(_) => "RISK_COMMON_ERROR", - RiskServiceError::PositionLimitExceeded { .. } => "RISK_POSITION_LIMIT_EXCEEDED", - RiskServiceError::VarLimitExceeded { .. } => "RISK_VAR_LIMIT_EXCEEDED", - RiskServiceError::DrawdownLimitExceeded { .. } => "RISK_DRAWDOWN_LIMIT_EXCEEDED", - RiskServiceError::CircuitBreakerActive { .. } => "RISK_CIRCUIT_BREAKER_ACTIVE", - RiskServiceError::KillSwitchActive { .. } => "RISK_KILL_SWITCH_ACTIVE", - RiskServiceError::MarketDataUnavailable { .. } => "RISK_MARKET_DATA_UNAVAILABLE", - RiskServiceError::ComplianceViolation { .. } => "RISK_COMPLIANCE_VIOLATION", - RiskServiceError::CalculationFailed { .. } => "RISK_CALCULATION_FAILED", - RiskServiceError::StressTestFailed { .. } => "RISK_STRESS_TEST_FAILED", - RiskServiceError::PerformanceViolation { .. } => "RISK_PERFORMANCE_VIOLATION", - } - } - - /// Check if this error should trigger a kill switch - pub fn should_trigger_kill_switch(&self) -> bool { - matches!( - self, - RiskServiceError::DrawdownLimitExceeded { .. } | RiskServiceError::ComplianceViolation { .. } - ) - } - - /// Check if this error should trigger a circuit breaker - pub fn should_trigger_circuit_breaker(&self) -> bool { - matches!( - self, - RiskServiceError::PositionLimitExceeded { .. } | RiskServiceError::VarLimitExceeded { .. } - ) - } -} - -/// Convert standard errors to CommonError for consistent handling -impl From for RiskServiceError { - fn from(err: std::io::Error) -> Self { - RiskServiceError::Common(CommonError::network(format!("IO error: {}", err))) - } -} - -impl From for RiskServiceError { - fn from(err: serde_json::Error) -> Self { - RiskServiceError::Common(CommonError::serialization(format!("JSON error: {}", err))) - } -} - -impl From for RiskServiceError { - fn from(err: anyhow::Error) -> Self { - RiskServiceError::Common(CommonError::internal(format!("Anyhow error: {}", err))) - } -} - -impl From for RiskServiceError { - fn from(_: tokio::time::error::Elapsed) -> Self { - RiskServiceError::Common(CommonError::timeout(5000, 2000)) - } -} - -/// Convenience functions for creating risk service errors -impl RiskServiceError { - /// Create position limit exceeded error - pub fn position_limit_exceeded>(instrument: I, current: f64, limit: f64) -> Self { - Self::PositionLimitExceeded { - instrument: instrument.into(), - current, - limit, - } - } - - /// Create VaR limit exceeded error - pub fn var_limit_exceeded(var_value: f64, limit: f64, confidence: f64) -> Self { - Self::VarLimitExceeded { - var_value, - limit, - confidence, - } - } - - /// Create drawdown limit exceeded error - pub fn drawdown_limit_exceeded(drawdown: f64, limit: f64) -> Self { - Self::DrawdownLimitExceeded { drawdown, limit } - } - - /// Create circuit breaker active error - pub fn circuit_breaker_active, R: Into>(instrument: I, reason: R) -> Self { - Self::CircuitBreakerActive { - instrument: instrument.into(), - reason: reason.into(), - } - } - - /// Create kill switch active error - pub fn kill_switch_active, R: Into>(scope: S, reason: R) -> Self { - Self::KillSwitchActive { - scope: scope.into(), - reason: reason.into(), - } - } - - /// Create market data unavailable error - pub fn market_data_unavailable>(instrument: I) -> Self { - Self::MarketDataUnavailable { - instrument: instrument.into(), - } - } - - /// Create compliance violation error - pub fn compliance_violation, M: Into>(rule: R, message: M) -> Self { - Self::ComplianceViolation { - rule: rule.into(), - message: message.into(), - } - } - - /// Create calculation failed error - pub fn calculation_failed, M: Into>(calculation: C, message: M) -> Self { - Self::CalculationFailed { - calculation: calculation.into(), - message: message.into(), - } - } - - /// Create stress test failed error - pub fn stress_test_failed, M: Into>(scenario: S, message: M) -> Self { - Self::StressTestFailed { - scenario: scenario.into(), - message: message.into(), - } - } - - /// Create performance violation error - pub fn performance_violation>(metric: M, actual: f64, threshold: f64) -> Self { - Self::PerformanceViolation { - metric: metric.into(), - actual, - threshold, - } - } - - /// Create configuration error using CommonError - pub fn configuration>(message: M) -> Self { - Self::Common(CommonError::config(message)) - } - - /// Create validation error using CommonError - pub fn validation, M: Into>(field: F, message: M) -> Self { - Self::Common(CommonError::validation(field, message)) - } - - /// Create internal error using CommonError - pub fn internal>(message: M) -> Self { - Self::Common(CommonError::internal(message)) - } -} - -/// Convert to CommonError automatically for interop -impl From for CommonError { - fn from(err: RiskServiceError) -> Self { - err.to_common_error() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_risk_service_error_categorization() { - let position_error = RiskServiceError::position_limit_exceeded("AAPL", 1000.0, 500.0); - assert_eq!(position_error.category(), ErrorCategory::Risk); - assert_eq!(position_error.error_code(), "RISK_POSITION_LIMIT_EXCEEDED"); - assert_eq!(position_error.severity(), ErrorSeverity::Error); - assert!(!position_error.is_retryable()); // Position limits should not be retried - - let market_data_error = RiskServiceError::market_data_unavailable("TSLA"); - assert_eq!(market_data_error.category(), ErrorCategory::MarketData); - assert!(market_data_error.is_retryable()); - } - - #[test] - fn test_critical_risk_errors() { - let kill_switch_error = RiskServiceError::kill_switch_active("GLOBAL", "Emergency stop"); - assert_eq!(kill_switch_error.severity(), ErrorSeverity::Critical); - assert!(!kill_switch_error.is_retryable()); - assert!(kill_switch_error.should_trigger_kill_switch()); - - let compliance_error = RiskServiceError::compliance_violation("MiFID_II", "Best execution failed"); - assert_eq!(compliance_error.severity(), ErrorSeverity::Critical); - assert!(compliance_error.should_trigger_kill_switch()); - } - - #[test] - fn test_retry_strategies() { - let var_error = RiskServiceError::var_limit_exceeded(1000.0, 500.0, 95.0); - assert!(!var_error.is_retryable()); - assert_eq!(var_error.retry_strategy(), RetryStrategy::NoRetry); - - let data_error = RiskServiceError::market_data_unavailable("SPY"); - assert!(data_error.is_retryable()); - match data_error.retry_strategy() { - RetryStrategy::Exponential { base_delay_ms, max_delay_ms } => { - assert_eq!(base_delay_ms, 1000); - assert_eq!(max_delay_ms, 10000); - } - _ => assert!(false, "Expected exponential backoff for market data errors"), - } - } - - #[test] - fn test_circuit_breaker_triggers() { - let position_error = RiskServiceError::position_limit_exceeded("BTC", 10.0, 5.0); - assert!(position_error.should_trigger_circuit_breaker()); - - let var_error = RiskServiceError::var_limit_exceeded(2000.0, 1000.0, 99.0); - assert!(var_error.should_trigger_circuit_breaker()); - - let data_error = RiskServiceError::market_data_unavailable("ETH"); - assert!(!data_error.should_trigger_circuit_breaker()); - } - - #[test] - fn test_common_error_integration() { - let config_error = RiskServiceError::configuration("Missing risk parameters"); - let common_error: CommonError = config_error.into(); - - assert_eq!(common_error.category(), ErrorCategory::Configuration); - assert_eq!(common_error.severity(), ErrorSeverity::Critical); - assert!(!common_error.is_retryable()); - } - - #[test] - fn test_error_conversion_chain() { - let io_error = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "Connection refused"); - let risk_error: RiskServiceError = io_error.into(); - let common_error: CommonError = risk_error.into(); - - assert_eq!(common_error.category(), ErrorCategory::Network); - assert_eq!(common_error.severity(), ErrorSeverity::Warn); - } - - #[test] - fn test_stress_test_error() { - let stress_error = RiskServiceError::stress_test_failed("BLACK_MONDAY", "Portfolio loss exceeds threshold"); - assert_eq!(stress_error.category(), ErrorCategory::Risk); - assert_eq!(stress_error.severity(), ErrorSeverity::Warn); - assert!(stress_error.is_retryable()); - - match stress_error.retry_strategy() { - RetryStrategy::Linear { base_delay_ms } => assert_eq!(base_delay_ms, 2000), - _ => assert!(false, "Expected linear backoff for stress test errors"), - } - } -} \ No newline at end of file diff --git a/risk/src/risk_engine.rs b/risk/src/risk_engine.rs index 8326fb2e7..770da43d3 100644 --- a/risk/src/risk_engine.rs +++ b/risk/src/risk_engine.rs @@ -53,66 +53,9 @@ use config::structures::VarConfig; // Default implementation now provided by config crate -/// Kill switch implementation for emergency trading halt -#[derive(Debug)] -pub struct KillSwitch { - active: std::sync::atomic::AtomicBool, -} - -impl KillSwitch { - /// **Create New Kill Switch Instance** - /// - /// Initializes emergency trading halt system with active state. - /// The kill switch starts in the active state (trading allowed). - /// - /// # Arguments - /// * `_config` - Risk configuration (reserved for future use) - /// - /// # Returns - /// * `Self` - Kill switch instance ready for operation - /// - /// # Safety - /// - Atomic operations ensure thread-safe state management - /// - Default active state allows trading unless explicitly disabled - /// - /// # Usage - /// ```rust - /// let config = RiskConfig::default(); - /// let kill_switch = KillSwitch::new(&config); - /// ``` - #[must_use] - pub const fn new(_config: &RiskConfig) -> Self { - Self { - active: std::sync::atomic::AtomicBool::new(true), - } - } - - /// **Check Kill Switch Status** - /// - /// Returns the current state of the emergency trading halt system. - /// When inactive (false), all trading operations should be rejected. - /// - /// # Returns - /// * `bool` - `true` if trading is allowed, `false` if emergency halt is active - /// - /// # Safety - /// - Uses relaxed atomic ordering for performance - /// - Thread-safe across all risk engine operations - /// - /// # Performance - /// - Sub-nanosecond atomic read operation - /// - No memory allocation or system calls - /// - /// # Usage - /// ```rust - /// if !kill_switch.is_active().await { - /// return RiskCheckResult::Rejected { ... }; - /// } - /// ``` - pub async fn is_active(&self) -> bool { - self.active.load(std::sync::atomic::Ordering::Relaxed) - } -} +// KillSwitch stub removed - use AtomicKillSwitch from safety::kill_switch +use crate::safety::kill_switch::AtomicKillSwitch; +use crate::safety::KillSwitchConfig; /// Position limit monitor implementation #[derive(Debug)] @@ -855,7 +798,7 @@ pub struct RiskEngine { #[allow(dead_code)] position_tracker: Arc, /// Emergency trading halt functionality - kill_switch: Arc, + kill_switch: Arc, /// Position and leverage limit monitoring #[allow(dead_code)] limit_monitor: Arc, @@ -932,8 +875,8 @@ impl RiskEngine { info!("\u{1f680} Initializing PRODUCTION RiskEngine with REAL broker integrations"); - // Initialize kill switch (fix: remove await and use proper reference) - let kill_switch = Arc::new(KillSwitch::new(&config)); + // Initialize kill switch using AtomicKillSwitch (no Redis required for basic operation) + let kill_switch = Arc::new(AtomicKillSwitch::new_test(KillSwitchConfig::default())); // Initialize position limit monitor let limit_monitor = Arc::new(PositionLimitMonitor::new(config.clone())); @@ -1090,7 +1033,7 @@ impl RiskEngine { ); // 1. Kill switch check - CRITICAL SAFETY - if !self.kill_switch.is_active().await { + if self.kill_switch.is_triggered() { warn!("\u{1f6d1} KILL SWITCH ACTIVATED - Rejecting all orders"); return Ok(RiskCheckResult::Rejected { reason: "Kill switch is activated".to_owned(), diff --git a/risk/src/safety/kill_switch.rs b/risk/src/safety/kill_switch.rs index f116f313b..680dceeb8 100644 --- a/risk/src/safety/kill_switch.rs +++ b/risk/src/safety/kill_switch.rs @@ -441,62 +441,9 @@ impl TradingGate { } } -/// Unix socket kill switch for IPC control -// Infrastructure - fields will be used for Unix socket-based kill switch -#[allow(dead_code)] -pub struct UnixSocketKillSwitch { - socket_path: String, - kill_switch: AtomicKillSwitch, -} - -impl UnixSocketKillSwitch { - pub async fn new( - socket_path: String, - config: KillSwitchConfig, - redis_url: String, - ) -> RiskResult { - let kill_switch = AtomicKillSwitch::new(config, redis_url).await?; - Ok(Self { - socket_path, - kill_switch, - }) - } - - pub fn trigger(&self) { - self.kill_switch.trigger(); - } - - #[must_use] - pub fn is_triggered(&self) -> bool { - self.kill_switch.is_triggered() - } - - pub async fn engage( - &self, - scope: KillSwitchScope, - reason: String, - user_id: String, - cascade: bool, - ) -> RiskResult<()> { - self.kill_switch - .engage(scope, reason, user_id, cascade) - .await - } - - #[must_use] - pub fn is_trading_allowed(&self, scope: &KillSwitchScope) -> bool { - self.kill_switch.is_trading_allowed(scope) - } - - /// Create a test-only unix socket kill switch without Redis dependency - #[cfg(test)] - pub fn new_test(socket_path: String, config: KillSwitchConfig) -> Self { - Self { - socket_path, - kill_switch: AtomicKillSwitch::new_test(config), - } - } -} +// Re-export the full UnixSocketKillSwitch from its dedicated module +// (the thin stub that was here has been removed to eliminate duplication) +pub use crate::safety::unix_socket_kill_switch::UnixSocketKillSwitch; #[cfg(test)] mod tests { @@ -762,14 +709,14 @@ mod tests { #[tokio::test] async fn test_unix_socket_kill_switch() -> RiskResult<()> { - let config = KillSwitchConfig::default(); - let unix_switch = - UnixSocketKillSwitch::new_test("/tmp/foxhunt_killswitch.sock".to_string(), config); + // Test trigger/is_triggered via AtomicKillSwitch directly + // (UnixSocketKillSwitch requires an async listener and Arc) + let atomic_switch = create_test_kill_switch(); - assert!(!unix_switch.is_triggered()); + assert!(!atomic_switch.is_triggered()); - unix_switch.trigger(); - assert!(unix_switch.is_triggered()); + atomic_switch.trigger(); + assert!(atomic_switch.is_triggered()); Ok(()) } diff --git a/services/trading_service/src/core/risk_manager.rs b/services/trading_service/src/core/risk_manager.rs index c53150c66..c4aae916d 100644 --- a/services/trading_service/src/core/risk_manager.rs +++ b/services/trading_service/src/core/risk_manager.rs @@ -341,7 +341,7 @@ impl RiskManager { let mut latency_tracker = LatencyMeasurement::start(); // REAL KILL SWITCH CHECK - Hardware-level emergency stop - // Check kill switch status - it returns Result + // Check kill switch status - it returns Result let is_active = self.kill_switch .is_active() @@ -537,7 +537,7 @@ impl RiskManager { symbol: &str, quantity: f64, price: f64, - ) -> Result { + ) -> Result { let _exposure = self.get_account_exposure(account_id).await; // Get historical volatility for Monte Carlo simulation @@ -550,7 +550,7 @@ impl RiskManager { // Conservative estimate: assume 10% daily volatility with overflow check let conservative_var = quantity.abs() * price * 0.10; if !conservative_var.is_finite() { - return Err(RiskError::CalculationError(format!( + return Err(risk::error::RiskError::CalculationError(format!( "Conservative VaR overflow: {} * {} * 0.10", quantity.abs(), price @@ -810,7 +810,7 @@ impl RiskManager { } /// Update market data for VaR calculations - REAL-TIME INTEGRATION - pub async fn update_market_data(&self, symbol: &str, price: f64) -> Result<(), RiskError> { + pub async fn update_market_data(&self, symbol: &str, price: f64) -> Result<(), risk::error::RiskError> { let _timestamp_ns = HardwareTimestamp::now().as_nanos(); // REAL-TIME RISK MONITORING - Check for extreme price movements @@ -854,7 +854,7 @@ impl RiskManager { } /// Calculate portfolio VaR - REAL SIMD-OPTIMIZED IMPLEMENTATION - pub async fn calculate_portfolio_var(&self, account_id: &str) -> Result { + pub async fn calculate_portfolio_var(&self, account_id: &str) -> Result { let mut latency_tracker = LatencyMeasurement::start(); // Get account positions @@ -872,7 +872,10 @@ impl RiskManager { .unwrap_or(0); if max_history_length < 30 { - return Err(RiskError::InsufficientData); + return Err(risk::error::RiskError::InsufficientHistoricalData { + required: 30, + available: return_history.get(account_id).map_or(0, |r| r.len()), + }); } for i in 0..max_history_length { @@ -895,7 +898,10 @@ impl RiskManager { // Historical Simulation VaR using proper quantile and CVaR calculations if portfolio_returns.is_empty() { - return Err(RiskError::InsufficientData); + return Err(risk::error::RiskError::InsufficientHistoricalData { + required: 30, + available: return_history.get(account_id).map_or(0, |r| r.len()), + }); } // Sort returns ascending (worst losses first) for quantile extraction @@ -1109,7 +1115,7 @@ impl RiskManager { symbol: &str, _quantity: f64, price: f64, - ) -> Result { + ) -> Result { let returns = self.return_history.read().await; if let Some(symbol_returns) = returns.get(symbol) { @@ -1121,7 +1127,7 @@ impl RiskManager { return self .kelly_sizer .calculate_kelly_fraction(&symbol_obj, "default_strategy") - .map_err(|e| RiskError::CalculationError(e.to_string())); + .map_err(|e| risk::error::RiskError::CalculationError(e.to_string())); } } @@ -1148,7 +1154,7 @@ impl RiskManager { symbol: &str, quantity: f64, price: f64, - ) -> Result { + ) -> Result { // Simplified incremental VaR calculation // In production, this would use the full covariance matrix let returns = self.return_history.read().await; @@ -1168,7 +1174,7 @@ impl RiskManager { Ok(quantity.abs() * price * 0.02) // 2% of notional } - async fn recalculate_symbol_var(&self, symbol: &str) -> Result<(), RiskError> { + async fn recalculate_symbol_var(&self, symbol: &str) -> Result<(), risk::error::RiskError> { // 1. Collect affected account IDs (those holding a position in this symbol) let affected_accounts: Vec = { let exposures = self.exposures.read().await; @@ -1343,7 +1349,7 @@ impl RiskManager { } /// REAL PRICE SHOCK MONITORING - async fn monitor_price_shock(&self, symbol: &str, new_price: f64) -> Result<(), RiskError> { + async fn monitor_price_shock(&self, symbol: &str, new_price: f64) -> Result<(), risk::error::RiskError> { let price_history = self.price_history.read().await; if let Some(prices) = price_history.get(symbol) { if let Some(&last_price) = prices.last() { @@ -1443,29 +1449,29 @@ pub enum ComplianceSeverity { Critical, } -/// Risk error types -#[derive(Debug, thiserror::Error)] -pub enum RiskError { - #[error("Insufficient data for calculation")] - InsufficientData, - #[error("Calculation error: {0}")] - CalculationError(String), - #[error("Configuration error: {0}")] - ConfigurationError(String), -} - /// Convert RiskError to RiskViolation for error propagation with ? operator -impl From for RiskViolation { - fn from(error: RiskError) -> Self { +/// Maps canonical risk::error::RiskError variants to RiskViolation +impl From for RiskViolation { + fn from(error: risk::error::RiskError) -> Self { // When risk calculations fail, treat as a calculation-based violation // We use DailyLossExceeded with special sentinel values to indicate // this is actually a calculation error, not a genuine loss violation match error { - RiskError::InsufficientData => RiskViolation::DailyLossExceeded { - loss: -1.0, // Sentinel: negative loss indicates calc error - limit: 0.0, + risk::error::RiskError::InsufficientHistoricalData { .. } => { + RiskViolation::DailyLossExceeded { + loss: -1.0, // Sentinel: negative loss indicates calc error + limit: 0.0, + } }, - RiskError::CalculationError(_) | RiskError::ConfigurationError(_) => { + risk::error::RiskError::CalculationError(_) + | risk::error::RiskError::Configuration { .. } => { + RiskViolation::DailyLossExceeded { + loss: -1.0, + limit: 0.0, + } + }, + _ => { + // For other error types, map to a generic calculation error RiskViolation::DailyLossExceeded { loss: -1.0, limit: 0.0, @@ -1565,7 +1571,7 @@ mod tests { assert!(var_result.is_err()); assert!(matches!( var_result.unwrap_err(), - RiskError::InsufficientData + risk::error::RiskError::InsufficientHistoricalData { .. } )); } } diff --git a/services/trading_service/src/event_streaming/mod.rs b/services/trading_service/src/event_streaming/mod.rs index 91a2838ca..fd5f9abc2 100644 --- a/services/trading_service/src/event_streaming/mod.rs +++ b/services/trading_service/src/event_streaming/mod.rs @@ -42,12 +42,12 @@ pub struct TradingEventStreamer { /// Event buffer for reliable delivery pub event_buffer: Arc>, /// Configuration for the streaming system - pub config: StreamingConfig, + pub config: EventStreamingConfig, } impl TradingEventStreamer { /// Create a new trading event streamer - pub fn new(config: StreamingConfig) -> Self { + pub fn new(config: EventStreamingConfig) -> Self { let (sender, _receiver) = broadcast::channel(config.max_subscribers); Self { @@ -166,7 +166,7 @@ impl TradingEventStreamer { /// Configuration for the trading event streaming system #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StreamingConfig { +pub struct EventStreamingConfig { /// Maximum number of concurrent subscribers pub max_subscribers: usize, /// Event buffer size for replay capability @@ -181,7 +181,7 @@ pub struct StreamingConfig { pub max_buffer_memory: usize, } -impl Default for StreamingConfig { +impl Default for EventStreamingConfig { fn default() -> Self { Self { max_subscribers: 1000, @@ -341,7 +341,7 @@ mod tests { #[tokio::test] async fn test_event_streamer_creation() { - let config = StreamingConfig::default(); + let config = EventStreamingConfig::default(); let streamer = TradingEventStreamer::new(config); assert!(streamer.start().await.is_ok()); @@ -350,7 +350,7 @@ mod tests { #[tokio::test] async fn test_event_publishing() { - let config = StreamingConfig::default(); + let config = EventStreamingConfig::default(); let streamer = TradingEventStreamer::new(config); streamer.start().await.unwrap(); @@ -372,7 +372,7 @@ mod tests { #[tokio::test] async fn test_subscription_management() { - let config = StreamingConfig::default(); + let config = EventStreamingConfig::default(); let streamer = TradingEventStreamer::new(config); streamer.start().await.unwrap(); diff --git a/services/trading_service/src/repository_impls.rs b/services/trading_service/src/repository_impls.rs index 8edb6a118..cad49f1d1 100644 --- a/services/trading_service/src/repository_impls.rs +++ b/services/trading_service/src/repository_impls.rs @@ -1280,233 +1280,238 @@ impl ConfigRepository for PostgresConfigRepository { // Mock Implementations for Testing // ============================================================================= -/// Mock implementation of TradingRepository for testing -#[derive(Debug, Clone, Default)] -#[allow(dead_code)] -pub struct MockTradingRepository; +#[cfg(test)] +mod mock_repositories { + use super::*; -impl MockTradingRepository { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl TradingRepository for MockTradingRepository { - async fn store_order(&self, _order: &TradingOrder) -> TradingServiceResult { - Ok(uuid::Uuid::new_v4().to_string()) - } - - async fn update_order_status( - &self, - _order_id: &str, - _status: common::types::OrderStatus, - ) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_order(&self, _order_id: &str) -> TradingServiceResult> { - Ok(None) - } - - async fn get_orders_for_account( - &self, - _account_id: &str, - ) -> TradingServiceResult> { - Ok(Vec::new()) - } - - async fn store_execution( - &self, - _execution: &crate::repositories::ExecutionEvent, - ) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_execution_history( - &self, - _request: &GetExecutionHistoryRequest, - ) -> TradingServiceResult> { - Ok(Vec::new()) - } - async fn store_position(&self, _position: &TradingPosition) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_positions( - &self, - _account_id: Option<&str>, - _symbol: Option<&str>, - ) -> TradingServiceResult> { - Ok(Vec::new()) - } - - async fn get_portfolio_summary( - &self, - account_id: &str, - ) -> TradingServiceResult { - Ok(PortfolioSummary { - account_id: account_id.to_string(), - total_value: 0.0, - cash_balance: 0.0, - positions_value: 0.0, - unrealized_pnl: 0.0, - realized_pnl: 0.0, - }) - } - - async fn get_realized_pnl( - &self, - _account_id: &str, - _symbol: Option<&str>, - ) -> TradingServiceResult { - Ok(0.0) - } - - async fn get_day_pnl(&self, _account_id: &str) -> TradingServiceResult { - Ok(0.0) - } -} - -/// Mock implementation of MarketDataRepository for testing -#[derive(Debug, Clone, Default)] -#[allow(dead_code)] -pub struct MockMarketDataRepository; - -impl MockMarketDataRepository { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl MarketDataRepository for MockMarketDataRepository { - async fn store_market_tick(&self, _tick: &MarketTick) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_order_book( - &self, - symbol: &str, - _depth: i32, - ) -> TradingServiceResult { - Ok(crate::repositories::OrderBook { - symbol: symbol.to_string(), - bids: Vec::new(), - asks: Vec::new(), - timestamp: chrono::Utc::now().timestamp(), - }) - } - - async fn store_order_book( - &self, - _symbol: &str, - _order_book: &crate::repositories::OrderBook, - ) -> TradingServiceResult<()> { - Ok(()) - } - async fn get_latest_prices( - &self, - _symbols: &[String], - ) -> TradingServiceResult> { - Ok(Vec::new()) - } - - async fn store_market_event( - &self, - _event: &common::MarketDataEvent, - ) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_historical_data( - &self, - _symbol: &str, - _from: i64, - _to: i64, - ) -> TradingServiceResult> { - Ok(Vec::new()) - } - - async fn get_order_book_level_count( - &self, - _symbol: &str, - _price: f64, - _side: common::OrderSide, - ) -> TradingServiceResult { - Ok(1) - } -} - -/// Mock implementation of RiskRepository for testing -#[derive(Debug, Clone, Default)] -pub struct MockRiskRepository; - -impl MockRiskRepository { - pub fn new() -> Self { - Self - } -} - -#[async_trait] -impl RiskRepository for MockRiskRepository { - async fn store_var_calculation( - &self, - _calculation: &VarCalculation, - ) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_risk_limits(&self, account_id: &str) -> TradingServiceResult { - Ok(RiskLimits { - account_id: account_id.to_string(), - max_order_size: 1000000.0, - max_position_limit: 10000000.0, - max_drawdown_limit: 0.10, - daily_loss_limit: Some(50000.0), - }) - } - - async fn update_risk_limits( - &self, - _account_id: &str, - _limits: &RiskLimits, - ) -> TradingServiceResult<()> { - Ok(()) - } - - async fn store_risk_alert(&self, _alert: &RiskAlert) -> TradingServiceResult<()> { - Ok(()) - } - - async fn get_risk_metrics(&self, account_id: &str) -> TradingServiceResult { - Ok(RiskMetrics { - account_id: account_id.to_string(), - current_var: 0.0, - current_drawdown: 0.0, - position_concentration: 0.0, - leverage_ratio: 1.0, - }) - } - - async fn store_position_risk( - &self, - _account_id: &str, - _symbol: &str, - _risk: &PositionRisk, - ) -> TradingServiceResult<()> { - Ok(()) - } - - async fn validate_order_risk( - &self, - _account_id: &str, - _order: &OrderRequest, - ) -> TradingServiceResult { - Ok(true) - } - - async fn calculate_margin_used(&self, _account_id: &str) -> TradingServiceResult { - Ok(0.0) + /// Mock implementation of TradingRepository for testing + #[derive(Debug, Clone, Default)] + #[allow(dead_code)] + pub struct MockTradingRepository; + + impl MockTradingRepository { + pub fn new() -> Self { + Self + } + } + + #[async_trait] + impl TradingRepository for MockTradingRepository { + async fn store_order(&self, _order: &TradingOrder) -> TradingServiceResult { + Ok(uuid::Uuid::new_v4().to_string()) + } + + async fn update_order_status( + &self, + _order_id: &str, + _status: common::types::OrderStatus, + ) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_order(&self, _order_id: &str) -> TradingServiceResult> { + Ok(None) + } + + async fn get_orders_for_account( + &self, + _account_id: &str, + ) -> TradingServiceResult> { + Ok(Vec::new()) + } + + async fn store_execution( + &self, + _execution: &crate::repositories::ExecutionEvent, + ) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_execution_history( + &self, + _request: &GetExecutionHistoryRequest, + ) -> TradingServiceResult> { + Ok(Vec::new()) + } + async fn store_position(&self, _position: &TradingPosition) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_positions( + &self, + _account_id: Option<&str>, + _symbol: Option<&str>, + ) -> TradingServiceResult> { + Ok(Vec::new()) + } + + async fn get_portfolio_summary( + &self, + account_id: &str, + ) -> TradingServiceResult { + Ok(PortfolioSummary { + account_id: account_id.to_string(), + total_value: 0.0, + cash_balance: 0.0, + positions_value: 0.0, + unrealized_pnl: 0.0, + realized_pnl: 0.0, + }) + } + + async fn get_realized_pnl( + &self, + _account_id: &str, + _symbol: Option<&str>, + ) -> TradingServiceResult { + Ok(0.0) + } + + async fn get_day_pnl(&self, _account_id: &str) -> TradingServiceResult { + Ok(0.0) + } + } + + /// Mock implementation of MarketDataRepository for testing + #[derive(Debug, Clone, Default)] + #[allow(dead_code)] + pub struct MockMarketDataRepository; + + impl MockMarketDataRepository { + pub fn new() -> Self { + Self + } + } + + #[async_trait] + impl MarketDataRepository for MockMarketDataRepository { + async fn store_market_tick(&self, _tick: &MarketTick) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_order_book( + &self, + symbol: &str, + _depth: i32, + ) -> TradingServiceResult { + Ok(crate::repositories::OrderBook { + symbol: symbol.to_string(), + bids: Vec::new(), + asks: Vec::new(), + timestamp: chrono::Utc::now().timestamp(), + }) + } + + async fn store_order_book( + &self, + _symbol: &str, + _order_book: &crate::repositories::OrderBook, + ) -> TradingServiceResult<()> { + Ok(()) + } + async fn get_latest_prices( + &self, + _symbols: &[String], + ) -> TradingServiceResult> { + Ok(Vec::new()) + } + + async fn store_market_event( + &self, + _event: &common::MarketDataEvent, + ) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_historical_data( + &self, + _symbol: &str, + _from: i64, + _to: i64, + ) -> TradingServiceResult> { + Ok(Vec::new()) + } + + async fn get_order_book_level_count( + &self, + _symbol: &str, + _price: f64, + _side: common::OrderSide, + ) -> TradingServiceResult { + Ok(1) + } + } + + /// Mock implementation of RiskRepository for testing + #[derive(Debug, Clone, Default)] + pub struct MockRiskRepository; + + impl MockRiskRepository { + pub fn new() -> Self { + Self + } + } + + #[async_trait] + impl RiskRepository for MockRiskRepository { + async fn store_var_calculation( + &self, + _calculation: &VarCalculation, + ) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_risk_limits(&self, account_id: &str) -> TradingServiceResult { + Ok(RiskLimits { + account_id: account_id.to_string(), + max_order_size: 1000000.0, + max_position_limit: 10000000.0, + max_drawdown_limit: 0.10, + daily_loss_limit: Some(50000.0), + }) + } + + async fn update_risk_limits( + &self, + _account_id: &str, + _limits: &RiskLimits, + ) -> TradingServiceResult<()> { + Ok(()) + } + + async fn store_risk_alert(&self, _alert: &RiskAlert) -> TradingServiceResult<()> { + Ok(()) + } + + async fn get_risk_metrics(&self, account_id: &str) -> TradingServiceResult { + Ok(RiskMetrics { + account_id: account_id.to_string(), + current_var: 0.0, + current_drawdown: 0.0, + position_concentration: 0.0, + leverage_ratio: 1.0, + }) + } + + async fn store_position_risk( + &self, + _account_id: &str, + _symbol: &str, + _risk: &PositionRisk, + ) -> TradingServiceResult<()> { + Ok(()) + } + + async fn validate_order_risk( + &self, + _account_id: &str, + _order: &OrderRequest, + ) -> TradingServiceResult { + Ok(true) + } + + async fn calculate_margin_used(&self, _account_id: &str) -> TradingServiceResult { + Ok(0.0) + } } } diff --git a/test_data/ES_FUT_unseen.dbn.old b/test_data/ES_FUT_unseen.dbn.old deleted file mode 100644 index caf50c5e3ca708743da6b97e29288301156fbad4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 192991 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μMimi8{(-WT9g*%GmfcL1!arCH!~ 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�O5TtvP^*ITEl>|Tb=>|y50H03kN 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 z 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%0AF+y z1x(GCP@vKC0tyJ}-=aYF_7f=JcyJg6{KM-|z?^SGftHh1D4>CdJ3|y2%2B|%t{4SE zZ!JOrOJF_FS5P23{~`)FPM(pLLLJakC}1`mMS+%e`%yr1Yc~oM27ZnL&Y2rgAhe+Z z1uPvUD9|?WQxp(O=A%Ga>&GbI@}7wT@tIRlz-mixg=l{=x(z}X_1VgCV1j*$mKx7;tGa&4pohZQT{y+g!=~EPF zymSu*1i#xTkUixp3OH(7QNaIs6AGAPze0hQ#m7)U({um@3gtB(I zDA0KF843vW0SaUr?x28U-8B^Ozx5pom;=wDK+DXpQ9#r11qu{)97F-eYVd~!1bg61>&RTpn$btHVU+V^C1f8xC|7i8fDCfaBnnN zAPjeOYawhw3ELq$bE5Y{7>|TCLezC?n<0oU;5tM`sro*I{gT2B!TYiQf`DntKOU<= zpt0sp6cC>Of&$sG4^hCe_$L(bZ@P{GX88{&&|*A~0-CCCP@wScaTIX&K7<0Hd3#a7 za(pKWw7uDk0%Gzy6ez1GMFH2Yy$g1$1r`3RJ~qqJaCev>_0N z=G4&;HqW6SKy;2z%z-d&k68#&_n_|z2ofH=5h5f1ooWdCNuR?IJXL-JVKPX*gJ@j$ z1_gv$9Vn0;_$vxHWs!*V;z={H{Z^}?0o-0NH>!?L2(7tgl3h3_Spg>j7G!$^>OhN&}k+I5k zAYkhp5dcd&hYSmaFqRH{7ozS`Y%&D#ix>-$F{PIY!d}xo4}yR0`zeGe)^im^<6^D~ zLTGx60@?DvP{3jQ4F&wG9;1Ny?$0RD()$()X!0(jK;iKVGAq=}0+xc4DA4xJ z5fl)){mKm>P&R5e3b;0ILxK1^8&JR+RDlBRIVC8dJF)}?sygSPfP2WtC}1d^fdaNm zlTo0vOZr|2OPVIG9_ zL2WTad*7YwAawbgcR*B~Tzde*O-fHf7z{-hAZ+Uv-hk-5Ip+a{F);fNh`O1k|DbwF zLxyJ+RBuLy@f}!dAD9{j!I$XcAWW?ZDG-g`G2pAs9|Em#0Du%Oq^J-pk#^3 z3W}AOGciX=4HFyQutA9v6DJldFk;J~V+mr}V0aJlH|h5xs|eLp@8Y=5ej$( zw%{rVD3n$Tn73Of;Oj6`Ksj{>1uUz!P{3bbL;=-}0t#3ctfhe9?UyN_KJ_96Y?GE! zK=@qxVMJp|{SL8TO>RR(4?cVm(XM~!PsDM2<`CA4M@5d^jrF?4;df)|+!-2)NX`e( zLG;r`KZ>|={v(0(;7t-3{MSg}PP|G2+2)HRFrN8^1fEbA3FOP1BrxrBkigq#CxK$l zK@ylZd_n?W(?=kfsT{j2@I$P(sLE;JuPmj3>S8estTX;c1Hr4W(?EUr6&l!XucU$S zu`C*Bwq_I~_Opvkh-mWTdl2o4N9qxWb?#S)*f-0G=;qDnK%5(={)R|S-gTX?!QhF4 zJ$QL%jG2I;boVd`43`H<;Ewi@K(^LP0^?B+2|R*xB#z|fxmbs2~?-HlE6CY9T12F&%HUe8V_MvM*-W_H53p&xQYUr z^~)$=KmJS_A{w=18KPah@D;?dGob(xpO4#&=%zjRKH|)|zXp*Uy!RlY_uu^u;z|^? zBGS#m^N8V$;CI9w`VU_OvSoh-TCjcJ?*#DlT_S*dPA36O8_p8I+jNQmim~4jz^pn( z0AJ-H0w^!m5x_EI9|8Qiy9uB={2l?Ux8Egz;IT~vP;V_HfbDEP0fduZC4gqdOM;KE z-TDFnM80RIAli9p(THPXiX0K2Ok9ZQg5?>AGeh|I2cPodBMWUINIcUM7HP)p-JV>wg-%7Y}jc2LhNEoFIU&xRC(L zQw;>LO!|xfe)Y!$P+2}Cfc5J81Q0w}MgaBt5(3zcZzOG*_?p45#xp-0(hDR2p}K(2LVi~-w5EX{FMNTi$4>^Q@|f{ zgaWGd2Pj}YUP}SND9fn*xWQsG1#CM_6cC;_Qb04UhywPU0t$!@=21ZF&!K=L@kI)V zH$P7S-I=F%AkNU#YDBUu`7=bnZ^1Fd)iUkegz_#%N3J6c`pnxX0lmhmQ;&E08h<0zFfcElQ3OJ%)p@4YpN($(X zE~S7|knsW{Q7&GC=(j(%0daLaY(%6}=T;zwRkQXX?)u0t5ZR4z2Vz`s*BQi9JmFVF zerj|tVwyBe0k3*+)F-%srJn-kt5+%Di}6rEx!z3y%W)S4{DCbLP%UnvfOY3l3JA_0 zq=0(brxdW|?4^M4;4TVi{N)s|CvK&HX!F|?(4P4p1stL4C?H<82LFN$Z-LnqaQ0DYv45Q8diCE~7($wOopXS{_NXWY9D@#NlJg~$(2`~)%G z9&-foCJUMo#a2HB%x8TR@P++O0p*HI6tGy&jjMxzzrT$Fs(Gg5pN z5N@ugfac6T3fMzGq=0DI`xMab+eQIL-zEx(=WL{aZo_&CIGbLjfMo1y3g}hKDd4Ki zq=58d+E0jKhVl~P&QKT|-k`!ogAmrqc@7JZxo!nKDf zpgH;(1?+G-r8*zOIyXQU0=7vCwu(7bc} zRK(XAFcVQuyG;R0&J7CqZGTWe<^PQW*2D`G5NvLzfci`;1#F=wDIi?-H3c;LzNCP? z?{f->=G0O^yP=u_j;2Zqh{u{Jpi>#gAEZE~fdZ0?dJ5=g{BB6Z}X4q4IkQXtsYt0ei<03W%oK zD4<oz zzvMp(sLtG^fHkzA0)k~%DWKkWaeM;>`hKB+aE^-tnhj10*qa;_5RE-b0j=sF1ss*1 zP(Xa~BMRtd?4p1(x10i!!=)6^-!7(rEBSvEkZygQ0*14%P{19QO##`8ED9K{8DkFP z2K|deF_h1H{656A@sZhxw|Q?nrsg2Z%XhtjSfVFxLi~ASb|R{y z!xXRz{1gxNyH%R<%*UUf(kEOKiWxBuPERs{(=JHQ=d{m zH>rjKPIVOpB$k~N&|lq30awgB6p*felLCh0>nPw3%%y;A@hS=!cP^uVr}LSg5&5(w z-H0h?p%3xe68^(ODEx6jM<|f^;ABjFCHG4a<(Yfq5KE}|F~pyRf1`^JaGy|vSo;L8 zB7!;pP(Z!mFACV2ey4zNY&Qios!j^nE6-9ubnz4gv@^b=fFt)91;mFBQ9yUQZj2o_ zNZv;Q$=2Nz(4T#e0Q%QWV5`4Q0pX2a3TPHwrhvWpJOxCjexiVO(rF4f)F&t)wlq>eceQ~6&X~_AAX)!0 z1@y;1q<|~1f&$XTWkFw2U}p&h+?^XKAe;6E1&lebQNUwcO#!)oIR#9KY6^HuRBs}R zGs;rL9EzV1fDbTh{(eNcZ*~J>>5KXn@yAYcA*v0NyAW&B9e*H#vEy%Hy;>C@Jcb)o z4pBgOaexAv8Glf~p8FdGM2CN+fcEy!6mTTBQb4@5nF6}A$0^_p`;r2Z6`xZ;Z{1G; zSN|RgNas~jz_4*U1>DV>DIg0rP{5d>4`{^pDjfynm$eiyMX#WMH}5$LD2_h$1Y#B} z%0zrANvja$_W0KkOGj)G;tzjdJEB^3--n2`{+>ESaO2LShyIzaSB|$KmkWg2L;6I&rm>j{3HdOf!|O-viJxE^g9nwz|~nx0qHag z1q?Z63b<`13dsCM3K$cMDBvk6pn&{L9tBLHFH^vq^&$ln`<@qmjT`hmeHVtl*wpEW zazk<~Vrg2SK>WgomLMwC%q+xOIsIisaB+$rQO^i1L2S9f?;*m&qxK@2+y7F)o_v!6 zqOI2`pgr3|0Y}(H3W!(yLIIui$AE9Jz5ja(Nai^xpxA;RcDHQovoZmjbdg zyC`4`-AMsY)>aD0_q|O4Q{Vq6;Ei2J0mX(j6fiesQ@|%&MggVj85?4$eDZ6=-}U%u zL^UI!6S3yTc@e?kn48$HzC9!GTWn9h_YMq&Tkn=2nzIvQ5PO*LVMMe-un5sw{SYfUD|T3P>-1MFB(fAqu$j>L?&PT0;S&U^fIr zo|N~3zvF9Q`@0k{b-Y6XZ+IaE6sz(nV6M-lfNx+G1(XY3pn#?L*+RtMvScfwnw0VZ zVpS*BA_B|2!w7x=<9CQHM%so5*Gn!Un&aX>5qsdIA*>fI9y|5~Zm<(SAP65|=RXt> zPrFV5T~03roVIQXNc^1?&?n+2mEi*{`GEq`Gv86b5ZXuqcUA)hWc%tVVC>sR0Z;6Q z6p(Ltp8}?)Z4~edODLdFZ47M2_R94X@O8aP0p*O<6tLtjr+~j9lLD&SX==optb7R( zY*nm7)Mw?zh%Ick2@$S{+Jk7U5%q|@KkO?+H17^4qTM*I193Eu{0$KY4^cpuF+c%l zRbTK)3S7QS0e$qZ6maGJOabZ9(-bfWnknE;IZgrD_QMn~c6>$wPxyWc$XD&5fT_NM z0^Wf#3MdwArhvKlEeiNr-k^YTl8yovwUz?@YR%a1af7RB3Rq*FibMqKpO}NFk3ae- zVhfBm)4>*YE5^qz$S#pB{k~4o$ zKp%RA0RNu_q}Y+we67j7?usz#}|B0lBJ{0;bAp3V6FJDWI5P zqJTNqNC97ifdb0gdJ0&Q^C;ji&7pwm>ht;>`P(0FtAB6F@JxMgUhz z4*{gxy9i+D=pcYQ{6_-FR(($ZWBoS-@C+OodkPP+z(xR5v6TSc77GCslRhATS-pb* zzUnOmP+lz}fF))F0sQ%E37|UuG6AfCD+wT2yp#ayo$22ow$9X6L^$oS^N1$r;olLv z?V&+z7x`z7`T^S$Bk#n}Q4$`3h|h#Ri0DEmJc2m0MyDc@eg6?a-*=M$uGoG8NH<(1 zfT8Ik0o+120c0u{0gRPS0(iO{1dz`-N&r*tK>~OiJ|TeO_D6!#*q*$L0KU?40w~Xx z62KDnHUa#a{}DiCeVqW-{#OVfn3qie^~Nj$*qSrmMTEgm?m{#fk6RIY)gxabqRVqn zAlm4dvxp;ah6fQJoq7$?3GTj)hj6A$9M_76*gi&#p}u370IqO90i>&Z1TfTl3E&>^ z5J0xz9082QZ3OVNv=Bf(>01Jr)L#+6Tm1zA6jwhbfH|gy0KWVx0w|B~B!DIGT>|)1 z-x+lV+jqW60Bh$u0tlw%5?I5H5z(21w-IgVyd8)m z>%nS7yzlF3opMw0Znw~48)$7_7EaEnv#TQ1qv17NRhvQ zh_}nuAi56e2E-X2VMHXW!YUAb{iJ<}Yhdgbh;+e72Vy9`MFDrqbqdHP^-;j6zDxm6 z_4!e4xWUz*C}4^?O#yHI2?{8VH&VbHc$fmd)XylO-1#vDES(=xz#maT0aZ>J1+2Cb z3JClgDWFb#g95ga*C-%7vzh{$Pz?p_S!xQ1_Nhjk#r8hsL<}9VkA@@S4f7vBbWOA8 zBTiv-3L;TWdj`>0PF{()y6(tBq%+39g&1-Jwju6@AqvQD4^Y6E{09X*rN2=?e)d-i zn8MmA;MKHJKw)jBfVuzpxSuEx_az0C8$YLjrFlOE{6W9N~+8MZ~L;dJ$cHydUeG1F@rj#tjxc z5Q3q;_`Yd~tK}XUBApbHfEd(4ixGEq;PZ&=>TL=bWB#UqC;v|h$d6y40DhA)1-z*p z6j1CuLjiN=NecKPzM+6J=LiKXwgVLK57v%o$M!@E1*|1z3JA`aD4-7ALIGP=5e0<% z3MioI%cFoj_GJo)HoQmyZPW7*mWoJj-j~K)LqkuQ;0kNE;k}OKA{IO29CLb?Vi-(5pHbXIYa}cE_{;$BGozy zXe-x@KSzPCYzl~HETe!f_nEnfv*F2PL~{G_rx1N|!g9n_8kdVm&&K=@F@)W}1#xTc ztwdzjyFW&Z{Syx%o;YC>BHt+Z5ivFUDc}wIivo&_-zi|Ox-_~IH|Rb`0cG@A3Rv<^ zQNVBimI5llF$!2y4pBg`y^aFvjv5Ns!go_ZxavI$XzJgkfPLT{3WydIQb1drPXR|u zE(OGsUZQ|b{Q?D?)z6MRkL_2Nh%nU0q)bIz`H3?T>G65-h#^p>MBJ&;XA#*>Nj75a z6t6`*5t9svJZEefVzQ0cjd%zDp@1UsIt9!ny%g}Zc2htZdY%H7te+_0ulWH2GgW=x zjsF$@HE4}(q<~;U0|nGg^%SrPKc;|C^&tf`mG4u)-nESaq8TL=(B^KWfTLkO1;n>s zrGPGZH3gid%PAl^n@ItEnCb%J(kS~7sa5e0ZeZw_kGX&w#LW)HP_{AZKE&7@F&ps& zO-@AQ8FxI1n5xDtMZDc3a}Y)J5CzP60~GMt`zW9k{6+yw%C8jgm;FouRmW)xSi_qs zAXs&r0_yt16tE3^HnI!b7wo5irg#qp>@5`(5KY=n0j+v71sv6HQ9yk44GQRDbQEyr zYbhW(uAzWF@Hq;&QlI)1k?wrLju<*0J&Cv@;(tbDIkDY{(H8APJcHBz!$Zgu?+Lod z*FedglQH$Sj+Y{e(7-svoOPQ5zM2~pQ1<;n0ZZ%^3it~yP(anxP64a%3Qj(>8{5q(qXI5rd)dDfPT^r#T)nC zi|Cqn&qAC*?=L_k8QY#f^i`WO5m$HNDnuH+{&mEV_i7R1w!gF;kqMT6h!|5c>kv;_ z+EGN_k@7ua3Rkov-fa10L{TsM3o#E!hw%`;_=sTd1jQU-ao9bWT3RMWA%4-gd5B6q z@^Qpked}pNaP>L`)G>V&u;pK-fbjTv3TOgCL#9<1Eb3UVh z&h{|{oP!@yK$2J?{C$FAp0K2h1g_Q+5=cYeB7q_64HCF(UL%35Z#4;wu^JM13e+T! zH>pYxlkkc65O3O}dl5zDd>dl!n*BB6i;O;vD08QEB9?~9Uc^6i$4#tPC65p6ZITE} z1Ma|3aCV3U>aZImuxb7vfzbLJ2{iq`lE5C4W?5E-&lQk-!+9Ljq6s3KGccpCf^3 z;Hh-P8^34;q9{&!4KcUG7a~5cD?VU`zd*1j3zv3jZKM=M@szBf3Z+%IP41)^>&jj=_^85GQ^^0$s@w5;$8AkU$b@ zC4oN6LIPKfnFP{4{GEr7Fm?+G+yzA>kTn&Mz$jcx0#Dk@B#>9W82BgNWV)Uwfj2T; zf+%uRV-Ryg^23O4Xu%>xnf#C%v6Rky3Gug0Ux%o|!iy2BCe(xotigK_b^oY(#1{7- z34|MOl0egZjRf|ft0WL*TqJ?E>KDEW9Nj+#U;9WPjCN8$m*=2>({87LL~xJ-`jk&7 z;41rw0@98TC}0RLr+_=VlmfE)VhR`sHc-G5|2hTa#jjAn)UuKSUQreW6zYsW5p(t8 zA#C^cJT|r;Cy06EZVWB?b0ZOd9{U9WTm_#JK-#pI z00v; zU!HgnQFYGy2C+uSS`k5x^gN=rNq$FcgW^GK7bZ>`HGu6UWADV!-Z~-z5ry6&fHvzo z0UR~G1Q7Ri6F?Vxo&e5*p9mmn`hfs?;Ryn`(i#aMt!yBGp{t$%?#Pb`Aj|!b0LF&* z3E&ypMgVzoiQopdmu@70w{1NE6k)Fsz^qwK0AKBL0x0`431Ep+y@>b=l{!S#tk{TH zgXX`B2r_2xLey1JR>amF@g*XRo_qq)&`h>ku2 z=)!*^fHV770!ZqACV+n6Gyz=k%>wkESAm#Q5hB zbxG`M#MT;}j|fBWdmGVY-LnI+*W6i+i2BBVhG=60k0FkN+XN6d-5`KY_$L9JX;%m! zsk|T<#P+Us0=Od25I~xHk^qK=uL4y*h|NCd zAw(#cnS^LkrmGNp*_0O$QAfxcL>nHw0dZuHG9u#oe<`3F_?rUG_-ho96!%a--_k_^ zm*^J?NYy`5z)<}?1>8N~jQR&Rh_O?^m~W$ir_o9QdEiGBFr|J#0dM&Z3Me|aP{15f zOaY&E0|k_}wG^-nzDxms!b%FLN|sW<+M3>i2tpVCf~d0|yMowi9v;AUVc$a|Zee@u ztce)f3nIf2QB(K>h*o&le8iD9Aq5dvj(!Hwb^S*HXXGFSB)R<*&^KJAfNSU?1*FMt z3K&XV6mYjWDIg1LqJU9zlmedGgA|bWe?kFM+}?3R6BG-Cg}W%AXfCIKIcO^deCcmf zKw0%a3Rt>dr+_Lio2R$hvv_)e;F$_Uo$};9#8&qB7l^Rq5eK3Pk2{0dvtxcmMD;Uz z5$(WKKlVD}?;d@7gIc)KgZL% z8{2q#S>P$2-kAC=Pwy%Jil>)%e!NAmVd6H#o;I%v5mi3; z38L+~{|MrUlr$saT=7qcu0eDOaSjRl5lQlhfAJ9d(tpPNH$gmC*!CAsFAeME=?$80 zp59&C$fdbz2dI~72_EEsxy_*8QsP`$L%-co* zi+vLX{KJJ5P^GM=fVJ$^(Zjew$4eAYhcBmqEjyC}!uqrXL^F`G7_rAIo<~H*axJ25 zk*!A@qNsNeu{xq0(N%|85NFS%&k;$?xJE>uKk^jfYP>}OY2W|_45@t(nCULRJVG!6 zUww9-CxJ2IXA*d{r%52Uogjf}u#p7bgu^6Ilzc`4bL+<>@P+IlfikOt1eTgI68L*d zNT7;+iv-q!H%K68dW{5Xp_T-;Gz|%am1+>o)O4xFkHAydBcBMvRFwPZ{fM?<{#?W{ zG&>m)Cr3Yp=t`$8N1Sbwa}i0{o&Q7hn(T4P(^1h^j+5R~Ve8c-`piHTzfu*dH2LAT#G*E>b zX<*GZ&_GbHr-6DvM*~}Y4h@9GD`=o;d5#8l(bH~3q+Zm6XseTMV7;R!ek6XMk3bwV zM}(;^|ADE9v+=%}h$L`IJfcqxQ6jGLpl1xEpjalgt;%%K-2Id4eUeD(?FD*{xPC0O+AD-+LD_PaoEE@B09}O7Z7Ld z%sxcYKm8x9*T;pA8HM$(!q8AmrOmvQvk`ate>9L)-K2rB`x*^AQCDdo&$~zi zll>POc!z(afg;6819O>!2EKMX4V2*rX<*6zga-b)k7%G8_<#o1`0|mXv0hMIN&|IE zF%4{@|It9Gew_xI>Q`uB?^#I$QA`#MwD}op5l7=<1A^b=REFqMAK8sK%jbTINIGZP z5q-prli1|aPW_piq_(@dF*OWM@FDJmG5_HYWhKMo$KVf*t%F4Hg!qUc&+-z%RO2Cn zx7SSsMQj@p%mpn(@Hv`@pcH;Z1WVc%MDSO9N(5EcULsf{tB4@T-AM#>!&V~LhTbNE zF!@a&h%}|^#t89OVcQxSh{9IUK&x3s14r#MiHNxW$tMwA+`^@ZvoIkCku=A>f#`!C z+=RH&@85|?tM2^>F?8R30C7i&jw7->;SY$>E;xsHhW#{pO7mvlix})t zvk>=i!~#T?GWiL_SawGy;%OhZ3Xz8gypEW%hbZ8!8=!zL?(#X(^x^)KI{g@EipsB~LXV`qn4DMO-0CE<~CY--Q@zV*fzgz0tRD16k~S z!U?!R!990j=yBXR9gz!zVi8kXpaSt$+@^q{>jnkPk$+Oax8@23lnoasU>RzsfIsOB z1yrRcDPV2;ngW8bBNR|;4p6{WTT209e>DX(apr)D*j{L&fT-C>0c}tb1sv%G6cAVC zQ9#$7Ljh;hixiOLJx>9>{pljaHN0p$B27vD5HXZ3s6*WCbB-di@R{Eu#_Z|sh^KDK zWkfy@@)u%?4<3eqw`f$b2m*?he<@%V-K2mo^BM(|)jbrj^mI|cKkF9?sPccLfVJ^^ z3J3xn6i}zyDPSwNQ9#&frGO^lBMR8HA5cJK+d%>C;1&uv5{fAxF4-WQgd4Q3rGPW! z6$(hQR#HG;vy=j^-t;AiGkd;0|nWyGjAS*h2x8#!Ufht&0MJekTRgaZMDk z6&|I4u=yYbG(n$Iz@EOB0-~y26wr2;Q@|0ml>*|tw<(~r|BnLB;nyi3Nm)YyeOWdH zM6UL%;5#n~gyGNJgP|e&$tc8K_xLXH4-j+Dm|Da)OK=!b=KBd?Y4j1mAMiT?RH>H;U@bpK06}M)Fa#%vI7I-P_FDo7 zZC?>UGx!Ao>1-S%pIaU!s zDtv(ehO}qBh`VCRO>CESEes6B_Q=FLF!ZdMCqd*555^#-q5B_3yh)Nph@w=iM$B!Z zmk^(L>^elL8BvT_YX2dC|Jq*!P{s8Uz*^W%06}vn0n|Z15x|!I0|A6p-w{C5eT**w zdsIX4U3dwI^6Dv|weO>VV|X_O#3}DnKv%Yn0?zhL6p(~(q<}tqJq28KuTnrd@Dc?K z@yjXTF3O~UtR?L`#3)j>A)ZXdMMPdL{}VCw$cAtO@2se?cjE^65qD!~ZVZb=d;xdN zL6oWE9z`tWBhwK7xmy%aMGR2Ds_mnIz;>Ae>cR6AuqFIV0b$8$3TRqSP{1B?oC2b( z!xYfgd`1CB@5dAn$L~RG9IEuPXL1`j{xq>90JIyR}jG1^Be&@ zvz}gt$nzJyf|weU3J`C=g3X8`bUx%`0|#CPt#gNQO>$~TBb8`6sS4+Nb@RD*%P zBi4j}2_Pu>JMbQCZ~c=1wvZkI2(!8fpsDE~fW7w&0YtIi6F^(=4FMdEBLol&Z3NJz zSqb2*un<7fWhQ_=at8riYqk(T+E7FQ!%zVM+(~N*AS-=YFa_J&UL=4=yp#ZPO?ot9 zs!f$6-fPJV5k=g?8Hl;?p%)R~iJ3Y?85Fq@v80E;i}-hi?m|@E!B)f?HTp|LkoO+} z)b^VMunk`$fH37M0W@V731DykWo$SeBHTp)ZMKsDjyeYc!~=E$=;99&z*+PO0VFLS z5kN26MF3Z3IRT{Ar35ha6cfNb>wg50<-bk^i8Zt2-5oC>d8&TH`6TsFx zNC081j{uqiF9GZh4*^8Na|F<)wGqHk(Lw-mSCimgY>)hk0M0dE5J1xKDFO6DdkNr5 zsv>~2bSD7}ZCeT87QaIPndVIb7;D!Nz;kU40pxM32w*B)MgZ@LXHFoBpe1J!bNWIL z;@g#Q4N-Q--NrkhCF;R(61)TYb@z)gRoU;Ifmny{eh3kyh>{R>nNWq;+66Bl!tj46 zpvnG=0`|J!DIglSL;-DlCj}fuXDJ|VIYj}T=sOBHGmlX~QhkU5`kp!n%yiA#H!1?_ zrTM!_U}$`g1nz)sB#@yh;L-_9YT{54=DE#o)6$5OYFWHR9Wp z@)@FRO+1EJLgX!of2r&jL{%fbf>?Vc10WE@hK-m8f`UmCF|;|xh9g4Z$OjNj+AR{; zE3T73)YVG@ZRBMVIM$pefw>wHITq1{)_}t&Br7#)P6_; z_qF#)Ad4#_fw8b;+fhqkp5_os5CV`@RISI^BY7+Q#sx^qxuH1lF zh80G{zi@s9qAHuc53#mKeSrwVr#TRH_T)2&t?rIr5#hkNUPKcg;O9%QXlT@Q+@NKE z0$R}@6mVqzMgej4uN2Vr{7eDotX2w0@|!83Z#+%`SHPDPkfwf40YmwI3b@bhp@1x+ zk^)BUb_#e7Y^H#G@GT0M67&@CZqkj2#P(J#1- zAgY4+m59|5n}-O554?q_)9%}b*edR+LWEs+eu8KsgN`8fHG$2DsNpsRv_m&2;7IzD z0^-su6wtL@7&ij~PH_hXB$_i6(AS=%fa}`V6p+Rpp@51GOe zc9|$3?>17v6jek4udaXs3VR*}%)>bp@GX3i0?M-IDPU=Tdi4Fc!Iabx3{~05(-3Rj z0vRG0n3I60<7X~LY(>+bM}#d?w1`F&x*oA-2ET)dsz;S0+Ma(Y;Fxuj0^sM$aPbMIOT_-4OC0cF8T3RoOVDc~QI@e`s-TYL$zRy@{^2)Z8r7Xs?Y zx#OcCU|TaQ3{zo4X;rx(=+1+w%cdjJMsZ+&%axQp|)}2RKyV=oQa521@VZk+)n}LIUfZi5x-MFuf0S8 z*MV~skPfy{z>si?0`5)UQb5-F6$OkThbZ7#T1NqSO$`N1y;T(O&VG*qih_5?&xC;4 z@eT!iV+tvtOv|T$r6QLC{tK%ppo)Bf0@gLp<|2ZICI3UzLkqVcwxq;LL|8iSV?@*T z;332=mNp?GjpRo}TPwbRIIfBM5OLhte{cg`;fOJ_aDx;7P(TuNodWvwUJAH&byGmv z-AMsM)K3&}>wchs%>Eq(jKjw$;91x}0eM+H1x)SxDBzv)Aq5oK?^D2Bw~Ye6>zgQ` zjNeEBOVRp~G1%^Wl>#c!Y6@60ms3Dcok;<8Pnr_3%~C##2=f)$h^A4#7O@A+HXx$Z zs4_%b9r4AB2D*^;XDPVGZMgi}b{S;87?V*6VqJjdx3uP2gMsB8nWzAa@ z@E>}E0;-|cC}2&}Qb16up@6zgJw}Ebh@T3@P^fw0K15Ud=xoG(ZGIvmii>>`(H2H8 zMI0xl342IoV&*F#Q&LN*F#t5Opr_#cF+8qx>1n<58=C@bm5cmzmeEO z??v>(!Ltz8!qE#5Y1x0guc7@W@9Un@&-==!5TeyL=V2m&hH*gH|zV0!8-dC39<9&@4Uf$Pp!NdE?BhT@^rZsK6 zulG<3@2ePU;(g6YU*Wz%zAayTgecoSJ%CumHOCSE@~R&YRqf7mi1pgmtB4@(9SW!m z-wc?C?I+ezKp2!u0ZsZU3fOloqkyRUnI{l!)RIiZpkGp9Sg#GqybDvu(zNM_xF#hQ(e)-O5a(?95=2rU%R=-H z>C1>~OoSehriGOthKfn=A?^!f_ad^$kv7D*<`xM&hpv-AKGaJBQ_^J;c(csGpb8SG)5}O;+f_mWVfRK7 zXrkUAfnE0+2}JhQB+w2oCxK(3ngrr9)nACNT{(;!IHx=syZ|?lWY50`Q+?g+D8zL= zY91nupY}LnD4Lv(xSe;bKxCrvuOY_FfI`HxXNUyyo&ge=X8l0|@4DYeplJM+1m=Kt z68ILil0aGBOajZf<9reLC0`03#+!`xa|&1w?5BWWa1RC436&JEZQ4!&Ve4iJXhIAW zurJk9KvbinfVNjl0mtkW6c86YM**GVsjm^|m_?@%Nm^1TqOXYeBCZRuH~A8bd?4@< zY+rNV9T>U~-6KI{LwCj?#-yN!5zm&uMTorZHU&)LzbW8d{wD<#wO1%$zIK5EzPTL~ zP!^t{faSzV3i!u=LjhI#5eis$9iV`qyOsj#C<_H_I&*LW1cY`I1vJA(3fLDGQ9x8y zKml!g9t9jzUZ#LJ`$Y=q>Yk^7^ZL_U5lMXN2Z+8XxfXFb7aT^UqKCdi44E_A5ci(x z7ZF*{ls^&Utk5Ccz_TuRY$9&ZIO=W;O#%N=z`N)s1r+7iC}2Ln1w|1g$m-s6#%XfNkkV6cE;YKmkqf4hq<3mr_7fP#mbhcE<(^ zIL5qA0dd+Z6wplj(>)Zh>~d4Uf5}Aw zRa6TFthy!&2<%5GpdLO*0o%e)DIhG{O94&$E(+ME?4*Dwdn*OBb#GI^as7W35XY~h zfUal_1)R?8u}QdrXc-0cna@Nbu02oAL8Lv8KZ+P;C8Qzlb#coOS!2vAh%w;)0>rcE z-pz=-{OkW7mXONJt#*!bEOLrd7&f& zF`W>yEf`h3{3|jA`tJ* zum=%E!X1ww=1t>L5#Q;N&mqc?Ap%&I4iLb60dz^53E{$n;}~dFhlE#J4x( z7ev_`bOo`@9yNgNe*M3KMcD56n*i1^*9ag;>mh);qKg2w3mpUyM*c_u&6@8CU_bN? z0YpPb2%t@}5x}v0deCe6wn3irGRtME(%D>%PF8gS4shwPo~i=I%Vi zzhvTDh^k`DHpF^im;!=GKLylld=#)9@=`!J@TiPffYHOi@R{ZU_B@l2d z|B3?Q+Ak=eyY?vsoO5d^AStY(fd0fz3b@9dQMZZ$ zo+HaBARm6_3}RZiXixCt2IeXAMyKHhD<2HOP+52XG{kcKUK!$_FHS&I zMWV%s)hT=)5r{@;5q0K26tM02ivq%)-zlJ()lC8Wx=sp+8qZQd8}I`K9E-lAfVlh^ z1$5^QQNSswAECl_?LG?V5A3FZ>*jkDkS1)SfML@n3b;=fQa~26o&v_DuTsFX_azF* zdtab{X?A86;?<{pf+!p*M-cNEMKj`CBL4|dR>&?PmJ8B;#6LaaUkIqygpGd+0@g#5 z!Y~yKjlCaHCykto*tXoFfUxa41vKJ53fPxlrhusSJO#AZexiV5?r92$3r|o$ccPI3 z&hdvSAW8p>0{UGaQ^0lULkdWvDkxykm5qKHH#ky40om|I3K$o@K>^RU*C-%wUrhni z6b%KuE7cTG)T!1Z=IhFL5a0Yq%MoSKd<$Z6&i)+nPmFFvRGHIGA=W*U-H4#)jvhok zYy1stx2+2pnU3v^Lln>i+@OGc(H|5LmH$Qo?YUnm;E=RaK&)-0fbKvu1)MjJQ$UjN zB?a`GKBs`|^nMCRL#ioYSXxN|_ulOkko9h+fN{2g0v^4d0&<6LdYg*A-B}bUlv(-uW+6KvDD}1KweQy0n>#I6!1=eodSwAuTa2zXe9-Fx0X^s znUrw>v20n~hxlEO{ev5*#E*@Q3WhKiC7mU{EP_7P6i|v^qJU-j3l#A0 zfA$STb#2Ke#5y-+Cn6|J{0LE>n0ElNjh7uqgz3^B5X~;hImCWRd=(KzP5K+#wYsqZ zS=fGL#3T&G!~ak~x9~azoZEUSAZhQWfPTt(3b` zbD0#dN>ooH0xiDn1Rvl5#jA+zru~CmnZG#=}lYq@bq3+1y8RKZ|CXF%Qy4%zWs0U^vY{*@bs3sI-cJDrk1ByozMg< z$M*5h@$`c9r|w16yPlYZ*e*S~01-yTKY?g;v6+bdNc1X1G(7EfM7!{wBE+%n&h3b} zef)=rZc1Ps;#_%~rL4>QJfmlR4d%6N~=%*|} z#Ey_GL^me*WyHB;lpc{({7V7-g}*7_ntqJ}(ltF4FdXWlfcsVl1!PG-Qoy+7dkT15 z-%vm~iDPY)hl>+W77bzf{<)(mfor?mVV@?Xl z1DYscT6B~G-W>-ipg8vl1raYUD*jFxDfr#oBzJ_S8Cln%%`SZ3S;-UvXKy=RgYZ2!}$zep2 zDgF-8?-8{jt}DWeh;-J7KM}*ae<3V3#OQb2z0ECo!GA1L5m z{T&4q2aZv|eDe?me2>&qK)Goj1uUm`Q^0@c`xHC&E$KQ$k`z&l=(mKK z5SMGx9z-f0SC1H$kNgU8@4rO>*|qBwFwX6xfalH26p)`dPXW{TpM!HK@bqa4D0ZEo zfca7*1$@%O6j16uqk!ee#}x4Y_aOyT3o9sK-Bv~cL3;@W)KlJ~fNkX)6cEM>)7OF zh&15NSN>1Yy@$78sBs*RstPS)Wzs{5>%yvwx&Y}59A!RhwB`&M;`~CHOp7%WGyy^Q%dOV&)5W|SzMTk4Q z{whRv;~fQzk{1;4B>hVP`N4lEV0v|v0^aEB6i}?ZL;>@~QVRG&e+x{;_DM%6VA*+q z0{-d(3aEPIQ^2avrGUV(fdcA=Mhe(Q8z>;$tfzqHmW~4UPO~T=nmv^Q+QVN_!0~ph z6A=#?QGw`I5BUdiy81uIc8R#BU^cc-`Lrp9u7Zwj5b4v_-4H|X7IMU$9x@D(oedn1 z7=!;KfG7SX0pwes5WsZjJ^{SaDgr2U6$CIJyG#II-5&%{4l5>rW!*6X_{$FxK-I=d z0PA#1qZB-deP#lvpPL9^>$jQ!!sW{epm8oDfW6sV0*EGjO91WmX#{ZGpGW|4_i;Ih zZhqV@#CbC2S42|(^I}9lqSqzFl^yXnBE8Y68Zk)1Ut_yFsbz!ju>D|@Pz;T)@OCKt ze9?6Tkgt420Mo^%1n`DFB!FVlZ338g{zU*^^%VjrdpHST(K`s>FSZjv)$msWSV!+A zfMD|u0;q3oA%LyZMgjKwh~&no zXhbg=7KgZ!27ZM|5B5ny46h>75O?$^KOnM|?XnT$#a8);G=%`V-ro?wnLe2SlCuf#@gVfUV;awE zD()wYj}>Do-8!fXVz|?{7vh%6h9EMXRD~FiNhTwnI`M2oKCJm-#I&xF5%HGS+l(mM zyrqD7`g02S3LjBG`TQ;gEdBnbfZy;Z1ys%p6tFfs+h9I!FyRyh)Z2?FV7tGc0>bV; zQ$RC+8wKnqb0{FHzn%ix5t$TlWG|zD_{IVX=p=I};7m%QfaKs53g}-=pnxk{^%o*t zsl1OEE-GrV-5uJmUK+Mf>d_2C&{YSBgx47|_h@xTr>49_Ju)e)W0l|<`3aD3~rhv_LgaShG0SH8BrW7<@ zfIo-X3${~0^mH=?w7oY_z>&U+0^+mZQ$QE2r+_nFM*+!JEd}&9PHwkpj9`1`0T%7g0d6GL-`Qi?b-;3Y|s)>7{eV2gQ`~beB5xRR3^9%FREl_W+Wd(qZne0Bm^(G8L43&#{)2$>@PCaL zLBR6%B?bJ0pHe`z`T+&3t||%$#D7shJ>?1oYz5~iAbeU(0Znf^1?=fI3W&~HDWDDB zK>BW>Y|SXEgLk-vNj_} zBF5=oOhi0|pKB5M^Ii)OQ@`#j5U-*0MnvHZw;<+bA0I$`2~AHS%I!ht5zGBL3iu;l zQ$RJph62`;59=+#_WB+Qs7JUdV9Rz*vVdnw?E-bn%R z%B>X8UED|kXXqLVNGAP20sYP;6mV6~qky!>Yzi3kGbrFLPK1C+*6_>VbpAPP^r$dQ zJvl=^LFBihdn2Y!ePR%Aa^zS)P(XX}Bn2FyMHCQE+8_Kq1$OSHfV281 z2#6fjrAsoFON2ds<9T)Zqdc#(_yErAp4AbNub$ciF}c1Pgm_!Uk3tkvModD?1w&>bz9;<`BFf%9 zS0a}5Pd6d{GaYvzs^B&U5o>&l(}-Ye$OS}wC-4Shlm5r^3Ux1eUd^#50V}Y*?mo{e z8dk;gYS&fpypHnAJg>OTIi6QHy_n~979QhyCC?A?y!w7t1L87R)+17<`6tBCEV~eK zC#<$3vhBrxpxuy{BM1T>EyIJJP2>Vq>zA0&Kw5?B)9Ao(0BTk0@?tEfjDM z-bex2>MRNvT^SVcv|2&|`ILDSFclSG^IKx}nQXClI34d)@6bss2TFMmw|Q5zox zw9~y5a1?qdAb#$qfUe(F3OEgA6p%PeD4=h4f DLm@^AY%ipM;r>nvxFhl?Ae+C5 z0>+bTDBuZLK>_)QbPAZV=TpGzo=pLTWF`g7NncaIci_t+MEPpe?}#OOm<#c*7+8g< zF828sv4(#37V8C*J`t{ND(@@Y+3sUZZPl$hA;KP_C`6+dDiC|IU^F6X_=W`9(a%WW z$f+iQ_|_c~=sH!Bz?poF1d_w&NuYoGI|*EaPm(~o`Y;I$u6-nMx7tkt*_5B^XW@5D z!H*>HJXuQudGD1ZFr_agf%i-r2^7KKk-!}P4GDZ(CX+yUCt*2ak&fAb_~*oKLsZ8G z?L(|}eUBr8VX||GdY$w-Vk?*2LxgR@o*|m)%|GBl*b5sqSwn*7^;%=9?e~@h4#RU2 zh@Fo}plf!I1kQxNNg&z&CkgcTFOa|$ah3$q`KL%=I9Wsjcfc|jGQqyP6CV$b>LA|krgxDwHJs_#V{$?qT#AwFDNe;xji zp?mw71kS<#kU+BfCJFSeauT>&T_S;WN+}5p1*b{iesY8avfc+sU`#I{f#=M263Byd zNnncKKmzZURU}Z{`JM!3sh$MBIXV(3k7-F@snaxBPlBOekwCR>Y*)luKD;j?XftFe zqMqJ=9AYbsnu-XYcbkJ~`gL52*bS}MAR=ez7DU@DWDnv<2t0y_x4$QW?*0oBI3u2r zKr;V63G^p#k-!yDA>06h5tm6|$o_)_Zuf5_kV%e_z?gK91fBzXNFaZePXbf4nFQVy zCK4zv8cARdT}}eu#6=`f?o1_trFs?#{IY2zQ0XU3RTPl+{)+;J^eYr_pE*YXS+Iiw#&~;^Z2ltHVxxfkj+Fu?=?)5b=WL;X;#f8X z%yp|N;2WAj0p+^I6tI-frGUS6G6huAr&GXMIEey+=i|;G>V6}yA~wSpw-KT9^CyU= zS?_mPZ%^pnXd`a0y>lxJwfDn2BaVoVdm`fbO$Q^olR;w;XTS#vNJhM-fIhp10xtJM z3P>d$3K){y6mTDKQ9$;}NdaSY2?abWj#EH>@mC6%LJKM2owze_6SnW%N&$2AMhf_3 zYbc=9|3Cpt@e&I7gXU8}HF`D$tT{6%Ah?xC0d=P@O^7Xd)OJL8c<6pa^EUbfVjtYc ziHKH5Rv=nemwymPt9H+^T|A|g;74pPXx0=%=aYtQ5J_)AH$0AU}430H(S_1n>^sM*zjTT?8`Y| zrEo=~96X3;=>$;qOCx~QkU{`~b0z`Q%_bATmXPo@BHTVY8PVJywg|CD3|fVV=J(A( zv?o8?g*XDFzaruhl43-cExv>}-J-t{iKJ0AqED*#8rxk5-UM#O_E*mcV2FN10QZW! z1dv^UzafBa@Du_FS5F{-#x+Kc*jp)wA)+aY@rbrSJ`Hg^ zk?9a|@2<-bU3&Yqi1SR?Rzwo~krmO$H$IBEwgj9-q<7vDz#y$9fP2nk0?3ZtBY?5) z??z@kh@s^KkgvN)08{x{0(e`WCV*o45dxSCe<6VH+0O(}_S;SXi(xYX{O8saK-Fv& z0jvq%6F{(iApz9)=Mcaap(TKDzJ>srlWGFk1I9Mkg6$)QhhnJB9^4*rxchyEh$T_c zh%TvH9O69C;VVS)>VHXyJ~}iFajgjc0g+y;pN$wo-xI(+@dW{7JO3qsvHBkZcx1N- zAlF|ffT{Qr0lYzf2)1JT=-&un&N)f|-^~LAPS|kz9

Ha?L8jxFP7BjP(F7b809P$S};6TKOc9P7Os(bsi9 zgt&%waUjxlZ7(B+@{eyK?$*s7A+qTW{kVa#@IwQBG553A6p;7xQNU#IQowu8Ljgsz z8x$}nT%~|-TNwqE_e&^Xi8w(4|GYyKP@OEKfHhzj1q37VD4@>XL;;(74F!af6%^1U zrBlFuV17M*G54$46wpS`q<~|^*Ax(6{IVaS3mqMcI42JK5|Qj2I33Yf_en)uvd_Lp zr20?RA%^01d5Akmycdy;79B&3Il@xJb5rmqBJcEu0;c3=6!0FZ=4(Ll_D*1#D-2q<}DZ9R)P;D=A># zvXla%J82ZqO24CkW6n1e5FeXN0bN}}5Wksw=$IB5O4h}8K=kE|s7E*ps$ z3Z)Yf_cMtWk@X8(fEW$US0J8qjW#0kW&sw&l<<}U-fhn*pt%2t0_KQ&6!6Xan*z#{ ze^S5_aIqf0nS1zI3aGMAQNZdhqJTj13kB3kKU2VVU>gO5uW~4$iC#|u`-)5oh%PRp zfHrg?1soISP(Zvhi2}OnDHL$Z)D)2DRd*15v9bnn1r7cW4?;S+U+`}Jg-1@0FihPy zyMBVmI(6uc7?b}OgLn>oG!~J+Z9D}r4X&Spc#ZEUpm5bvz})Iz3iu}fLjh&MO$u0^ zlvBXp>k7^8~o;ghcLGV!usN)X={frxIDWHJx&UOlDq`4HZ&)Gl$(Xmw&(AIrV z0mo211;p!g6wsAxDd229l>(CKUr|6`IQ9VIdN%wNBJDTiJYq2PcO&j|Q4bJVvrk`O zyD_0-0Kb`gTWb-9^82A}5mQ7+1mc|+*dI}xd`|&$z)K4FhCiWzGW$LSEbdzr@VBd= zfGX)S1*`}Dpn%}jZxm2RAESV6#X$-PFYcj$Ce%U!`$RJZL_1BvdnizCq<}-VoC0F~ zA`0k=Qz_sK`j!Hc(bFiP&zVR8*Uk7Ph_qAOYQ&HnlZ&_y4fq+6z3q7zF%FI>K|IDz zR}i_Y%`L>#s^w$EJGsdltXC8?XlTU^p8Q7vU$0jbP^LenfaS~s3iun}rhqE`FA7+< zT%myA&N&LGr49<%=GZA9JZ7VSrp`(M`_LT}5Utxn0d09U1stujC?KAmK>=Oi;()!_ z{%kGj>ppf^mXfa}~O3P_ucS0aXlkqL-<+ZQts+5OMwBF2c`2E;S3`+7uvvhz=f zDWGj3;vN369Z_UA{R1((gUS(KyAKplCcUPB3RqXTDImD$ zqJTQIi~_caB@__uJWc^k^{*7L%L*wV((j~zwsuV6WlId*#!(#+nQLed#Mo-!AjC7d&nQG*5IG4kJ?SzF z@%C!B5K*MJT8Ws?G~0yu8Vh$I%6P#+#InUt0l&vb0hP3x0@gWqC?Gg?g97Ths{#A5 zedu`#2-p2i0ZsV{3fNm8rhsVrJ_=|HcTvFcERO=>em_z`XIM)C=eZRWkThFL0ewOm z1zg)wC?LH*lLCf_$rNzUOSp^3PLB2=#(>y5JP6P5K_S2JHIUu+f0&xwpGgsKJ82(8 zktF#7F&_}eBfgg+4Wf*0^c{jXpr#}K3vVc(3Vlui>%>PC5bV550d;jH1#GfEDInBe zpn#_M3tCrZe(~h_`W%m)Nd|?^^#Lwr^?Q z97CTctR141e$*YY%xOFT@gEHsj;QM1QouU2mI8uxk13!ozefRE>zfo1PA{i`rtl&K z?9a|pK-BLv1+<1E6mXpTg#zMc1r*RFY^Q*8+vX4(1@5n>fIeas1zhvKr-1b2LJAlH zbQEw8*HS>1t)YO?t)_sd-PkNdo;2Kym<|jsK)f&e6(Ne~sNWIuif%5%ccDWSq6}^Q zFJhS(`WEZ`JA#G3;s(|AKgQ51drtv@{sjfp#s5;k7IdEi!qK-Vpvk#T0sGBM6cBa# zg96&*-zeZXbd&<(w+ASo8@z`CPGdd=B(7Wv=v$d6;F@ftfV99+{}8r6(Nn{LSmP4Ytu*bm*NfavXC^^aitU>5}(MkfWtE(Zm4 zt&UT`Ir&!#NDB5+K>uV11zf$hQb3x%kphM@Srl+L{(%Cr_$3rDZka~`k0+S|a_I~T znC2u>znv_QY!+}i{aKBtb0a^443K&&+x0gox*7=Die;_SaEpmYC80cX356p$pHrGWmxDGIn= z7EwSN{R;&QD}JVc`@%K~$U-+$z&LR|1w1=415aXm^)d>WWD6+n1eJF z@QqSaK$)ZZ2C>{!&PV(m2WKFvCMDVujendUE!wJM@{GSsMx<0BvG_8XF zLF|+3KSx9b?lBd0U!s71ODP3hp3@YNN{>>& zFy{aT+(!#2AgkL>0prkI3V7CTpn$x56$MPK4HWQB(^EiEsH1@SnYPhs+(15+0!qVI z6tJ8d+Y|9O9WfYDB@7vZShw~68WG%&N=DQXpDsdd^E$3VgeP0)Aew*{yAb>EkY5o| zc3?50b-$;8quomih?AaBKzHCi1)MK$1@fD@qbn$&UvZfNt_y!qKpI+10mH;&6mai2 zNC8>(9ts#`77BP4nkgVJHc`M7w3-6mQOhZy$XP@I^UYKW_&R<|0cG+u3Rn(Jgn-EZ zCSJgA<{li^6ho^qrVS!+4d{lbTlJD7w#gC05Me>5@rdS0n`wx>S4$ltN^i0Z(Vl6r z7I8GLBY-&m6#;Zxo)W<6c|ZV(^fm$XbN(WL>*y5%NbAlKz%bO&$bkp3)=mIfxs3qE z)_V!ynYM!f^1>|yFg?pAfLESH0EHog0OoUx3E*owj{wSqWCB>WO(%f=pGgEzMU20M zSm%xW8xfrRq8d>LM8C#%+wk5E_{H4W-9s_dxI4E;?CsiqhKQ0rjz+Wxn#LiHmqA}4 z;^+?q(5-k)0Oy4o0!Tu=1kg|P5Wuy=O#o@NivR{$83Ei2O9&t;eNMimP9 z#oReN31GUpl>pw3n+TvtUPA!$p&tm~d$WW9%E9vqU@^`nfd9%20;pPjO#tiUFSjCs zf>BmP{bcA-#MW!zSwxuL=Nh6p6X`+hjX!ych~nG5$Ai#rY1Q})9)zcv7(=mC*agwe z5%fZwNBsnl)cFXYA6iWS*V;P-ke1&dfT8s@0=TE0CxEQ*cLErnogjcmewYAq!#)C- z&g~+Ax9Lv=P$c|F0Q0uB4fws>|EwT@GGZwKEc4O`;6IT<09C*@1h5XDOaMW4LK32O zk4{5u?P7mGgh_+45zT?V`H21HXTKn#Xz58ryFyZiI4+3)Ld2oX?<2a2jcT#oxuaga zQf#k&Ljb+(IRRV?9}z%We3t-*puY*=9`z>yWH}cIV7z&T0G^Jg2p~@`B7o`8egb&k z>?VL>@HPUNjX4DHU0Fu}WvfgASSBwcfdA(O1W-Nst}(usyPvRE5(Nb5Qz)Q5Gl2rO z#;PHRFkY!bG+Pvt5xYk|8xcu+EJn0*x*8G3(e|4Wab4JML^t%KLx^*2V+SHB54eoz zTfd`#Yg#P@q=kqj#5C;>L3NollM@- z_j5i4luvRgVCiL|fPaaR0;)3x3RoL2qJSVil>+K5vnXKmOr?NOI*|gJIq^3U`_U1P z5K-L_Kh|r94hTAr8?5cw0z+~6ryUSo>rOJnIjzk=L{ivdB%*(YPp;tylsC{KQp0}~ zFr0fy0e91<6p$r6pn!2(6$L#1R8T-3afJe=dFLqLJyA>nMSz_G=Ha$_7qH!ArGV0H zp@5~`77F;k$)`C?Kp$rhsPX3<}uSCQ?9DK7IkBZ9QrQ z;+QscBO)%0wjjD^y$>KxdE_blm5-xhQNQm3@Ts4+b9qYL%x`&0=l*FtrR2p#o>D(x z98c-Wh~p`x=VN$EL-WshO1HWfPbsrR@RY`fop?%5WH?VLU(k}LG@WjOQ$~0LgM#N^ zlVW5YpUmb>ulQv4RX*jDS=r$sPidKTo2T^K{^BWB{wqACb&!*%6s&UalIx zSDsS%^?X(q#P@w+!*t zG(C?f`ZjbU=4BuFZ1$DD#*~C{b73(eIy?i?R^E`YytM0k^bhdW8%BQnnZW#^K z$4h8n6P%!dP^q1U~O4R1HsqVg63hpdiQx6 z*dG5*17XjTG|((QOapt#J{pJ`?WTcN^%D&oxj)iCe0wbobX```z?rg?29hIbG|<0K zp@A#r8yZO0Os0Y1TEYXw9X93#*2|{G2F%BLqjiu7Q%_Cbwurp1ECMktllDivr4l8g zXcCryn8!Dtf%x(o%|(>=>KPDAx3@I#r#`2F>i8oXSOxcJAW;5I1NDYKX<)0kKm%dh zvoz4mJVgWh{-WSCJPOf^{WQ=H_?ZTdjBPX!pUu(74P23P zXdqpXL<7U=DKu~gs%an_sVYH?o0L}&Po?4(BJa@eF=Cq4;|+eLcx_!9F2Ju8fBTjg zng{)_BjU^as0X6F+;|XTX<2_1;!k`>0@dzX5?CKUCV`;mKO|5uzDWXGNjV9GjV_Tu zqbenVJ@+&TM7NKSK-=X22^=W}AP|X3_E13gFrNa>NHYZ_3rrNypEgp!6}Y_NBHUo)A_^Eb zrBc9MIg0|a4$~-LoHdaG9$Wk*MD8Ck3o#9fS%`Qu2dqRCmwRqP%q=5!Ail&-2NC7& zHm4EG;}#bXe^iqjh-z_zhlsW0KMDvMy$ayF%T-S)V9R|#0paZ`3TV3gMFD%t6$*%s zoTGsDeK7?bF?I@w*VrhayJn?;Gi(P1BvZFgKyS^afU9OT1*Cm5C}3E&m;&z7xfGB! zNv42t{Pd6|5b)$pqJaF~I4NT4HnI=mP5t5vL~;D{c*HE|twDUT-M>SW8#<>WmWuE! z#2@~#8Bxt_T7X#h2NfZL7j+a+4|q)hTSg59gy$bpK-1hq0lV7WFda9rxG11~=%j!n zvV;QS1;;6%JN+vKoPmWDkc`|(0sW?}6mV5;q=2-;8VVR@{XhY?Z3zWr{&^HI4w_8? zPv#5?$S)^Sz|``~m)P!2995t1F5f-0IfmxP(d`glRG;pMa&hDU#8T2_IN}$!n}DcP zt!5(D+-CC-!R?025p@^A2E>-)r-1N?j{=(aUJBS_?odFq<^~0{*RE2)5q6#e;;Fw= zKxaJ>vJ3*wnnM(j^xa1R{jyyYaFym!K-%O-3K+((rGPtc1qEdH(kWol?7${}TlagK{X~&Rj^h?A()Aui)c>Qe}(P#!1wik!1j?ZD4^Z+F9jTx|4=~O;T8pSv#wLXX}d%LiNBNr`a!=@ zz?FHF0@BL|C}3#0hXU@zd(;LN%+&3J92U zr&7RI^%Vt_UE=#9mXr}g5r5H;afs@D|EY*Irso_)u;$aHi27Q`HHa;&%@#yBwZ$Gp zV+}ci*lPmMAfmqiQ9!%wB?TO%PbeU6@_+)m@m0c=xItb81tj+_Q$XMC90gpd#T1Yp zKSlwAz(xUgtd#!rk3*&du5Xh zMARY3glK2gQNUq)MFFw@DFt+c9#X)Wd7A=~%YRWo-_k_^SE7>w(%p_Gs~})_Y^Q)b z>Q@TL7Vo8iv1A7YJi@IMkgGOQz?7Rs0dG|X1r%MDP{5orj{?4;WC|$XPp5z-CXoXE ztnpV7)wPkg5o_4cCx~Eb^gG-@ZSCF2h#S;&Z-t?-Zwh`sdV!HB3yvoVNv ze8aC1N8Seti0{3ofUcX50?t$~1tiBk6wnK9P{0*?l>*WYWfU+}lu*DOeu4tBnTIG~ z++P^D8ry4kQb0Z+j{>HQO%(8!t)YOT`3ee{)#()Q<SR!Xqz@PRt1yrZM z+>TfSNAE`jBZr+p)SCu65nE-S3Pjl9vwslHtWTa}yWQ4Kz)t}9TQ$W{J4n<9abya+ zA>zvdIihR%h5*jQX9SS!t|ox~@f`xVqACd>U3`rIhLZCHa0|~6K&CoL0Auc90(h$S z5kTH$Hvvp3KM}xN^dkWj@7Fe3g9j0_k^sJ}r36r3OCx|K>^lPZHQx|GWt~g_YfVBj zBIrA25u#oey9%+D4$47glob&`-uxE= znAATLz?;8~0E&k>1TaUgCx9<4lK{%o%LrfzTqszF?QwGmpxTr~0BhwG0th;&380>( z8iv?x%JGQMub75t2KCb+_RJp35YgqXYY}bB4qFjN;{U9Oc=ty~5#8g)XAx&q{cDJ1 z@jC+OOKJ(=68=j7sp_9b>+v9RZxX;=RZaj|mrDdNrj!!EQ*@dD^7lswV2U|F0B=?S z0TkD^6TlpnO8}o{0|AuQRRplqd`|#>A3Xt7%X9>=mTCzgXfl-m>hWJS;M@Q6#)e`j zyf?f(qUko|GsK?SKN=AokBURIf=|Cf9I+jf5b=iAX^5^O^asQl9+HhnW(MXX`u*<- z;HrH=0O^1y1TbXWCxE-`76D|qMI0+=F?5x|>vkN}F)dkA0- zv=G1-XC{DhlZgP9N+SXM?UxfkHER(8thQ7F2>i1MpdK`h0JhAD1Q1@1_aK^rivp_g85FSQEvA6r-dqZ(yCqY=mO7mR!sC-Dpb?D!1+mADJc)=l zd{KsID?a}VafJ82kBDb>uf=xV{?7II_W#=OW*ABad>oGGGn#&ixXOb1A=2g_C}2>( zrhq%Yh61vO4=G@b^iaT)=B9xBw2K0!z%mMW<4P!?*mRr%=E`3w;A>w<0p+Zn6tLK~ zQo#RuW8)topc=G>0@lnQC?L4JgaYc8^C@6UoJ|4Y?im!&JWiy5J?hKFh-mRBBcd%C zx*2f@2ku71sy>GhU2dcUaaMJ?j7Yk)yNT#iT0KHsMa}%Uf%JXDAin=UM$iI7cb1<5 zvTHsH7{jV5;L+TnfZTe60;Za)6!7*rPXWcU-zi`&JwX9q$YBa7$M2(nC2to6{CD#x zpz8J`1+1xSDIhq$f&yy6(t3RVe{32Bgd0*QpsAQi0ekpl3W#PV#3I`LqrXHPwZo<( z;sJwF5nV>#?-6I&XX_A2b7>x;S4;LHu6*$^MEX!viWnjr{fW5K>fJ$Pr{7S(82Fq5 zp14Pib0Hw#be96A%1R1&+y6-c#jFbyFx$>h!1wwj1(bt|C}7FlPXYg>-4sx@+(rRw zVh#layVp@b{dgq>Y*EW7AY8nF0-BQVC}0;RQ9z`c62uPxwg%CuhyMowXa3;etq_nr z>=%ZqJ~HYP#Ff^qHzGaVAqFu7wjPVP<3gt(vQ5D$h_SN%62#N~Jq6^mUQobf`LUVG?fD4_g_&!7ZZO9ab}G;k4UZ!aU=S$0S^$D zrsoU%@KWoi0YBk~H`H_#Ve0PFrY#~{)*=EimWK34JRuE~h}$4MYi{YnCL?p_kus&X(h*?4bN7g(Fh_5A6Ko>TH0!~dL1tiw-35dRCSI{@Gj!Df1rRu;HQ8&))#DnfN#B*0?G;x z1uWq=DBz!Ql>(~$WfZX1mQX-2-~chLVZm35O#5o4;X z58|;)zd+=I_VI`*HcW$f*EjzTQB*WaN6g^?S%`1OTM8)mKc|4D_7MgA{qIpgmGL(P ztYv>vK+yam1=Q-Z6tLx=YPcIWcvwUMP2?{Wu&4b@0nzDg6wn54rhp@EJq5&@GAW>| zTt)$B`-K#c%$h?1y)B6XuGdp2ARVNpfFVDMM7Exq$*CXb#&Y6f$ z)b>Y2ss4B;V##l6L;MedenV7|A1Gi=drbks=^6^C1HBZm#d+%U69Ajs6wp+FjWn0jj6Zuz^;fQrB7ePTogGJ@xALZ4pGLm zn~GSnTFpWHSDP(GRAIt3h*cxlf(WdB3aD#*6tMNFrhstS9SUelZ&1J$14pTsvx{m@*`z{Jd1V2$gANwN(TosDzNzo#2VK~jR-c?`vy^0zM+7v{c{QkXFZ~T#&(wi_ScmZ5Dog10@}tYd-}fk9RjY2myW6HVU{FboOHe%6qeS-M+w10;isA|F* z*>HnCAGN|zu&i-sL|q!t6S0N7qkwRHEd?}rk11fkdyfL5ZZ|2QO)aN@!+wzhVnHbd zbg`!?;9P%%0+NbfD4-87pnz+}b_z)MZ>E5uc6}f}0nmRH1!NiDQ@~iZkOCf&jskME zmI9`H4F$Xp)D%!ej-8K~(}rgtzEguuh%&JMcEl1FwIA_s>~;cCRd#S9*7mI{5W%d_ ze-O1T_&K)QUe_1!699wWH^ESo`GNrUOaBr;)bc(7w28L};MjGY0OH4&2%wAlg8jBMu+hrvSty$ zdvz)S6k!tyVAjMBMtpllj6sw&L%v2VeFh{W{-r$^A*#|(S0UDrPC1BRe4AZ}ImhB6BQ+@dW6kg2l?V9Z|)0J-PE@)JdPV#^~JJ5iayls3151MjJC ziJ%Ca4ulAE+$6yfOnn>2HN{j}8P^7}wEvi3eGVuC`3qdosEw2 zRaoU9gRAp#GDuT?C4-@8FB#nLc9209vy}|Stc_&wT+JebJnRQDm^4es;N3Hi42qg$ zGMM|!AcJpdA{msWXyDzLP6I{dd>WYB zr_jKsok;_w?Q0rXUVmvt`~ycHMO2x?&LY-J1Fs>1mVG^lI`Oloh;7#=@9`*vkJ~jq zjz^)15{oglFA{Y@L?yyrh*nr{2;vy?h6du?XEe}NRnx%P`7R719G!zNpTd7%oKpWL z|HGnpys-0KEiNpQ#QfXfH2xK+tbcf5*VUUmur%yC4{Xp};(^_JN_k*e&1oLk*ykt@ z>{)ss4Uv}?{D7E3^0N`|xZHe1k+X|DdhKc2(@u zdcR@&`mdT{D5w}4j;O;&e2Um+4C#jm_xF!QG__G*BKH2DPDex;9a9l)S?li+hp5Fm zM63?ULv;CpdlBb@_q?wp@+I%9PkX}qx=!8aeWig_yssgyg7M(O$%rD;JR31zGA%}YEmj*5W#aP9h-KHJ-H88D>LElG z^{oT3E}C{35tK}%fLb{25n>w?=f@3%xiLWw+@NYe3k>a@dv!oWDG@S6ThwVF;&|6) zBqEM!IT6uiHPIr@s|^+)lCU}o=ryk>;M(()0@9iX6fpF;O#%1PzbGIpy+Q#a{xxC5 zGtL2l2zj2pUI~79(_I@0yj}N_K#{tG1ZMjd68Ju3lRz1pMFPwE3=;USFD8L1d>#p` zGm=Rl*gu^F>e@*pu=O8*3=w9GEJZYBU;K&KMbUQz}ER|5(rbiT!CncMr}mw?}l0sQOv*th&HRwDa3I#@;o9A`^1gtH0>TB z&ONPO@FiH&EP!t_?;{jp=vpdhi%3iT6flH5qkwx{H3ejOcPL=IdxHX=uGc6aPd!fo zll^xJct4z=fFkxV1AmQF0iO zREkRwef#ED5SO;mEktUo_ZTs}e!~}md*Jh+KX8N0M-(t#x=R61i@zx#PyCYtrd<~( z;C*z40*a_p6fiF;qJYn_p8`tZ&lIqX*+v2X<{S#Bs@74!+BuT~f|O+xP!}zrfbHFP z6cENF1@LX=SyL!rzdC^eqA*niqSYw-BaS@^B_ghoCm^~$J!T-zrCsMDlG64DL?80M z^@wZSM?WFbyvBuy;ckE(ad&-30a&t_kO(l(l*Dq2) z5nf6G^NiCJ@a;Q70cGtk6tMI!pn!k*b_%GL>*)oexsLKYI@aR7Lp|u)fQsfFQ<10dH6QsI8!Yx&IXk_?Dle zfU>NZ0v3^-0{#g$3aIj}6tF(9G`xr#L~fygIxU+5wo|JqAPmf)fF^D+1?(H=Qb1Js zEd{jgr&GY8okRh#ZQLeA_d0F|;vD$JK}3@I`DsLdsn-R>)uQ_iM4H(7A!67S{u0~W zk6PB}+svbyHpkGoD5xFcanw;jE__V^)0i3xcsDurBWWZ|t` zh;c@{rwHE;0DWm6i}3TDPR`ep@46~4GJjpuTsGBpo{|k?!Qw& zm3D#x)>DTlAPC$?0d?Fi3fMO0Q9xL^i2|DTYbjvYuAqR(mQDff>-iLL3{0u-!uHIW z6wqDzngY%i3EdG%;^+a0e%G+!i0je735YbR?@Yw7=(Bl<+wsYAL?)DMK#XI=+YrxY z(LO|8B|MIpI@ddgcxS(%fTHLb123-LOMjw(CM1Uf_HpYdAj(@w0qxzT6mWE1Kml>;cNEaszoCHh z!(<9bVkf-C4fN~B2(RG=*JD4%P#Ui2gcxSXqY(E#nF5j3N=GBc{_PVH&+@Qu5qVj2 zJz^3y&P2Qu0)9jk`EMy;e(;N+A74!?&O#teRjxHtFv00CK5k0#|1Fm~?N8dJ~g4qXv>(f|4)rgt9= zMZ80T$03TW`co0})pr!|iC<7asri=zmOcMa!2k3n1yp^mQ^30P5(NaMr4&$y{6+!W zxT6#h<{hAb=5B%TI&RQ4p8}%PTncFI8z|uTu!;iWSOW!g>-7|HUe{4T5_bH%iUqu0{wt@lz+hq!{Jx!h< zzNZb|;Reb+bwW38u=EuL{AZt1Ko#ZE`$)jEz#vMb zfP2FD&k?kdM4r}rK4Lo6Jp=L9?_xp}ac#FF=8YfkM|?M$ou?vjJ-wy&HD3!H?jTt?-US)pQM0x#$gIL z_U)s9xONu>bp3y#fOGke6p)myrGQ?vk^-&?ODQ1DPose0K?()j-M^uLENwCcjHePR z5KsLv|7Yvo)W-qX1dWsJ*Gh^5GGEd+kUu^6Iq zA4LGds$m2WoPB`+>KB6uK#6#W0K#3rA%I5Pg#a{4Cjy8TwIYDF@)`or|7@`P6COms zc?8hqoJIh~wPOe%`A~)c`nUrKz%1E|0MeeF2w(I88v$e&(hHI4Y z`5xX4h^dov55jSXdI(W034abTR|UU^-Fbt)Hh;nH!Cr2#Xc4>mLiif{DG=2cVl;#> z?*jq|4!uPH^@EoPKpFQ00fbqP5I|GkivYB@-3TC>c@qJ&`PUJEe&>4x5YsLrfG)KT z0T^eh5kT^+l3;}0!ww?=bH_mhkhbna00T*a0Id091dvsT5Wv_cL;$w$3Ivdg7Vm|a z8WT$)ocFO+5Jk*P6~tT=@dJc+FQgNqbf0)1VoCEhLilIhh9IgJ)Q|8W2ocuy1Mnbr zeMA7Y_8kIHD6bJfxacVYXe#>=fcEDD1P}%Mf&ki_TL?hE_A>&AKm33Iy0}IJV3b@y z07=go1klqJ2*4D|5kPuDh5&{kDFU#j@3I+$-S=!o0AuF{1YkR?MF9Ddl?Y&}N|_Jg z3?{9BD1zr^L(Jk?J0LvG)cp|Umr3Oi3)lY)gn!8A5=8aDy#+!T=XeJq$g=qzqOSjh z0F<}?B7kt_5aBWGp8pI1Xm|cZ01@qX1kk4biU9O8w-G@6>;?kp!ZZlL*r7%MNvjG0 z^rRXDV9q~*0Md$b1Tgd!BLK^{00Cs8-3VZ8+=c+`_ZtyF9+L?Gp{AlV`zLS&F!0b|uSD8L@vjso)F9278#vrvGeS%m_MFH4_7 z%-n@BtdFo!R{ z3gPWa);oO1q#rGgD4=r@COR$hVG*PV|o_~NcMD~fWET@1(*)kP(Zq* z0R;?I=TLw(c*^QI>>hj!1&rb{6kuzLP(c1=4+@yLJ5hjh=qnUZJXntcW}kEv;AN$v zfUn8;uqtXDMFB18Eeg=*zeE9X#bXrE^*uxZhHozlNJQNzpl|F%0p|Nw6p+Szj{=6G zODMp)cOC^~?$sz@Oshlz_Sr)yAb)WH1x(@lY+u3*c72Tk3hfpYFq1_nz*{6l0cE8C z1uXsil@NYFq6nhOiQNeyT#G7%2tI^YK-6&|N(iOI?=nQ#~1$3QHP=Mjkj{=e<4^Ti~bq@uYgEvt?8vHW~7{ouI084Wj z1!Q09P{7DNg97YB$5BB3;4li9d}JuV$&#XgqF#am=3y}k@Mdm60cCy$^)(1sbSqGR zPfH1cs8W+6A%rt=b0LCfGZ#bDVN=r~lpT{cLximpzkz5-KE)8)eD~uJQH8^Kh_=tV z2}1Y%gaTsGzbK$%R4Xy2fKGIcu&SgJRn0RLGg3aG+Xp#WjW z(l&^ob-}L?H7TJVLYW`^5+baK{10ZJ=?f#jff@J)dcdMcG=3sP+c+j1LVxcx2O^Fk zFNEldzMuf(-g^{~xSLTxpJqY<=2;^ONM9IGz!2Vp0<2wkP(Y?_LjfcCClp{Wx{3nw z%8Mvq>aQgZ!|nkmQ9zM%1O?1Z-=YBT!+sP{#^s@ar8pM__&qr&prWrs0fKNf3J5MN zI|NY=Ej$IGOy^#N2=~NjAezqUw;(i!DZLQUlAu9|wrYYILLc<}3vnLAhh>0jzB~)tQv^6h}j6C-(y^dh~39_L3C*@eGtZ3yT2il z7gqnm?)vai;=i!_u3;3AYG0s$f&3T+Sc@K_fUNR26fpL8p#VFe69wcstten>x`qOr z4-F`wh&zu0=Hk;R!0S1N0!sQJ6tJWpKmq=Ry(pj>+KB>$>03}huqWGL1O(Kb=_mjn zFbW8l@TWsGRlIlzZIF`!5d}vHAzE?xR}i`;I1eKJ;#&gIalI4}#v#`_h~$C&HHhAa z)DB^0eLw+e{aX|;48KGH*32g;Aj^M5eh)Ly^`Zcqb`J&QsW(x;RDB%W3<0xQBI*bDRii0Si>f46`1YZdX2t;BOP&bNDfbw350>YRTD4;1? zOtQf4_Yz%TQRE&s0isQt83v)BjhF=yzX(Z$=)xzaLKwTe*Fhv&x9t!;*&!doTx5M1 zBCY(00*3x~D8LGMjRLZqrzl`->PG?ghX*JikNX7$OvSfQfYbA{!+#*4pf{s{IlU1D zco#09fO6;z3Rof)D8Sz%M*&r*30yIsE z9wPdZ^e05io%;$xKQwC;W*~krl`;x5@Ch9Qi;OIPe~6^sCjz1$cIQBtGaZv5(tMj$ z5QFX$3b1G+C?HE6LIGp-GZbJy`x6D^VZWn*Dfd?t;I!UG0R>5m0_G$Q3h*k_D4^_9 zkv_ogj2aZ+i%y_`s<9je2=9wgKoC=a0_vjOC_uTl4F!bmn@~WLmWcwivuP+Gda>j* zL>s=K9zx$0|06`KoqZdkBS$`fFcyV9hDa&{-az#I(O0?N)SC}5#p zL;?PiS`<)KRiOZ3umS}H!QWCo!3@OvQGlZP1_gv)wxfWCn}Y(hLs=*wdaw!wv_8u= zLg-lwB@l6a!U2eGIQl4rF>`t?M3O&Q4bkfYe}*t=6S^VNRL?&khH7UMg!PO(0=vt? z2-crr_go7Kuv^V2ASXRX0aMZd3UDg=P(abwg92v8T@>Jn+EGB+_!A0PMy{d&Kc*f9 zR7Gb|fN<|53JBbfqJTQB6a^?}3sFG$A`b;L;X6=(wrjKF7Z4C>*P(!xyaom6iV;fa_l=@~iSZTw@1@h-}C!7GjKaTLfY6p{|C= zJ4u@$ChA8t;4FEE28yazXkZ?Af(E?cel$>uAE1Gy`5qeZzuZIv758T}ARPJu4FnG^ zTU*2SYM%>eK*>6T2EzK|XrLKBj0UusGBgn7OVL29lb`{ewiOM;sTr4kJ^{R%Xo1(e&_!lMx76r+K*DIX2!A9kUEIBpvn=!!R@0i$Ov8c67A zXrNDDvJ1kzF#jM#IyCnf#1J|AEQGaZ+7*bbGxP?;NDa6LVJ{i?5F)Sgcn&cQIKGEN z5hvKzhKvWo&q!cy9zg=$mmwrja{opGOW9vYz<=;N5~zIiNI=NaA%UP?iv;Rn4H8gh zUO@t3{&z^A(bXUUjaG#OqSOi`&{mfq0sUD45YWV7-@tt-I1p|JflzJkskKxX1-juYUrli<&X?A6&BhVF8j60&4HmWO z=Y1jc3#X?*#6!oTA-c#z^C65q2Ub8NoqMw(dg|9ZAj~CO_Cusq+2s(!K>8U7D_C#| zA`|mlAjW3i9SHji=XZ#l8}$@oDhq#$x(|Y_oM3k!KWA7pXL^l;@akPBL6pPxQ4mWM zX&!{1|6wUarF**;LZH1u0YT~$6i`<`LIKLNUK9|9-9rIQ?oAY+wO&U75$OjM&?a3* z0eVFp3W)ovQ9#EyZtn~N4AEf}kTf1d0sY866kx_kP(WHFMgc>&2nAT~87Lr2TY&<` z+Ql*m`$b|UL>?Y@4q}qbyb9rHBeW0&dD1TsbMnMT5MHJC3y8Ac%>py9_&eCr+8C4^ zYj;>wHGM<@!iRS#Ac%X70_x(YC_w4yM*$)Ie<+|y{{;nT7jB_|Xy|7Y&_*_+0DVs* z3Wz%|pn#65L;=PU1qw*2Ss%o17 z2;tc$6cB`spny7e$letMD6P*>KuG!v1vE*&qX4bqR}>KS-9`Z|LyH1*kp>0CjcOFo zji^w75mSQ#lA;qRpzkh60j7Hi3P{rmP{2^T8wFS|wxNJ5d=m;7C7HHv@G94)p@5vc z)DL1xUciEID&u1yivHOPAZGu_l@MM|m zQ9vTDMFD+t6$&uFRG@&A`z;C>%J!oG>%liDAoIyZ0b^zk3b5<5P(VJs3I$A2%l?6I z@)v#r0fjDs>Hz{~=a{ju%1fOd1W{H`j)YjA2F``>!zL_-sB%5iA%s@v%@6^J@(o0t zL@0((Dl8}<>@%Z)hVdK)XrciW5H}y9{d{$h{atfplj|x0mhdW6p(PQp@6=u0R@;3 z&Y^(R=QNcL0*1_ED8Q;OLjl=v5egWi_M!kge`w}AP8~Y#3K+SL^kA)eC>^)#n*hriR z(Tt3u04?S%3W$ncqJXyhF$&P#AEAIatrrD!wcRMdc+rUhlJM&&pqG4)0!-~C6p)hZ zP{5E}jRLI7N)(XwA3_16|3RV`?4Gj^1>{X%qkw623kq=JL@1yr7NUSzFF*kveQ_2< znVz@}VyTOjLij^bhajrRh*J>4o{)4&Y^+JSI)Io@5fMkZyfH-R| z^3R}vOm`dwjLvctV5iDZKwd3H0n<|n3UI=LoDS< zb0GY_xP=fEV^$i3Aey=XB50iSHAFo!u?Rwm@i_t!7P;3zG~Eu35SqKqb%-eK6AEZ+ z|3v}%i`OV14u6IMI?12Jaj?7ge<&a!|B3?o{0a=q0 z1&pH#6kx}dqkz1)7zIrFd=%i&ccXwJeH#jx>o%eQZ)hzFC?nHQz>>G*GYIfI=R1rC z0Tngg3swnBW-}pzs%g_9>VeRB2qicm1tJuW6GAl29$!IdUmWuwBCc%-L|gV51?Ue( zP(bYS4+`iq|3(2u{a+{`8U7sw^ig^gVCL&kK&sP{C%_Dxe?$RR>J=1_Rey&9#-}wX zzz(ZI0eNl(3Yc0-P=G_)j{=IMZ&1Koz8wX4eVb4~$;d(hOZF-h;5RPqf~ZCo^g#$Q z34cQbMbZDl?&|JH5(9R3pX>sQ!nD8%5KZm)FbM6%m{|}}xN{;zD1tk4O6wv$kp#U?d2L+@}cTm7E+J*wGxSvo!R(ur&jQWcxz^0#d@C5;R z`biWp)g3_r&d|3gpolC)0drm+3h+8}Q9w!Ei~^RFbtu5ET8#p#fn^E^Avn1XA`o-0 zLDbDL?GVbB>3WEeJLOM^rYz_cg!W*p%fnZVL(+TbfXSO=>^^dD1x)FqNM| z0Z!jh6i_hANE2cA>>?E4HSR$H<;V^cuta}_0{o)&D4^U%Z79fG@%3Y$PCjNk;+!SAYcSGCl`FdB97C z2z_E#K{T0B8zHp%a0x^-9DD$xjq*DRq33(mLc}^(HALr3{TaeYC3Qn2)gO>R|MV>q zFvDIUfi(9CDG&%+9w7mX^Z*HDN%xSzSbh@;*nQWLK+gC92~63Sk$}@!hXjg|Y9ug6 zA4dXS(P1P|b{|9ni<=Y)_-PU(P}Pc&fbc?u1cLAkBv4CMAOS_Y*f9uZAS5Sw!>T4Z zE*L_qoH+v`>W@f(X#FQGfzWd%Wq1L_SFFr_r30H>-E1r!4pP{7Poq5w~< zKmld590e?&WhlVs=A(eBY!?a;9&AMcfzL)1P-m`10ZRQ!6c7%l`~lHK%{M`4`Ey6` zC8(Qa9SplWPjiJudTJ;GBChtI4ADLHnGIotc_cw3xsC#ezQtxegh~300@9=r6fl$z zp#ZDz84Ac4f1!Xe`*##zH~xwO@{!vpV2akF0H;WU0*Y=m3Ygu#a|{6iURn(bC~Hrk zfaQ5P3h={AP(URqKmmewHwp;I+fhKBya@#;m6<3Y>`y}hjsMaM5L(WH?;)b5_zsA6 zbaoGf9vAr+L|h#98luw&e1I7+=;Q4s!3@&Jc*3H-&M5%G9I~AXkw$(&0Ylz<6kv7! zg90+D2?dNPMigLI8Bjnzphp1{^9~Ad#BC^`X#Noe%%88I0FQeS1(apAC}8QWLIJ+d z5$jOcJ@Z=>Ak^qYMFC2F4hjf$Sty`!UX22@)MdF4QT4(Ci1ulM96}F^ zsfLJir#C=!Et7wOFi1fwjTQuujhGR@ z82uap*hK>fAn)!&0F&E&1mL9IMF2%@I|7)W|AYX%@FoOMO6n27a^oxl@X4nTK$UzH z0SJ|)2q5S$L;$t_9t5D|?68{*529%^0%%6pApk9I4FZUYm&Zc1`s76rI(^=1h&X-D zCWx+X#x4kBXv#r|B$9ayqR(TTg)lqmS0GZV%MFMj#qJ)2Rb}-MA{+P*0gTLH1YnC_ zSWki7n+FlV^!X12;BbFK07Y3B0+@R{5P;{?iU7*YYY1Su*nj~1;d2O}iaLz|g#2R& zAkdW|fZF*00#H)-B7m@ZCjw}meuV(Euxtbn<)#x@uzO1?0?uoWZgs+#OU?`0oZA85kOx15&=xl zA0q%K{1E~uB)te=zR_(L4iAES69JUT*Ac*S?0W>@_g_K)m46)q5OS&!K+sf)0P4|0 z2tbKDhycRkeF&h@e~kb%x)=dO=^_Ns)(H`SJ|sW@apdCX5M5s4d)S@P8EX>(yHjVn z!JZz#FMEdorruWw!0~yC z0E*0h1TbHGfB?MVdkCP6x`hB1>CXtj*Zn}43cEWuA^;)v0s;uC&me&M>2U<0gvk*= zm@7j7O^Xx(Xrx^TAWGVb0NU~m2te=4KmajgB?9QOQ+|Lj8k0I9l99Ok5PkG4BZOHr zbqFHup7arpfCe{z`)P0lWTpAgVO3V^9tbf$cbEoYhug$Kc;^#2tFESqkl0Ke%53aCb#QGgJqMgc*w3I)`9B??gJCu|~N2I=J} zps6cH0oqVL3Wy?iqkuMV8w$`nH==-;nu!9slr$7zR4v&Lkqpc)hv=E{XCO@R>`M@7 z^RyO-;dAI62#Xu=J49AC?kU9B>+u$LXTuHe>9BjIturi|E`CM<&hQ8dD5CyB0kiaP z6yWLpLII_-0R=3}^(erv)}esvsTKtYVLzgPAomIis9U~60Sc)W1%ydeD4;2?Kml4` z2?~f9`$2%F&Hl!Ih7(~drExn7h(|V|fG#=<1sFxEP(aeXln>FnEnEX(rX}P+q_xqz zA%^FXG6*YtawSA22|Nce-WY!s!X|raA@XGBUm&Jqn6wu_IL;+go5fl(n zOHn|ZQiuZdsyq}B59FeNj=32H7~*wSQLuaSY823aUN#QG1yq-+Zc=zFtKfaxPd0coZH1q>JYHzBNH-fs|DRO|r6D2@6D z!q$a<0s*;m2sIi6Ow0Yo!YZfQD+r=^>KX|#hf?Q4c)6s-5M|2;6tEEAp#VSW6$+@z zpP&Gt?-2?J7!OcDoqZ1lD2+E!Ksa(81vJq=pa8AtG75;g>rg=JcE&aaW{`Fq1;n+7 zQ9$?nAPO+Tr6?egNKin3LyQ7U@&*);CTF04;n)fkVD&GqfXMulln~>lxXTcB)670tG9N0bGfd-4pI_vQe%j=IQz>oYF1yp&jQGn3- z6a@s-KT$xP@;?-yRQ-Yi!hu^TpkdxX0h+iO1w_q_D4_j(0R`w>B?^ek6eytUm7@T| zrx*n!nfX)>28xUi zDRkO#2q!o6JVepr-vlue$F)ItNglsKl;w{75R1X)C4|rTi~_3c5!+apLE{h#2u7Zv zfI9jw6rdFSjsn8&Ur|8grb7W*nid5_wHg%AK3AgvJ^VWq5KC%MKzHK=3NXkOC?H8L zK>_`-0u*5O??wTs|8^8GY}!POgWa1lQ9w4Dh62XerJ)dZ@q(EUxjvo;F?mKWhj7v( zvmlDPux$|Y>i{W)7dieAM430{6vWcubP>X*k~I)j$`=$MRJ}(5!N5N#pk|s-fFd@c zfUwy>oeKh*&w3P~aqpmjsH_bIw7ox~0Nv**3Wzf=qJZvVEebG(t584^bp!?U(r;0K zsoReNQs+DrFf7kS0akSm3do*jp@1=TH43nEm;DDbkhd%($HNSWTn|{}B*jdGD9WdY zL(GQBb09oM&_akZdqNt-a@lhOgg@f^HAEFnDS{A+;AaNG2iR>v0kzv36riL%M*(5& z019ZH_n`nS{5}eZBzI9jd!wD00K1c0P(YmAgaW!_^(esTKZ^nq|5GTS-*glOm`$Z9 zARR440YmH_6krwaKmnP4GYS|z*P{SCeGLl8>z3bwm|iFMLO7B01|f>PIcA8tW5#C? z;89r)To6#EFuh>aa)QBx@CWGAAu6UzJcJ;&OM$?FO$bqc{tpEx++h?Dmc2j$P46HI z(0m@EfGG1f6wqGmLIL`42MUOzT2VkJy@mn|T>}b8oX?Zz!3>t4MgeB^F%*zKEkglA z=m8X97RDmO0@4|Y#;b%8}et^EXu`Z+NSLJ9wX0z%1K6wuswi2^k86BH07 zKSBZRv0fCQ_jjX!*#9O9=r&zP0Y=mJC?FZVgaZ25Iuu|QSEGPbUx@++&%-FdNT2PhzR{sjee%Wt6oqxxqQkUaeX1@xhfD8S6UfCAE%GbmsnDo}uxBu4>RxeNu2 z1}O@#8M{zGp1qYcA9laI0R=cC87QELUWo$c!jxGMUUyO=MCmp+6=GRAYaN7NJ9Rrm z^?Xu3gb?n37$T7PoQ9}xxYt7{WXB&N!epD<5Y4eqC_wA~7X?KALnxr#^b7^)O@BHp z00Hsn|4={|`zs1Cif^NUM1KPX^qv|NV5X~4Kw76l0mEx03a}zipnxo|90iOW#VEk0 z7NCGUWj6|#PHaN~&cH?#P%tx5z`P|51$fO%C<|c*pXZN(MN2}wKZIX4I|8EWoyLI> ze8Q3;g3N$b5cS1z8zGco4+%sV<#Yg|k=h=G&~%?sK;--$1+>fmK>>R8-zXq{`WFi5 zLJcUu$kn5Oq(w(chTVy6D8Nkm5e1~>S5UxU_zneFj9L_sWmlnq@p1(Uut!QzKpwpx z1x$tCpa7?PI|?Y=a!|m$G7ANGwX0A-`F!aEh=sNAF@!Hkcmq-0i2ecs1oCvpMIazZ zp6m^)>SKYy5K8~}84#hrX97gC$$1Hc){ZdfJLpR@L>iiXHmd-;v@>N2aceCoLPzjrY(gi zz-i7y0mbKB6fh@jMgd;gIuua$u0{ci_wrf@KQmbkQC;Nz3?U51bVCGDGyZ_6rBh50 ziY{mbb{9G`tQW)X%jvGLNUNqXAfl&~$q;R*)ocho_dgU6x4c0C9q|PUFp>sQKvMn( z3g`{@QGm(lLIG)Z2MQQ2x1a!PqzMIN(G4hIEIfw-?Cw)2Aa^^40;ZK^jwv9(sVzbQ z#q&KVU}o(^0iNV56j0t+j{+9kbQItxr*46$jxE{)A@tAt79#NHoPek|u`fUIJ?%fh4Cv`ZyCpD#x=|F+y&gsZM&wHrkmNl^0e#0q z6kt+&Q9zo~jRJ-fohZN>Xhi`T^LrF9Zn=a4?B??*Apd+C1xyK*D8MN@gaV4*11Mnj z-UkAqyv(nym%<0HytoAkEN`-rfFC790+m#N1Oy#F2_kS#6hPF=W7k6{)ls<+;nVN} zh$b{d4x#1xRYOEAUJVc}(d{P)J&CG=h|5X;gXjz&kbuE>hXj)BS4g10{KRn?JPPK> zBP5VU!x!+tM_70d30U1XkwE75GZGkA{(uDR+RI2Fe_n?KCe|4w;7E=mf#SwtBrw~` zkbsvgMFQn92@+TyiIITszX1tUn=+7q(6qvCIS`C4_Jl=sY*GM(QXDrGBGk`}g=jpd zE`rd~C#{Bv>LzZ2XkUBpg3u$~4?@Iw4#yz64(qd46qdFl?EMP(km%Gvo0agjNGapp z@Gnn{@r5u3oTfk|OmZ|tzvT{+zAm! z2i=G03MUvLjBd{%h{TQd5gvqoCB=RfJcwEX9Tug}EjV5S>kW>VC3%kHmE9P?@fvOa z!11z^@8fvo$L`{IO^@1fyd3`)9Is+i6O7lve6{`xgg1Kj21FTq>K?=*JNgj9*Oxwr zs630_BX0U0o7J#;-3|m$zut@hl*si6Ak15X0Gf{F^C2|qq7@KP%Dild_QaeW5c

kruOR^MX#)Z%L(e0CW&3Fa;I|w@02T2N z0uYi8Ab_BJF9N6yI}w1w*n$AU>}&uC)m%=uPlx9sZ6p;1MA7^}h_;Y74MOkc#6iSv zQGAGQW%wEhqc%7PB6;q+8=_}<$skOLYb8W_!~Pt^U`x6RVI_Y+0ok#)C}4c_5(U`) zPf$R<=@AN;uJ+mpVFsh!D4>YFi2`QXbrj&~zefS3=VcVItf>P5HovYqTL_O=^}2Eg z)Pvyh`Cp<($p|sj1Ag3t2hGFQ!xlcJ`~UtP{-nJm4dxN(V+hRu&#H?Hfk623lhwPu ztF7Tr@K0Dd?IUmq41xoJM6lu$=mau>C?_xp_FAHrFpc1(h2?mHtJazYf9oN&5nBlf zW2LrgD=}f5lx((=5`3i;7W^*(QadqGNeGtOtBGnt7>8;kY6w#ZaMM;tm`ZTulX?l$ zwT@EK6T(cXlbU2EL`$8Gq>ls+hsLmWu!`rn2(5jrcpO)yb*R+>j++s_{B{w+oo2(c zT0-#P+X$>u31j3oBC9llr`|?ll}@13Y-LuNgt2^Eg;h4eOKz*O+NAZ;!tcxbO6yG^ zcUo|Hgv1llKs9`Xr=>w=J1Mb7%4FHgiRY!kVtXa=J86g- zKI}`IY z*n85cX80P19_cg|&WRH#bX-L;aHcCAb4ZUkGmMUTBqN8-a4ID|;Y10YPLWKUnM$Vy z(ksp^qf;x%%$dz_?k2tCL<^lCku01TrL&3jfiOq!Y$1IiaA-7&HOVTLPorCtt>WY~ zrZv@SuAauWb_Q6Ncx%@-nEg`gF>MJ_mn>_qHm=qs*Lr;0Jc4VXwO<>69JLN?OO(2v zvkqxX(z;%=p4>K{;MQp!(FRJr*3;V-O5L7V&umN9y1lcGX#+EBo49;F3+NK^7mM8) zHi`KuYIl~+!u%y>caF{C{G}}LT$aB~>>;#Sk-uE+A+}kapK69@S4O^o1<332SBS?b zZ8qeuRF6^HY|c+JkI~v}&0oa=`5pPI#hyl+UHNO&o@Sdp`RQg)f^B}jkOfbNqWlao zonc#?zgA6W*_P#Jn&}+d^875;*afynW7i4CuCi6cu2+uDu{{-=Z5*3ttBe&fyh?4) z#cmLKow8NMZd7_T*fzv&GJ3Vzs$+8)-rcs>VmAxD4Yti~o2A}QY=3Hl=hi#hpWC(& ze5}dsZDNj(JNZ@{Jk$KiciXmUeWsCrY1>X17f-(5mdhEpl>EOoc=~0L|7hE(9hXb~ zv+Zlb_(Jl&!8QqJ{893=Hh5B=Bfn_dtsQ@jJk<6LVL~T-cVZrELO1zc>>l9+_&zPG zz48er@&~JZ`Uw{D7b_`^L7|X{`FsYQLM9f-8BB_Odx4fQjpEe4pWqu$acwW;_%5Z4 zX)lucW>LJ_4`_XJDdXD@68s7&e(mr?KS~K~FP8e9qlC1VX#K8HCbxe}nAk~)XoqKi zFJ*drnRMb4%FOmd+KKNdG41eLu(pe9mvj8x?YQmbQh$HDq;_~^OtVXFKSBtIw@Yb1 z$_ZF%x4ht(I6!E(qM%X@FPGH?$IWm+$S6>-0_ApDaVLa*nLf`mIs;acDGk+gWlQgDL7*c`e-MOQ!r(SmvQM~LV7!AtEc+s})GSJ^A#>XgAb_9uxK^uZGQGei|FL}p*x zuHu9owXbW3SLr$Xi|rTH@M67GP;U;=+ND1fAtMX7LwTsxK4`ow-X>THg#Z&LMU*m)=rT(uSUh7%ZKiYrL zhUHTKY;Pt^E~E|=Xjqfw)TaeMiYF_n&*OelPHv#Sifb`WZl#*zS{YNisqcu_`BQqS zBkgc-ctRa*|5-cb9raV(4daxL)Gu)OWm!8AJG2}Y&B11Wo0!FLu-o6RhU16h+zuma zHXKGeY2iEvH_}ahxWK`abW0vCa_}bI)`sUgjPJNjh>$w??$@y*Kvw$?(wG@9A=Pykxw-_%p(1&pK5WKL(bS3kBh@s zFs7F}X3gyrPFFZ)lm3uTS2=F%_(MDWn&Vd;4+%5i8;U#N$kyw)v!h=+Qp-Sv5`I7>2SwmLR7p{MF$)T`A*07 zKNUu;aym|WCXLE+s_J;AjS@SZn)|mgD$hwtGSOxhI@Rnqv1XP!?K?|)&Y!7ps_S?z zoq5jbV#f>h%m$~1xi5_~Tb{r=bDS&y7Fq#^k%lhaQfL+V*( zr`Eau7-xNS(#|zAW;-}{klyfT)17aVhNZLpo$q!Gt7nJ8A^5F+_B7{TI^GhZInMgI z?-~do#QX4(zwEVoIGKlm6q! z9Cd!y@t-*66db`vr7`E6U+f=M$22&<>=@O?TyuWC|AR3`sF|6-hDasIsj zi+RpRX967kIo32{At9Ut&n0WCSdKf*w$Lhr<3l4`6Z1I?8oUXXbC@*yLgE#87CIJ^ z1~{{6w0LXZ*abBAc$>ws0-9&1O@6G9Ha6b2JXS>W?zC--&83Zpqgbqz=37V!k1M6Y z0VFo=C=HGk8F30)khOh&9K3c5?XSc&&_d&>J#p7)lRM!(NlOclcW{}jqfNDTjGfy{ zi;Q>7m}{WTuy)Fy`-C>L)9K3GSF~By&TVtw(W2v>2j*I6b2^>h%q6(Q7Q+8Zb4e(4 z*&5GqNi1}|63=p35brh+Kih>L@9vwh0N&s{7AGuqSzhR|HDQ%Ys`Z%i1d&UcwP#O4 zuFINEPb+Sr%i2zQEcd9(`p&Uixk{G}g-fi4#E}ILz2e=xSuR6Woa9dr( z@jfo|bS~Si$HmUmyX+_&w{_kl7fJm1nt88WzOkOrHqY#`r*Oi+ypJx@c!n>}!F9j2 z?_!?2>w!YwtvrUSEZ(o2$8`PHdSV-In(Lv$iEntbUFGrqzKIK5kH!aNBnn)QcLwAq zX1P{%!dq{yYjtOkRZ^jAO(8QpN$z?+J~$)klU%+&`+X?UL)7*aPoNBc&-tB(pwAh7ww+DrhTNeu5`Up54{0f}etS(A@vrOudksxqcF7wE|DTrNu z)MI_oiml6)9-@SmHOm`3auU)8mbZF{6IS`A>O8jFtX`bj>#_4DoC-bh*nM+NTk1QH zy*JaX1lD5;ZVF=s?qd$zg!3c+F(o(Gwh5+qx6SVIH7d`SHsAED z(Rlu2ljoAI^BlgJ7oM*7{I_V&)^wxie?@z*zzN)^qJ3}ReBSDSG+YQjahIE)A%u$> zZb6MOlS*qee8h)SKy3q+<}8{GNg3h1G4b7 za(cjl;;n0y^xy*}SKu@-?7+8g)@teD2TH>;_4LRCWm_|ibT;=;O{R%H%l2>^oFYaa zIQ%B_Bb~#Q`(`bq{i*rnWK8S7S!6>uwS z*5!;{#XUZ-E^n-mtMFZ4IyQ@YB4hoju_A6&&H9G1Ioy*2>s!Z)xu<-yyT|5oPiJI5 z8Y|&e*JQsMo5wvfko|G2l&ka=Id~OvYcfPWUNUZNjVRQslzVnSG}}wgJ?Fb&f!9&) z`HT&#ycFEJnhiN#r??jeHspCJxhmg{rC#T_-(_q(<)z|Ytl8M$)xfPE*x2f|Pt9%c z-PG-Mje99$(<3hp_j1jqS6;2$#(_;Ay|i4lZ;pd^C-+K5j*s_k+pFa{{@!F!eR|K0jNe{+`iAGdyJ+nnqD=dEU|uL`{fZ)sw`I_mxG){psL zo%4Qi3r>Zvc@N!cvD(tVJy9DEplb@CE5{*Ae{<~+FWc(Sj$z``}{8{qz#k&>b zFOXl9?^cbkC%^33tr_1)hPM^n_$Kn}#orjlHU!foinf^nY3cW>#0C-eTx*n4V%Y2Ikf-i8UU=6&edtC{c*`J>A|-Gt%WA7l6RP8hlU zDSzLS38S|^x9xj3;qz^{6tQLyb%Z#nJHu9IRUlZgf@aNgBEl@CmDE1WvD#j!VwY}gP zW3rA)+~3KF&^g5I?`2HaITq}H!kDRptDJX?7@ae*(AqanM~f?T_vPwb3JU#wlXP(D zGtDdvN2bS;F!kq94yN774qnQ2TFajcw^TdIOQwi zdDR|h@Xg_Q-#?)7-2!(54tDx()A__5?DgHL8&`1fiSKS5TtL0^-K(2Glv(=~=ooP_ zcfSKV-vXJx-@XzZTvkoGXIETzTYufz_wzc-w9b@ZE=p@DPGWEah{)& z$Mh>H^*hH4UR!d?PsIzVEoty;;7uAVY4ua{LjAtg`883(Qoc3#HB%-ReEY=jC*9<$ z-t&}nG4lkm%G<0G&Z|eQhX%p|srVhjPTaRp7MA?Fg zy}Zb^WveFsPMKa%CY;zOn_gR%Gw~s1MtfQA#D3X~VYtK_kg+2U$tOPHMWq~4O#GWN zv*6IViO+d6YY(X=zNE}*KXh&4kZu<7aOcD~ve^-byC=S-M5i1!OdQcg7aTTD{7)8h z^{{#3C)u3g!yhLS5;=Zy2Y>5Cc>AUM+un)YCTI9l65}f5On>`3aP`RYcPySe3>T3u ziFoeg?{+6Xu6(KgnBoL@Yw@SsaSO_`{JrkL1*X{FCvn~&Jc${JJl6`C?;T!T#Zmu& z;>5KTr~HHLk}4`x{vpLlS1TI)L+$3@uejzPmN&irAxFlE6>)R9g=SP*eU z?;n}CaP1L;|MWWxw;eJ1vlEjmj+p#s-bucCva!Ydh@%bx z@o?^NlpZk8E~VfoBOtLj;^9x6x)yk;c++rIYha^Yj_XNXV3XbE zl#_?tImL@?GH1ci=+bI;gE!90Aw+a7woJluwW@aa--F z(4g+b?SrS-LBHOCn*i}aJ;k|xrxyhEChk~!dR5Txb~`Ihi-I27eSQD5Btl zcMr7Jh?(P(4&JYkFvs6LI1IN4eM@8!aML27qPW*gZc1Ve&Kpxane!0^9$fc$gw!MAF{0E*tYY+U_nx4#rdq@m3RN| z-I1i@_s>g$)9n?mb+X`0`x7a3M}ybjJ+TdLnTV3AYU>(;bCOQpuhRr?xqEWBP8+-x zj!_r7gSXqCPPt$R{@T8};({soo4eKR7tFzXO3n;lAcRPhlzu8o$bS2p6cwCdm(;9P z(L)a2ttr5lXi4o=6)U7P>Fl6tb_jeskn4B6kRx}`#eKIlq_X7vw(o=?CrawBekTq& zm2_e7ySxx(lFIL5X~?;x@77*C6{1SISbMP{q#>z(@M3F7qkV&Gy)LBbZbL-9KIB@` zrM2~sLNrO2YwKTyv?etU)_)AqCaL`z942)pU0K`UGwJr-D+LYylkVQVdbNQy>0U|G zaKr3L`lM@qmljOwP5OTArB#y*Nk7zH%9-@gzWM$o$)vyRHLjOslOEgum~vS$>2Ld= zDlV%gy|iz+e_1o>ANy9h_SaJy4U^v6|6I{%n)Jc`#{EXiq%Zbx(M1U*QQK0~ z^iVRjy+X|lweMVlg`@u99=opD!|hK}jFS#TvQ)T`@O`<2|#@m+97sW8;9 zOBZ+bXlP*9or0_9LPNUlwqLy#I=KsOGj)bWbalry^@dLGx>wNjBy?sM+;n;u8q@VF z@tSp5T$euXntK?xtET`CfJt3&Cu&+)a@TLf@8iQ#x_aZjUmCW&>p{WySz#-?;PzB* z*qW~2i9ZyEt?e?z{ctpFeOF(>59h)*cELTXYhhn?JtQ`FhHd@!QAD#oY{$3#+nSAG zyXOB{+x#jlZ~k9{%^$<|N#`5=G!Bys=MSva_)M0~AFS1cPA;AQcu+GN?s7eG{gF5M z2=!^okAlg^sn05Y6iq%!{rmoplF4VNCfA>2lh0D0r~IUte1ZC+;wRPQdg{yjKWQd6 zQeU~Y=q5K&U#GMfCO1=uDq2jFTd4osZ?R0iL4^xe%9IZ3o0L}il-tzdidN>7F6!I+ zt?VhkQs22==S}&I`ftj0!Ia;rBNf+0Qyx;^-@h)I@)y^*iLM6CqDJ+tM)eM*rK&x$N{+z$8cqDq~bI?Sx-Qs=iFgz(r> z+EloUmr&}my^|51SnAr;$qHXs3fIh>@WrL>Q{mR$vQm%jH-+IVO2;(a6o;=a^?VDT zJ;*4fPlfw^>q^INzoiV{Q0momOC7$s)cfr%ZTQwwpQ*R?;X6wIPjBA=6-BnSYXZd) zM9By$Akj!pqLPEq#0I*F-AzL`IY$A>Ip>_4Bsc~@aLj^&f+)}gNfHIcghbQZh0ffW z>&#vEU+;g9HKT1+b)Bkns?OQ_3ma~a2jilC@%yhlcqbYde_#G z9v&2niRZln0AWHr-{QkFVkz;5#M|}6GUE9?+O5TM;sve%YFH32xCk^wNxYDFN3vK& zys!sgh}H4%D*h@~7k_xMWUVh+$C$+l`THIOD?UeO8nI>`JS$8;%|4IWP0*Yd~uh8;gbRJm0hROp1c(Q zwCi-wlU4C=yUs9m?~vF^uV~oKF0q|nDXm*b;um`5o^Bb5-SjF;-JYIRiPQ9YOuaiKmFe{jd)Xz`=?&6)g(S~*8rOC39WTO&x%7aJciD)e z^stW4;bmsMv-O`AF-tJMV% zw1h0GD{bqvimX9by7IJWpRnw;xoyz#KatrnPOQo zDmQJWO4dFxw`}HutOG+{&&*X>=dQf1v-f4)67$7pAIo~83J9~qvc9OowAq)k0SrYw zvma%H5{njRH_3%076VEz7mfn_f=e!%p)?G_*%(x5*-Ih0xUSNBfORLJ$|hgR$R#n9 zZ=F+-OHC{n2jDvcRY90Dm&-;`%H~LNd0mt%bB=QPsLIK?b3i2mau>c4`Oinom337q z&nE*!UTrv^Emz4<6E+~vdze6JqMU16wGe$_44 zfT}ZmH6Yj6Rp$Yi`ZZL2*{hdwEnW3jUcHn14Rvwy)n~a|48JnH-X?#y3-ESE`TGdk z#tupQr7%F>AtWz*eMJ5->PpXR3Hh$BD~qpB%0FSax^>}<{8Q9brUec8XI)p77xd)& z5*s`gtmOw$jb#fY`R5ExJqy0_qg_pl3+DlLyvFw?T7Cj%i8t}`Q>bRcH|g>-iOqyJ z`SP<}&0%kf<>wMx%HCX%f7J!R{#E%mUDp@i+(z(yrnmRygWo6qCjR!Z{362*!?y$S zD-1Wo-cHG{CEmR9_MQBf#9NDRH=W#^ber$pu9I7L-vKuBB<+(s9>56DCEdO9?!-xY zU?<+moZP+pJ`+H?Oi%7B15Ln^)Jk}74s1bN+IyRmoJnnE?;TI>+x-BLh?D!19!$RX zJ$c~CgT?odG6PupA^Ie5(nCn*PVzl@2r1J^e%*G$hwPIOC5IsaUs4AW${gAKsOJN$ zyh)E1KiocfLiaJ#;=_~TPaZ1+10k8z>9GiJgl<>a;>(kAPd2*pr0$cR#nqFicXx07 zxJ^MZsT;^W1?49|QZOp0COrk%L_vM`voOepG?SiP`FKJ>C#h%h<4FbG-Mt8KYw)C3 zd`VluaCe{bl97UOQlH0?xq_)KHEoHcVD^N1Wyw{+T(`ex$ydSh2~Zv96>v!d;>+<0 zgx!ON%jpWXPX;}f^S|>S>e>( z=~eJf8X>Fr3`#a zM$$B4&HPl>?wPPPn^QSSGi7Uzr}Cc6T-hLDW_#8=PZcK3F0P$NNOc6XE`_ybJsy~u zIpy{AQx!>bg!TMWRl4(O>*c3vlIF|SFPy5~{iZ$rCueN@=f9g`wYw=Irry7zL z2%m<5ym=G$Y3fu<(wi%v-krLk`*!lv>Zw~#-Yx>8b0_JYI50Y`8))R|wxstSpSeyy zc=A5%^TE>(AxHfzbo$Zm#h%YHr=L98AbpZLevD32la@RX?nigI>G`Df zD_<|1ezp74y{}hKFC=}M{Ce&5o1{;RUvHm&r~8@d+ry1i|J&o!i@U!Ve(OEGr2B>N zZQ%6ElP?}XE3GAcE&KNR^rzk5?g6{>Mfcm}x6h|HF#@ztcV;W&W}{6UXK1@Ohi~FP zv$K24{Y{c*7?QV60Z$gp$OvVbG|wT`F7q&Pa6wS2cY` zuAAZh)}=G2x_58eMx&_I4a^mbqFVQ!@NN8xTHSl^Zs1;zMT=y%k3eqWlG!DI+#)7(c+!?Al9M^hY3mdn^*{`8L(x?ac>hjCPd%Xc zM-+YafXrV|JjZyTcl(-RFyley9dt@zjKIcoC`B;>@h+?s#|Rv|tWpvq&>NacX^g;} zW0bNO1=8urN_mWey>xy`MT|nsJ7bi}7$Kd@QL1DF?z&p3mho`!&Ss^Hj7OM%X;-?! z2(0s?NU(g z)&m+>N4Zx|(uUpws5Pl{2o8Xpy-M$sRs)|o$_lv z`6-4Q%J1}m?CezjsHb4Gdr*0~TOk}sICzQ5cP}V^);m45`-}1>{WFIc=~cGrE7~w} ztL)GRrthfAE`8wdfU(=7!VLHi(;ndAFe+?&fNLYG?AxQ>yT?!Ez*F^Ydm~hMpK7S= zO;O=b0n)2PML0$4Do}YxQ_g+_k&0*vFjG%eBvW)ef!LExfkolHib9I+N2blHiYdTT z?@?7v(f0(lPcy~fDzm7nPKx12W(8F}eIRahRE_k3oUuUYm~`H_x{;8=7Ylf=rQ;a8Ti7Gh-dzJN)s*}Fi6zdIDmp$gpZ0)KZdn}CD2339b zSf;bhsRrz^>SbF~4c=qT3>a3}9$+Ro)S~v_)7gd9;`R`F*=5y|_Si6UXsV^{A*yho z)iP3uo*X!}oD|!uKr$Aj*nQ*(Q7cIyNpPm9RiuzTIZM>4Q|zw-^H`VS@Da2)mr?+n zf2!7y0yz8&wdNFn+~2F+NO6JRV61*8#Z>}$$X0zf8!m43hx+d2Tu0R(>w8SWT;8n@ zptp{CufCTJw}twEzIQpdllrhez}123!Q=YAhxR9^PwD&F>@QG%sqbIDzfS$Ne!$fJ z8|v@$0YC0k|EM2mb6`Y$RX?cwz=HZ`eL#M{sBbbjf9N2+#x{cxn}ggMI}Ae04<6On zWdOLWtOf%t9tRaQ_M`#?tFOV58sW)<)8I&ryb6pbcPfCVAsRfX0F$O@@TUUMQ=%c9 z8he%ZlE%^0xR0PR5={jV^QnepDuj(MG-M%a;aku+X^=Dp6WketZmIbaf=@NQQVTo*WAaNayehb$dCs6{O7M&3 zd4u9ZLiAeU1_1JKYegHBmJ1!#iZ>|h6_V9T+FQ;ntf`f@x57vmqm{K65EU{QUMcs5 zy|fCS0YMv~Rr;(-MI=S5BDLC6q(rOApr%};PV0g}?Ucw3tzQiQqUhARYEWl$ctq=( zL4En*1+Cu0c(qIKKg*F#6>&)zox$bkFoLjynuj-GvN06>7k*=_^K`E}0r8bEq) zadyA}GI^)7!v;-LM+47}8(cedEaB{wL9@-Vg0r)GThfnJpMACWdhfC3vv2qQ#tbOw z;@%rZ#|O`@?7f+OeC{k{#`ljeo&Eak);3^8xAfdr0e+LV2k2pb?VUY$@1Ky=X6U*1 z@r0uGp0xWCKo+y4wR(!;v^mn+u8KNoa~VG974_3T!1RzAAXh%7b|bMIZ9%4vbg^pf z!#%(!H)tR0d9+QuRa>m*v5I(~wp34NxcIa-By?BB-)lpV)GNNGeVVCzn*>6}c7w1; zN15rVkpzd1Iun4d!a8S}dhSCqrIXh45$I_BG{~&>x9ZVn~` zDY+Y{UkzU$lJ7)aHC(WfA3xr2}=@J)c!h_vtS8AaRB6sv!gobGn~;zTO84|4Z7pDP+}W0w$kEZ*w{<@O$)V z(qSp**4tsUCF2YrmAydkOX@N7ZlzIF)dN)_KO!z*ra>!W^jOnryc8|;*qLb~6p4Bq z>9h@sPI~*8xA!RmO=q-yTG3B$fA4lgR>+gSLsBUPQ3s%vGW1~GBq|l?2^sCIP^#90 zC9**Y)LP8H^eHvy9qavNN$CdY5O=XCx9f>B)1#FK^+0kIp**c8m(I|D#O6l3rxDo< zGb0PASwN#Asj^33H67+^ZhZ|S5Lz77*EVA6L&iEC34`@PoIsBnQ)W@W=HmCw`Zp7B7>ZcFu0}W8t_+Ah!r05gVIlR;g^hwN|8EVz~ z4$S*n)f)7HQ(aQKq3^=XMFVrWdpeh-`cr+cbZ#&85q)3g{Tb@O2=?x8PzQp~=s=(P znto94fhF}X`oO!Xn zC?LuR7EM)ylyqQ4^bInM_=%bp2H8de6`D>4`9^}%ntljkPNNlJP?|0zsg(e#31PHW z27(h4wF(e~H$$t!pgLWoLaWZ;g3;kVt!9IZy@!{y0QzJ;!g99N;BqhMEBXu?(vK3) zju>2HK9+HI+MuQPSnJs(1DI@<&TclmlMWNg9>dmjQKB}t;X@;_3hkqYj{zak2Bar_ zBgyDymVouz@L8{fst($a+AHY=Z}woiRD}-N@Hw+|pN^N|Xsok%2rhPPsc`BaAPoZ%;Cc#zi&zamjA z#HQ#oED&36Wr63C#fUZo9zkxS9cX~kj~eYl!{ef01P=&{zNQfqiz-?lgA6SqL*U4O z;d3+M&QM>{4>UT6^nZ*IAJWGe$Wr5FP+%m)0v%XwbeILYrP=6MA9TbWBT=+AzhS45 z1X_m(Qx)7$gyFQ2Tpy~{5MJR7U8ppSR?N_oG@?hVp!JDH5I6Mcdl_wvZ-x;JNgpUq z;2dbfJ|kH)Dg*fz8ZI0jMl>?4Xy~;*BULmSZQO^BKoil}4C4m08`>hnWEvfawnm%s z8^@pteWt3$8ED%+OoVX-+P)9l3I{p&nbDZEqTTzHq7KQ_U*98D%-3yiDK_7&JV;|pkqY3vQ-i!6>Tz@uGZ zaYCC78bkfL46`}o>ntvPW^2Z`SX^1m=}hjixS`ECOdhbfXP66{JnD0AHJ3E$>H`gp zs>!oHPgM)F3AN8F!h&cr)CY(05K1?r^y;Fv4ouWW5ixH%lg8`+}E1g0so^JU@sWw;6|^tvO7$vjXEMZ2Ajp zSf915>2B6=7Kk~RSR>Flj42yyWCo6Gx{o!g59eokfHj&0A7jeL8iU5?m?Ltk8LDrl(nxS#0Pq%B(498xD**D|DqW z<}7PkpN%X=mlXn7O^hK#szfx#n3@qmBx11C%vR`TOKKJkh>Y-5&;ey&Y^gaB(CPNn z+*aELj59Tl#;z6PP6csLAI6(n5CJ3MPc3Y*#W zD@hWYNG+owt722BAR|I!GpQ93FlM<_N-Nn5TS%>>v5&x(QbAjkfeofmt0Q0}Yp6A? z_6^v2YAuaJEA}!K6i0p7MrvII3~vjyzSUs~dy{&R#*xPCE)`@*EM{%gOA(IzW*yYa zt&WmrUDPWyPO4_lsGv#CogGWzFmx>unZSI`ge; zchIgN2JgQU0bK3Q{=2QNlI9Hk_h>*HxVQhlDm+}Q{jCvhM03vmwpKSU^ZorGf{Flc z_Ms#Y*8ExRUhXC4!dV>+?w8DuW<6SRzhf?%^;i-FQj#W}M2``38I!IGj|KCSCQqh4 zzL=je>E`#Ow@@*8O7!Hm&@g#c;d#_T+oWgOQ^7*dq?g}I$HK^@kLYD#VQNCH@N%*+ zH|d}D3be2`8Q}L$u&^;1BzhNEkW7X$ysIr7*q--!H(R){4YL42?7=pI_8GMBVH?fx znX?FB8|(8~vj}DzXYr-83}btN_T{jQVw=eD6}F6Ho9y$IwM=50V)4_oOkho{5yvX*NC7|8%3flrY zV9>IO?M+6&oaJ@4w|xO?mbcj6u>d`NkL^AB9Ea5dwhtNSgsmR2E%u$0wd(3$Tso&{ z^)&0FWT3uPU)GXWAkJzqYqZQqgMNpm9Ym-mY zK{u@4nSACC?zH-7@`V^YVzp}WwIX=I>a)qW>EJI`n@l$eoTsnno+j&WAhJmfLA*$AU2S81Rwq_lmi3}lHa}Ln9g?L%-AJ|SC8ez>l01`8x?z8E< zL1`wOy|Xd&lJ(K-UzS6G-Z2Gkz0+EPo!&TX&{~?EAv0{wTAqD3HEhlLG&>_}I2}%z z9n_>8ICb{Dnc>2?v+PXNa9Nx#J0y~tI74=r8ZkH%cGk=YGR};hjT+&Hvtnmwjf}w& z2H4eLj<6j7^;!kaet@$rvH|Bju#YzC4$du`ODgIq&MTYSJL(0_FMEF@DBA+F4=hK0 z!3Be}A({psl6_DhnjRl+%3~YNjSr4C<)uU)#mAfS%|t8Ulh{F1p@~mphZ!D&&teC< zpNt1;zAeTJUpOF08ykWz$rh4|O~F@~3fsmO;HykUD6w_;3#Nx>VsGGoH9aB_*N(r! ze$+T_5Z^Qa;<;&j%fPX=xF!6}f#bCCG=#eYC)8lxY6Fo}JP)BgTZ|Hal<;UktPNNU zU}~1*6$wwXC8QGc34Pg;-U&FuV762vi0+0>rKt&igs}l>+QbOL!~jU}G6*vRvXO}Z zOlQkACSD>eWXmrn-XXltJ}H&dN%&|AIO8B;c>v^j(}eZxQxpV28#vXLv_$wiaGExm z#%9YPX!claXmb?3LAtjCqm-E}Z1W3;GBsJ&W;dcpR}!wmA;Qk+fDDQH(aC zpAa7eIp9-R)O2jq25qD{P?2eX_UE7|X%NSAK&-xm1k^KWUu;ef>VgrQsF(v0DnKk8 zNQ_8S%hC4+8KDNoAQOqGats=gAZUa6A);~&XCR6};&w2q=wXbI*w<%|#_F2__VGl2+8hH+*F(KZKTn^{1# z^*i%xWdN51LD5J%#nd!ZvG==)+-|nKeiB zAGB=CdQUuuv7%?<9JWz|xJVE`MuV;;Ti7;^gV2^O zX`7fsSk6|oP06u=B+ND)Lo`ON17#awtK|kLCu%7 z>&ijEXuEC<2w!yUdNE$M1r~M#7;j2}lihO;A8LW0-RPhXZD9l=ijgWzv75s985d^Q z&E)vm78ckA&vN)j7FO8J<@h%i)`6ZgfLhpK_hvAFw&;%C`oz4O=b- zoSZY9wgmL)AjT=tBry$vMifnA&5c01ift&uyTpRT&WQ{vi8D8{vBZhAk28u|;zv3# z6t!G}sO+LyOJhj9Lm>Q0AsyoUE=|J5*p}vy1czcGODjmixv`YeI?`dzxVF*;($U)eC5h$63m}Z_5NJ|?D1~UPY>*^1lmJW~Nd}vkSvF0Q%T3%M zVGvSdiF7JA34xx^a3-^s(~uQ&lcmZ5;KQcamUENUIa4D+(yEC~rIZVk&km)wl}nO! zfC?*DAnS3avsP%54TeDOst+MXhH(XkYz%8%g$3CZo0(Yw&u(rerGiX08_H~~a3h;z zv#1rYY7J%4QX8gvLCjPQaMKs7%FV5Tp|Yz7137F*oO{*%9h1Ga;U@`#J17c zQc9Juee6(aTa~1J;!xQ#IK@)16#I(YYH!f$R_E4GYU)6;TT88Ju&>LlovCTIzX-Oo+B^1_axX~L zcG_QsSh%(idGNh!N9>z8>mxzj+l;NxtevyJK2+aW``-RW?#1QWFZQ>wAjzV4xR1SL ze1XH^0q5n&3;YfpLzmkwh&puU0?VT4@D!12={WRoHW=4o9QrvMkx5`^sIjfi%VBh= ziMBq%VIudMRDFuWG`wN;1r9Hw9oH&gWQ-d?{4F-qK@== z_m;s=w(ZH7dQ&;>&U!Z z?Ra?KqqfTpj={(B9xa1T`NY1*tXJ9{#rJg@Um0|i-q)20G^YGMgn)8XKviXplaskw|4c)m zleHNL*bh&qjL`1_rmxZhjY~P7a%2dj>(&#Ts!I! zuc$4}&h^h<(Oz$LzJy5YIspb=P`lpe-1vMU^7;$sYi4gK*B6|BGkZI8ea-pSzIUv@ z(Yf5)_ulw74wnb}K4d~b-0>VV+LA6^&lhO{%zl>lQ3@c3zPu&x8#tE%vt`N+CzoNf zm6;oXE@S&vS#QR;Og>*#yP4rKlegx5v&3aCZ@m#j-3xi2mcje@KJT;Ct*0(ad0)J5 zy>MB}``QRf?=N}ZmT$qY%+B8=eS429P5x$|+dQsx`CFPm{!O31b>+6A>z@2=(sy)R znayeJ?pV08o6}a_adPD{-#&XM(Dk7C4#B$#u7}L&?Cuu03YqV$yj$mb#Qc}pyEj}< znC}w2*Xb%@4m0|Qs|*;v?#;Q%bM5X2uheO9D}hl#X&AKREN*JUd!j%_sWrU!!F@?L z)G*WbR#i8HVGx)@0-Vp{13Gg|J`ARtn?*j`N^6K4E}vZ*f&pSahYyI)$@!d3ZSYKT z?dxxAc5~t4Vtdf;=E22n@?g--hiiY0uLZ#bQdxU%Gu#kF3hC5|g zII5$Ob-z4(L>*Gv#^Iw;kEY#QhL1gf1mouL@s&rLJ?`Y6kOt{{ zYrd$@V{VU!=3-f((0jxs-v3zE;|Z4pTc@T+4;SPh7>|A~sjN=2$8#>}{!TxSF)kUl zt{9I=E?E;;{${!4vbw50UUA9ycQt#wOzCd{eMD}|S@urR^GvwaDoUn1LK`7zL zIReUiFHi0Qb&$7v0+p}=I(!}r4Z&xC{*P#=15z(Aq8SC5kjMy#^BX*mjhx-y)9NWU zqOINo*lvN2PtOZa*#cBk&wEdW0$nH{=A~Ew_}v~a)dGD`-+O6T7*zHi_0qO5ob6Tc z(z7rU?9=oz905E<-wR!U_UXexJOdLwWR2V={e50umLn$HsS%(qg-|rbi&%j1p_X`& z3$RTf|92`dTcO_Yaeig?F|EgfC9s`4)DwgMQvc+^}ZHc$aaL zk^GG^0+xab@0t<&2SW|s^&<}3pSOBn9&uEE{?z-b1q2`?-q$RgE1%DKU*~q|f4=5@ zi`$iLn9k=Ox0}f@htC6U_pD)lpN z;^s3_;Ijh$gck+A(xWLp(*=G$qa~0b`d5zD`MkCWm>s?0^UmU&;8>^6M~gtau@RqD zi=fJ}1)t9r!Lws)KHs>{gUi}?%jkLa@jbpYmLYcI+`c<3Lo3IRg1sROlt{h|qhZ@$ zsQT_51+xR%m!&Ym2RsiPg^^7!+I+<;X&QEzi@N*n~>Z2)9Q~Z8f zqp1(3B>hmMY1^k&{R~Fa)ggs79?giFCi-DVGapQQ`B@fbtxSjb;R>^*!CXNs%<-8i z@go=JHqBh}b1KY(`OVMOGGB1E-OpoxfeDO`&;G)w*=axj(ZUC_OMXG4MJuzL{X+_i z1z*zphg+7|z2x?fwk)lDdDK7NvJBKXh!kP_oT`6HVY&32j(__83U$bpGmvo5!av&* zA_*t|yip3w6aEE-l`C_B{zdz%*ybbrOAD){!P8N0S#3980E18+HDBUiU0BmJf62eD zuyzGJ;K7$HFR;C8_rJ2g&g9jgf757P)T?R#=EC}>SMU9Q+kdhD)sp|sQ80sS4!BeJ ztMqI7fcuu0Odu<7E4<|MnkV4lhD;n3x|b_o9}Rc}vcK1&0iBjtW?#z&bd6p?6cpWH zGha{zKNdo81w0$Qst%TtUckT>FaiCe4N(iYfC0e>k zZTp*ufER_=q``bLUD)jNrUcUemdZEP0k8I7?|;)A@MiS-3OLx`S^g&YwlmsC=T2BX7F_H+Ct=lTw>WZ6 z#;U7waqgV_fhPz)ajNJEqLVmd)s48%m4S`^$P%bF_7r%_K+U3OO<w}E zG$@LJdI0P%X$Asuq`rg>G%o7%S;7UHLZ-At4m3N^-@oJ+XgSuueK{f!KQ^GgoDpa{ z2F9C`K$6u^<#Ju1qt)}-Wmszu4707Y2YQTw8|P`DSJ8+Mm~#AzMw?dN2cEMU>t9(5 z3_dWZMS9-lx;PW zwMGug9h+%da|MJKptgQy7ZD_Zga{9?drx$=us z@UYcN{};dD(Xo~7Un4+>zbgGTC3w1M&F5=L@XG`1SzoJzUmf_=|Ft>z?bxU7-|hsz zFZwL~?P>5wt1ounMuJzZzGi)!3;uNATmQGU;ICufwr_%8OZ{EPzx5zMwVdZ^$2a?K z;yq7S3>8Sk&eIofT?LmClow&&taX0x_%@Br2Iryj1ZlJ7c>v0wlnDMjyEW7hu|LmQ zOnYs!`*|+w?Jqa`pFc3ZeaDv2^E`+-Aoe^TVsFYkFECCQy@hgKWPIntEsf`ojsLQ` zT69~~7 z-}`WzR0wJu+)-*F2II^cGyp9Zv-kqOgDGacM&ll0QOveV6B>dmW|sly6tS4Ym$op3 zWX)McTOZ@5 zYbdpl7+P)(scCK~Wn4IVCndCIT;w5uWcA}<(P|65JPvgusiBSIN27n432hk%!`5=> z&GF+qcF~629Y3M5i#4omTr?VRw2pCbaY==Bjf?M~R||VKE};Ra8g*PUn%*{SXdJ9v z-eIHT(mTNLKQS(&0Z`k_xNI~7C2W2i{9cV=Z^q?!>~0JDFn&@4@VDi0g=j>rRSa6H z4`E-5Prn31`z9RN)EUCJ;lP8wKYRyHsfzJf_%59COU6^-j5rmcJ*aSI93<41;p{lI zsy+7M`*_p`_V|Y%;L%{;8yn8Y1D5ODa6ule?7cPNhk3v{-4cGB2Yk^T;o>|xrjUV4 z^PsXpB`MFNI{-4U(>%ac?u=080oIQ*LY)Wf#v&1Cc?<`bSnyHb4~RB9nM**g3Q! z(|75lYa(lT93FBuMuJiFC8TqZ ztFiCvh`hq%WV&xCvWdqzdf!ZBbBXh{eQzRv<8gr+PLa2GT-mvHM%{hks=>t?)%Lit2jdv4dMJ>e&lV4Q}Ho>I<)EZriA#7vP`sjv9U8vjcpQFG_r6_NPV# zPnUq*u{3I~1e}eRqZUfQ#CSLAeaSf)Fe)yU1o|GBh*~QF`{9SEFD1dN;JDjddR_)h z^E9O)z6W`u=}JSd9TbbEF9o5WQuLnEa2c?Evy?{o^5CO6N+IoXkLE6oS_N+&PieFa zZ)!AuX^by#X|!-@>^0uY(ML<;R(bD6iB!SmnXnpNR4frsEY=Gw`HRK z;o;@jn-dqI)=}J@(qCnc?2WsRzeGB+Kkgy^a@CPzagTYg3>=Y*d%}B_{is%44{w9% zQEXg4Z)5gR`?%-4fC&4?jqzS%KNcG|$qQbO+_>3^=ICRTxcLe2DmKQwnYg~=cw5|u ziQhDiQvpl@L&ikhS}AxhKE!<~1)If|_|0YDr~oSk0f46c@jD22t4VCQ?;E07bPXZesTy< ziUg;!=c`aC$E|EwM*3NTSJ?=}*a^Ob(JJXT3Fio7FQvaGoF|M6$uJ~_6F`Q}nHV_< zeoKMG*vW~9GE#|&lao7S)e=+6rewfz-JEM43T}#RL#^CnlB? zK&@SvSjh)cY)WEH+59z7UDuVpT7~*Nm&#tt$UjSLCwdiuvm$Gj!Pk%+KX@U4Nc}w{wVMO(L2-Kde$+T0OW6lU9?=0Wa zeCAm4E}N}$XHF&Wp4tkfLXsJ6ws9z`CGVZuFqE>`0GNnPW}Tw(Qv`cI0{7b@BFtt* z=j45dwzmWLzkdq!mQV}H285Qe$-GmbuuM%pWCNPY++;z>Ba}*$h08%c2_<}NKrnd| z{HP$21piF=E@5S;djsk+qP4lKQ?prQk@X=<_!=;lzV={EdiP0Q458-W~6`_#NCfp#E13L%8l3{3^88y)x= zOQ%5GmYP}tP^}i&{tk)cXw{_FOo=ojwp*LSgIeH@D?hvjg`hy4CVaLl^{UNL^4ZbU zYc?P@dz1RxA&{4?r`|e*)ZI$EI|Y_p*0i=M(HLM+I?BbGwU4DeJ_KZnR9aU#h`*H5 zo+5@u6bSl&TEV7KrzB&*wL4f2vMu+t5gR1YN*g-_(yYj|i76RPRA$dIjF(E;1Z5Z=bhHjG)PV7MXrJo>b3xm8xf?o$? z2&%Mc9Y4bfM6{A)IG-*zjY53I3KgJB`3m-8Jr0ndC{63h8tu(ctL9a8EBSjv~z|z5tJJK8CLv26UAl_rcE_LhCxK| z^ui1hLK@X%IPe2$)R^Hs4RVUR8EzHk011I?(n8aOn&AbIJD?Ok(-tvcVD=+gR-4Rc z1Wa4Dn*gOtv_e>>plK__)eLN^uqlLU{GfT@%#7j(d4ou196tye5C#gA45u=a_<@L0 z%LEUqEKpv+Sz$2P%&ci5Fq4@%MB8eNeP-^oZ8OFlA*tptL7Bxwk}wbpPp4nX4_ZWL-I)wgqIn(Hvjf!fLxWIp=JP z=Ip-aoVNv`(atQ`etBeC2BO4%BC##1QpS{meLP=K7l=jKo-%|N*n zAnZy|Zm}&eD2ch{wm^>*=2qF(RNL3*Ua+klv~S71C;;R|NA48?ASi}%n*{1}9OiSc z3jo`2G%$NY+@v(W60)V;a=FJMUaGS$*XZ}{fI~Z5a{Oy8wbHJ$li{QO>SE+o4*?V+uO8I*# z!LntL&r;dy2d;LGN>drex8xre ze9Ym|kuNR?DD6*Vo^ZD|EfW3m1WcCT2CvAb!Y`3N-Yk}Grt7J1_WxRuqKe3-m@byJ~IXWv!m@k%LPHRV|0*MhR%*_`mz>A&c2B86)23IooEN* zGjVnjmd?VI$|+$#RAD-(g@7x|5(FC5zA#S^z%&2CBEgpk8Cp8~Qqw=PkTMHad`e-> z?0h>=qxG||=m46!Jo{P`7*YV0`~oHln=1h-`cQbI65yaMMRzIz^4VL|S_wc7Z&7>Y zhvsu)MV*z4Yv+`To>l_dVo=mq2~Y~YXs~j*Incdmq!J*J(4rTWtFmC|pRQc<3o0#| zt6Xmmx?HqS`DqR8{qHM3%LYFyTB-z$VWMcQ5^#hMMPDibE7(%JxeAJp?k%RNg6y8R zn67F|3wZwNtB_J_#f&7Ro>noF5Dhj2Tg)Z|%(Q*+J|Re~{fiF>?cfZJE#?!V!-nP- z3kpF>TvL2l=$E0;mg3_=yEvh)p12S_Hf*R^T8JSxY`$1tX!lUqdhux?M$Yh^CCWm3 zu;H8~>OzoXij>>Q=_*Gua znS>!E!PXsFOY!UWLJ&KY!orWG}3xSp<5p^vQZs0t%88gz6OCc?(K8TJ(*d+b11 z@M@FwjGzi@NZvCeD?mUfmzfB49bhrd1c3wwUc3rB7_rP!V92pGne`P8!W%@nb2Y4u zpnjV(gS8Q%%{j9=Dm>=QkwOq&WDEN&;34KLVzVYHe94x%S@RWu=mKt95m*h28|bX& zpjN~dN(k9nBzq?Xe5(1#VFg>rEYR)o8hOVy4d-z}_2!HR}znC*usO><7Mz_&J6 z!^Hjp;rxg4)+sluVJiPhxlM*S90O@V#ob77y30&l?c zeGp)Eoa`$C>Dr_)%#^v6v%>zlg*BD0gh5=;Qu$W++|Hu5$`5nrw2G*eOVvOPOjNE_ z2elM^sQf|>o-g`}RMg-s-dVL}9tvx)R?*If#1;!w?VJzoD3+=MBkp>!QdKa3S0Yer zkJ%oC1(sFp_K`Iu&Q)CYKqmxM9kh>znnG24A~9H~*&-+cZ*fi4VUf6w(#EQzHF5K$ zH<5}QoMmlQV)Ia#gIXmupAcI%QzbW_*a4!3Q}anX%W12X5c}`mYE{rIl<%)rpHGP` z=dIR+1fX1`8uEY^cn`Ei;K`S&Mj^IGh??!wIV-fP4d&CeDvYZk#PqMQtcGw9(eGgA zGh3k82Rv=_6%afjk5VKQ_<(d49uRwY7T_U(d4@u%hVgEJdLWMWdGj!Cu-bAWLu8*X zQrS`MJ`Y7go>hAxJw8Qqg#J=_7R23|gFHMS;8gpv!5=83==y=!2c)~iEnN@}X) z;7%*-k;|#66sd^?wFdmO)YRA1iqv-0Tn6n%?NCi4^6zyhCVL5rl+V)>Uk=|JLaL}jby6s#yQlWP2vmP$2FWknDBG)6f2hR5ez-07 z5+ZhKYq{ipf!pE1&?Wy1`(Hg+hw>~4{&+d|0^i|wB(3CkKq?Gf5Pa1hd%5(2Fw)ia z7Y-kOGz9&0?A0SEsBuBG_OZy7t_u=}JF!=WE=V8l%DpmwLGD!-Vu@C$eX@RKOPykE zx7^jeb*i;b{h`*2X6>_&zYc=Ya#U zb{G(8OsunUfKD$2T@*|@HFXY$pAUfogWl!v@Xn^TI=9+kIj9xmRXgI}G*RbQJKECp zp)Rm?Y#r(bht!UXTw|yYKl}m<0~2)^BsC)Safc^|uF2IWy_($FtX7};YD%lwxE{)` zBR?$LIDV7d#7^r$AQ7E#EXiEKyu{&B0Li_iB@ zGz3Q%44WUGgt6W+y94wcoM{{$PZ^P72d<2q1;&;h;aC?=PM3m($+fERel<^p3cpGbhjkb?a`ZT&B9i_s8`|iyvNXVWmKeA1ihU#D3iVzuV%rA0znVrTEcg zZV3%*?&4N~*r{Ixe!aXCc`$ao9YDqm9=jdLV~70w7bCXo_tSrWyMCPh%~{s-(gG6{ z27!(WhK;9A)etMmzD?CoL-OxV%37Sco#U@B%kS6y$2)K zAf5H&7fSpWzcAuIy`ikb)C0&HF0h03b>2RBnb~Pwan3e45BAbu0(i;JHbh@50y42U zd+^E~G~@k!?%=gJa*+4?4y%o$h#UI@c%5-hL^#sf%h3^zuq6{6ZLI#f?fdzyka-dg zDxBgpGwvH}1PbrC? z+xpXv|I`>VL;l>@?_2+u8-v;L=eB-q{9kTrV;%pwu|GEdFE{r+RQS2Qe{KJ_29Y=5 z?}qJP+yB$3eV-+NZth>(|Gl~Y(VXzptN6zx_0x<0=RN<{CQL9yPcqzwKXRdeor-?n zCnaI#>|ufI|I_UT@FHLHcX8gB&d^56KPDucr>8p^@8!8MGX}t1!Ao!_;yhj4H>NR1 zGTt5M?uX1`u6{Uo_wUb|H__dL?Ba}U6%#vl;@AmZ=e}$73~z2?KZY4Dg9-?53*79kNqe+ zI2QRg{A`?MV?UhJM-jdUe1^O{DrPK(HIY1_>3G6JQ(4O%FXk(XH<7f(nMgWdO-YUfKTRo3HBUH)BnfYV zM!x4|W8&+ft*l7KIypMx)eUU%PHLW5LpV-dQWUT5gPg|)1K-D(qHJw69DO$SDQk)1 zogCr(29BC!A3HdoDBe^_(p20)lAw;9SCNbt({jU_piX?>FY0Kct_J5YAZgmW*b)7- z9Pv&lZ>&1p3vx}$TE@nz7*AbgEm>RRzg)XNa=eMS2lE|M<*Y=)I~kA&qO#w&ZR|I8#XIXu{L^*Ym^gabsMwK_KEaz9 zpTL+HkZjad|L7}QLm%Y&|J0t6D8|Xxl^_QF>nQ7rRo486>-yW{{Z!|^YD|Cjy$%{9U^Ll8Icvw=RsI~&0BpuchMpT=BS3;G6if>I&ylH^qr*l& z{c}J1>yS6@7oHBpPx6Zldm_%R9_7Bz5jo{9w)2^UQF!-%KrPk|5sz{L~_N7 zeSa4IrmtLZCN?hr*gyZ?-p}Lcgjbg(*{ERt_8q}H8++h2;9b!8%d_u+7bpDF*!{8Z z|6l*0piR_|IRNX5HSu*Y75mqCV#IvC31aB~zhmZ%-Iy0)p8ALP@GtxS@%{VbIr(|q zM3MLJkNyA6`{#_3#G7b&zv=h!`Rlv@&-%tOrmo(2 zr|;{Cv9W=Qrn9K5Da^Z?PHHD?G_+hX&c-LOrW@;?G%_D+I-&fqCTb_};+n`j4ReYw z9HT`dI6G{tMKFKDXDx3Vzl~=?3~Q?8k28_=+E_2_HT|^h6}@y|?Lp@Jf4w_cM}jlT z6)z^SaSk1_guk}E4%rr2kCCNWQLw6X)TmgA9SUBwPrwl$G|i*Kyo z$oj4%gM2hX{@7Ubm2{4KA3qNNP*NssY;-{W+*t27{)xl^ii$QGC|j&47Upg^8T=?K zD(WaIVvxT#_QNKXA3+5Ct*D6fnWC1;;p4}V=2X=-f()hK;}7HrnU=O19JU-$R`z@Kd%4#_#;o&pZg2OSy7RoVNAxWJNo0)js1`{ z8QG?&gGa9a_fA6pxIPUeeuSTm!9+g5C4IlXpT-})qNwyemeT)me;fC?@qBH36aG-x v#D35f=kDf3^gK#%bv?QfL?8SSBp>`9K66|E*z-U9x2cU~(