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)
317 lines
11 KiB
Rust
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(())
|
|
}
|