Files
foxhunt/crates/ml/tests/contract_multiplier_test.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

360 lines
12 KiB
Rust
Raw Permalink 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,
)]
// Contract Multiplier Tests - Wave 16S-V8 Bug #3
// Tests for futures contract multiplier in portfolio calculations
//
// Bug: All price calculations treat price (index points) as dollars,
// ignoring futures contract multipliers (ES=$50/point, NQ=$20/point, ZN=$1000/point)
//
// CRITICAL: These tests are designed to FAIL with the buggy code (no multiplier)
// and PASS after the fix is applied.
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
#[test]
fn test_es_multiplier_50_per_point() {
// ES futures: $50 per point
// Test: 1 contract @ 5600 points = $280,000 notional value
let mut tracker = PortfolioTracker::new(
500_000.0, // initial_capital
0.0001, // avg_spread
50.0, // contract_multiplier (ES = $50/point)
);
let price = 5600.0;
let max_position = 10.0;
// Buy 1 contract (Long100 with MAX_POSITION_CONTRACTS=1.0 limit)
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
let portfolio_value = features[0];
// Expected: Portfolio value should stay ~$500,000 (cash down, position value up)
// Actual (before fix): Portfolio value = $500,000 - $5,600 - fees = $494,316 (50× too small)
assert!(
(portfolio_value - 500_000.0).abs() < 1_000.0,
"ES multiplier broken: portfolio_value={} (expected ~500000)",
portfolio_value
);
}
#[test]
fn test_nq_multiplier_20_per_point() {
// NQ futures: $20 per point
// Test: 1 contract @ 18000 points = $360,000 notional value
let mut tracker = PortfolioTracker::new(
500_000.0, // initial_capital
0.0001, // avg_spread
20.0, // contract_multiplier (NQ = $20/point)
);
let price = 18000.0;
let max_position = 10.0;
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
let portfolio_value = features[0];
// Expected: ~$500,000 (cash down $360K + fees, position up $360K)
// Actual (before fix): $481,973 (20× too small)
assert!(
(portfolio_value - 500_000.0).abs() < 1_000.0,
"NQ multiplier broken: portfolio_value={} (expected ~500000)",
portfolio_value
);
}
#[test]
fn test_zn_multiplier_1000_per_point() {
// ZN futures: $1000 per point
// Test: 1 contract @ 110 points = $110,000 notional value
let mut tracker = PortfolioTracker::new(
200_000.0, // initial_capital
0.0001, // avg_spread
1000.0, // contract_multiplier (ZN = $1000/point)
);
let price = 110.0;
let max_position = 10.0;
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
let portfolio_value = features[0];
// Expected: ~$200,000 (cash down $110K + fees, position up $110K)
// Actual (before fix): $199,835 (1000× too small)
assert!(
(portfolio_value - 200_000.0).abs() < 1_000.0,
"ZN multiplier broken: portfolio_value={} (expected ~200000)",
portfolio_value
);
}
#[test]
fn test_es_round_trip_with_multiplier() {
// Test round-trip trade verifies multiplier in both buy and sell
let mut tracker = PortfolioTracker::new(
500_000.0,
0.0001,
50.0, // ES multiplier
);
let buy_price = 5600.0;
let sell_price = 5650.0; // 50 point profit
let max_position = 10.0;
// Buy 1 contract @ 5600
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, buy_price, max_position);
// Sell 1 contract @ 5650 (50 point profit)
let sell_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
tracker.execute_action(sell_action, sell_price, max_position);
let features = tracker.get_raw_portfolio_features(sell_price);
let final_value = features[0];
// Expected profit: 50 points * $50/point = $2,500 (minus transaction costs ~$850)
// Net: $500,000 + $2,500 - $850 = ~$501,650
// Actual (before fix): $500,048 (no multiplier, 50× too small)
let expected_profit = 50.0 * 50.0; // 50 points * $50/point
let expected_final = 500_000.0 + expected_profit;
assert!(
(final_value - expected_final).abs() < 1_500.0, // Allow $1500 tolerance for transaction costs
"Round-trip profit broken: final_value={} (expected ~{})",
final_value, expected_final
);
}
#[test]
fn test_cash_accounting_with_multiplier() {
// Test cash deduction includes multiplier
let mut tracker = PortfolioTracker::new(
500_000.0,
0.0001,
50.0, // ES multiplier
);
let price = 5600.0;
let max_position = 10.0;
let initial_cash = tracker.cash_balance();
// Buy 1 contract @ 5600
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let final_cash = tracker.cash_balance();
// Expected cash reduction: 1 * 5600 * 50 + transaction_cost = ~$280,420
// Remaining: $500,000 - $280,420 = ~$219,580
// Actual (before fix): $494,316 (no multiplier)
let expected_cash_reduction = 1.0 * 5600.0 * 50.0; // $280,000
assert!(
(initial_cash - final_cash) > (expected_cash_reduction - 1_000.0),
"Cash reduction broken: initial={}, final={}, reduction={} (expected ~{})",
initial_cash, final_cash, initial_cash - final_cash, expected_cash_reduction
);
}
#[test]
fn test_no_multiplier_default() {
// Test with multiplier = 1.0 (no multiplier, backward compatibility)
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
1.0, // contract_multiplier = 1.0 (no multiplier)
);
let price = 5600.0;
let max_position = 10.0;
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
let portfolio_value = features[0];
// With multiplier=1.0, portfolio value should be ~$100,000 (small change from 1 contract)
// Cash reduction: 1 * 5600 * 1.0 = $5,600
// Remaining: $100,000 - $5,608 = ~$94,392
// Position value: 1 * 5600 * 1.0 = $5,600
// Total: ~$100,000
assert!(
(portfolio_value - 100_000.0).abs() < 1_000.0,
"No multiplier (1.0) broken: portfolio_value={} (expected ~100000)",
portfolio_value
);
}
#[test]
fn test_multiple_contracts_with_multiplier() {
// Test multiple contracts (position limit prevents this, but test the calculation)
// This test will actually only execute 1 contract due to MAX_POSITION_CONTRACTS=1.0
let mut tracker = PortfolioTracker::new(
1_000_000.0, // Large capital for multiple contracts
0.0001,
50.0, // ES multiplier
);
let price = 5600.0;
let max_position = 10.0; // Request 10 contracts
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
let features = tracker.get_raw_portfolio_features(price);
let position = features[1];
// Due to MAX_POSITION_CONTRACTS=1.0, position is clamped to 1.0
assert_eq!(position, 1.0, "Position should be clamped to 1.0 (MAX_POSITION_CONTRACTS limit)");
}
#[test]
fn test_short_position_with_multiplier() {
// Test short position profit calculation with multiplier
let mut tracker = PortfolioTracker::new(
500_000.0,
0.0001,
50.0, // ES multiplier
);
let short_price = 5600.0;
let cover_price = 5550.0; // 50 point profit for short
let max_position = 10.0;
// Short 1 contract @ 5600
let short_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, short_price, max_position);
// Cover @ 5550 (50 point profit)
let cover_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
tracker.execute_action(cover_action, cover_price, max_position);
let features = tracker.get_raw_portfolio_features(cover_price);
let final_value = features[0];
// Expected profit: 50 points * $50/point = $2,500
// Actual (before fix): ~$50 (no multiplier)
let expected_profit = 50.0 * 50.0;
let expected_final = 500_000.0 + expected_profit;
assert!(
(final_value - expected_final).abs() < 1_500.0,
"Short position profit broken: final_value={} (expected ~{})",
final_value, expected_final
);
}
#[test]
fn test_unrealized_pnl_with_multiplier() {
// Test unrealized P&L calculation includes multiplier
let mut tracker = PortfolioTracker::new(
500_000.0,
0.0001,
50.0, // ES multiplier
);
let entry_price = 5600.0;
let current_price = 5650.0; // 50 point profit
let max_position = 10.0;
// Buy 1 contract @ 5600
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, entry_price, max_position);
// Calculate unrealized P&L at 5650
let unrealized_pnl = tracker.unrealized_pnl(current_price);
// Expected: 50 points * $50/point = $2,500 (minus transaction costs)
// Actual (before fix): ~$50 (no multiplier)
let expected_pnl = 50.0 * 50.0; // $2,500
assert!(
(unrealized_pnl - expected_pnl).abs() < 1_000.0, // Allow $1000 tolerance for transaction costs
"Unrealized P&L broken: unrealized_pnl={} (expected ~{})",
unrealized_pnl, expected_pnl
);
}