Merge branch 'worktree-dtype-cleanup'
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -218,6 +218,7 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"rust_decimal",
|
||||
"rustls 0.23.32",
|
||||
"secrecy 0.8.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1652,7 +1653,6 @@ dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1799,7 +1799,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
"bigdecimal",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"common",
|
||||
@@ -11309,7 +11308,6 @@ dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
"bigdecimal",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
|
||||
@@ -383,15 +383,15 @@ impl RealDataLoader {
|
||||
|
||||
Ok(Indicators {
|
||||
rsi: self.calculate_rsi(&closes, 14)?,
|
||||
macd: self.calculate_macd(&closes, 12, 26)?,
|
||||
macd_signal: self.calculate_macd_signal(&closes, 12, 26, 9)?,
|
||||
macd: self.calculate_macd(&closes, 12, 26)?.iter().map(|&x| x as f32).collect(),
|
||||
macd_signal: self.calculate_macd_signal(&closes, 12, 26, 9)?.iter().map(|&x| x as f32).collect(),
|
||||
bb_upper: self.calculate_bb_upper(&closes, 20, 2.0)?,
|
||||
bb_middle: self.calculate_sma(&closes, 20)?,
|
||||
bb_middle: self.calculate_sma(&closes, 20)?.iter().map(|&x| x as f32).collect(),
|
||||
bb_lower: self.calculate_bb_lower(&closes, 20, 2.0)?,
|
||||
atr: self.calculate_atr(bars, 14)?,
|
||||
ema_fast: self.calculate_ema(&closes, 12)?,
|
||||
ema_slow: self.calculate_ema(&closes, 26)?,
|
||||
volume_ma: self.calculate_sma(&volumes, 20)?,
|
||||
ema_fast: self.calculate_ema(&closes, 12)?.iter().map(|&x| x as f32).collect(),
|
||||
ema_slow: self.calculate_ema(&closes, 26)?.iter().map(|&x| x as f32).collect(),
|
||||
volume_ma: self.calculate_sma(&volumes, 20)?.iter().map(|&x| x as f32).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -436,7 +436,10 @@ impl RealDataLoader {
|
||||
}
|
||||
|
||||
/// Calculate MACD line
|
||||
fn calculate_macd(&self, prices: &[f64], fast: usize, slow: usize) -> Result<Vec<f32>> {
|
||||
///
|
||||
/// Returns f64 to avoid precision-losing f64→f32→f64 round-trips
|
||||
/// when the result feeds back into `calculate_ema` for the signal line.
|
||||
fn calculate_macd(&self, prices: &[f64], fast: usize, slow: usize) -> Result<Vec<f64>> {
|
||||
let ema_fast = self.calculate_ema(prices, fast)?;
|
||||
let ema_slow = self.calculate_ema(prices, slow)?;
|
||||
|
||||
@@ -454,10 +457,9 @@ impl RealDataLoader {
|
||||
fast: usize,
|
||||
slow: usize,
|
||||
signal: usize,
|
||||
) -> Result<Vec<f32>> {
|
||||
) -> Result<Vec<f64>> {
|
||||
let macd = self.calculate_macd(prices, fast, slow)?;
|
||||
let macd_f64: Vec<f64> = macd.iter().map(|&x| x as f64).collect();
|
||||
self.calculate_ema(&macd_f64, signal)
|
||||
self.calculate_ema(&macd, signal)
|
||||
}
|
||||
|
||||
/// Calculate Bollinger upper band
|
||||
@@ -476,7 +478,7 @@ impl RealDataLoader {
|
||||
let variance = window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
upper.push((sma[i] as f64 + num_std * std_dev) as f32);
|
||||
upper.push((sma[i] + num_std * std_dev) as f32);
|
||||
}
|
||||
|
||||
Ok(upper)
|
||||
@@ -498,7 +500,7 @@ impl RealDataLoader {
|
||||
let variance = window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / period as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
lower.push((sma[i] as f64 - num_std * std_dev) as f32);
|
||||
lower.push((sma[i] - num_std * std_dev) as f32);
|
||||
}
|
||||
|
||||
Ok(lower)
|
||||
@@ -540,7 +542,10 @@ impl RealDataLoader {
|
||||
}
|
||||
|
||||
/// Calculate EMA (Exponential Moving Average)
|
||||
fn calculate_ema(&self, prices: &[f64], period: usize) -> Result<Vec<f32>> {
|
||||
///
|
||||
/// Returns f64 to avoid precision-losing f64→f32→f64 round-trips
|
||||
/// when the result feeds back into further f64 arithmetic (e.g. MACD).
|
||||
fn calculate_ema(&self, prices: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
if prices.len() < period {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Need at least {} prices for EMA (got {})",
|
||||
@@ -554,31 +559,33 @@ impl RealDataLoader {
|
||||
|
||||
// Start with SMA for first period
|
||||
let initial_sma = prices[..period].iter().sum::<f64>() / period as f64;
|
||||
ema.extend(vec![initial_sma as f32; period]);
|
||||
ema.extend(vec![initial_sma; period]);
|
||||
|
||||
// Calculate EMA for remaining values
|
||||
for i in period..prices.len() {
|
||||
let prev_ema = ema[i - 1] as f64;
|
||||
let new_ema = alpha * prices[i] + (1.0 - alpha) * prev_ema;
|
||||
ema.push(new_ema as f32);
|
||||
let new_ema = alpha * prices[i] + (1.0 - alpha) * ema[i - 1];
|
||||
ema.push(new_ema);
|
||||
}
|
||||
|
||||
Ok(ema)
|
||||
}
|
||||
|
||||
/// Calculate SMA (Simple Moving Average)
|
||||
fn calculate_sma(&self, prices: &[f64], period: usize) -> Result<Vec<f32>> {
|
||||
///
|
||||
/// Returns f64 to avoid precision-losing f64→f32→f64 round-trips
|
||||
/// when the result feeds back into further f64 arithmetic (e.g. Bollinger bands).
|
||||
fn calculate_sma(&self, prices: &[f64], period: usize) -> Result<Vec<f64>> {
|
||||
let mut sma = Vec::with_capacity(prices.len());
|
||||
|
||||
for i in 0..prices.len() {
|
||||
if i < period - 1 {
|
||||
sma.push(prices[i] as f32);
|
||||
sma.push(prices[i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let window = &prices[i - period + 1..=i];
|
||||
let avg = window.iter().sum::<f64>() / period as f64;
|
||||
sma.push(avg as f32);
|
||||
sma.push(avg);
|
||||
}
|
||||
|
||||
Ok(sma)
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::dqn::mixed_precision::training_dtype;
|
||||
use candle_optimisers::adam::ParamsAdam;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::multi_step::{create_multi_step_transition, MultiStepCalculator, MultiStepTransition};
|
||||
use super::multi_step::{MultiStepCalculator, MultiStepTransition};
|
||||
use super::rainbow_config::{RainbowAgentConfig, RainbowAgentMetrics, TrainingResult};
|
||||
use super::rainbow_network::RainbowNetwork;
|
||||
use super::{Experience, ReplayBuffer, ReplayBufferConfig};
|
||||
@@ -169,16 +169,6 @@ impl RainbowAgent {
|
||||
|
||||
/// Add experience to replay buffer and multi-step calculator
|
||||
pub fn add_experience(&self, experience: Experience) -> Result<(), MLError> {
|
||||
// Convert experience to multi-step transition
|
||||
let _transition = create_multi_step_transition(
|
||||
experience.state.iter().map(|&x| x as f64).collect(),
|
||||
experience.action as i64,
|
||||
experience.reward as f64,
|
||||
experience.next_state.iter().map(|&x| x as f64).collect(),
|
||||
experience.done,
|
||||
0, // timestep will be set by calculator
|
||||
);
|
||||
|
||||
// Add to replay buffer
|
||||
{
|
||||
let buffer = self.replay_buffer.lock().map_err(|_| {
|
||||
|
||||
@@ -1695,7 +1695,7 @@ impl PPOTrainer {
|
||||
|
||||
// Apply composite risk-adjusted reward (downside dev + differential return)
|
||||
if let Some(ref mut comp) = composite_reward {
|
||||
reward = comp.compute(reward as f64) as f32;
|
||||
reward = comp.compute(reward);
|
||||
}
|
||||
|
||||
// Apply reward shaping (hold penalty + rolling Sharpe + diversity bonus)
|
||||
@@ -1713,11 +1713,11 @@ impl PPOTrainer {
|
||||
false
|
||||
};
|
||||
reward = shaper.shape_reward(
|
||||
reward as f64,
|
||||
reward,
|
||||
is_flat,
|
||||
has_signal,
|
||||
action.to_index(),
|
||||
) as f32;
|
||||
);
|
||||
}
|
||||
|
||||
let done = step_idx + 1 >= max_episode_length;
|
||||
|
||||
@@ -75,7 +75,8 @@ impl CompositeReward {
|
||||
///
|
||||
/// # Returns
|
||||
/// Composite reward combining all components
|
||||
pub fn compute(&mut self, raw_return: f64) -> f64 {
|
||||
pub fn compute(&mut self, raw_return: f32) -> f32 {
|
||||
let raw_return = f64::from(raw_return);
|
||||
// Update portfolio tracking
|
||||
self.current_value *= 1.0 + raw_return;
|
||||
if self.current_value > self.peak_value {
|
||||
@@ -103,7 +104,7 @@ impl CompositeReward {
|
||||
let differential = self.compute_differential_return(raw_return);
|
||||
let differential_component = differential * self.config.differential_weight;
|
||||
|
||||
return_component + downside_component + differential_component
|
||||
(return_component + downside_component + differential_component) as f32
|
||||
}
|
||||
|
||||
/// Compute downside deviation (semi-variance of negative returns)
|
||||
@@ -184,12 +185,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_composite_reward_positive_return() {
|
||||
let mut reward = CompositeReward::new();
|
||||
let r = reward.compute(0.01); // 1% return
|
||||
let r = reward.compute(0.01_f32); // 1% return
|
||||
// With only 1 data point, downside=0, differential=0 (baseline < 5)
|
||||
// So reward = 0.01 * 1.0 = 0.01
|
||||
assert!(r > 0.0, "Positive return should yield positive reward, got {r}");
|
||||
assert!(
|
||||
(r - 0.01).abs() < 1e-10,
|
||||
(r - 0.01_f32).abs() < 1e-6,
|
||||
"Single positive return should equal return_weight * raw_return"
|
||||
);
|
||||
}
|
||||
@@ -197,7 +198,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_composite_reward_negative_return() {
|
||||
let mut reward = CompositeReward::new();
|
||||
let r = reward.compute(-0.02); // -2% return
|
||||
let r = reward.compute(-0.02_f32); // -2% return
|
||||
// With only 1 data point, downside=0 (need >=2 in window), differential=0
|
||||
// So reward = -0.02 * 1.0 = -0.02
|
||||
assert!(r < 0.0, "Negative return should yield negative reward, got {r}");
|
||||
@@ -207,10 +208,10 @@ mod tests {
|
||||
fn test_composite_reward_downside_deviation() {
|
||||
let mut reward = CompositeReward::new();
|
||||
// Feed a sequence with several losses to build up downside deviation
|
||||
let returns = [
|
||||
let returns: [f32; 10] = [
|
||||
-0.01, -0.02, -0.015, -0.005, 0.003, -0.01, -0.02, 0.001, -0.008, -0.012,
|
||||
];
|
||||
let mut last_reward = 0.0;
|
||||
let mut last_reward = 0.0_f32;
|
||||
for &ret in &returns {
|
||||
last_reward = reward.compute(ret);
|
||||
}
|
||||
@@ -228,10 +229,10 @@ mod tests {
|
||||
let mut reward = CompositeReward::new();
|
||||
// Build a baseline of small returns
|
||||
for _ in 0..10 {
|
||||
reward.compute(0.001); // 0.1% baseline
|
||||
reward.compute(0.001_f32); // 0.1% baseline
|
||||
}
|
||||
// Now a large positive return should get differential bonus
|
||||
let big_return = 0.05; // 5% return — well above 0.1% baseline
|
||||
let big_return = 0.05_f32; // 5% return — well above 0.1% baseline
|
||||
let r = reward.compute(big_return);
|
||||
// The differential component should be positive (outperforming baseline)
|
||||
let pure_return = big_return * 1.0;
|
||||
@@ -247,25 +248,25 @@ mod tests {
|
||||
fn test_composite_reward_drawdown_tracking() {
|
||||
let mut reward = CompositeReward::new();
|
||||
// Go up then down
|
||||
reward.compute(0.10); // Portfolio: 1.0 * 1.10 = 1.10
|
||||
reward.compute(0.10_f32); // Portfolio: 1.0 * 1.10 = 1.10
|
||||
assert!(
|
||||
(reward.current_drawdown()).abs() < 1e-10,
|
||||
(reward.current_drawdown()).abs() < 1e-6,
|
||||
"No drawdown at peak"
|
||||
);
|
||||
|
||||
reward.compute(-0.05); // Portfolio: 1.10 * 0.95 = 1.045
|
||||
reward.compute(-0.05_f32); // Portfolio: 1.10 * 0.95 = 1.045
|
||||
let dd = reward.current_drawdown();
|
||||
assert!(dd > 0.0, "Should have drawdown after loss from peak");
|
||||
// Expected: 1 - 1.045/1.10 = 0.05
|
||||
// Expected: 1 - 1.045/1.10 = 0.05 (tolerance accounts for f32 input promotion)
|
||||
assert!(
|
||||
(dd - 0.05).abs() < 1e-10,
|
||||
(dd - 0.05).abs() < 1e-6,
|
||||
"Drawdown should be ~5%, got {dd}"
|
||||
);
|
||||
|
||||
// Go to new peak
|
||||
reward.compute(0.10); // Portfolio: 1.045 * 1.10 = 1.1495
|
||||
reward.compute(0.10_f32); // Portfolio: 1.045 * 1.10 = 1.1495
|
||||
assert!(
|
||||
reward.current_drawdown().abs() < 1e-10,
|
||||
reward.current_drawdown().abs() < 1e-6,
|
||||
"Should be at new peak, no drawdown"
|
||||
);
|
||||
}
|
||||
@@ -275,7 +276,7 @@ mod tests {
|
||||
let mut reward = CompositeReward::new();
|
||||
// Accumulate some state
|
||||
for i in 0..30 {
|
||||
reward.compute(if i % 2 == 0 { 0.01 } else { -0.005 });
|
||||
reward.compute(if i % 2 == 0 { 0.01_f32 } else { -0.005_f32 });
|
||||
}
|
||||
assert!(!reward.return_window.is_empty());
|
||||
assert!(!reward.baseline_window.is_empty());
|
||||
@@ -297,10 +298,10 @@ mod tests {
|
||||
baseline_period: 30,
|
||||
};
|
||||
let mut reward = CompositeReward::with_config(config);
|
||||
let r = reward.compute(0.01);
|
||||
let r = reward.compute(0.01_f32);
|
||||
// With custom return_weight=2.0, single step: 0.01 * 2.0 = 0.02
|
||||
assert!(
|
||||
(r - 0.02).abs() < 1e-10,
|
||||
(r - 0.02_f32).abs() < 1e-6,
|
||||
"Custom return_weight should scale return: got {r}"
|
||||
);
|
||||
}
|
||||
@@ -309,9 +310,9 @@ mod tests {
|
||||
fn test_composite_reward_all_positive() {
|
||||
let mut reward = CompositeReward::new();
|
||||
// All positive returns — no downside deviation penalty
|
||||
let mut last = 0.0;
|
||||
let mut last = 0.0_f32;
|
||||
for _ in 0..25 {
|
||||
last = reward.compute(0.005);
|
||||
last = reward.compute(0.005_f32);
|
||||
}
|
||||
// Downside deviation should be 0 since no negative returns
|
||||
// The differential component may be close to 0 (all returns are similar)
|
||||
@@ -323,7 +324,7 @@ mod tests {
|
||||
// With 25 data points, baseline mean ~ 0.005, so differential ~ 0
|
||||
// Reward ~ 0.005 * 1.0 + 0 + 0 = 0.005
|
||||
assert!(
|
||||
(last - 0.005).abs() < 0.001,
|
||||
(last - 0.005_f32).abs() < 0.001,
|
||||
"All-positive sequence should have reward near raw return, got {last}"
|
||||
);
|
||||
}
|
||||
@@ -332,7 +333,7 @@ mod tests {
|
||||
fn test_composite_reward_mixed_sequence() {
|
||||
let mut reward = CompositeReward::new();
|
||||
// Realistic trading sequence: mostly small gains, occasional larger losses
|
||||
let returns = [
|
||||
let returns: [f32; 20] = [
|
||||
0.002, 0.001, -0.003, 0.004, 0.001, -0.002, 0.003, -0.005, 0.002, 0.001, -0.001,
|
||||
0.003, 0.002, -0.004, 0.001, 0.002, -0.001, 0.003, -0.002, 0.001,
|
||||
];
|
||||
|
||||
@@ -74,17 +74,20 @@ impl PercentileScaler {
|
||||
|
||||
/// Update running percentiles from a batch of values.
|
||||
///
|
||||
/// Accepts `&[f32]` at the API boundary; converts to f64 internally for
|
||||
/// sorting and percentile computation to preserve numerical precision.
|
||||
///
|
||||
/// Sorts the batch, computes exact P5/P95, then applies EMA update:
|
||||
/// `p_new = decay * p_old + (1 - decay) * p_actual`
|
||||
///
|
||||
/// On first call, directly sets `p5`/`p95` to the actual percentiles
|
||||
/// (no EMA blending with zero).
|
||||
pub fn update(&mut self, values: &[f64]) {
|
||||
pub fn update(&mut self, values: &[f32]) {
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted = values.to_vec();
|
||||
let mut sorted: Vec<f64> = values.iter().map(|&v| f64::from(v)).collect();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let n = sorted.len();
|
||||
@@ -124,25 +127,28 @@ impl PercentileScaler {
|
||||
|
||||
/// Scale a value by the running percentile range.
|
||||
///
|
||||
/// Accepts f32, computes in f64 internally, returns f32.
|
||||
/// Returns `value / max(P95 - P5, min_scale)`.
|
||||
/// If not yet initialized, returns the value unchanged.
|
||||
#[inline]
|
||||
pub fn scale(&self, value: f64) -> f64 {
|
||||
pub fn scale(&self, value: f32) -> f32 {
|
||||
if !self.initialized {
|
||||
return value;
|
||||
}
|
||||
let range = (self.p95 - self.p5).max(self.min_scale);
|
||||
value / range
|
||||
(f64::from(value) / range) as f32
|
||||
}
|
||||
|
||||
/// Scale a batch of values in-place.
|
||||
pub fn scale_batch(&self, values: &mut [f64]) {
|
||||
/// Scale a batch of f32 values in-place.
|
||||
///
|
||||
/// Computes the range in f64, then divides each element.
|
||||
pub fn scale_batch(&self, values: &mut [f32]) {
|
||||
if !self.initialized {
|
||||
return;
|
||||
}
|
||||
let range = (self.p95 - self.p5).max(self.min_scale);
|
||||
for v in values.iter_mut() {
|
||||
*v /= range;
|
||||
*v = (f64::from(*v) / range) as f32;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,22 +183,23 @@ impl PercentileScaler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPSILON: f64 = 1e-6;
|
||||
const EPSILON: f32 = 1e-6;
|
||||
const EPSILON_F64: f64 = 1e-6;
|
||||
|
||||
#[test]
|
||||
fn test_percentile_scaler_new() {
|
||||
let scaler = PercentileScaler::new();
|
||||
assert!(!scaler.is_initialized());
|
||||
assert!(scaler.range().is_none());
|
||||
assert!((scaler.p5() - 0.0).abs() < EPSILON);
|
||||
assert!((scaler.p95() - 0.0).abs() < EPSILON);
|
||||
assert!((scaler.p5() - 0.0).abs() < EPSILON_F64);
|
||||
assert!((scaler.p95() - 0.0).abs() < EPSILON_F64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_percentile_scaler_first_update() {
|
||||
let mut scaler = PercentileScaler::new();
|
||||
// 100 values from 0..100
|
||||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||||
let values: Vec<f32> = (0..100).map(|i| i as f32).collect();
|
||||
scaler.update(&values);
|
||||
|
||||
assert!(scaler.is_initialized());
|
||||
@@ -222,7 +229,7 @@ mod tests {
|
||||
let u = (i as f64 + 0.5) / n as f64;
|
||||
// Approximate inverse normal CDF (Beasley-Springer-Moro)
|
||||
let z = approximate_inv_normal(u);
|
||||
values.push(z);
|
||||
values.push(z as f32);
|
||||
}
|
||||
scaler.update(&values);
|
||||
|
||||
@@ -243,11 +250,11 @@ mod tests {
|
||||
fn test_percentile_scaler_heavy_tail() {
|
||||
// Heavy-tailed data: most values small, a few large outliers
|
||||
let mut scaler = PercentileScaler::new();
|
||||
let mut values: Vec<f64> = (0..990).map(|i| (i as f64 - 495.0) / 495.0).collect();
|
||||
let mut values: Vec<f32> = (0..990).map(|i| ((i as f32 - 495.0) / 495.0)).collect();
|
||||
// Add extreme outliers (1% each tail)
|
||||
for _ in 0..5 {
|
||||
values.push(-100.0);
|
||||
values.push(100.0);
|
||||
values.push(-100.0_f32);
|
||||
values.push(100.0_f32);
|
||||
}
|
||||
scaler.update(&values);
|
||||
|
||||
@@ -264,7 +271,7 @@ mod tests {
|
||||
fn test_percentile_scaler_min_scale() {
|
||||
// Constant values should not cause divide-by-zero
|
||||
let mut scaler = PercentileScaler::new();
|
||||
let values = vec![5.0; 100];
|
||||
let values = vec![5.0_f32; 100];
|
||||
scaler.update(&values);
|
||||
|
||||
// P5 == P95, so range falls back to min_scale
|
||||
@@ -275,9 +282,9 @@ mod tests {
|
||||
);
|
||||
|
||||
// Scaling should not produce Inf or NaN
|
||||
let scaled = scaler.scale(10.0);
|
||||
let scaled = scaler.scale(10.0_f32);
|
||||
assert!(scaled.is_finite(), "scaled value should be finite");
|
||||
assert!((scaled - 10.0).abs() < EPSILON, "10.0 / 1.0 = 10.0, got {scaled}");
|
||||
assert!((scaled - 10.0_f32).abs() < EPSILON, "10.0 / 1.0 = 10.0, got {scaled}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -286,13 +293,13 @@ mod tests {
|
||||
let mut scaler = PercentileScaler::with_params(0.9, 1.0);
|
||||
|
||||
// First batch: values in [0, 10]
|
||||
let batch1: Vec<f64> = (0..100).map(|i| i as f64 / 10.0).collect();
|
||||
let batch1: Vec<f32> = (0..100).map(|i| i as f32 / 10.0).collect();
|
||||
scaler.update(&batch1);
|
||||
let p5_after_first = scaler.p5();
|
||||
let p95_after_first = scaler.p95();
|
||||
|
||||
// Second batch: values in [100, 200]
|
||||
let batch2: Vec<f64> = (0..100).map(|i| 100.0 + i as f64).collect();
|
||||
let batch2: Vec<f32> = (0..100).map(|i| 100.0_f32 + i as f32).collect();
|
||||
scaler.update(&batch2);
|
||||
|
||||
// P5 should have moved toward batch2's P5 (≈ 104.95), but EMA dampens
|
||||
@@ -321,21 +328,21 @@ mod tests {
|
||||
// Realistic trading return magnitudes:
|
||||
// Most returns are tiny (-0.001 to +0.001), occasional larger moves
|
||||
let mut scaler = PercentileScaler::new();
|
||||
let mut values = Vec::new();
|
||||
let mut values: Vec<f32> = Vec::new();
|
||||
|
||||
// 90% tiny returns in [-0.002, 0.002]
|
||||
for i in 0..900 {
|
||||
let r = (i as f64 - 450.0) / 450.0 * 0.002;
|
||||
let r = (i as f32 - 450.0) / 450.0 * 0.002;
|
||||
values.push(r);
|
||||
}
|
||||
// 8% moderate returns in [-0.02, 0.02]
|
||||
for i in 0..80 {
|
||||
let r = (i as f64 - 40.0) / 40.0 * 0.02;
|
||||
let r = (i as f32 - 40.0) / 40.0 * 0.02;
|
||||
values.push(r);
|
||||
}
|
||||
// 2% large returns in [-0.1, 0.1]
|
||||
for i in 0..20 {
|
||||
let r = (i as f64 - 10.0) / 10.0 * 0.1;
|
||||
let r = (i as f32 - 10.0) / 10.0 * 0.1;
|
||||
values.push(r);
|
||||
}
|
||||
|
||||
@@ -361,7 +368,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Scaling a normal return should produce a reasonable magnitude
|
||||
let typical = scaler.scale(0.001);
|
||||
let typical = scaler.scale(0.001_f32);
|
||||
assert!(
|
||||
typical.is_finite(),
|
||||
"scaled typical return should be finite"
|
||||
@@ -371,23 +378,23 @@ mod tests {
|
||||
#[test]
|
||||
fn test_percentile_scaler_scale_batch() {
|
||||
let mut scaler = PercentileScaler::new();
|
||||
let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||||
let values: Vec<f32> = (0..100).map(|i| i as f32).collect();
|
||||
scaler.update(&values);
|
||||
|
||||
let mut batch = vec![10.0, 20.0, 50.0];
|
||||
let mut batch = vec![10.0_f32, 20.0, 50.0];
|
||||
let range = scaler.range().unwrap_or(1.0);
|
||||
scaler.scale_batch(&mut batch);
|
||||
|
||||
assert!(
|
||||
(batch[0] - 10.0 / range).abs() < EPSILON,
|
||||
(f64::from(batch[0]) - 10.0 / range).abs() < EPSILON_F64,
|
||||
"batch[0] should be 10/range"
|
||||
);
|
||||
assert!(
|
||||
(batch[1] - 20.0 / range).abs() < EPSILON,
|
||||
(f64::from(batch[1]) - 20.0 / range).abs() < EPSILON_F64,
|
||||
"batch[1] should be 20/range"
|
||||
);
|
||||
assert!(
|
||||
(batch[2] - 50.0 / range).abs() < EPSILON,
|
||||
(f64::from(batch[2]) - 50.0 / range).abs() < EPSILON_F64,
|
||||
"batch[2] should be 50/range"
|
||||
);
|
||||
}
|
||||
@@ -402,24 +409,24 @@ mod tests {
|
||||
#[test]
|
||||
fn test_percentile_scaler_single_value() {
|
||||
let mut scaler = PercentileScaler::new();
|
||||
scaler.update(&[42.0]);
|
||||
scaler.update(&[42.0_f32]);
|
||||
assert!(scaler.is_initialized());
|
||||
// P5 == P95 == 42.0, range falls back to min_scale
|
||||
assert!((scaler.p5() - 42.0).abs() < EPSILON);
|
||||
assert!((scaler.p95() - 42.0).abs() < EPSILON);
|
||||
assert!((scaler.p5() - 42.0).abs() < EPSILON_F64);
|
||||
assert!((scaler.p95() - 42.0).abs() < EPSILON_F64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_percentile_scaler_uninitialized_passthrough() {
|
||||
let scaler = PercentileScaler::new();
|
||||
// Before initialization, scale should be passthrough
|
||||
assert!((scaler.scale(42.0) - 42.0).abs() < EPSILON);
|
||||
assert!((scaler.scale(42.0_f32) - 42.0_f32).abs() < EPSILON);
|
||||
|
||||
let mut batch = vec![1.0, 2.0, 3.0];
|
||||
let mut batch = vec![1.0_f32, 2.0, 3.0];
|
||||
scaler.scale_batch(&mut batch);
|
||||
assert!((batch[0] - 1.0).abs() < EPSILON);
|
||||
assert!((batch[1] - 2.0).abs() < EPSILON);
|
||||
assert!((batch[2] - 3.0).abs() < EPSILON);
|
||||
assert!((batch[0] - 1.0_f32).abs() < EPSILON);
|
||||
assert!((batch[1] - 2.0_f32).abs() < EPSILON);
|
||||
assert!((batch[2] - 3.0_f32).abs() < EPSILON);
|
||||
}
|
||||
|
||||
// ---- Helper for approximate normal distribution ----
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::dqn::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
|
||||
use crate::dqn::mixed_precision::training_dtype;
|
||||
use crate::dqn::portfolio_tracker::PortfolioTracker;
|
||||
use crate::dqn::xavier_init::linear_xavier;
|
||||
use crate::dqn::reward::RewardNormalizer;
|
||||
use crate::ppo::reward_normalizer::RewardNormalizer;
|
||||
use crate::common::action::FactoredAction;
|
||||
use crate::MLError;
|
||||
|
||||
@@ -965,24 +965,20 @@ impl PPO {
|
||||
// Apply reward normalization if enabled
|
||||
if let Some(ref mut normalizer) = self.reward_normalizer {
|
||||
for reward in &batch.rewards {
|
||||
normalizer.update(*reward as f64);
|
||||
normalizer.update(*reward);
|
||||
}
|
||||
// Normalize all rewards in batch
|
||||
let normalized_rewards: Vec<f32> = batch.rewards.iter()
|
||||
.map(|&r| {
|
||||
let norm = normalizer.normalize(r as f64);
|
||||
norm.clamp(-1.0, 1.0) as f32
|
||||
})
|
||||
.map(|&r| normalizer.normalize(r).clamp(-1.0_f32, 1.0_f32))
|
||||
.collect();
|
||||
batch.rewards = normalized_rewards;
|
||||
}
|
||||
|
||||
// Normalize advantages (percentile scaling or standard mean/std)
|
||||
if let Some(ref mut scaler) = self.percentile_scaler {
|
||||
let adv_f64: Vec<f64> = batch.advantages.iter().map(|&a| a as f64).collect();
|
||||
scaler.update(&adv_f64);
|
||||
scaler.update(&batch.advantages);
|
||||
for adv in &mut batch.advantages {
|
||||
*adv = scaler.scale(*adv as f64) as f32;
|
||||
*adv = scaler.scale(*adv);
|
||||
}
|
||||
} else {
|
||||
batch.normalize_advantages()?;
|
||||
@@ -1028,23 +1024,19 @@ impl PPO {
|
||||
// Apply reward normalization if enabled
|
||||
if let Some(ref mut normalizer) = self.reward_normalizer {
|
||||
for reward in &batch.rewards {
|
||||
normalizer.update(*reward as f64);
|
||||
normalizer.update(*reward);
|
||||
}
|
||||
let normalized_rewards: Vec<f32> = batch.rewards.iter()
|
||||
.map(|&r| {
|
||||
let norm = normalizer.normalize(r as f64);
|
||||
norm.clamp(-1.0, 1.0) as f32
|
||||
})
|
||||
.map(|&r| normalizer.normalize(r).clamp(-1.0_f32, 1.0_f32))
|
||||
.collect();
|
||||
batch.rewards = normalized_rewards;
|
||||
}
|
||||
|
||||
// Normalize advantages (needed for returns computation consistency)
|
||||
if let Some(ref mut scaler) = self.percentile_scaler {
|
||||
let adv_f64: Vec<f64> = batch.advantages.iter().map(|&a| a as f64).collect();
|
||||
scaler.update(&adv_f64);
|
||||
scaler.update(&batch.advantages);
|
||||
for adv in &mut batch.advantages {
|
||||
*adv = scaler.scale(*adv as f64) as f32;
|
||||
*adv = scaler.scale(*adv);
|
||||
}
|
||||
} else {
|
||||
batch.normalize_advantages()?;
|
||||
|
||||
@@ -92,7 +92,8 @@ impl RewardNormalizer {
|
||||
/// - After 500 steps: weight = 0.007 (99.3% decay)
|
||||
///
|
||||
/// This allows the mean to "forget" old regimes and adapt to new ones.
|
||||
pub fn update(&mut self, value: f64) {
|
||||
pub fn update(&mut self, value: f32) {
|
||||
let value = f64::from(value);
|
||||
if !self.initialized {
|
||||
// First sample: initialize mean and variance
|
||||
self.mean = value;
|
||||
@@ -101,7 +102,7 @@ impl RewardNormalizer {
|
||||
} else {
|
||||
// EMA update for mean
|
||||
self.mean = self.alpha * value + (1.0 - self.alpha) * self.mean;
|
||||
|
||||
|
||||
// EMA update for variance
|
||||
let diff = value - self.mean;
|
||||
self.variance = self.beta * diff.powi(2) + (1.0 - self.beta) * self.variance;
|
||||
@@ -112,20 +113,21 @@ impl RewardNormalizer {
|
||||
///
|
||||
/// # Returns
|
||||
/// `(value - mean) / std` if initialized and std > epsilon, else value unchanged
|
||||
pub fn normalize(&self, value: f64) -> f64 {
|
||||
pub fn normalize(&self, value: f32) -> f32 {
|
||||
// Need at least one sample to have valid statistics
|
||||
if !self.initialized {
|
||||
return value;
|
||||
}
|
||||
|
||||
let value = f64::from(value);
|
||||
let std = self.variance.sqrt();
|
||||
|
||||
// Avoid division by zero for constant values
|
||||
if std < self.epsilon {
|
||||
return value;
|
||||
return value as f32;
|
||||
}
|
||||
|
||||
(value - self.mean) / std
|
||||
((value - self.mean) / std) as f32
|
||||
}
|
||||
|
||||
/// Get current mean and standard deviation
|
||||
|
||||
@@ -84,11 +84,12 @@ impl PPORewardShaper {
|
||||
/// `shaped = raw_reward + hold_penalty + sharpe_component + diversity_bonus`
|
||||
pub fn shape_reward(
|
||||
&mut self,
|
||||
raw_reward: f64,
|
||||
raw_reward: f32,
|
||||
is_flat: bool,
|
||||
has_signal: bool,
|
||||
action_index: usize,
|
||||
) -> f64 {
|
||||
) -> f32 {
|
||||
let raw_reward = f64::from(raw_reward);
|
||||
let mut shaped = raw_reward;
|
||||
|
||||
// 1. Hold penalty: penalize flat position when signals exist
|
||||
@@ -123,7 +124,7 @@ impl PPORewardShaper {
|
||||
shaped += self.diversity_weight * normalized * normalized;
|
||||
}
|
||||
|
||||
shaped
|
||||
shaped as f32
|
||||
}
|
||||
|
||||
/// Compute rolling Sharpe ratio from return window.
|
||||
@@ -204,8 +205,8 @@ mod tests {
|
||||
fn test_reward_shaping_no_penalty_when_not_flat() {
|
||||
let mut shaper = PPORewardShaper::new(0.5, 0.0, 0.0);
|
||||
// Not flat => no hold penalty even with signal
|
||||
let shaped = shaper.shape_reward(1.0, false, true, 0);
|
||||
assert!((shaped - 1.0).abs() < 1e-10, "Expected 1.0, got {}", shaped);
|
||||
let shaped = shaper.shape_reward(1.0_f32, false, true, 0);
|
||||
assert!((shaped - 1.0_f32).abs() < 1e-6, "Expected 1.0, got {}", shaped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,14 +214,14 @@ mod tests {
|
||||
let mut shaper = PPORewardShaper::new(0.5, 0.0, 0.0);
|
||||
|
||||
// Flat with signal => penalty applied
|
||||
let shaped = shaper.shape_reward(1.0, true, true, 0);
|
||||
assert!((shaped - 0.5).abs() < 1e-10, "Expected 0.5, got {}", shaped);
|
||||
let shaped = shaper.shape_reward(1.0_f32, true, true, 0);
|
||||
assert!((shaped - 0.5_f32).abs() < 1e-6, "Expected 0.5, got {}", shaped);
|
||||
|
||||
// Flat without signal => no penalty
|
||||
shaper.reset();
|
||||
let shaped_no_signal = shaper.shape_reward(1.0, true, false, 0);
|
||||
let shaped_no_signal = shaper.shape_reward(1.0_f32, true, false, 0);
|
||||
assert!(
|
||||
(shaped_no_signal - 1.0).abs() < 1e-10,
|
||||
(shaped_no_signal - 1.0_f32).abs() < 1e-6,
|
||||
"Expected 1.0, got {}",
|
||||
shaped_no_signal
|
||||
);
|
||||
@@ -232,13 +233,13 @@ mod tests {
|
||||
|
||||
// Feed consistent positive returns to build up Sharpe
|
||||
for _ in 0..10 {
|
||||
shaper.shape_reward(1.0, false, false, 0);
|
||||
shaper.shape_reward(1.0_f32, false, false, 0);
|
||||
}
|
||||
// All returns identical => std ~ 0, Sharpe returns 0 (division guard)
|
||||
// Feed slightly varied positive returns instead
|
||||
shaper.reset();
|
||||
for i in 0..10 {
|
||||
let reward = 1.0 + (i as f64) * 0.1;
|
||||
let reward = 1.0_f32 + (i as f32) * 0.1;
|
||||
shaper.shape_reward(reward, false, false, 0);
|
||||
}
|
||||
|
||||
@@ -247,7 +248,7 @@ mod tests {
|
||||
assert!(sharpe > 0.0, "Expected positive Sharpe, got {}", sharpe);
|
||||
|
||||
// Shaped reward should be above raw because sharpe_weight * sharpe > 0
|
||||
let raw = 1.5;
|
||||
let raw = 1.5_f32;
|
||||
let shaped = shaper.shape_reward(raw, false, false, 0);
|
||||
assert!(shaped > raw, "Expected shaped ({}) > raw ({})", shaped, raw);
|
||||
}
|
||||
@@ -257,19 +258,19 @@ mod tests {
|
||||
// Diverse actions
|
||||
let mut diverse_shaper = PPORewardShaper::new(0.0, 0.0, 1.0);
|
||||
for i in 0..20 {
|
||||
diverse_shaper.shape_reward(0.0, false, false, i % NUM_FACTORED_ACTIONS);
|
||||
diverse_shaper.shape_reward(0.0_f32, false, false, i % NUM_FACTORED_ACTIONS);
|
||||
}
|
||||
|
||||
// Repeated single action
|
||||
let mut mono_shaper = PPORewardShaper::new(0.0, 0.0, 1.0);
|
||||
for _ in 0..20 {
|
||||
mono_shaper.shape_reward(0.0, false, false, 0);
|
||||
mono_shaper.shape_reward(0.0_f32, false, false, 0);
|
||||
}
|
||||
|
||||
// The diverse shaper should yield higher cumulative diversity bonus.
|
||||
// We check the next call: diverse should give more bonus.
|
||||
let diverse_reward = diverse_shaper.shape_reward(0.0, false, false, 5);
|
||||
let mono_reward = mono_shaper.shape_reward(0.0, false, false, 0);
|
||||
let diverse_reward = diverse_shaper.shape_reward(0.0_f32, false, false, 5);
|
||||
let mono_reward = mono_shaper.shape_reward(0.0_f32, false, false, 0);
|
||||
assert!(
|
||||
diverse_reward > mono_reward,
|
||||
"Diverse ({}) should exceed monotone ({})",
|
||||
@@ -284,7 +285,7 @@ mod tests {
|
||||
|
||||
// Accumulate some state
|
||||
for i in 0..15 {
|
||||
shaper.shape_reward(1.0 + i as f64 * 0.1, false, false, i % NUM_FACTORED_ACTIONS);
|
||||
shaper.shape_reward(1.0_f32 + i as f32 * 0.1, false, false, i % NUM_FACTORED_ACTIONS);
|
||||
}
|
||||
assert!(!shaper.return_window.is_empty());
|
||||
assert!(shaper.total_actions > 0);
|
||||
@@ -310,12 +311,12 @@ mod tests {
|
||||
|
||||
// Build up enough state for all components to fire
|
||||
for i in 0..15 {
|
||||
let r = 0.5 + (i as f64) * 0.05;
|
||||
let r = 0.5_f32 + (i as f32) * 0.05;
|
||||
shaper.shape_reward(r, false, false, i % NUM_FACTORED_ACTIONS);
|
||||
}
|
||||
|
||||
// Now shape a reward where all three components activate
|
||||
let raw = 1.0;
|
||||
let raw = 1.0_f32;
|
||||
let shaped = shaper.shape_reward(raw, true, true, 3);
|
||||
|
||||
// Hold penalty subtracts, Sharpe adds (positive returns), diversity adds
|
||||
@@ -337,9 +338,9 @@ mod tests {
|
||||
|
||||
// With fewer than MIN_SHARPE_WINDOW returns, no Sharpe component
|
||||
for _ in 0..4 {
|
||||
let shaped = shaper.shape_reward(1.0, false, false, 0);
|
||||
let shaped = shaper.shape_reward(1.0_f32, false, false, 0);
|
||||
assert!(
|
||||
(shaped - 1.0).abs() < 1e-10,
|
||||
(shaped - 1.0_f32).abs() < 1e-6,
|
||||
"Expected no Sharpe with <5 returns, got {}",
|
||||
shaped
|
||||
);
|
||||
@@ -347,7 +348,7 @@ mod tests {
|
||||
|
||||
// 5th return triggers Sharpe computation
|
||||
// But all returns are identical (1.0) => std ~ 0 => Sharpe returns 0
|
||||
let shaped_5th = shaper.shape_reward(2.0, false, false, 0);
|
||||
let shaped_5th = shaper.shape_reward(2.0_f32, false, false, 0);
|
||||
// Returns are [1.0, 1.0, 1.0, 1.0, 2.0] — mean=1.2, std>0, Sharpe>0
|
||||
assert!(
|
||||
shaped_5th > 2.0,
|
||||
|
||||
@@ -57,7 +57,6 @@ thiserror.workspace = true
|
||||
uuid.workspace = true
|
||||
num-traits.workspace = true
|
||||
rust_decimal.workspace = true
|
||||
bigdecimal.workspace = true
|
||||
|
||||
# Internal workspace crates
|
||||
common = { workspace = true, features = ["database"] }
|
||||
|
||||
@@ -77,7 +77,6 @@ md5.workspace = true
|
||||
hostname = "0.4"
|
||||
num-traits.workspace = true
|
||||
rust_decimal.workspace = true
|
||||
bigdecimal.workspace = true
|
||||
rand.workspace = true
|
||||
sysinfo = "0.33"
|
||||
log.workspace = true
|
||||
|
||||
Reference in New Issue
Block a user