//! Portfolio Value Normalization Tests (Wave 16S-V12, Agent 17 - TDD) //! //! Test suite defining EXPECTED normalization behavior BEFORE implementation. //! These tests will FAIL initially (TDD approach) and guide the implementation. //! //! # Problem Context //! Q-value explosion (2463-2694) caused by absolute dollar values ($100K) in portfolio features. //! Need to normalize to 1.0 ± 0.01 scale to prevent Q-value instability. //! //! # Current Implementation (Bug #17) //! ```rust //! pub fn get_portfolio_features(&self, current_price: f32) -> [f32; 3] { //! let portfolio_value = self.get_portfolio_value(current_price); //! [ //! portfolio_value, // [0] ABSOLUTE DOLLARS (e.g., 100,000.0) ❌ //! self.position_size, // [1] Raw position //! self.avg_spread, // [2] Spread //! ] //! } //! ``` //! //! # Expected Implementation (After Fix) //! ```rust //! pub fn get_portfolio_features(&self, current_price: f32) -> [f32; 3] { //! let portfolio_value = self.get_portfolio_value(current_price); //! let normalized_value = portfolio_value / self.initial_capital; // RATIO ✅ //! [ //! normalized_value, // [0] RATIO (e.g., 1.0 = initial capital, 1.02 = 2% gain) //! self.position_size, // [1] Unchanged //! self.avg_spread, // [2] Unchanged //! ] //! } //! ``` //! //! # Test Coverage (8 tests) //! 1. `test_portfolio_features_normalized_to_ratio` - Verifies features[0] is ratio not absolute //! 2. `test_initial_capital_normalization_divisor` - Tests scale-invariance across capital levels //! 3. `test_reward_scale_correct` - Validates reward magnitude suitable for Q-learning //! 4. `test_large_portfolio_changes_bounded` - Extreme gains/losses stay bounded //! 5. `test_bankruptcy_scenario_graceful` - Portfolio value = $0 doesn't crash //! 6. `test_position_feature_unchanged` - Normalization only affects features[0] //! 7. `test_multiple_trades_accumulate` - Gains/losses compound correctly //! 8. `test_normalization_consistency_across_prices` - Price-independent normalization #![allow(unused_crate_dependencies)] use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency}; use ml::dqn::portfolio_tracker::PortfolioTracker; // ============================================================================ // Helper Functions - Create FactoredActions for Testing // ============================================================================ /// Create a BUY action (Long100 + Market + Normal) fn create_buy_action() -> FactoredAction { FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal) } /// Create a SELL action (Short100 + Market + Normal) fn create_sell_action() -> FactoredAction { FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal) } /// Create a FLAT action (closes position) fn create_flat_action() -> FactoredAction { FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal) } // ============================================================================ // Test 1: Portfolio Features Normalized to Ratio (6 assertions) // ============================================================================ #[test] fn test_portfolio_features_normalized_to_ratio() { // Setup: $100K initial capital let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let price = 5000.0; // Get initial features let features = tracker.get_portfolio_features(price); println!("Initial features: {:?}", features); // Verify features[0] is RATIO not ABSOLUTE assert!( features[0] >= 0.95 && features[0] <= 1.05, "Portfolio value should be normalized ratio ~1.0, got {}. \ Expected: 1.0 ± 0.05 (within 5% of initial capital). \ If this is absolute dollars (e.g., 100000.0), normalization is missing.", features[0] ); assert!( features[0] < 200.0, "Portfolio value should NOT be absolute dollars (would be ~100000), got {}. \ This indicates features[0] is not normalized by initial_capital.", features[0] ); // After profitable trade let buy_action = create_buy_action(); tracker.execute_action(buy_action, price, 2.0); // Buy 2 contracts at $5000 let price_after_gain = 5100.0; // +2% price increase let features_after = tracker.get_portfolio_features(price_after_gain); println!("Features after +2% gain: {:?}", features_after); assert!( features_after[0] > 1.0, "Profitable trade should increase ratio above 1.0, got {}. \ Expected: >1.0 (gained value). \ If features_after[0] ≈ features[0], normalization may be missing.", features_after[0] ); assert!( features_after[0] < 1.10, "2% gain should be ~1.02 ratio, not >1.10, got {}. \ Expected: 1.00-1.05 (2% gain + small transaction cost). \ If features_after[0] >> 1.10, normalization may be incorrect.", features_after[0] ); // After loss let sell_action = create_sell_action(); tracker.execute_action(sell_action, price_after_gain, 2.0); // Close position let price_after_loss = 5050.0; // Slightly lower tracker.execute_action(create_buy_action(), price_after_loss, 2.0); // Re-enter let price_loss = 4950.0; // -2% from 5050 let features_loss = tracker.get_portfolio_features(price_loss); println!("Features after -2% loss: {:?}", features_loss); assert!( features_loss[0] < features_after[0], "Loss should decrease ratio, got {} vs previous {}. \ Expected: Lower value after loss.", features_loss[0], features_after[0] ); assert!( features_loss[0] > 0.0, "Even with loss, ratio should be positive, got {}. \ Expected: >0.0 (portfolio still has value).", features_loss[0] ); } // ============================================================================ // Test 2: Initial Capital Normalization Divisor (4 assertions) // ============================================================================ #[test] fn test_initial_capital_normalization_divisor() { // Test with different initial capitals let tracker_100k = PortfolioTracker::new(100_000.0, 0.0001, 0.0); let tracker_50k = PortfolioTracker::new(50_000.0, 0.0001, 0.0); let price = 5000.0; let features_100k = tracker_100k.get_portfolio_features(price); let features_50k = tracker_50k.get_portfolio_features(price); println!("100K capital features: {:?}", features_100k); println!("50K capital features: {:?}", features_50k); // Both should start at 1.0 regardless of initial capital assert!( (features_100k[0] - 1.0).abs() < 0.001, "100K capital should normalize to 1.0, got {}. \ Expected: 1.0 (initial state = 1.0 × initial_capital). \ If not ~1.0, normalization divisor is incorrect.", features_100k[0] ); assert!( (features_50k[0] - 1.0).abs() < 0.001, "50K capital should normalize to 1.0, got {}. \ Expected: 1.0 (initial state = 1.0 × initial_capital). \ If not ~1.0, normalization is not scale-invariant.", features_50k[0] ); // Scale-invariant: Same % gain = same ratio change let mut tracker_100k_mut = PortfolioTracker::new(100_000.0, 0.0001, 0.0); let mut tracker_50k_mut = PortfolioTracker::new(50_000.0, 0.0001, 0.0); // 100K: Buy 2 contracts at $5000 (2% of capital) tracker_100k_mut.execute_action(create_buy_action(), price, 2.0); let price_gain = 5100.0; // +2% gain let after_100k = tracker_100k_mut.get_portfolio_features(price_gain); // 50K: Buy 1 contract at $5000 (2% of capital) tracker_50k_mut.execute_action(create_buy_action(), price, 1.0); let after_50k = tracker_50k_mut.get_portfolio_features(price_gain); println!("After +2% gain (100K): {:?}", after_100k); println!("After +2% gain (50K): {:?}", after_50k); // Both should have similar ratio increase (2% gain) assert!( (after_100k[0] - after_50k[0]).abs() < 0.01, "Same % gain should produce same ratio regardless of initial capital. \ Got: 100K={}, 50K={}, diff={}. \ Expected: diff < 0.01 (scale-invariant normalization). \ If diff > 0.01, normalization may not use initial_capital.", after_100k[0], after_50k[0], (after_100k[0] - after_50k[0]).abs() ); } // ============================================================================ // Test 3: Reward Scale Correct (5 assertions) // ============================================================================ #[test] fn test_reward_scale_correct() { // Verify normalization produces correct reward scale for Q-learning let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let price = 5000.0; let features_before = tracker.get_portfolio_features(price); println!("Features before trade: {:?}", features_before); // Buy 2 contracts tracker.execute_action(create_buy_action(), price, 2.0); let price_gain = 5050.0; // +1% price move let features_after = tracker.get_portfolio_features(price_gain); println!("Features after +1% gain: {:?}", features_after); let pnl_delta = features_after[0] - features_before[0]; println!("P&L delta (ratio change): {}", pnl_delta); // 1% price move on 2 contracts = ~0.01 ratio change // Note: 2 contracts * $5000 = $10K position = 10% of $100K capital // 1% price move on 10% position = 0.1% portfolio change = 0.001 ratio // But our position is 2 contracts, so expect ~0.002 ratio change assert!( pnl_delta > 0.001 && pnl_delta < 0.025, "1% gain on 2 contracts should produce 0.001-0.025 ratio change, got {}. \ Expected: Small ratio change (~0.002). \ If pnl_delta > 1.0, normalization is missing. \ If pnl_delta < 0.001, position size may be incorrect.", pnl_delta ); assert!( pnl_delta < 1.0, "Ratio change should NOT be absolute dollars (would be ~1000), got {}. \ This indicates features[0] is not normalized.", pnl_delta ); // Verify this is suitable for Q-learning assert!( pnl_delta.abs() < 0.10, "Reward magnitude should be <0.10 for stable Q-learning, got {}. \ Expected: Small ratio changes (<0.10). \ If pnl_delta >> 0.10, Q-values will explode.", pnl_delta ); // Verify reward is positive assert!( pnl_delta > 0.0, "Profitable trade should produce positive reward, got {}. \ Expected: pnl_delta > 0.", pnl_delta ); // Verify magnitude is reasonable assert!( pnl_delta > 0.0005, "Reward should be detectable (>0.0005), got {}. \ Expected: Meaningful signal for Q-learning. \ If pnl_delta < 0.0005, position may be too small.", pnl_delta ); } // ============================================================================ // Test 4: Large Portfolio Changes Bounded (6 assertions) // ============================================================================ #[test] fn test_large_portfolio_changes_bounded() { // Test extreme scenarios stay bounded let initial_capital = 100_000.0; // Extreme gain: +50% let mut tracker_gain = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let entry_price = 5000.0; tracker_gain.execute_action(create_buy_action(), entry_price, 2.0); let gain_price = 7500.0; // +50% price increase let features_after_gain = tracker_gain.get_portfolio_features(gain_price); println!("Features after +50% gain: {:?}", features_after_gain); assert!( features_after_gain[0] > 1.0, "Large gain should be >1.0, got {}. \ Expected: Ratio > 1.0 (gained value).", features_after_gain[0] ); assert!( features_after_gain[0] < 2.0, "50% gain should be ~1.5 ratio, not >2.0, got {}. \ Expected: 1.0-1.6 (50% gain on partial position). \ If features_after_gain[0] > 2.0, normalization may be incorrect.", features_after_gain[0] ); // Extreme loss: -50% let mut tracker_loss = PortfolioTracker::new(initial_capital, 0.0001, 0.0); tracker_loss.execute_action(create_buy_action(), entry_price, 2.0); let loss_price = 2500.0; // -50% price decrease let features_after_loss = tracker_loss.get_portfolio_features(loss_price); println!("Features after -50% loss: {:?}", features_after_loss); assert!( features_after_loss[0] < 1.0, "Large loss should be <1.0, got {}. \ Expected: Ratio < 1.0 (lost value).", features_after_loss[0] ); assert!( features_after_loss[0] > 0.0, "Even 50% loss should be >0.0 ratio, got {}. \ Expected: Positive ratio (portfolio still has value). \ If features_after_loss[0] < 0.0, calculation is incorrect.", features_after_loss[0] ); assert!( features_after_loss[0] > 0.5, "50% loss on 2 contracts should be ~0.9 ratio (small position), got {}. \ Expected: >0.5 (small position relative to capital). \ If features_after_loss[0] < 0.5, position may be too large.", features_after_loss[0] ); // Verify gains and losses are symmetric let gain_ratio_delta = features_after_gain[0] - 1.0; let loss_ratio_delta = 1.0 - features_after_loss[0]; println!("Gain ratio delta: {}", gain_ratio_delta); println!("Loss ratio delta: {}", loss_ratio_delta); // Symmetric check (gains and losses should be roughly equal magnitude) assert!( (gain_ratio_delta - loss_ratio_delta).abs() < 0.2, "Symmetric 50% price moves should produce symmetric ratio changes. \ Gain delta: {}, Loss delta: {}, diff: {}. \ Expected: Similar magnitudes (within 0.2). \ If diff > 0.2, normalization may be asymmetric.", gain_ratio_delta, loss_ratio_delta, (gain_ratio_delta - loss_ratio_delta).abs() ); } // ============================================================================ // Test 5: Bankruptcy Scenario Graceful (3 assertions) // ============================================================================ #[test] fn test_bankruptcy_scenario_graceful() { // Test bankruptcy (portfolio value = $0) doesn't crash let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let entry_price = 5000.0; tracker.execute_action(create_buy_action(), entry_price, 2.0); // Extreme loss causing bankruptcy (price → 0) let bankrupt_price = 0.0; let features_bankrupt = tracker.get_portfolio_features(bankrupt_price); println!("Features at bankruptcy (price=0): {:?}", features_bankrupt); assert!( features_bankrupt[0] >= 0.0, "Bankrupt should be 0.0 ratio, not negative, got {}. \ Expected: >= 0.0 (portfolio value can't be negative). \ If features_bankrupt[0] < 0.0, calculation is incorrect.", features_bankrupt[0] ); assert!( features_bankrupt[0] < 0.1, "Bankrupt should be near 0.0, got {}. \ Expected: < 0.1 (minimal value remaining). \ If features_bankrupt[0] > 0.1, normalization may be incorrect.", features_bankrupt[0] ); assert!( !features_bankrupt[0].is_nan(), "Bankrupt should not be NaN. \ Expected: Valid number (0.0 or near-zero). \ NaN indicates division by zero or invalid calculation." ); } // ============================================================================ // Test 6: Position Feature Unchanged (2 assertions) // ============================================================================ #[test] fn test_position_feature_unchanged() { // Verify normalization only affects features[0] (value), not features[1] (position) let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let price = 5000.0; tracker.execute_action(create_buy_action(), price, 2.0); let price_gain = 5100.0; let features = tracker.get_portfolio_features(price_gain); println!("Features after buy: {:?}", features); assert_eq!( features[1], 2.0, "Position feature should be unchanged (absolute contracts), got {}. \ Expected: 2.0 (bought 2 contracts). \ If features[1] != 2.0, position tracking is broken.", features[1] ); assert_eq!( features[2], 0.0001, "Spread feature should be unchanged, got {}. \ Expected: 0.0001 (default spread). \ If features[2] != 0.0001, spread is incorrect.", features[2] ); } // ============================================================================ // Test 7: Multiple Trades Accumulate (8 assertions) // ============================================================================ #[test] fn test_multiple_trades_accumulate() { // Test that gains/losses accumulate correctly in normalized space let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let start_price = 5000.0; let start = tracker.get_portfolio_features(start_price)[0]; println!("Starting ratio: {}", start); assert!( (start - 1.0).abs() < 0.001, "Starting ratio should be ~1.0, got {}. \ Expected: 1.0 (initial state). \ If start != 1.0, initialization is broken.", start ); // Trade 1: +1% gain tracker.execute_action(create_buy_action(), start_price, 2.0); let price_after_trade1 = 5050.0; // +1% let after_trade1 = tracker.get_portfolio_features(price_after_trade1)[0]; println!("After trade 1 (+1%): {}", after_trade1); assert!( after_trade1 > start, "First trade gain should increase ratio, got {} vs start {}. \ Expected: after_trade1 > start.", after_trade1, start ); // Close position tracker.execute_action(create_flat_action(), price_after_trade1, 2.0); // Trade 2: +1% gain (on new base) tracker.execute_action(create_buy_action(), price_after_trade1, 2.0); let price_after_trade2 = 5100.0; // +1% from 5050 let after_trade2 = tracker.get_portfolio_features(price_after_trade2)[0]; println!("After trade 2 (+1%): {}", after_trade2); assert!( after_trade2 > after_trade1, "Second trade should compound gains, got {} vs {}. \ Expected: after_trade2 > after_trade1.", after_trade2, after_trade1 ); // Verify compounding let total_gain = after_trade2 - start; println!("Total gain ratio: {}", total_gain); assert!( total_gain > 0.01 && total_gain < 0.05, "Two +1% trades should compound to ~2% ratio gain, got {}. \ Expected: 0.01-0.05 (2× ~1% gains + transaction costs). \ If total_gain < 0.01, position may be too small. \ If total_gain > 0.05, normalization may be incorrect.", total_gain ); assert!( after_trade2 > 1.0, "Multiple profitable trades should keep ratio >1.0, got {}. \ Expected: >1.0 (accumulated gains).", after_trade2 ); assert!( after_trade2 < 1.10, "Two +1% trades should be <1.10 ratio, got {}. \ Expected: 1.01-1.05. \ If after_trade2 > 1.10, normalization may be incorrect.", after_trade2 ); // Verify intermediate values are reasonable assert!( after_trade1 < 1.05, "Single +1% trade should be <1.05 ratio, got {}. \ Expected: 1.00-1.03.", after_trade1 ); assert!( total_gain < 0.10, "Total gain should be suitable for Q-learning (<0.10), got {}. \ Expected: Small ratio changes.", total_gain ); } // ============================================================================ // Test 8: Normalization Consistency Across Prices (5 assertions) // ============================================================================ #[test] fn test_normalization_consistency_across_prices() { // Verify normalization is price-independent when no position is held let initial_capital = 100_000.0; let tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); // Get features at different price levels let features_low = tracker.get_portfolio_features(100.0); // Low price let features_mid = tracker.get_portfolio_features(5000.0); // Mid price let features_high = tracker.get_portfolio_features(50000.0); // High price println!("Features at low price (100): {:?}", features_low); println!("Features at mid price (5000): {:?}", features_mid); println!("Features at high price (50000): {:?}", features_high); // All should be ~1.0 (no position, so price doesn't matter) assert!( (features_low[0] - 1.0).abs() < 0.001, "No position: low price should give ratio ~1.0, got {}. \ Expected: 1.0 (price doesn't affect cash-only portfolio). \ If != 1.0, normalization may depend on price incorrectly.", features_low[0] ); assert!( (features_mid[0] - 1.0).abs() < 0.001, "No position: mid price should give ratio ~1.0, got {}. \ Expected: 1.0. \ If != 1.0, normalization is broken.", features_mid[0] ); assert!( (features_high[0] - 1.0).abs() < 0.001, "No position: high price should give ratio ~1.0, got {}. \ Expected: 1.0. \ If != 1.0, normalization is broken.", features_high[0] ); // Verify consistency across prices assert!( (features_low[0] - features_mid[0]).abs() < 0.001, "No position: features should be price-independent, got low={}, mid={}. \ Expected: Same value (~1.0). \ If different, price affects normalization incorrectly.", features_low[0], features_mid[0] ); assert!( (features_mid[0] - features_high[0]).abs() < 0.001, "No position: features should be price-independent, got mid={}, high={}. \ Expected: Same value (~1.0). \ If different, price affects normalization incorrectly.", features_mid[0], features_high[0] ); } // ============================================================================ // Additional Edge Cases // ============================================================================ #[test] fn test_normalization_with_short_position() { // Verify normalization works correctly for short positions let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let entry_price = 5000.0; // Open short position tracker.execute_action(create_sell_action(), entry_price, 2.0); let features_short = tracker.get_portfolio_features(entry_price); println!("Features after short entry: {:?}", features_short); assert!( features_short[0] >= 0.95 && features_short[0] <= 1.05, "Short entry should be ~1.0 ratio (no P&L yet), got {}. \ Expected: 1.0 ± 0.05.", features_short[0] ); assert_eq!( features_short[1], -2.0, "Position should be -2.0 (short 2 contracts), got {}.", features_short[1] ); // Price decreases (profitable for short) let price_decrease = 4900.0; // -2% let features_gain = tracker.get_portfolio_features(price_decrease); println!("Features after -2% price (short profit): {:?}", features_gain); assert!( features_gain[0] > 1.0, "Short position should profit from price decrease, got {}. \ Expected: >1.0.", features_gain[0] ); // Price increases (loss for short) let price_increase = 5100.0; // +2% let features_loss = tracker.get_portfolio_features(price_increase); println!("Features after +2% price (short loss): {:?}", features_loss); assert!( features_loss[0] < 1.0, "Short position should lose from price increase, got {}. \ Expected: <1.0.", features_loss[0] ); } #[test] fn test_normalization_after_multiple_reversals() { // Test normalization remains consistent after position reversals let initial_capital = 100_000.0; let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 0.0); let price = 5000.0; // Long → Short → Long → Flat tracker.execute_action(create_buy_action(), price, 2.0); let after_long1 = tracker.get_portfolio_features(price)[0]; tracker.execute_action(create_sell_action(), price, 2.0); let after_short = tracker.get_portfolio_features(price)[0]; tracker.execute_action(create_buy_action(), price, 2.0); let after_long2 = tracker.get_portfolio_features(price)[0]; tracker.execute_action(create_flat_action(), price, 2.0); let after_flat = tracker.get_portfolio_features(price)[0]; println!("Reversal sequence ratios: Long1={}, Short={}, Long2={}, Flat={}", after_long1, after_short, after_long2, after_flat); // All should be close to 1.0 (no net price movement) assert!( (after_long1 - 1.0).abs() < 0.05, "After first long: ratio should be ~1.0, got {}.", after_long1 ); assert!( (after_short - 1.0).abs() < 0.10, "After reversal to short: ratio should be ~1.0 (minus transaction costs), got {}.", after_short ); assert!( (after_long2 - 1.0).abs() < 0.15, "After second reversal to long: ratio should be ~1.0 (minus more transaction costs), got {}.", after_long2 ); assert!( (after_flat - 1.0).abs() < 0.20, "After closing: ratio should be ~1.0 (minus all transaction costs), got {}.", after_flat ); // Should have lost money due to transaction costs assert!( after_flat < 1.0, "Multiple reversals should lose money due to transaction costs, got {}. \ Expected: <1.0.", after_flat ); }