- 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>
790 lines
28 KiB
Rust
790 lines
28 KiB
Rust
#![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
|
||
);
|
||
}
|