Files
foxhunt/crates/ml/tests/portfolio_integration_tests.rs
jgrusewski 448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00

903 lines
31 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 Features Integration Tests (TDD Approach)
//!
//! These tests verify that portfolio features are correctly integrated into
//! the DQN training pipeline, from PortfolioTracker -> TradingState -> RewardFunction.
//!
//! Test Strategy:
//! - Test 1-2: Verify portfolio features are populated in TradingState
//! - Test 3-4: Verify P&L rewards are calculated correctly
//! - Test 5-6: Verify portfolio tracking across different actions
//! - Test 7-8: Verify edge cases (zero position, negative P&L, large positions)
//! - Test 9-10: Integration tests with batch processing
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
use ml::dqn::agent::TradingState;
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::reward::{RewardConfig, RewardFunction};
use tracing::info;
// Helper functions for consistent 3-action semantics in tests
fn buy_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
}
fn sell_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal)
}
fn hold_action() -> FactoredAction {
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal)
}
/// Helper function for approximate equality checks with transaction costs
/// Tolerance of 0.2% (20 basis points) to account for:
/// - Transaction costs: 0.05-0.15% per trade
/// - Slippage and fees in HFT environments
/// - Floating point precision errors
fn assert_approx_eq(actual: f32, expected: f32, context: &str) {
let tolerance = expected.abs() * 0.002; // 0.2% tolerance
let diff = (actual - expected).abs();
assert!(
diff < tolerance,
"{}: Expected {:.2}, got {:.2} (diff: {:.2}, tolerance: {:.2})",
context,
expected,
actual,
diff,
tolerance
);
}
// ============================================================================
// Test 1: Portfolio Features Populated in TradingState
// ============================================================================
/// Test that portfolio features are correctly populated in TradingState
/// from PortfolioTracker.get_portfolio_features()
#[test]
fn test_portfolio_features_populated() -> anyhow::Result<()> {
// Setup: Create portfolio tracker with known state
let tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
let current_price = 100.0;
// Execute: Get portfolio features
let features = tracker.get_raw_portfolio_features(current_price);
// Verify: Features array has expected structure [value, position, spread]
assert_eq!(
features.len(),
3,
"Portfolio features should have 3 elements"
);
assert_eq!(
features[0], 10_000.0,
"Portfolio value should equal cash when no position"
);
assert_eq!(features[1], 0.0, "Position size should be 0 initially");
assert_eq!(features[2], 0.0001, "Spread should match initialization");
// Test with active position (long)
let mut tracker_long = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
tracker_long.execute_action(buy_action(), 100.0, 10.0);
let features_long = tracker_long.get_raw_portfolio_features(110.0);
assert_approx_eq(
features_long[0],
10_100.0,
"Portfolio value = cash + position_value = 9000 + (10*110) = 10100"
);
assert_eq!(
features_long[1], 10.0,
"Position size should be 10.0 (long)"
);
// Test with active position (short)
let mut tracker_short = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
tracker_short.execute_action(sell_action(), 100.0, 10.0);
let features_short = tracker_short.get_raw_portfolio_features(90.0);
assert_approx_eq(
features_short[0],
10_100.0,
"Portfolio value = cash + position_value = 11000 + (-10*90) = 10100"
);
assert_eq!(
features_short[1], -10.0,
"Position size should be -10.0 (short)"
);
Ok(())
}
// ============================================================================
// Test 2: Portfolio Features Dimension in TradingState
// ============================================================================
/// Test that TradingState correctly includes portfolio features in its dimension
/// calculation. The state should have 128 dimensions, not 125 (after adding
/// 3 portfolio features to the existing 125 features).
#[test]
fn test_portfolio_features_dimension() -> anyhow::Result<()> {
// Setup: Create TradingState with portfolio features
let state = TradingState::from_normalized(
vec![0.0; 16], // 16 price features
vec![0.0; 16], // 16 technical indicators
vec![0.0; 16], // 16 market features
vec![0.0; 3], // 3 portfolio features (value, position, spread, vec![])
vec![], // 0 regime features (legacy test)
);
// Verify: Dimension should be 16 + 16 + 16 + 3 = 51
assert_eq!(
state.dimension(),
51,
"State dimension should include all feature groups"
);
// Verify: to_vector() produces correct length
let vec = state.to_vector();
assert_eq!(
vec.len(),
51,
"State vector should have 51 elements (16+16+16+3)"
);
// Verify: Portfolio features are at the end of the vector
assert_eq!(
vec[48], 0.0,
"Portfolio feature 0 (value) should be at index 48"
);
assert_eq!(
vec[49], 0.0,
"Portfolio feature 1 (position) should be at index 49"
);
assert_eq!(
vec[50], 0.0,
"Portfolio feature 2 (spread) should be at index 50"
);
Ok(())
}
// ============================================================================
// Test 3: P&L Reward Non-Zero for Profitable Trades
// ============================================================================
/// Test that P&L rewards are calculated correctly for profitable trades.
/// This verifies Bug #2 fix (portfolio features populated).
#[test]
fn test_pnl_reward_nonzero() -> anyhow::Result<()> {
// Setup: Create reward function with default config
let config = RewardConfig::default();
let mut reward_fn = RewardFunction::new(config)?;
// Create current state (no position)
let current_state = TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
], // spread at index 0
vec![1.0, 0.0, 0.0001], // portfolio: normalized value=1.0 (10000, vec![]), position=0, spread=0.0001
vec![], // 0 regime features (legacy test)
);
// Create next state (profitable BUY position)
// Portfolio value increased from 10000 to 10100 (1% gain)
let next_state = TradingState::from_normalized(
vec![0.01; 16], // log return = 0.01
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.01, 0.1, 0.0001], // portfolio: value=1.01 (10100), position=0.1 (10/100 normalized), spread=0.0001
vec![], // 0 regime features (legacy test)
);
// Execute: Calculate reward for BUY action
// Provide diverse recent actions to avoid diversity penalty (entropy threshold = 0.5)
let recent_actions = vec![buy_action(), hold_action(), sell_action()];
let reward =
reward_fn.calculate_reward(buy_action(), &current_state, &next_state, &recent_actions)?;
// Verify: Reward should be positive for profitable trade
// Note: Reward is clamped to [-1.0, 1.0] range
let reward_f64: f64 = reward.try_into().unwrap();
assert!(
reward_f64 > 0.0,
"Reward should be positive for profitable BUY trade, got {}",
reward_f64
);
// Test losing trade
let next_state_loss = TradingState::from_normalized(
vec![-0.01; 16], // log return = -0.01
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![0.99, 0.1, 0.0001], // portfolio: value=0.99 (9900), position=10, spread=0.0001
vec![], // 0 regime features (legacy test)
);
let reward_loss = reward_fn.calculate_reward(
buy_action(),
&current_state,
&next_state_loss,
&recent_actions,
)?;
// Verify: Reward should be negative for losing trade
let reward_loss_f64: f64 = reward_loss.try_into().unwrap();
assert!(
reward_loss_f64 < 0.0,
"Reward should be negative for losing BUY trade, got {}",
reward_loss_f64
);
Ok(())
}
// ============================================================================
// Test 4: P&L Calculation Accuracy
// ============================================================================
/// Test that P&L rewards accurately reflect the magnitude of profit/loss.
/// This verifies the normalization by initial_capital (Bug #4 fix).
#[test]
fn test_pnl_calculation_accuracy() -> anyhow::Result<()> {
let config = RewardConfig::default();
let mut reward_fn = RewardFunction::new(config)?;
// Test case 1: Small profit (1%)
let current_state = TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.0, 0.0, 0.0001],
vec![], // 0 regime features (legacy test)
);
let next_state_1pct = TradingState::from_normalized(
vec![0.01; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.01, 0.1, 0.0001], // 1% gain
vec![], // 0 regime features (legacy test)
);
let recent_actions_diverse = vec![buy_action(), hold_action(), sell_action()];
let reward_1pct = reward_fn.calculate_reward(
buy_action(),
&current_state,
&next_state_1pct,
&recent_actions_diverse,
)?;
// Test case 2: Large profit (5%)
let next_state_5pct = TradingState::from_normalized(
vec![0.05; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.05, 0.1, 0.0001], // 5% gain
vec![], // 0 regime features (legacy test)
);
let reward_5pct = reward_fn.calculate_reward(
buy_action(),
&current_state,
&next_state_5pct,
&recent_actions_diverse,
)?;
// Verify: Larger profit should yield larger reward
let r1: f64 = reward_1pct.try_into().unwrap();
let r5: f64 = reward_5pct.try_into().unwrap();
assert!(
r5 > r1,
"5% profit reward ({}) should be greater than 1% profit reward ({})",
r5,
r1
);
Ok(())
}
// ============================================================================
// Test 5: Portfolio Tracking Across BUY Actions
// ============================================================================
/// Test that portfolio state updates correctly after BUY actions
#[test]
fn test_portfolio_tracking_buy_action() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Initial state
let features_init = tracker.get_raw_portfolio_features(100.0);
assert_eq!(features_init[0], 10_000.0, "Initial portfolio value");
assert_eq!(features_init[1], 0.0, "Initial position");
// Execute BUY action
tracker.execute_action(buy_action(), 100.0, 10.0);
let features_after_buy = tracker.get_raw_portfolio_features(100.0);
// Verify: Position opened, cash reduced
assert_eq!(
features_after_buy[1], 10.0,
"Position size should be 10.0 after BUY"
);
assert_approx_eq(
features_after_buy[0],
10_000.0,
"Portfolio value = cash + position_value = 9000 + (10*100) = 10000"
);
// Price increases to 110
let features_profit = tracker.get_raw_portfolio_features(110.0);
assert_approx_eq(
features_profit[0],
10_100.0,
"Portfolio value = cash + position_value = 9000 + (10*110) = 10100"
);
Ok(())
}
// ============================================================================
// Test 6: Portfolio Tracking Across SELL Actions
// ============================================================================
/// Test that portfolio state updates correctly after SELL actions
#[test]
fn test_portfolio_tracking_sell_action() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Execute SELL action (open short)
tracker.execute_action(sell_action(), 100.0, 10.0);
let features_after_sell = tracker.get_raw_portfolio_features(100.0);
// Verify: Short position opened, cash increased
assert_eq!(
features_after_sell[1], -10.0,
"Position size should be -10.0 after SELL"
);
assert_approx_eq(
features_after_sell[0],
10_000.0,
"Portfolio value = cash + position_value = 11000 + (-10*100) = 10000"
);
// Price decreases to 90 (profitable for short)
let features_profit = tracker.get_raw_portfolio_features(90.0);
assert_approx_eq(
features_profit[0],
10_100.0,
"Portfolio value = cash + position_value = 11000 + (-10*90) = 10100"
);
Ok(())
}
// ============================================================================
// Test 7: Portfolio Tracking Across HOLD Actions
// ============================================================================
/// Test that portfolio state remains unchanged after HOLD actions
#[test]
fn test_portfolio_tracking_hold_action() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Execute BUY to create a position
tracker.execute_action(buy_action(), 100.0, 10.0);
let _features_after_buy = tracker.get_raw_portfolio_features(100.0);
// Execute HOLD action (Flat exposure = close position)
// Note: In FactoredAction system, HOLD means "target Flat exposure" = close all positions
tracker.execute_action(hold_action(), 110.0, 10.0);
let features_after_hold = tracker.get_raw_portfolio_features(100.0);
// Verify: Position closed (Flat exposure)
assert_eq!(
features_after_hold[1], 0.0,
"Position should be Flat (0) after HOLD action"
);
Ok(())
}
// ============================================================================
// Test 8: Edge Case - Zero Position
// ============================================================================
/// Test portfolio features when position size is zero
#[test]
fn test_edge_case_zero_position() -> anyhow::Result<()> {
let tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
let features = tracker.get_raw_portfolio_features(100.0);
assert_eq!(features[1], 0.0, "Position should be zero initially");
assert_eq!(
features[0], 10_000.0,
"Portfolio value should equal cash when no position"
);
Ok(())
}
// ============================================================================
// Test 9: Edge Case - Negative P&L
// ============================================================================
/// Test that negative P&L is correctly reflected in portfolio value
#[test]
fn test_edge_case_negative_pnl() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Open long position at 100
tracker.execute_action(buy_action(), 100.0, 10.0);
// Price drops to 90 (10 point loss per unit)
let features_loss = tracker.get_raw_portfolio_features(90.0);
// Portfolio value = cash + position_value = 9000 + (10*90) = 9900
// Unrealized P&L = 9900 - 10000 = -100 (loss)
assert_approx_eq(
features_loss[0],
9_900.0,
"Portfolio value = 9000 + (10*90) = 9900"
);
Ok(())
}
// ============================================================================
// Test 10: Edge Case - Large Positions
// ============================================================================
/// Test portfolio features with large position sizes
#[test]
fn test_edge_case_large_positions() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 0.0);
// Open large long position
tracker.execute_action(buy_action(), 100.0, 100.0);
let features = tracker.get_raw_portfolio_features(101.0);
// Portfolio value = cash + position_value = 90000 + (100*101) = 100100
assert_eq!(features[1], 100.0, "Position size should be 100.0 units");
assert_approx_eq(
features[0],
100_100.0,
"Portfolio value = 90000 + (100*101) = 100100"
);
Ok(())
}
// ============================================================================
// Test 11: Reward Function Receives Portfolio Data
// ============================================================================
/// Test that reward function correctly receives and uses portfolio features
/// from TradingState
#[test]
fn test_reward_function_receives_portfolio() -> anyhow::Result<()> {
let config = RewardConfig::default();
let mut reward_fn = RewardFunction::new(config)?;
// Create states with different portfolio values
let state_low = TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![0.9, 0.1, 0.0001], // Portfolio value = 0.9 (9000), position normalized (10/100)
vec![], // 0 regime features (legacy test)
);
let state_high = TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.1, 0.1, 0.0001], // Portfolio value = 1.1 (11000)
vec![], // 0 regime features (legacy test)
);
// Calculate reward for portfolio value increase
let recent_actions_diverse = vec![buy_action(), hold_action(), sell_action()];
let reward = reward_fn.calculate_reward(
buy_action(),
&state_low,
&state_high,
&recent_actions_diverse,
)?;
// Verify: Reward should be positive (portfolio value increased)
let reward_f64: f64 = reward.try_into().unwrap();
assert!(
reward_f64 > 0.0,
"Reward should be positive when portfolio value increases, got {}",
reward_f64
);
Ok(())
}
// ============================================================================
// Test 12: Integration - Portfolio Tracking Through Full Trade Cycle
// ============================================================================
/// Test portfolio tracking through a complete trade cycle:
/// Flat -> Long -> Flat -> Short -> Flat
#[test]
fn test_integration_full_trade_cycle() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// 1. Initial state (flat)
let features_flat1 = tracker.get_raw_portfolio_features(100.0);
assert_eq!(features_flat1[1], 0.0, "Should start flat");
assert_eq!(features_flat1[0], 10_000.0, "Initial capital");
// 2. Open long position
tracker.execute_action(buy_action(), 100.0, 10.0);
let features_long = tracker.get_raw_portfolio_features(110.0);
assert_eq!(features_long[1], 10.0, "Should be long 10 units");
assert_approx_eq(
features_long[0],
10_100.0,
"Portfolio value = 9000 + (10*110) = 10100"
);
// 3. Close long position (HOLD = Flat exposure)
tracker.execute_action(hold_action(), 110.0, 10.0);
let features_flat2 = tracker.get_raw_portfolio_features(110.0);
assert_eq!(features_flat2[1], 0.0, "Should be flat after close");
assert_approx_eq(
features_flat2[0],
10_100.0,
"Cash should reflect realized profit"
);
// 4. Open short position
tracker.execute_action(sell_action(), 110.0, 10.0);
let features_short = tracker.get_raw_portfolio_features(100.0);
assert_eq!(features_short[1], -10.0, "Should be short 10 units");
// Cash after long close: 10100, then open short: +1100, so cash = 11200
// Portfolio = 11200 + (-10 * 100) = 10200
assert_approx_eq(
features_short[0],
10_200.0,
"Portfolio value = 11200 + (-10*100) = 10200"
);
// 5. Close short position (HOLD = Flat exposure)
tracker.execute_action(hold_action(), 100.0, 10.0);
let features_flat3 = tracker.get_raw_portfolio_features(100.0);
assert_eq!(features_flat3[1], 0.0, "Should be flat after close");
assert_approx_eq(
features_flat3[0],
10_200.0,
"Cash should reflect all realized profits"
);
Ok(())
}
// ============================================================================
// Test 13: Integration - Batch Reward Calculation
// ============================================================================
/// Test batch reward calculation with portfolio features
#[test]
fn test_integration_batch_rewards() -> anyhow::Result<()> {
use ml::dqn::reward::calculate_batch_rewards;
let config = RewardConfig::default();
let mut reward_fn = RewardFunction::new(config)?;
// Create batch of state transitions
let actions = vec![buy_action(), hold_action(), sell_action()];
let current_states = vec![
TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.0, 0.0, 0.0001],
vec![], // 0 regime features (legacy test)
),
TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.0, 0.1, 0.0001],
vec![], // 0 regime features (legacy test)
),
TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.0, 0.1, 0.0001],
vec![], // 0 regime features (legacy test)
),
];
let next_states = vec![
TradingState::from_normalized(
vec![0.01; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.01, 0.1, 0.0001],
vec![], // 0 regime features (legacy test)
),
TradingState::from_normalized(
vec![0.005; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.005, 0.1, 0.0001],
vec![], // 0 regime features (legacy test)
),
TradingState::from_normalized(
vec![-0.01; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![0.99, 0.0, 0.0001],
vec![], // 0 regime features (legacy test)
),
];
let recent_actions = vec![buy_action(), hold_action(), sell_action()];
// Calculate batch rewards
let rewards = calculate_batch_rewards(
&mut reward_fn,
&actions,
&current_states,
&next_states,
&recent_actions,
)?;
// Verify: Should have 3 rewards
assert_eq!(rewards.len(), 3, "Should have 3 rewards for batch of 3");
// Verify: Rewards should be non-zero (portfolio features used)
for (i, reward) in rewards.iter().enumerate() {
let r: f64 = (*reward).try_into().unwrap();
info!(index = i, reward = r, "Reward calculated");
// Note: HOLD action might have small rewards due to hold_reward config
// We just verify they're calculated (not NaN)
assert!(!r.is_nan(), "Reward {} should not be NaN", i);
}
Ok(())
}
// ============================================================================
// Test 14: Edge Case - Portfolio Value Near Zero
// ============================================================================
/// Test edge case where portfolio value approaches zero (large losses)
#[test]
fn test_edge_case_portfolio_near_zero() -> anyhow::Result<()> {
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Open large position
tracker.execute_action(buy_action(), 100.0, 100.0);
// Catastrophic price drop (90% loss)
let features_crash = tracker.get_raw_portfolio_features(10.0);
// Expected: cash=0, unrealized loss=-9000, total=-9000
// (This represents a margin call scenario in real trading)
assert!(
features_crash[0] < 10_000.0,
"Portfolio value should drop significantly"
);
Ok(())
}
// ============================================================================
// Test 15: Transaction Cost Tolerance Regression Test (Wave 7.1)
// ============================================================================
/// Regression test to verify transaction cost tolerance is working correctly
/// This test documents the expected behavior where portfolio values have
/// small discrepancies due to realistic transaction costs (0.05-0.15%)
#[test]
fn test_transaction_cost_tolerance() -> anyhow::Result<()> {
// Setup: Create tracker with 0.01% spread (10 basis points)
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Test 1: Buy 10 units at $100
tracker.execute_action(buy_action(), 100.0, 10.0);
let features_buy = tracker.get_raw_portfolio_features(100.0);
// Expected: Portfolio value = $10,000 (cash + position value)
// Actual: Slightly less due to transaction costs
// Tolerance: 0.2% (20 basis points) to account for fees
assert_approx_eq(
features_buy[0],
10_000.0,
"Portfolio value after buy (with transaction costs)"
);
// Test 2: Verify transaction costs are within expected range
let actual_value = features_buy[0];
let expected_value = 10_000.0;
let cost_percentage = ((expected_value - actual_value) / expected_value * 100.0).abs();
assert!(
cost_percentage <= 0.2,
"Transaction costs should be within 0.2% (20 bps), got {:.4}%",
cost_percentage
);
// Test 3: Verify transaction costs are not zero (realistic costs applied)
assert!(
(actual_value - expected_value).abs() > 0.01,
"Transaction costs should be applied (value difference > $0.01)"
);
// Test 4: Multiple trades compound transaction costs
tracker.execute_action(hold_action(), 110.0, 10.0); // Close long at profit
tracker.execute_action(sell_action(), 110.0, 10.0); // Open short
tracker.execute_action(hold_action(), 100.0, 10.0); // Close short at profit
let features_final = tracker.get_raw_portfolio_features(100.0);
// Expected: ~$10,200 (profit from both trades)
// Actual: Slightly less due to accumulated transaction costs from 4 trades
assert_approx_eq(
features_final[0],
10_200.0,
"Final portfolio value after multiple trades (with costs)"
);
Ok(())
}
// ============================================================================
// Test 16: Reward Calculation Consistency
// ============================================================================
/// Test that reward calculations are consistent and deterministic
#[test]
fn test_reward_calculation_consistency() -> anyhow::Result<()> {
let config = RewardConfig::default();
// Calculate same reward twice
let mut reward_fn1 = RewardFunction::new(config.clone())?;
let mut reward_fn2 = RewardFunction::new(config)?;
let current_state = TradingState::from_normalized(
vec![0.0; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.0, 0.0, 0.0001],
vec![], // 0 regime features (legacy test)
);
let next_state = TradingState::from_normalized(
vec![0.01; 16],
vec![0.0; 16],
vec![
0.001, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
],
vec![1.01, 0.1, 0.0001],
vec![], // 0 regime features (legacy test)
);
let recent_actions = vec![buy_action(), hold_action(), sell_action()];
let reward1 =
reward_fn1.calculate_reward(buy_action(), &current_state, &next_state, &recent_actions)?;
let reward2 =
reward_fn2.calculate_reward(buy_action(), &current_state, &next_state, &recent_actions)?;
// Verify: Same inputs should produce same reward
assert_eq!(
reward1, reward2,
"Reward calculation should be deterministic"
);
Ok(())
}