Files
foxhunt/crates/ml-dqn/src/experience.rs
jgrusewski 78fd699946 feat: core hyperopt families (30D→14D) + regime distribution logging per fold
Task 11: Restructure PSO search space from 30D to 14D. Group 21 individual
params into 5 core families (learning, exploration, replay, architecture,
risk) with intensity scalars. Keep gamma, iqn_lambda, c51_warmup_epochs as
independent breakout dimensions. Fix batch_size, tx_cost, v_max, min_hold
at TOML defaults. Hyperopt adapter: -1340/+655 lines (massive simplification).

Task 12: Add regime distribution logging to walk-forward evaluation. Each
fold now reports Trending/Ranging/Volatile percentages alongside Sharpe.
Stored in TrainingMetrics.additional_metrics for downstream JSON export.
Three utility functions (Vec<f32>, flat f32, flat f64) + 7 unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:05:25 +02:00

346 lines
11 KiB
Rust

//! 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<f32>,
/// Action taken (as integer index)
pub action: u8,
/// Reward received (scaled to fixed-point)
pub reward: i32,
/// Next state representation
pub next_state: Vec<f32>,
/// 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<f32>]) -> (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<f32>, action: u8, reward: f32, next_state: Vec<f32>, 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<Experience>,
/// Number of experiences in batch
pub batch_size: usize,
}
impl ExperienceBatch {
/// Create a new batch from experiences
pub fn new(experiences: Vec<Experience>) -> 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<f32>>, Vec<u8>, Vec<f32>, Vec<Vec<f32>>, Vec<bool>) {
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);
}
}