Files
foxhunt/ml/tests/transaction_cost_calculation_test.rs
jgrusewski f5947c2b22 Wave 16S-V11: Bug #8 fix + P2-A/B implementation
Bug #8 (CRITICAL): Fixed action selection frequency catastrophe
- Root cause: execute_action called during training (522,713 orders/epoch)
- Fix: Removed execute_action from experience collection loop (line 928-936)
- Impact: 522,713 → 0 orders/epoch (100% reduction)
- Transaction costs: $338K → $0 (eliminated)
- Test suite: ml/tests/action_selection_frequency_test.rs (3/3 passing)

P2-A: Configurable Initial Capital
- CLI argument: --initial-capital (default: $100K, min: $1K)
- Files modified: trainers/dqn.rs, train_dqn.rs, hyperopt adapter
- Test suite: ml/tests/configurable_capital_test.rs (8/8 passing)
- Supports: Small accounts ($10K), Standard ($100K), Institutional ($500K+)

P2-B: Cash Reserve Requirement
- CLI argument: --cash-reserve-percent (default: 0%, range: 0-100%)
- Reserve enforcement: BUY trades only (SELL always allowed)
- Dynamic reserve adjusts with portfolio value
- Files modified: portfolio_tracker.rs (70 lines), trainers/dqn.rs, train_dqn.rs
- Test suite: ml/tests/cash_reserve_requirement_test.rs (10/10 passing)

Test Status: 21/21 core tests passing (P2-C deferred due to API mismatch)

Wave 16S-V11 Agents:
- Agent #1: Bug #8 investigation (transaction cost analysis)
- Agent #2: P2-A implementation (configurable capital)
- Agent #3: P2-B implementation + test fix (cash reserve)
- Agent #4: Integration validation (certification report)
2025-11-12 23:05:51 +01:00

