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

181 lines
6.6 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,
)]
#[cfg(test)]
mod cash_accounting_tests {
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
#[test]
fn test_buy_long_decreases_cash() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
let initial_cash = tracker.cash_balance();
// Buy 1 contract at $5,600 (go from 0 to +1 position)
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5600.0, 1.0);
let final_cash = tracker.cash_balance();
// Cash should DECREASE when buying
assert!(final_cash < initial_cash,
"Cash should decrease when buying. Initial: ${:.2}, Final: ${:.2}",
initial_cash, final_cash);
// Should be approximately -$5,608.40 (price + 0.15% market fee)
let expected_decrease = 5600.0 + (5600.0 * 0.0015);
let actual_decrease = initial_cash - final_cash;
assert!((actual_decrease - expected_decrease).abs() < 1.0,
"Expected decrease: ${:.2}, Actual: ${:.2}",
expected_decrease, actual_decrease);
}
#[test]
fn test_sell_short_increases_cash() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
let initial_cash = tracker.cash_balance();
// Sell 1 contract at $5,600 (go from 0 to -1 position)
let action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 5600.0, 1.0);
let final_cash = tracker.cash_balance();
// Cash should INCREASE when selling short
assert!(final_cash > initial_cash,
"Cash should increase when selling short. Initial: ${:.2}, Final: ${:.2}",
initial_cash, final_cash);
// Should be approximately +$5,591.60 (price - 0.15% market fee)
let expected_increase = 5600.0 - (5600.0 * 0.0015);
let actual_increase = final_cash - initial_cash;
assert!((actual_increase - expected_increase).abs() < 1.0,
"Expected increase: ${:.2}, Actual: ${:.2}",
expected_increase, actual_increase);
}
#[test]
fn test_close_long_increases_cash() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// First, buy 1 contract
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy_action, 5600.0, 1.0);
let cash_after_buy = tracker.cash_balance();
// Now close the position (go from +1 to 0)
let close_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
tracker.execute_action(close_action, 5650.0, 1.0); // Price increased
let final_cash = tracker.cash_balance();
// Cash should increase when closing long position
assert!(final_cash > cash_after_buy,
"Cash should increase when closing long. After buy: ${:.2}, After close: ${:.2}",
cash_after_buy, final_cash);
}
#[test]
fn test_no_free_money_exploit() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
let initial_portfolio = tracker.total_value(5600.0);
// Execute 10 round-trip trades at same price
for _ in 0..10 {
// Buy
let buy = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(buy, 5600.0, 1.0);
// Sell
let sell = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
tracker.execute_action(sell, 5600.0, 1.0);
}
let final_portfolio = tracker.total_value(5600.0);
// Portfolio should DECREASE due to transaction costs, not increase
assert!(final_portfolio < initial_portfolio,
"Portfolio should lose money from transaction costs, not gain. Initial: ${:.2}, Final: ${:.2}",
initial_portfolio, final_portfolio);
// Should lose approximately 20 × (5600 × 0.0015) = $168 in fees
let expected_loss = 20.0 * 5600.0 * 0.0015;
let actual_loss = initial_portfolio - final_portfolio;
assert!((actual_loss - expected_loss).abs() < 10.0,
"Expected loss: ${:.2}, Actual loss: ${:.2}",
expected_loss, actual_loss);
}
}