Files
foxhunt/ml/tests/dqn_portfolio_normalization_test.rs
jgrusewski 3bd1518785 feat: Make Huber delta configurable and hyperopt-tunable
Changes:
- Add --huber-delta CLI flag with default 100.0
- Add huber_delta to hyperopt search space (10.0-200.0)
- Update DQNParams to include huber_delta
- Add 2 new tests for configurability and hyperopt bounds
- Optimal value identified: 24.77 (Trial 3)

Validation:
- 10/30 trials completed successfully
- Gradient stability: 0.0-1.1 (target <1000) 
- Q-values: ±2-25 (vs ±10,000 before fix) 
- Best Sharpe: 0.3340 (Trial 3, huber_delta=24.77)

Impact:
- 46K-94Kx gradient improvement
- 400-5000x Q-value improvement
- Optimal range identified: 20-30

Tests: 14/14 passing (2 ignored)
Files: 3 modified (train_dqn.rs, dqn.rs, test files)
2025-11-19 23:14:04 +01:00

317 lines
11 KiB
Rust

//! Test Suite: Portfolio Feature Normalization for DQN
//!
//! This test suite validates that portfolio features are properly normalized
//! to prevent Q-value explosion (Bug #36).
//!
//! Root Cause: Portfolio features using absolute dollar values (100,000)
//! instead of normalized values (1.0) causes Q-values to be 100x too large.
//!
//! Expected Behavior:
//! - Portfolio value should be normalized to initial capital (1.0 = 100% of capital)
//! - Position sizes should be normalized to [-1.0, 1.0] range
//! - Q-values should converge to ±100 range (not ±10,000)
use anyhow::Result;
use ml::dqn::{PortfolioTracker, TradingState, FactoredAction, ExposureLevel, OrderType, Urgency};
use candle_core::Device;
/// Test 1: Portfolio features are normalized to reasonable ranges
///
/// Validates that:
/// - portfolio_features[0] is normalized by initial capital (1.0 = 100% of capital)
/// - portfolio_features[1] is normalized to [-1.0, 1.0] range (position size)
/// - All features are in expected ranges
#[test]
fn test_portfolio_features_are_normalized() -> Result<()> {
// Create PortfolioTracker with $100K initial capital
let initial_capital = 100_000.0;
let tracker = PortfolioTracker::with_default_spread(initial_capital);
// Get normalized features at current price
let current_price = 4500.0; // ES futures price
let features = tracker.get_portfolio_features(current_price);
// Test 1: Portfolio value should be normalized to 1.0 (initial capital)
assert!(
(features[0] - 1.0).abs() < 0.01,
"Portfolio value should be normalized to 1.0 (got {}), representing 100% of initial capital",
features[0]
);
// Test 2: Position size should be 0.0 (no position)
assert_eq!(
features[1], 0.0,
"Initial position should be 0.0 (no position)"
);
// Test 3: All features should be in reasonable ranges
assert!(
features[0] >= 0.0 && features[0] <= 10.0,
"Normalized portfolio value should be in [0, 10] range (got {})",
features[0]
);
assert!(
features[1] >= -1.0 && features[1] <= 1.0,
"Normalized position should be in [-1, 1] range (got {})",
features[1]
);
Ok(())
}
/// Test 2: Portfolio features remain normalized after trades
///
/// Validates that normalization is consistent across trading operations
#[test]
fn test_portfolio_features_normalized_after_trades() -> Result<()> {
let initial_capital = 100_000.0;
let mut tracker = PortfolioTracker::with_default_spread(initial_capital);
let current_price = 4500.0;
// Execute buy action (Long100 exposure, market order, normal urgency)
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal
);
tracker.execute_action(action, current_price, 10.0); // max_position = 10.0
// Get normalized features after trade
let features = tracker.get_portfolio_features(current_price);
// Portfolio value should still be close to 1.0 (minus transaction costs)
assert!(
features[0] >= 0.95 && features[0] <= 1.05,
"Portfolio value after trade should be close to 1.0 (got {})",
features[0]
);
// Position should be normalized (not absolute contract count)
assert!(
features[1] > 0.0 && features[1] <= 1.0,
"Position after buy should be normalized in (0, 1] range (got {})",
features[1]
);
Ok(())
}
/// Test 3: Reward calculation works correctly with normalized portfolio
///
/// Validates that percentage-based rewards still function correctly
/// even though portfolio features are normalized
#[test]
fn test_reward_calculation_with_normalized_portfolio() -> Result<()> {
let initial_capital = 100_000.0;
let mut tracker = PortfolioTracker::with_default_spread(initial_capital);
// Initial state at $4500
let initial_price = 4500.0;
let initial_features = tracker.get_portfolio_features(initial_price);
// Create initial state
let current_state = TradingState::from_normalized(
vec![4500.0], // price_features
vec![0.5, -0.3, 0.8], // technical_indicators
vec![0.1, 0.2], // market_features
initial_features.to_vec(), // normalized portfolio_features
vec![], // regime_features
);
// Execute buy action
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal
);
tracker.execute_action(action, initial_price, 10.0);
// Price increases to $4600 (profit scenario)
let new_price = 4600.0;
let next_features = tracker.get_portfolio_features(new_price);
// Create next state
let next_state = TradingState::from_normalized(
vec![4600.0],
vec![0.6, -0.2, 0.9],
vec![0.15, 0.25],
next_features.to_vec(),
vec![],
);
// For this test, we just validate that features are normalized
// The actual reward calculation is tested in other test files
// Validate both states have normalized portfolio features
assert!(
(current_state.portfolio_features[0] - 1.0).abs() < 0.01,
"Current state portfolio should be normalized to 1.0 (got {})",
current_state.portfolio_features[0]
);
assert!(
next_state.portfolio_features[0] > 0.9 && next_state.portfolio_features[0] < 1.2,
"Next state portfolio should be normalized around 1.0 (got {})",
next_state.portfolio_features[0]
);
Ok(())
}
/// Test 4: State conversion preserves normalization
///
/// Validates that TradingState construction with normalized features
/// maintains proper ranges throughout the pipeline
#[test]
fn test_state_conversion_normalization() -> Result<()> {
let initial_capital = 100_000.0;
let tracker = PortfolioTracker::with_default_spread(initial_capital);
let current_price = 4500.0;
let normalized_features = tracker.get_portfolio_features(current_price);
// Create TradingState with normalized features
let state = TradingState::from_normalized(
vec![4500.0, 4505.0, 4495.0], // price_features (OHLC)
vec![0.5, -0.3, 0.8, 0.2], // technical_indicators
vec![0.1, 0.2, 0.3], // market_features
normalized_features.to_vec(), // normalized portfolio_features
vec![], // regime_features
);
// Validate portfolio features are normalized
assert!(
(state.portfolio_features[0] - 1.0).abs() < 0.01,
"State portfolio_features[0] should be normalized to 1.0 (got {})",
state.portfolio_features[0]
);
assert_eq!(
state.portfolio_features[1], 0.0,
"State portfolio_features[1] should be 0.0 (no position)"
);
// Convert state to vector for network input
let state_vec = state.to_vector();
// Find portfolio features in the flattened state vector
// State structure: [price_features, technical_indicators, market_features, portfolio_features, regime_features]
let portfolio_start_idx = state.price_features.len()
+ state.technical_indicators.len()
+ state.market_features.len();
// Validate normalization persists after tensor conversion
let portfolio_value_in_tensor = state_vec[portfolio_start_idx];
assert!(
(portfolio_value_in_tensor - 1.0).abs() < 0.01,
"Portfolio value in tensor should be normalized to 1.0 (got {})",
portfolio_value_in_tensor
);
Ok(())
}
/// Test 5: Edge cases - Zero portfolio value
///
/// Validates handling of edge case where portfolio value drops to zero
#[test]
fn test_normalized_features_edge_case_zero_portfolio() -> Result<()> {
let initial_capital = 100_000.0;
let tracker = PortfolioTracker::with_default_spread(initial_capital);
// Simulate catastrophic loss (portfolio value = 0)
// Note: This is a theoretical test; actual implementation may prevent this
let current_price = 4500.0;
let features = tracker.get_portfolio_features(current_price);
// Even at zero, normalization should not cause NaN or Inf
assert!(
features[0].is_finite(),
"Portfolio value should be finite even in edge cases"
);
Ok(())
}
/// Test 6: Integration test - State tensor construction with normalized features
///
/// This test validates that normalized features are properly converted to tensors
/// and maintain their normalized ranges throughout the pipeline.
#[test]
fn test_state_tensor_construction_with_normalized_features() -> Result<()> {
let initial_capital = 100_000.0;
let tracker = PortfolioTracker::with_default_spread(initial_capital);
let current_price = 4500.0;
let normalized_features = tracker.get_portfolio_features(current_price);
// Create state with normalized portfolio features (1.0 for portfolio value)
let state = TradingState::from_normalized(
vec![4500.0; 3], // price_features
vec![0.5; 10], // technical_indicators
vec![0.1; 20], // market_features
normalized_features.to_vec(), // normalized portfolio_features
vec![], // regime_features
);
// Convert to vector
let state_vec = state.to_vector();
// Find portfolio value in state vector
let portfolio_idx = state.price_features.len()
+ state.technical_indicators.len()
+ state.market_features.len();
let portfolio_value = state_vec[portfolio_idx];
// Validate normalization persists after tensor conversion
assert!(
(portfolio_value - 1.0).abs() < 0.01,
"Portfolio value should be normalized around 1.0 (got {})",
portfolio_value
);
Ok(())
}
/// Test 7: Verify get_portfolio_features vs get_raw_portfolio_features
///
/// Validates that normalized version returns values ~100x smaller than raw version
#[test]
fn test_normalized_vs_raw_portfolio_features() -> Result<()> {
let initial_capital = 100_000.0;
let tracker = PortfolioTracker::with_default_spread(initial_capital);
let current_price = 4500.0;
// Get both versions
let raw_features = tracker.get_raw_portfolio_features(current_price);
let normalized_features = tracker.get_portfolio_features(current_price);
// Raw portfolio value should be ~100,000
assert!(
raw_features[0] >= 99_000.0 && raw_features[0] <= 101_000.0,
"Raw portfolio value should be ~$100K (got {})",
raw_features[0]
);
// Normalized portfolio value should be ~1.0
assert!(
(normalized_features[0] - 1.0).abs() < 0.01,
"Normalized portfolio value should be ~1.0 (got {})",
normalized_features[0]
);
// Ratio should be ~100,000x (initial capital)
let ratio = raw_features[0] / normalized_features[0];
assert!(
(ratio - initial_capital).abs() < 1000.0,
"Ratio between raw and normalized should be ~initial_capital (got {})",
ratio
);
Ok(())
}