596 lines
18 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.
//! Transaction Cost Calculation Test Suite
//!
//! Wave 16S-V11 Bug #8: Transaction Cost Catastrophe
//!
//! **Problem**: Net transaction costs = $338,375 (338% of $100K capital!)
//! - 522,713 orders in 1 epoch (~31 orders per training step)
//! - Total fees: $501,795, Total rebates: $163,420
//! - Portfolio dropped to $28,573 (-71.43%)
//!
//! **Root Cause**: High-frequency flipping causing excessive transaction costs
//! - Not a fee calculation bug (formula is correct)
//! - Issue is ACTION FREQUENCY and position reversals
//!
//! **This Test Suite Validates**:
//! 1. Fee calculation correctness (per-order-value, not per-contract)
//! 2. Order type fee rates (Market 0.15%, IoC 0.10%, LimitMaker 0.05%)
//! 3. Transaction cost accumulation over multiple trades
//! 4. Position reversal handling (Long→Short should charge fees correctly)
//! 5. Fractional contract handling
//! 6. Integration with PortfolioTracker
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::TradingModel;
// ============================================================================
// Category 1: Fee Rate Validation
// ============================================================================
#[test]
fn test_market_order_fee_rate() {
// Market orders should charge 0.15% (0.0015)
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
assert_eq!(action.transaction_cost(), 0.0015);
}
#[test]
fn test_ioc_order_fee_rate() {
// IoC (Immediate or Cancel) orders should charge 0.10% (0.0010)
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::IoC,
Urgency::Aggressive,
);
assert_eq!(action.transaction_cost(), 0.0010);
}
#[test]
fn test_limit_maker_fee_rate() {
// LimitMaker orders should charge 0.05% (0.0005) - rebate in reality
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::LimitMaker,
Urgency::Patient,
);
assert_eq!(action.transaction_cost(), 0.0005);
}
// ============================================================================
// Category 2: Per-Order-Value Fee Calculation (NOT per-contract)
// ============================================================================
#[test]
fn test_fee_calculated_on_order_value_not_contracts() {
// Wave 16S-V11 Bug #8 FIX: Fees MUST be calculated as:
// fee = (position_delta × price × multiplier) × fee_rate
// NOT: fee = position_delta × fee_rate × price
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0), // 10% margin, effective_multiplier = 50 × 0.10 = 5.0
);
let initial_cash = tracker.cash_balance();
// Execute a Market order for 1.0 contract at $5,600 price
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
let price = 5_600.0;
let max_position = 1.0; // Clamped to 1.0 contract
tracker.execute_action(action, price, max_position);
// Expected fee calculation:
// trade_value = 1.0 (delta) × $5,600 (price) × 5.0 (effective_multiplier) = $28,000
// transaction_cost = $28,000 × 0.0015 (Market fee) = $42.00
//
// Cash update:
// cash_after = cash_before - (position_delta × price × multiplier) - transaction_cost
// cash_after = $100,000 - (1.0 × $5,600 × 5.0) - $42.00
// cash_after = $100,000 - $28,000 - $42.00 = $71,958.00
let expected_cash = initial_cash - (1.0 * price * 5.0) - 42.0;
let actual_cash = tracker.cash_balance();
assert!(
(actual_cash - expected_cash).abs() < 0.01,
"Fee calculation error! Expected cash: ${:.2}, Actual: ${:.2}. \
Fee should be calculated on order value ($28,000 × 0.0015 = $42), \
not per-contract ($1 × 0.0015 × $5,600 = $8.40 would be wrong).",
expected_cash,
actual_cash
);
// Verify transaction costs tracked correctly
let total_fees = tracker.transaction_costs();
assert!(
(total_fees - 42.0).abs() < 0.01,
"Transaction cost tracking error! Expected: $42.00, Actual: ${:.2}",
total_fees
);
}
#[test]
fn test_fractional_contract_fee_calculation() {
// Test that fees work correctly for fractional contracts (e.g., 0.5 contracts)
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let action = FactoredAction::new(
ExposureLevel::Long50, // 50% exposure = 0.5 contracts
OrderType::Market,
Urgency::Normal,
);
let price = 5_600.0;
let max_position = 1.0;
tracker.execute_action(action, price, max_position);
// Expected fee calculation for 0.5 contracts:
// trade_value = 0.5 × $5,600 × 5.0 = $14,000
// transaction_cost = $14,000 × 0.0015 = $21.00
let expected_fee = 21.0;
let actual_fee = tracker.transaction_costs();
assert!(
(actual_fee - expected_fee).abs() < 0.01,
"Fractional contract fee error! Expected: ${:.2}, Actual: ${:.2}",
expected_fee,
actual_fee
);
}
// ============================================================================
// Category 3: Fee Accumulation Over Multiple Trades
// ============================================================================
#[test]
fn test_fee_accumulation_across_multiple_trades() {
// Validate that transaction costs accumulate correctly
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
// Trade 1: Buy 1 contract (Market order, 0.15% fee)
let action1 = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action1, price, max_position);
// Expected fee: $28,000 × 0.0015 = $42.00
assert!(
(tracker.transaction_costs() - 42.0).abs() < 0.01,
"Trade 1 fee incorrect: ${:.2}",
tracker.transaction_costs()
);
// Trade 2: Close position (Sell 1 contract, Market order, 0.15% fee)
let action2 = FactoredAction::new(
ExposureLevel::Flat,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action2, price, max_position);
// Expected cumulative fee: $42.00 (Trade 1) + $42.00 (Trade 2) = $84.00
let expected_total = 84.0;
let actual_total = tracker.transaction_costs();
assert!(
(actual_total - expected_total).abs() < 0.01,
"Cumulative fee error! Expected: ${:.2}, Actual: ${:.2}",
expected_total,
actual_total
);
}
#[test]
fn test_fee_accumulation_with_different_order_types() {
// Mix Market, IoC, and LimitMaker orders
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
// Trade 1: Market order (0.15%)
let action1 = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action1, price, max_position);
let fee1 = 28_000.0 * 0.0015; // $42.00
// Trade 2: IoC order (0.10%)
let action2 = FactoredAction::new(
ExposureLevel::Flat,
OrderType::IoC,
Urgency::Aggressive,
);
tracker.execute_action(action2, price, max_position);
let fee2 = 28_000.0 * 0.0010; // $28.00
// Trade 3: LimitMaker order (0.05%)
let action3 = FactoredAction::new(
ExposureLevel::Long50,
OrderType::LimitMaker,
Urgency::Patient,
);
tracker.execute_action(action3, price, max_position);
let fee3 = 14_000.0 * 0.0005; // $7.00 (0.5 contracts)
let expected_total = fee1 + fee2 + fee3; // $77.00
let actual_total = tracker.transaction_costs();
assert!(
(actual_total - expected_total).abs() < 0.01,
"Mixed order type fee error! Expected: ${:.2}, Actual: ${:.2}",
expected_total,
actual_total
);
}
// ============================================================================
// Category 4: Position Reversal Handling
// ============================================================================
#[test]
fn test_position_reversal_fees_long_to_short() {
// Long→Short reversal should charge fees for BOTH:
// 1. Closing long position (1.0 contract)
// 2. Opening short position (1.0 contract)
// Total delta: 2.0 contracts
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
// Step 1: Establish long position
let action1 = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action1, price, max_position);
let fee_after_long = tracker.transaction_costs();
assert!((fee_after_long - 42.0).abs() < 0.01);
// Step 2: Reverse to short position (Long100 → Short100)
let action2 = FactoredAction::new(
ExposureLevel::Short100,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action2, price, max_position);
// Expected reversal fee:
// position_delta = -1.0 (Short100) - 1.0 (Long100) = -2.0 contracts
// trade_value = 2.0 × $5,600 × 5.0 = $56,000
// reversal_fee = $56,000 × 0.0015 = $84.00
//
// Total fees = $42.00 (initial long) + $84.00 (reversal) = $126.00
let expected_total = 126.0;
let actual_total = tracker.transaction_costs();
assert!(
(actual_total - expected_total).abs() < 0.01,
"Reversal fee error! Expected: ${:.2}, Actual: ${:.2}. \
Reversal should charge fees for 2.0 contracts (close long + open short).",
expected_total,
actual_total
);
}
#[test]
fn test_position_reversal_fees_short_to_long() {
// Short→Long reversal should also charge for full delta
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
// Step 1: Establish short position
let action1 = FactoredAction::new(
ExposureLevel::Short100,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action1, price, max_position);
let fee_after_short = tracker.transaction_costs();
assert!((fee_after_short - 42.0).abs() < 0.01);
// Step 2: Reverse to long position (Short100 → Long100)
let action2 = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action2, price, max_position);
// Expected reversal fee: $84.00 (same as long→short)
// Total fees = $42.00 (initial short) + $84.00 (reversal) = $126.00
let expected_total = 126.0;
let actual_total = tracker.transaction_costs();
assert!(
(actual_total - expected_total).abs() < 0.01,
"Short→Long reversal fee error! Expected: ${:.2}, Actual: ${:.2}",
expected_total,
actual_total
);
}
// ============================================================================
// Category 5: High-Frequency Trading Scenario (Bug #8 Root Cause)
// ============================================================================
#[test]
fn test_high_frequency_flipping_transaction_costs() {
// Simulate high-frequency flipping (31 trades per training step)
// This is the root cause of Bug #8: excessive trading frequency
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
// Simulate 100 trades (flipping Long↔Short)
let num_trades = 100;
for i in 0..num_trades {
let action = if i % 2 == 0 {
FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal)
} else {
FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal)
};
tracker.execute_action(action, price, max_position);
}
// Expected total fees:
// - First trade: 1.0 contract × $28,000 × 0.0015 = $42.00
// - Each subsequent trade (reversal): 2.0 contracts × $28,000 × 0.0015 = $84.00
// - Total: $42 + (99 × $84) = $42 + $8,316 = $8,358.00
let expected_total = 42.0 + (99.0 * 84.0);
let actual_total = tracker.transaction_costs();
assert!(
(actual_total - expected_total).abs() < 1.0, // Allow $1 tolerance
"High-frequency flipping fee error! Expected: ${:.2}, Actual: ${:.2}",
expected_total,
actual_total
);
// Verify portfolio is severely depleted by transaction costs
let remaining_cash = tracker.cash_balance();
println!(
"After {} trades: Cash = ${:.2}, Transaction costs = ${:.2}",
num_trades, remaining_cash, actual_total
);
// Cash should be significantly reduced
assert!(
remaining_cash < 92_000.0,
"Transaction costs should drain cash significantly. Remaining: ${:.2}",
remaining_cash
);
}
#[test]
fn test_transaction_costs_vs_capital_ratio() {
// Validate that 522,713 orders with 17× overcharge would drain capital
// This test calculates what the cost SHOULD be vs what was observed
// Observed in Bug #8:
let observed_net_costs = 338_375.0; // $338K
let _observed_total_fees = 501_795.0;
let _observed_total_rebates = 163_420.0;
let initial_capital = 100_000.0;
// Calculate ratio
let cost_to_capital_ratio = observed_net_costs / initial_capital;
assert!(
cost_to_capital_ratio > 3.0,
"Net costs (${:.2}) are {:.1}% of capital - CATASTROPHIC!",
observed_net_costs,
cost_to_capital_ratio * 100.0
);
// Expected Market order fees (assuming 67,908 orders at $100K avg):
let expected_market_fees = 67_908.0 * 100_000.0 * 0.0015;
println!(
"Expected Market fees: ${:.2}, Observed: ${:.2}, Ratio: {:.1}×",
expected_market_fees,
172_850.69,
172_850.69 / expected_market_fees
);
// The 17× ratio suggests either:
// 1. Orders are being executed 17× more frequently than expected
// 2. Each order is being charged 17× the correct fee
// 3. max_position is 17× larger than it should be
//
// Based on portfolio_tracker.rs analysis, #1 is most likely:
// High-frequency flipping (31 orders/step) × position reversals (2× cost)
}
// ============================================================================
// Category 6: Contract Multiplier Impact on Fees
// ============================================================================
#[test]
fn test_fee_calculation_with_different_multipliers() {
// ES (multiplier=50.0) vs NQ (multiplier=20.0) should have different fees
// even with same price and position size
// ES futures (multiplier=50.0, effective=5.0 with 10% margin)
let mut tracker_es = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
tracker_es.execute_action(action, price, max_position);
// ES fee: 1.0 × $5,600 × 5.0 × 0.0015 = $42.00
let es_fee = tracker_es.transaction_costs();
assert!((es_fee - 42.0).abs() < 0.01);
// NQ futures (multiplier=20.0, effective=2.0 with 10% margin)
let mut tracker_nq = PortfolioTracker::new(
100_000.0,
0.0001,
"NQ",
TradingModel::Futures(10.0),
);
tracker_nq.execute_action(action, price, max_position);
// NQ fee: 1.0 × $5,600 × 2.0 × 0.0015 = $16.80
let nq_fee = tracker_nq.transaction_costs();
assert!(
(nq_fee - 16.8).abs() < 0.01,
"NQ fee calculation error! Expected: $16.80, Actual: ${:.2}",
nq_fee
);
// Verify ES fees are 2.5× higher than NQ (multiplier ratio: 50/20 = 2.5)
let ratio = es_fee / nq_fee;
assert!(
(ratio - 2.5).abs() < 0.01,
"Multiplier ratio error! ES/NQ fee ratio should be 2.5×, got {:.2}×",
ratio
);
}
// ============================================================================
// Category 7: Edge Cases
// ============================================================================
#[test]
fn test_zero_position_delta_zero_fees() {
// Executing the same action twice should result in zero fees on second call
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
let action = FactoredAction::new(
ExposureLevel::Long100,
OrderType::Market,
Urgency::Normal,
);
// First execution
tracker.execute_action(action, price, max_position);
let fee_after_first = tracker.transaction_costs();
assert!((fee_after_first - 42.0).abs() < 0.01);
// Second execution (same action, no position change)
tracker.execute_action(action, price, max_position);
let fee_after_second = tracker.transaction_costs();
// Fee should be unchanged (no new position delta)
assert!(
(fee_after_second - fee_after_first).abs() < 0.01,
"Duplicate action should not charge fees! First: ${:.2}, Second: ${:.2}",
fee_after_first,
fee_after_second
);
}
#[test]
fn test_hold_action_zero_fees() {
// HOLD action should never charge fees
let mut tracker = PortfolioTracker::new(
100_000.0,
0.0001,
"ES",
TradingModel::Futures(10.0),
);
let price = 5_600.0;
let max_position = 1.0;
// Execute multiple HOLD actions
for _ in 0..10 {
// Note: FactoredAction doesn't have a HOLD exposure level
// Flat is the closest equivalent (target exposure = 0.0)
let action = FactoredAction::new(
ExposureLevel::Flat,
OrderType::Market,
Urgency::Normal,
);
tracker.execute_action(action, price, max_position);
}
// All HOLD actions should result in zero fees
let total_fees = tracker.transaction_costs();
assert!(
total_fees.abs() < 0.01,
"HOLD actions should never charge fees! Total: ${:.2}",
total_fees
);
}