Files
foxhunt/ml/tests/bug17_reward_normalization_test.rs
jgrusewski 15496deb1d docs: Fix hyperopt blocker investigation - all systems operational
Investigation revealed all 3 "blockers" were false alarms:

BLOCKER #1 (FALSE): 45-action space already operational
- ml/src/trainers/dqn.rs:573 uses num_actions=45 (production)
- ml/src/hyperopt/adapters/dqn.rs:286 had stale comment (3→45)
- Fix: Updated documentation to reflect reality

BLOCKER #2 (COMPLETE): Action masking params already exposed
- max_position_absolute field exists in DQNHyperparameters
- Search space: 1.0-10.0 contracts (6D hyperopt)
- Thrashing risk constraint implemented

BLOCKER #3 (FALSE): Transaction costs fully implemented
- Order-type specific fees: LimitMaker 0.05%, Market 0.15%, IoC 0.10%
- PortfolioTracker applies costs during trade execution
- Cumulative tracking operational since Wave 9-A3

Files Modified:
- ml/src/hyperopt/adapters/dqn.rs (3 lines - doc corrections)
- CLAUDE.md (hyperopt status updated to READY)

Production Readiness:  CERTIFIED
- 6D parameter space operational
- All Wave 9-16 features integrated
- Ready for 30-100 trial hyperopt campaign

Report: /tmp/HYPEROPT_BLOCKER_INVESTIGATION_COMPLETE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:22:57 +01:00

316 lines
10 KiB
Rust

