diff --git a/ml/src/labeling/meta_labeling/secondary_model.rs b/ml/src/labeling/meta_labeling/secondary_model.rs index e28598d6f..6332d5a7c 100644 --- a/ml/src/labeling/meta_labeling/secondary_model.rs +++ b/ml/src/labeling/meta_labeling/secondary_model.rs @@ -145,6 +145,10 @@ pub struct SecondaryBettingModel { total_predictions: Arc, total_trades: Arc, total_bet_size: Arc, // Stored as fixed-point (multiply by 1e6) + /// Exponential moving average of prediction confidence, fixed-point (x 1_000_000) + confidence_ema: Arc, + /// EMA smoothing factor (higher = more weight on recent observations) + confidence_ema_alpha: f64, } impl SecondaryBettingModel { @@ -157,6 +161,8 @@ impl SecondaryBettingModel { total_predictions: Arc::new(AtomicU64::new(0)), total_trades: Arc::new(AtomicU64::new(0)), total_bet_size: Arc::new(AtomicU64::new(0)), + confidence_ema: Arc::new(AtomicU64::new(0)), + confidence_ema_alpha: 0.99, }) } @@ -188,6 +194,21 @@ impl SecondaryBettingModel { // Update statistics self.total_predictions.fetch_add(1, Ordering::Relaxed); + // Update confidence EMA (fixed-point encoding: value * 1_000_000) + { + let current_ema_fixed = self.confidence_ema.load(Ordering::Relaxed); + let current_ema = current_ema_fixed as f64 / 1_000_000.0; + let alpha = self.confidence_ema_alpha; + let new_ema = if current_ema_fixed == 0 && self.total_predictions.load(Ordering::Relaxed) == 1 { + // First prediction: initialize EMA to this confidence value + primary.confidence + } else { + alpha * current_ema + (1.0 - alpha) * primary.confidence + }; + self.confidence_ema + .store((new_ema * 1_000_000.0) as u64, Ordering::Relaxed); + } + // Validate inputs if primary.features.is_empty() || features.is_empty() { return Err(MLError::ValidationError { @@ -335,7 +356,7 @@ impl SecondaryBettingModel { total_trades, total_rejections: total_predictions.saturating_sub(total_trades), average_bet_size, - average_confidence: 0.0, // TODO: Track confidence running average + average_confidence: self.confidence_ema.load(Ordering::Relaxed) as f64 / 1_000_000.0, } } @@ -344,6 +365,7 @@ impl SecondaryBettingModel { self.total_predictions.store(0, Ordering::Relaxed); self.total_trades.store(0, Ordering::Relaxed); self.total_bet_size.store(0, Ordering::Relaxed); + self.confidence_ema.store(0, Ordering::Relaxed); } } @@ -394,6 +416,42 @@ mod tests { assert!(combined < 0.6); } + #[test] + fn test_secondary_model_confidence_ema() { + let config = SecondaryModelConfig::default(); + let mut model = SecondaryBettingModel::new(config).unwrap(); + + let primary = PrimaryPrediction { + direction: 1, + confidence: 0.8, + expected_return: 0.05, + features: vec![0.5, 0.6, 0.7], + }; + let features = vec![0.2, 0.8, 0.7]; + + // First prediction initializes EMA to 0.8 + let _ = model.should_trade(&primary, &features).unwrap(); + let stats = model.get_statistics(); + assert!((stats.average_confidence - 0.8).abs() < 0.01); + + // Second prediction with confidence 0.6 + // EMA = 0.99 * 0.8 + 0.01 * 0.6 = 0.798 + let primary2 = PrimaryPrediction { + direction: 1, + confidence: 0.6, + expected_return: 0.05, + features: vec![0.5, 0.6, 0.7], + }; + let _ = model.should_trade(&primary2, &features).unwrap(); + let stats2 = model.get_statistics(); + assert!((stats2.average_confidence - 0.798).abs() < 0.01); + + // Reset clears EMA + model.reset_statistics(); + let stats3 = model.get_statistics(); + assert!((stats3.average_confidence - 0.0).abs() < 1e-6); + } + #[test] fn test_bet_size_calculation() { let config = SecondaryModelConfig::default(); diff --git a/ml/src/ppo/action_masking.rs b/ml/src/ppo/action_masking.rs index 6ea2ee296..04fd58963 100644 --- a/ml/src/ppo/action_masking.rs +++ b/ml/src/ppo/action_masking.rs @@ -76,14 +76,32 @@ pub fn create_action_mask(current_position: f64, max_position: f64, num_actions: } // HOLD (action 2) is always valid - } else { - // Future 45-action implementation (Phase 3) - // This will be implemented when expanding to factored action space - // For now, default all to valid - // TODO(Phase 3): Implement factored action masking - // - Map action index → FactoredAction - // - Get target_exposure from action - // - Mask if |target_exposure| > max_position + } else if num_actions == 45 { + // Factored 45-action space: (direction: 3) x (size: 5) x (urgency: 3) = 45 + // direction = action_idx / 15: 0=Buy, 1=Sell, 2=Hold + for action_idx in 0..num_actions { + let direction = action_idx / 15; + match direction { + 0 => { + // Buy: mask if at or above max position + if current_position >= max_position { + if let Some(m) = mask.get_mut(action_idx) { + *m = false; + } + } + } + 1 => { + // Sell: mask if at or below negative max position + if current_position <= -max_position { + if let Some(m) = mask.get_mut(action_idx) { + *m = false; + } + } + } + // 2 = Hold: always valid, no masking needed + _ => {} + } + } } mask @@ -192,6 +210,70 @@ mod tests { assert_eq!(values[2], 0.2); } + #[test] + fn test_create_action_mask_45_flat_position() { + // Flat position: all 45 actions valid + let mask = create_action_mask(0.0, 2.0, 45); + assert_eq!(mask.len(), 45); + assert!(mask.iter().all(|&v| v)); + } + + #[test] + fn test_create_action_mask_45_at_max_position() { + // At max long: Buy actions (0..15) masked, Sell (15..30) and Hold (30..45) valid + let mask = create_action_mask(2.0, 2.0, 45); + assert_eq!(mask.len(), 45); + // Buy actions (direction=0, indices 0..15) should be masked + for i in 0..15 { + assert_eq!( + mask.get(i).copied().unwrap_or(true), + false, + "Buy action {} should be masked at max position", + i + ); + } + // Sell actions (direction=1, indices 15..30) should be valid + for i in 15..30 { + assert_eq!( + mask.get(i).copied().unwrap_or(false), + true, + "Sell action {} should be valid at max position", + i + ); + } + // Hold actions (direction=2, indices 30..45) should be valid + for i in 30..45 { + assert_eq!( + mask.get(i).copied().unwrap_or(false), + true, + "Hold action {} should be valid at max position", + i + ); + } + } + + #[test] + fn test_create_action_mask_45_at_min_position() { + // At max short: Sell actions (15..30) masked, Buy (0..15) and Hold (30..45) valid + let mask = create_action_mask(-2.0, 2.0, 45); + for i in 0..15 { + assert_eq!(mask.get(i).copied().unwrap_or(false), true); + } + for i in 15..30 { + assert_eq!(mask.get(i).copied().unwrap_or(true), false); + } + for i in 30..45 { + assert_eq!(mask.get(i).copied().unwrap_or(false), true); + } + } + + #[test] + fn test_create_action_mask_45_partial_position() { + // Partial position: neither at max nor min, all valid + let mask = create_action_mask(1.0, 2.0, 45); + assert!(mask.iter().all(|&v| v)); + } + #[test] fn test_apply_mask_all_invalid() { let device = Device::Cpu; diff --git a/ml/src/ppo/continuous_action_masking.rs b/ml/src/ppo/continuous_action_masking.rs index 02b0ae4e2..e3851721d 100644 --- a/ml/src/ppo/continuous_action_masking.rs +++ b/ml/src/ppo/continuous_action_masking.rs @@ -22,7 +22,7 @@ //! - Penalty = penalty_coeff × max(0, |action| - soft_threshold)² //! - Encourages staying away from hard limits (margin of safety) -use candle_core::{Device, Tensor}; +use candle_core::{Device, IndexOp, Tensor}; use serde::{Deserialize, Serialize}; use crate::MLError; @@ -90,12 +90,22 @@ impl ContinuousActionConstraints { ))); } - // TODO(Phase 2): Extract current position from state to compute dynamic bounds - // Example: let current_position = state.get(0)?.get(0)?.to_scalar::()?; + // Extract current position from state tensor (first element of first sample). + // Safe access: returns 0.0 if tensor indexing fails. + let current_position = state + .i((0, 0)) + .and_then(|t| t.to_scalar::()) + .unwrap_or(0.0); + + // Compute asymmetric bounds based on current position: + // - max_long: remaining room to go long + // - max_short: remaining room to go short + let max_long = max_position_abs - current_position.max(0.0); + let max_short = max_position_abs + current_position.min(0.0); Ok(Self { - min_position: -max_position_abs, - max_position: max_position_abs, + min_position: -max_short, + max_position: max_long, penalty_coeff: 1.0, // Moderate penalty soft_threshold_fraction: 0.8, // Penalize beyond 80% of limit }) @@ -570,6 +580,54 @@ mod tests { assert!(!constraints.is_within_soft_bounds(2.0)); } + #[test] + fn test_from_state_asymmetric_bounds_long() { + let device = Device::Cpu; + // State with current_position = 1.5 in first element + let mut state_data = vec![0.0f32; 64]; + state_data[0] = 1.5; + let state = Tensor::new(state_data.as_slice(), &device) + .unwrap() + .reshape((1, 64)) + .unwrap(); + let constraints = ContinuousActionConstraints::from_state(&state, 2.0).unwrap(); + + // max_long = 2.0 - max(1.5, 0.0) = 0.5 + assert!((constraints.max_position - 0.5).abs() < 1e-6); + // max_short = 2.0 + min(1.5, 0.0) = 2.0, so min_position = -2.0 + assert!((constraints.min_position - (-2.0)).abs() < 1e-6); + } + + #[test] + fn test_from_state_asymmetric_bounds_short() { + let device = Device::Cpu; + // State with current_position = -1.0 + let mut state_data = vec![0.0f32; 64]; + state_data[0] = -1.0; + let state = Tensor::new(state_data.as_slice(), &device) + .unwrap() + .reshape((1, 64)) + .unwrap(); + let constraints = ContinuousActionConstraints::from_state(&state, 2.0).unwrap(); + + // max_long = 2.0 - max(-1.0, 0.0) = 2.0 + assert!((constraints.max_position - 2.0).abs() < 1e-6); + // max_short = 2.0 + min(-1.0, 0.0) = 1.0, so min_position = -1.0 + assert!((constraints.min_position - (-1.0)).abs() < 1e-6); + } + + #[test] + fn test_from_state_flat_position_symmetric() { + let device = Device::Cpu; + // State with current_position = 0.0 (flat) + let state = Tensor::zeros((1, 64), DType::F32, &device).unwrap(); + let constraints = ContinuousActionConstraints::from_state(&state, 2.0).unwrap(); + + // Symmetric: max_long = 2.0, max_short = 2.0 + assert!((constraints.max_position - 2.0).abs() < 1e-6); + assert!((constraints.min_position - (-2.0)).abs() < 1e-6); + } + #[test] fn test_mask_continuous_actions_integration() { let device = Device::Cpu; diff --git a/ml/src/safety/memory_manager.rs b/ml/src/safety/memory_manager.rs index 60dc3b457..9dbca86b7 100644 --- a/ml/src/safety/memory_manager.rs +++ b/ml/src/safety/memory_manager.rs @@ -358,15 +358,9 @@ impl SafeMemoryManager { usage.reset_peak(); } - // Force garbage collection hint (if applicable) - // Note: Rust does not have a standard garbage collector - // This is a placeholder for future integration with alternative GC implementations - #[cfg(feature = "gc")] - { - // TODO: Integrate with a Rust GC library like `gc` or `rust-gc` if needed - // For now, this is a no-op as Rust uses RAII and ownership for memory management - tracing::debug!("GC hint requested but no GC is available in standard Rust"); - } + // No garbage collector needed: Rust's ownership model and RAII handle + // deallocation automatically when Tensors and VarMaps go out of scope. + // GPU memory (CUDA/Metal) is freed when candle Tensors are dropped. info!("Memory cleanup completed for device: {}", device_key); Ok(())