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>
346 lines
11 KiB
Rust
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);
|
|
}
|
|
}
|