/// Bug #17 P1 Fix: Reward Normalization & Percentage-based P&L Tests
///
/// Tests for the reward normalization system that prevents the positive feedback loop
/// discovered in Bug #17 where amplified rewards were fed back into Sharpe ratio calculation.
///
/// Key improvements tested:
/// 1. RewardNormalizer using Welford's algorithm for online mean/variance
/// 2. Percentage-based P&L (pct_return = (next - current) / current)
/// 3. Defense-in-depth layering (normalize → clamp to [-3, +3])
/// 4. Stationary reward distribution ~N(0,1)
use ml::dqn::reward::{RewardFunction, RewardNormalizer};
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
use ml::dqn::agent::TradingState;
use ml::dqn::circuit_breaker::CircuitBreakerConfig;
use approx::assert_relative_eq;
#[test]
fn test_reward_normalizer_initialization() {
// Test that RewardNormalizer initializes with correct default values
let normalizer = RewardNormalizer::new();
let (mean, std) = normalizer.get_stats();
assert_eq!(mean, 0.0, "Initial mean should be 0.0");
assert_eq!(std, 0.0, "Initial std should be 0.0");
assert_eq!(normalizer.count(), 0, "Initial count should be 0");
}
#[test]
fn test_welford_algorithm_running_stats() {
// Test that Welford's algorithm correctly computes running mean/std
let mut normalizer = RewardNormalizer::new();
// Add known values: [1.0, 2.0, 3.0, 4.0, 5.0]
// Expected mean: 3.0, Expected std: sqrt(2.0) ≈ 1.414
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
for &val in &values {
normalizer.update(val);
}
let (mean, std) = normalizer.get_stats();
assert_relative_eq!(mean, 3.0, epsilon = 1e-6);
assert_relative_eq!(std, 1.4142135623730951, epsilon = 1e-6);
assert_eq!(normalizer.count(), 5);
}
#[test]
fn test_normalization_produces_standard_normal() {
// Test that normalization produces values with mean ≈ 0, std ≈ 1
let mut normalizer = RewardNormalizer::new();
// Simulate a sequence of rewards with known distribution
// Mean: 10.0, Std: ~3.16
let raw_rewards: Vec<f64> = vec![
5.0, 10.0, 15.0, 8.0, 12.0, 7.0, 13.0, 9.0, 11.0, 14.0,
6.0, 10.0, 15.0, 8.0, 12.0, 7.0, 13.0, 9.0, 11.0, 14.0,
];
// Build up statistics
for &reward in &raw_rewards {
normalizer.update(reward);
}
// Now normalize a new batch of rewards
let test_rewards = vec![5.0, 10.0, 15.0];
let normalized: Vec<f64> = test_rewards
.iter()
.map(|&r| normalizer.normalize(r))
.collect();
// Check that normalized values are in reasonable range
for &norm_val in &normalized {
assert!(
norm_val.abs() <= 3.0,
"Normalized value {} should be within [-3, 3]",
norm_val
);
}
// Check that extreme values get normalized appropriately
let low_value = 5.0; // ~1 std below mean
let high_value = 15.0; // ~1 std above mean
let norm_low = normalizer.normalize(low_value);
let norm_high = normalizer.normalize(high_value);
assert!(norm_low < 0.0, "Value below mean should normalize negative");
assert!(norm_high > 0.0, "Value above mean should normalize positive");
}
#[test]
fn test_percentage_based_pnl_calculation() {
// Test that percentage-based P&L is calculated correctly
// pct_return = (next_value - current_value) / current_value
let current_value = 100_000.0;
let next_value_up = 102_000.0; // +2% gain
let next_value_down = 98_000.0; // -2% loss
let pct_gain = (next_value_up - current_value) / current_value;
let pct_loss = (next_value_down - current_value) / current_value;
assert_relative_eq!(pct_gain, 0.02, epsilon = 1e-6);
assert_relative_eq!(pct_loss, -0.02, epsilon = 1e-6);
// Test with different portfolio sizes to ensure scale-invariance
let small_portfolio = 10_000.0;
let large_portfolio = 1_000_000.0;
// Both should produce same percentage for same relative change
let small_next = small_portfolio * 1.02; // +2%
let large_next = large_portfolio * 1.02; // +2%
let small_pct = (small_next - small_portfolio) / small_portfolio;
let large_pct = (large_next - large_portfolio) / large_portfolio;
assert_relative_eq!(small_pct, large_pct, epsilon = 1e-6);
assert_relative_eq!(small_pct, 0.02, epsilon = 1e-6);
}
#[test]
fn test_defense_in_depth_clamping() {
// Test that the defense-in-depth system correctly clamps outliers
// Even after normalization, values should be clamped to [-3, +3]
let mut normalizer = RewardNormalizer::new();
// Build statistics with normal values
for i in 0..100 {
normalizer.update(i as f64);
}
// Test extreme outlier
let extreme_value = 1000.0;
let normalized = normalizer.normalize(extreme_value);
// Should be clamped after normalization
let clamped = normalized.clamp(-3.0, 3.0);
assert!(
clamped.abs() <= 3.0,
"Clamped value {} should be within [-3, 3]",
clamped
);
// Test that reasonable values pass through
let reasonable_value = 50.0;
let norm_reasonable = normalizer.normalize(reasonable_value);
let clamped_reasonable = norm_reasonable.clamp(-3.0, 3.0);
// Should be unchanged by clamping (within bounds)
assert_relative_eq!(norm_reasonable, clamped_reasonable, epsilon = 1e-6);
}
/// Helper to create a TradingState with specified portfolio value
fn create_state_with_portfolio(portfolio_value: f64) -> TradingState {
TradingState {
price_features: vec![100.0, 100.0, 100.0, 100.0], // OHLC
technical_indicators: vec![0.5; 121], // 121 technical indicators
market_features: vec![], // Empty
portfolio_features: vec![portfolio_value as f32, 0.0, 0.001], // [value, position, spread]
}
}
#[test]
fn test_reward_function_integration_with_normalization() -> Result<(), Box<dyn std::error::Error>> {
// Integration test: RewardFunction with normalization enabled
// Create RewardFunction with normalization enabled
let circuit_breaker_config = CircuitBreakerConfig::default();
let config = RewardFunction::builder()
.pnl_weight(1.0)
.hold_penalty_weight(0.01)
.activity_bonus_weight(0.0)
.use_percentage_pnl(true) // Enable percentage-based P&L
.enable_normalization(true) // Enable normalization
.circuit_breaker_config(circuit_breaker_config)
.build()?;
let mut reward_fn = RewardFunction::new(config);
// Simulate a sequence of portfolio value changes
let portfolio_values = vec![
100_000.0, // Initial
102_000.0, // +2%
101_000.0, // -0.98%
103_000.0, // +1.98%
100_000.0, // -2.91%
105_000.0, // +5%
];
let mut rewards = Vec::new();
let action = FactoredAction {
exposure: ExposureLevel::Long50,
order: OrderType::Market,
urgency: Urgency::Normal,
};
let recent_actions = vec![action; 10]; // Dummy recent actions
for i in 0..portfolio_values.len() - 1 {
let current_state = create_state_with_portfolio(portfolio_values[i]);
let next_state = create_state_with_portfolio(portfolio_values[i + 1]);
// Calculate reward (which should use percentage-based P&L internally)
let reward = reward_fn.calculate_reward(
action,
&current_state,
&next_state,
&recent_actions,
)?;
// Convert Decimal to f64 for comparison
let reward_f64: f64 = reward.try_into()?;
rewards.push(reward_f64);
}
// All rewards should be within reasonable bounds after normalization
for (i, &reward) in rewards.iter().enumerate() {
assert!(
reward.abs() <= 3.0,
"Reward {} at step {} should be clamped to [-3, 3]",
reward,
i
);
}
// Rewards should not all be identical (diversity check)
let first_reward = rewards[0];
let all_same = rewards.iter().all(|&r| (r - first_reward).abs() < 1e-6);
assert!(
!all_same,
"Rewards should vary based on portfolio performance"
);
Ok(())
}
#[test]
fn test_normalization_disabled_backward_compatibility() -> Result<(), Box<dyn std::error::Error>> {
// Test that normalization can be disabled for backward compatibility
let circuit_breaker_config = CircuitBreakerConfig::default();
let config = RewardFunction::builder()
.pnl_weight(1.0)
.hold_penalty_weight(0.01)
.activity_bonus_weight(0.0)
.use_percentage_pnl(false) // Use absolute P&L
.enable_normalization(false) // Disable normalization
.circuit_breaker_config(circuit_breaker_config)
.build()?;
let mut reward_fn = RewardFunction::new(config);
let action = FactoredAction {
exposure: ExposureLevel::Long50,
order: OrderType::Market,
urgency: Urgency::Normal,
};
let recent_actions = vec![action; 10];
let current_state = create_state_with_portfolio(100_000.0);
let next_state = create_state_with_portfolio(102_000.0);
// Calculate a reward
let reward = reward_fn.calculate_reward(
action,
&current_state,
&next_state,
&recent_actions,
)?;
// Convert to f64
let reward_f64: f64 = reward.try_into()?;
// Without normalization, reward should still be clamped to [-1, 1]
assert!(
reward_f64.abs() <= 1.0,
"Without normalization, reward should be clamped to [-1, 1]"
);
Ok(())
}
#[test]
fn test_normalizer_handles_edge_cases() {
// Test edge cases: single value, identical values, zero std
let mut normalizer = RewardNormalizer::new();
// Edge case 1: First value should return unchanged (count < 2)
normalizer.update(5.0);
let norm1 = normalizer.normalize(5.0);
assert_eq!(norm1, 5.0, "First value should return unchanged");
// Edge case 2: Identical values (zero std)
let mut norm_identical = RewardNormalizer::new();
for _ in 0..10 {
norm_identical.update(10.0);
}
let norm_val = norm_identical.normalize(10.0);
assert_eq!(norm_val, 10.0, "Zero std should return value unchanged");
// Edge case 3: Very small std (near epsilon)
let mut norm_small_std = RewardNormalizer::new();
for i in 0..100 {
// Values very close together
norm_small_std.update(10.0 + (i as f64) * 0.0001);
}
let (mean, std) = norm_small_std.get_stats();
assert!(mean > 0.0, "Mean should be computed");
assert!(std >= 0.0, "Std should be non-negative");
}