//! Experience replay data structures use std::time::{SystemTime, UNIX_EPOCH}; // CANONICAL TYPE IMPORTS - Use common::Decimal use serde::{Deserialize, Serialize}; /// Experience tuple for `DQN` replay buffer #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Experience { /// Current state representation pub state: Vec, /// Action taken (as integer index) pub action: u8, /// Reward received (scaled to fixed-point) pub reward: i32, /// Next state representation pub next_state: Vec, /// Whether this was a terminal state pub done: bool, /// Experience timestamp pub timestamp: u64, /// Market regime at transition time: 0=Trending, 1=Ranging, 2=Volatile pub regime: u8, } /// Classify market regime from a state feature vector. /// /// Uses ADX (index 40) and CUSUM (index 41) to determine the market regime: /// - 0 = Trending (ADX > 0.25) /// - 1 = Ranging (default / low ADX, low CUSUM) /// - 2 = Volatile (|CUSUM| > 0.7) /// /// Returns 1 (Ranging) as safe default when the state vector is too short. pub fn classify_regime_from_state(state: &[f32]) -> u8 { let adx = if state.len() > 40 { state[40] } else { 0.0 }; let cusum = if state.len() > 41 { state[41] } else { 0.0 }; if adx > 0.25 { 0 // Trending } else if cusum.abs() > 0.7 { 2 // Volatile } else { 1 // Ranging } } /// Count regime distribution over a batch of state feature vectors. /// Returns `(trending_pct, ranging_pct, volatile_pct)` as percentages `[0.0, 100.0]`. pub fn regime_distribution(states: &[Vec]) -> (f64, f64, f64) { if states.is_empty() { return (0.0, 100.0, 0.0); // default: all ranging } let mut counts = [0usize; 3]; // trending, ranging, volatile for state in states { let regime = classify_regime_from_state(state) as usize; if regime < 3 { counts[regime] += 1; } } let total = states.len() as f64; ( counts[0] as f64 / total * 100.0, counts[1] as f64 / total * 100.0, counts[2] as f64 / total * 100.0, ) } /// Count regime distribution over flat state buffer (f32). /// `states_flat`: `[num_samples * state_dim]`, row-major. pub fn regime_distribution_flat(states_flat: &[f32], state_dim: usize) -> (f64, f64, f64) { if states_flat.is_empty() || state_dim == 0 { return (0.0, 100.0, 0.0); } let num_samples = states_flat.len() / state_dim; let mut counts = [0usize; 3]; for i in 0..num_samples { let start = i * state_dim; let end = (start + state_dim).min(states_flat.len()); let state = &states_flat[start..end]; let regime = classify_regime_from_state(state) as usize; if regime < 3 { counts[regime] += 1; } } let total = num_samples as f64; ( counts[0] as f64 / total * 100.0, counts[1] as f64 / total * 100.0, counts[2] as f64 / total * 100.0, ) } /// Count regime distribution over flat state buffer (f64). /// `states_flat`: `[num_samples * state_dim]`, row-major. /// Converts each row to f32 on the fly for `classify_regime_from_state`. pub fn regime_distribution_flat_f64(states_flat: &[f64], state_dim: usize) -> (f64, f64, f64) { if states_flat.is_empty() || state_dim == 0 { return (0.0, 100.0, 0.0); } let num_samples = states_flat.len() / state_dim; let mut counts = [0usize; 3]; // Only ADX (idx 40) and CUSUM (idx 41) are read, so convert on the fly for i in 0..num_samples { let start = i * state_dim; let adx = if state_dim > 40 { states_flat.get(start + 40).copied().unwrap_or(0.0) } else { 0.0 }; let cusum = if state_dim > 41 { states_flat.get(start + 41).copied().unwrap_or(0.0) } else { 0.0 }; let regime = if adx > 0.25 { 0 // Trending } else if cusum.abs() > 0.7 { 2 // Volatile } else { 1 // Ranging }; counts[regime] += 1; } let total = num_samples as f64; ( counts[0] as f64 / total * 100.0, counts[1] as f64 / total * 100.0, counts[2] as f64 / total * 100.0, ) } impl Experience { /// Create a new experience pub fn new(state: Vec, action: u8, reward: f32, next_state: Vec, done: bool) -> Self { let regime = classify_regime_from_state(&state); Self { state, action, reward: (reward * 1_000_000.0) as i32, // Scale to fixed-point (100x more precision for DSR values) next_state, done, timestamp: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos() as u64, regime, } } /// Get reward as f32 pub fn reward_f32(&self) -> f32 { self.reward as f32 / 1_000_000.0 } /// Check if experience is valid pub fn is_valid(&self) -> bool { !self.state.is_empty() && !self.next_state.is_empty() && self.state.len() == self.next_state.len() } } /// Batch of experiences for training #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExperienceBatch { /// Batch of experiences pub experiences: Vec, /// Number of experiences in batch pub batch_size: usize, } impl ExperienceBatch { /// Create a new batch from experiences pub fn new(experiences: Vec) -> Self { let batch_size = experiences.len(); Self { experiences, batch_size, } } /// Create empty batch pub const fn empty() -> Self { Self { experiences: Vec::new(), batch_size: 0, } } /// Check if batch is valid pub fn is_valid(&self) -> bool { self.batch_size == self.experiences.len() && self.experiences.iter().all(|e| e.is_valid()) } /// Convert batch to tensor format for training pub fn to_tensors(&self) -> (Vec>, Vec, Vec, Vec>, Vec) { let states = self.experiences.iter().map(|e| e.state.clone()).collect(); let actions = self.experiences.iter().map(|e| e.action).collect(); let rewards = self.experiences.iter().map(|e| e.reward_f32()).collect(); let next_states = self .experiences .iter() .map(|e| e.next_state.clone()) .collect(); let dones = self.experiences.iter().map(|e| e.done).collect(); (states, actions, rewards, next_states, dones) } /// Add experience to batch pub fn add(&mut self, experience: Experience) { self.experiences.push(experience); self.batch_size = self.experiences.len(); } /// Get batch size pub const fn len(&self) -> usize { self.batch_size } /// Check if batch is empty pub const fn is_empty(&self) -> bool { self.batch_size == 0 } } #[cfg(test)] mod tests { use super::*; #[test] fn test_experience_creation() { let state = vec![1.0, 2.0, 3.0]; let next_state = vec![1.1, 2.1, 3.1]; let experience = Experience::new(state.clone(), 2, 0.5, next_state.clone(), false); assert_eq!(experience.state, state); assert_eq!(experience.action, 2); assert_eq!(experience.reward, 500000); // 0.5 * 1_000_000 assert_eq!(experience.next_state, next_state); assert!(!experience.done); assert!(experience.is_valid()); } #[test] fn test_experience_batch() { let experiences = vec![ Experience::new(vec![1.0, 2.0], 0, 0.1, vec![1.1, 2.1], false), Experience::new(vec![2.0, 3.0], 1, 0.2, vec![2.1, 3.1], false), ]; let batch = ExperienceBatch::new(experiences); assert_eq!(batch.batch_size, 2); assert!(batch.is_valid()); let (states, actions, rewards, _next_states, _dones) = batch.to_tensors(); assert_eq!(states.len(), 2); assert_eq!(actions, vec![0, 1]); assert_eq!(rewards, vec![0.1, 0.2]); } #[test] fn test_regime_distribution_empty() { let (t, r, v) = regime_distribution(&[]); assert!((t - 0.0).abs() < f64::EPSILON); assert!((r - 100.0).abs() < f64::EPSILON); assert!((v - 0.0).abs() < f64::EPSILON); } #[test] fn test_regime_distribution_all_trending() { // ADX at index 40 > 0.25 => Trending let state = { let mut s = vec![0.0_f32; 42]; s[40] = 0.5; // high ADX s }; let states = vec![state.clone(), state.clone(), state]; let (t, r, v) = regime_distribution(&states); assert!((t - 100.0).abs() < f64::EPSILON); assert!((r - 0.0).abs() < f64::EPSILON); assert!((v - 0.0).abs() < f64::EPSILON); } #[test] fn test_regime_distribution_mixed() { let trending = { let mut s = vec![0.0_f32; 42]; s[40] = 0.5; // ADX > 0.25 s }; let ranging = vec![0.0_f32; 42]; // low ADX, low CUSUM let volatile = { let mut s = vec![0.0_f32; 42]; s[41] = 0.9; // |CUSUM| > 0.7 s }; let states = vec![trending, ranging.clone(), ranging, volatile]; let (t, r, v) = regime_distribution(&states); assert!((t - 25.0).abs() < f64::EPSILON); assert!((r - 50.0).abs() < f64::EPSILON); assert!((v - 25.0).abs() < f64::EPSILON); } #[test] fn test_regime_distribution_flat_basic() { // 2 samples, state_dim=42 let mut flat = vec![0.0_f32; 84]; flat[40] = 0.5; // sample 0: Trending (ADX > 0.25) // sample 1: all zeros => Ranging let (t, r, v) = regime_distribution_flat(&flat, 42); assert!((t - 50.0).abs() < f64::EPSILON); assert!((r - 50.0).abs() < f64::EPSILON); assert!((v - 0.0).abs() < f64::EPSILON); } #[test] fn test_regime_distribution_flat_f64_basic() { // 3 samples, state_dim=42 let mut flat = vec![0.0_f64; 126]; flat[40] = 0.5; // sample 0: Trending // sample 1: all zeros => Ranging flat[2 * 42 + 41] = 0.9; // sample 2: Volatile let (t, r, v) = regime_distribution_flat_f64(&flat, 42); assert!((t - 100.0 / 3.0).abs() < 0.1); assert!((r - 100.0 / 3.0).abs() < 0.1); assert!((v - 100.0 / 3.0).abs() < 0.1); } #[test] fn test_regime_distribution_flat_empty() { let (t, r, v) = regime_distribution_flat(&[], 42); assert!((t - 0.0).abs() < f64::EPSILON); assert!((r - 100.0).abs() < f64::EPSILON); assert!((v - 0.0).abs() < f64::EPSILON); } #[test] fn test_regime_distribution_flat_f64_empty() { let (t, r, v) = regime_distribution_flat_f64(&[], 42); assert!((t - 0.0).abs() < f64::EPSILON); assert!((r - 100.0).abs() < f64::EPSILON); assert!((v - 0.0).abs() < f64::EPSILON); } }