Merge branch 'worktree-dtype-cleanup'

This commit is contained in:
jgrusewski
2026-03-04 18:10:31 +01:00
11 changed files with 140 additions and 144 deletions

4
Cargo.lock generated
View File

@@ -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",

View File

@@ -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)

View File

@@ -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(|_| {

View File

@@ -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;

View File

@@ -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,
];

View File

@@ -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 ----

View File

@@ -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()?;

View File

@@ -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

View File

@@ -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,

View File

@@ -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"] }

View File

@@ -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