Files
foxhunt/crates/ml/tests/portfolio_value_normalization_test.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

790 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! 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;
use tracing::info;
// ============================================================================
// 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);
info!(?features, "Initial 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);
info!(?features_after, "Features after +2% gain");
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);
info!(?features_loss, "Features after -2% 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);
info!(?features_100k, "100K capital features");
info!(?features_50k, "50K capital features");
// 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);
info!(?after_100k, "After +2% gain (100K)");
info!(?after_50k, "After +2% gain (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);
info!(?features_before, "Features before trade");
// 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);
info!(?features_after, "Features after +1% gain");
let pnl_delta = features_after[0] - features_before[0];
info!(pnl_delta, "P&L delta (ratio change)");
// 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);
info!(?features_after_gain, "Features after +50% 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);
info!(?features_after_loss, "Features after -50% 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];
info!(gain_ratio_delta, "Gain ratio delta");
info!(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);
info!(?features_bankrupt, "Features at bankruptcy (price=0)");
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);
info!(?features, "Features after buy");
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];
info!(start, "Starting ratio");
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];
info!(after_trade1, "After trade 1 (+1%)");
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];
info!(after_trade2, "After trade 2 (+1%)");
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;
info!(total_gain, "Total gain ratio");
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
info!(?features_low, "Features at low price (100)");
info!(?features_mid, "Features at mid price (5000)");
info!(?features_high, "Features at high price (50000)");
// 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);
info!(?features_short, "Features after short entry");
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);
info!(?features_gain, "Features after -2% price (short profit)");
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);
info!(?features_loss, "Features after +2% price (short 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];
info!(after_long1, after_short, after_long2, after_flat, "Reversal sequence ratios");
// 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
);
}