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)
247 lines
10 KiB
Rust
247 lines
10 KiB
Rust
//! Transaction Cost Application Tests
|
|
//!
|
|
//! Validates that transaction costs are properly deducted from portfolio value
|
|
//! and P&L calculations in the PortfolioTracker.
|
|
//!
|
|
//! Bug Context: Agent B3 (Wave 16N)
|
|
//! - Transaction costs defined in FactoredAction but NOT applied to portfolio
|
|
//! - Results in P&L overstatement (0.01% leak per trade)
|
|
|
|
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
|
|
use ml::dqn::portfolio_tracker::PortfolioTracker;
|
|
|
|
#[test]
|
|
fn test_transaction_costs_reduce_portfolio_value_market_order() {
|
|
// Given: PortfolioTracker with $100K initial capital
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Execute Market order (0.15% fee)
|
|
// Buy 10 contracts at $5000 = $50,000 trade value
|
|
// Fee: $50,000 * 0.0015 = $75
|
|
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
|
let price = 5000.0;
|
|
let max_position = 10.0;
|
|
tracker.execute_action(action, price, max_position);
|
|
|
|
// Then: Portfolio value should be $100K - $75 (not $100K)
|
|
let portfolio_value = tracker.get_raw_portfolio_features(price)[0];
|
|
let expected_value = 100000.0 - 75.0; // $100K - $75 fee
|
|
assert_eq!(
|
|
portfolio_value, expected_value,
|
|
"Portfolio value should include transaction cost deduction. Expected {}, got {}",
|
|
expected_value, portfolio_value
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_costs_reduce_portfolio_value_ioc_order() {
|
|
// Given: PortfolioTracker
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Execute IoC order (0.10% fee)
|
|
// Buy 10 contracts at $5000 = $50,000 trade value
|
|
// Fee: $50,000 * 0.0010 = $50
|
|
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Aggressive);
|
|
let price = 5000.0;
|
|
let max_position = 10.0;
|
|
tracker.execute_action(action, price, max_position);
|
|
|
|
// Then: Portfolio value should be $100K - $50
|
|
let portfolio_value = tracker.get_raw_portfolio_features(price)[0];
|
|
let expected_value = 100000.0 - 50.0; // $100K - $50 fee
|
|
assert_eq!(
|
|
portfolio_value, expected_value,
|
|
"Portfolio value should include IoC transaction cost. Expected {}, got {}",
|
|
expected_value, portfolio_value
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_limitmaker_rebates_increase_portfolio_value() {
|
|
// Given: PortfolioTracker
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Execute LimitMaker order (0.05% rebate)
|
|
// Buy 10 contracts at $5000 = $50,000 trade value
|
|
// Rebate: $50,000 * 0.0005 = $25
|
|
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::LimitMaker, Urgency::Patient);
|
|
let price = 5000.0;
|
|
let max_position = 10.0;
|
|
tracker.execute_action(action, price, max_position);
|
|
|
|
// Then: Portfolio value should be $100K + $25 (rebate)
|
|
// Note: LimitMaker is treated as a "rebate" (reduces cost, effectively adds value)
|
|
let portfolio_value = tracker.get_raw_portfolio_features(price)[0];
|
|
let expected_value = 100000.0 - 25.0; // Still a cost, but smaller
|
|
assert_eq!(
|
|
portfolio_value, expected_value,
|
|
"Portfolio value should include LimitMaker rebate. Expected {}, got {}",
|
|
expected_value, portfolio_value
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pnl_includes_transaction_costs() {
|
|
// Given: PortfolioTracker with trade sequence
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Buy at $5000 (Market, fee: $75), sell at $5100 (Market, fee: $76.50)
|
|
// Buy: 10 contracts at $5000 = $50,000 trade, fee = $75
|
|
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(buy_action, 5000.0, 10.0);
|
|
|
|
// Sell: 10 contracts at $5100 = $51,000 trade, fee = $76.50
|
|
let sell_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(sell_action, 5100.0, 10.0);
|
|
|
|
// Then: P&L = gross profit ($1000) - fees ($151.50) = $848.50
|
|
let final_value = tracker.get_raw_portfolio_features(5100.0)[0];
|
|
let pnl = final_value - 100000.0;
|
|
let expected_pnl = 1000.0 - 75.0 - 76.50; // Gross profit - buy fee - sell fee
|
|
assert_eq!(
|
|
pnl, expected_pnl,
|
|
"P&L should include transaction costs. Expected {}, got {}",
|
|
expected_pnl, pnl
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_costs_accumulate_across_trades() {
|
|
// Given: PortfolioTracker
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Execute multiple trades with different order types
|
|
// Trade 1: Market order (0.15% fee)
|
|
let action1 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(action1, 5000.0, 10.0); // $25,000 trade, $37.50 fee
|
|
|
|
// Trade 2: IoC order (0.10% fee)
|
|
let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::IoC, Urgency::Aggressive);
|
|
tracker.execute_action(action2, 5000.0, 10.0); // $25,000 trade (delta from Long50 to Long100), $25 fee
|
|
|
|
// Trade 3: LimitMaker order (0.05% fee)
|
|
let action3 = FactoredAction::new(ExposureLevel::Flat, OrderType::LimitMaker, Urgency::Patient);
|
|
tracker.execute_action(action3, 5000.0, 10.0); // $50,000 trade (close position), $25 fee
|
|
|
|
// Then: Total fees = $37.50 + $25 + $25 = $87.50
|
|
let final_value = tracker.get_raw_portfolio_features(5000.0)[0];
|
|
let total_costs = 100000.0 - final_value;
|
|
let expected_costs = 37.50 + 25.0 + 25.0; // Sum of all fees
|
|
assert_eq!(
|
|
total_costs, expected_costs,
|
|
"Transaction costs should accumulate. Expected {}, got {}",
|
|
expected_costs, total_costs
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_costs_with_price_movement() {
|
|
// Given: PortfolioTracker
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Buy at $5000, price moves to $5100, then sell
|
|
// Buy: Market order (0.15% fee)
|
|
let buy_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(buy_action, 5000.0, 10.0); // $50,000 trade, $75 fee
|
|
|
|
// Price moves to $5100 (unrealized gain: $1000)
|
|
let mid_value = tracker.get_raw_portfolio_features(5100.0)[0];
|
|
let unrealized_pnl = mid_value - 100000.0;
|
|
let expected_unrealized = 1000.0 - 75.0; // Gross gain - buy fee
|
|
assert_eq!(
|
|
unrealized_pnl, expected_unrealized,
|
|
"Unrealized P&L should include buy transaction cost. Expected {}, got {}",
|
|
expected_unrealized, unrealized_pnl
|
|
);
|
|
|
|
// Sell: Market order (0.15% fee)
|
|
let sell_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(sell_action, 5100.0, 10.0); // $51,000 trade, $76.50 fee
|
|
|
|
// Then: Realized P&L = gross profit - all fees
|
|
let final_value = tracker.get_raw_portfolio_features(5100.0)[0];
|
|
let realized_pnl = final_value - 100000.0;
|
|
let expected_realized = 1000.0 - 75.0 - 76.50; // Gross profit - buy fee - sell fee
|
|
assert_eq!(
|
|
realized_pnl, expected_realized,
|
|
"Realized P&L should include all transaction costs. Expected {}, got {}",
|
|
expected_realized, realized_pnl
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_zero_transaction_cost_for_flat_to_flat() {
|
|
// Given: PortfolioTracker with no position
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Execute Flat action (no position change)
|
|
let action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(action, 5000.0, 10.0);
|
|
|
|
// Then: No transaction cost (no trade executed)
|
|
let portfolio_value = tracker.get_raw_portfolio_features(5000.0)[0];
|
|
assert_eq!(
|
|
portfolio_value, 100000.0,
|
|
"Flat-to-Flat should incur no transaction cost. Expected 100000.0, got {}",
|
|
portfolio_value
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_costs_for_partial_position_change() {
|
|
// Given: PortfolioTracker
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Go from Flat → Long50 → Long100
|
|
// Trade 1: Long50 (0.15% fee on $25,000 trade = $37.50)
|
|
let action1 = FactoredAction::new(ExposureLevel::Long50, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(action1, 5000.0, 10.0); // Trade value: $25,000
|
|
|
|
let value_after_trade1 = tracker.get_raw_portfolio_features(5000.0)[0];
|
|
let cost1 = 100000.0 - value_after_trade1;
|
|
assert_eq!(cost1, 37.50, "Long50 should cost $37.50 in fees");
|
|
|
|
// Trade 2: Long100 (0.15% fee on $25,000 incremental trade = $37.50)
|
|
let action2 = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(action2, 5000.0, 10.0); // Additional $25,000 trade
|
|
|
|
let value_after_trade2 = tracker.get_raw_portfolio_features(5000.0)[0];
|
|
let total_costs = 100000.0 - value_after_trade2;
|
|
assert_eq!(total_costs, 75.0, "Long50 → Long100 should cost $75 total ($37.50 + $37.50)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_transaction_costs_with_short_positions() {
|
|
// Given: PortfolioTracker
|
|
let mut tracker = PortfolioTracker::new(100000.0, 0.0001, 1.0);
|
|
|
|
// When: Short 10 contracts at $5000 (Market order, 0.15% fee)
|
|
// Short: $50,000 trade value, $75 fee
|
|
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(short_action, 5000.0, 10.0);
|
|
|
|
// Then: Portfolio value should reflect transaction cost
|
|
let portfolio_value = tracker.get_raw_portfolio_features(5000.0)[0];
|
|
let expected_value = 100000.0 - 75.0; // $100K - $75 fee
|
|
assert_eq!(
|
|
portfolio_value, expected_value,
|
|
"Short position should incur transaction cost. Expected {}, got {}",
|
|
expected_value, portfolio_value
|
|
);
|
|
|
|
// When: Cover short at $4900 (profitable short, Market order 0.15% fee)
|
|
// Cover: $49,000 trade value, $73.50 fee
|
|
let cover_action = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
|
|
tracker.execute_action(cover_action, 4900.0, 10.0);
|
|
|
|
// Then: P&L = gross profit ($1000) - fees ($148.50)
|
|
let final_value = tracker.get_raw_portfolio_features(4900.0)[0];
|
|
let pnl = final_value - 100000.0;
|
|
let expected_pnl = 1000.0 - 75.0 - 73.50; // Gross profit - short fee - cover fee
|
|
assert_eq!(
|
|
pnl, expected_pnl,
|
|
"Short P&L should include transaction costs. Expected {}, got {}",
|
|
expected_pnl, pnl
|
|
);
|
|
}
|