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)
257 lines
8.9 KiB
Rust
257 lines
8.9 KiB
Rust
//! Symbol-Specific Contract Multiplier Tests
|
||
//!
|
||
//! Wave 16S-V9 Bug #5 Fix: Verify symbol-specific contract multipliers
|
||
//! eliminate the 1,855% portfolio explosion bug.
|
||
//!
|
||
//! Root cause: PortfolioTracker hardcoded contract_multiplier=50.0 (ES futures only)
|
||
//! but training data contains 4 symbols with vastly different multipliers:
|
||
//! - ES: $50/point ✅ Correct
|
||
//! - NQ: $20/point ❌ 2.5× overvalued when using 50
|
||
//! - ZN: $1,000/point ❌ 20× undervalued when using 50
|
||
//! - 6E: $125,000/point ❌ 2500× undervalued when using 50
|
||
//!
|
||
//! Fix: Symbol-specific lookup in PortfolioTracker::new()
|
||
|
||
use ml::dqn::portfolio_tracker::PortfolioTracker;
|
||
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
|
||
|
||
// Per-symbol initial capital to afford 1 contract with proper multipliers
|
||
// Wave 16S-V9 Fix: Tests now use realistic capital amounts
|
||
const ES_CAPITAL: f32 = 300_000.0; // ES: 5600 × $50 = $280K (+ buffer)
|
||
const NQ_CAPITAL: f32 = 400_000.0; // NQ: 18000 × $20 = $360K (+ buffer)
|
||
const ZN_CAPITAL: f32 = 150_000.0; // ZN: 110 × $1000 = $110K (+ buffer)
|
||
const E6_CAPITAL: f32 = 150_000.0; // 6E: 1.0 × $125K = $125K (+ buffer)
|
||
const AVG_SPREAD: f32 = 0.0001;
|
||
|
||
#[test]
|
||
fn test_es_contract_multiplier() {
|
||
// ES futures: $50 per point
|
||
let tracker = PortfolioTracker::new(ES_CAPITAL, AVG_SPREAD, "ES");
|
||
|
||
// Buy 1 contract of ES at 5600 points
|
||
let mut tracker = tracker;
|
||
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||
let price = 5600.0;
|
||
let max_position = 1.0; // 1 contract
|
||
|
||
tracker.execute_action(action, price, max_position);
|
||
|
||
// Expected: 1 contract × $5,600 × $50 = $280,000 notional
|
||
// Portfolio value = $100K cash - $280K cost = -$180K + $280K position = $100K
|
||
let portfolio_value = tracker.total_value(price);
|
||
|
||
// Expected cash: $100K - (1 × 5600 × 50) - fees
|
||
// Position value: 1 × 5600 × 50 = $280K
|
||
// Portfolio = cash + position ≈ $100K (before fees)
|
||
assert!(
|
||
(portfolio_value - ES_CAPITAL).abs() < 5000.0,
|
||
"ES portfolio value should be ~$100K, got ${:.2}",
|
||
portfolio_value
|
||
);
|
||
|
||
// Verify position size
|
||
assert_eq!(tracker.current_position(), 1.0, "ES position should be 1 contract");
|
||
}
|
||
|
||
#[test]
|
||
fn test_nq_contract_multiplier() {
|
||
// NQ futures: $20 per point
|
||
let tracker = PortfolioTracker::new(NQ_CAPITAL, AVG_SPREAD, "NQ");
|
||
|
||
// Buy 1 contract of NQ at 18000 points
|
||
let mut tracker = tracker;
|
||
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||
let price = 18000.0;
|
||
let max_position = 1.0;
|
||
|
||
tracker.execute_action(action, price, max_position);
|
||
|
||
// Expected: 1 contract × $18,000 × $20 = $360,000 notional
|
||
// Portfolio value = $100K cash - $360K cost + $360K position = $100K
|
||
let portfolio_value = tracker.total_value(price);
|
||
|
||
assert!(
|
||
(portfolio_value - NQ_CAPITAL).abs() < 10000.0,
|
||
"NQ portfolio value should be ~$100K, got ${:.2}",
|
||
portfolio_value
|
||
);
|
||
|
||
assert_eq!(tracker.current_position(), 1.0, "NQ position should be 1 contract");
|
||
}
|
||
|
||
#[test]
|
||
fn test_zn_contract_multiplier() {
|
||
// ZN futures: $1,000 per point
|
||
let tracker = PortfolioTracker::new(ZN_CAPITAL, AVG_SPREAD, "ZN");
|
||
|
||
// Buy 1 contract of ZN at 110 points
|
||
let mut tracker = tracker;
|
||
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||
let price = 110.0;
|
||
let max_position = 1.0;
|
||
|
||
tracker.execute_action(action, price, max_position);
|
||
|
||
// Expected: 1 contract × $110 × $1,000 = $110,000 notional
|
||
// Portfolio value = $100K cash - $110K cost + $110K position = $100K
|
||
let portfolio_value = tracker.total_value(price);
|
||
|
||
assert!(
|
||
(portfolio_value - ZN_CAPITAL).abs() < 5000.0,
|
||
"ZN portfolio value should be ~$100K, got ${:.2}",
|
||
portfolio_value
|
||
);
|
||
|
||
assert_eq!(tracker.current_position(), 1.0, "ZN position should be 1 contract");
|
||
}
|
||
|
||
#[test]
|
||
fn test_6e_contract_multiplier() {
|
||
// 6E futures: $125,000 per contract (Euro FX)
|
||
let tracker = PortfolioTracker::new(E6_CAPITAL, AVG_SPREAD, "6E");
|
||
|
||
// Buy 1 contract of 6E at 1.11 points
|
||
let mut tracker = tracker;
|
||
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||
let price = 1.11;
|
||
let max_position = 1.0;
|
||
|
||
tracker.execute_action(action, price, max_position);
|
||
|
||
// Expected: 1 contract × $1.11 × $125,000 = $138,750 notional
|
||
// This exceeds capital, so position will be reduced to affordable amount
|
||
let portfolio_value = tracker.total_value(price);
|
||
|
||
// Portfolio should stay close to initial capital (insufficient cash protection)
|
||
assert!(
|
||
portfolio_value > 0.0 && portfolio_value <= E6_CAPITAL * 2.0,
|
||
"6E portfolio value should be positive and reasonable, got ${:.2}",
|
||
portfolio_value
|
||
);
|
||
|
||
// Position may be reduced due to insufficient cash
|
||
assert!(
|
||
tracker.current_position() <= 1.0,
|
||
"6E position should be ≤1 contract due to insufficient cash, got {}",
|
||
tracker.current_position()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_unknown_symbol_defaults_to_1_0() {
|
||
// Unknown symbol should default to multiplier=1.0 with warning
|
||
let tracker = PortfolioTracker::new(ES_CAPITAL, AVG_SPREAD, "UNKNOWN");
|
||
|
||
// Buy 1 contract at 100 points
|
||
let mut tracker = tracker;
|
||
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||
let price = 100.0;
|
||
let max_position = 1.0;
|
||
|
||
tracker.execute_action(action, price, max_position);
|
||
|
||
// Expected: 1 contract × $100 × $1.0 = $100 notional
|
||
// Portfolio value = $100K cash - $100 cost + $100 position = $100K
|
||
let portfolio_value = tracker.total_value(price);
|
||
|
||
assert!(
|
||
(portfolio_value - ES_CAPITAL).abs() < 1000.0,
|
||
"Unknown symbol portfolio value should be ~$100K (multiplier=1.0), got ${:.2}",
|
||
portfolio_value
|
||
);
|
||
|
||
assert_eq!(tracker.current_position(), 1.0, "Unknown symbol position should be 1 contract");
|
||
}
|
||
|
||
#[test]
|
||
fn test_pnl_realism_with_correct_multipliers() {
|
||
// Verify P&L stays within ±20% with correct multipliers
|
||
let symbols = vec![
|
||
("ES", 5600.0, 50.0),
|
||
("NQ", 18000.0, 20.0),
|
||
("ZN", 110.0, 1000.0),
|
||
];
|
||
|
||
for (symbol, price, expected_multiplier) in symbols {
|
||
let mut tracker = PortfolioTracker::new(ES_CAPITAL, AVG_SPREAD, symbol);
|
||
|
||
// Execute 10 random trades
|
||
for i in 0..10 {
|
||
let exposure = if i % 3 == 0 {
|
||
ExposureLevel::Long50
|
||
} else if i % 3 == 1 {
|
||
ExposureLevel::Short50
|
||
} else {
|
||
ExposureLevel::Flat
|
||
};
|
||
|
||
let action = FactoredAction::new(exposure, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, price * (1.0 + (i as f32 * 0.001)), 1.0);
|
||
}
|
||
|
||
let final_value = tracker.total_value(price);
|
||
let pnl_pct = ((final_value - ES_CAPITAL) / ES_CAPITAL) * 100.0;
|
||
|
||
assert!(
|
||
pnl_pct.abs() <= 20.0,
|
||
"{} P&L should be within ±20%, got {:.2}%",
|
||
symbol,
|
||
pnl_pct
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiplier_isolation_no_cross_contamination() {
|
||
// Verify each symbol uses its own multiplier (no global state)
|
||
let es_tracker = PortfolioTracker::new(ES_CAPITAL, AVG_SPREAD, "ES");
|
||
let nq_tracker = PortfolioTracker::new(NQ_CAPITAL, AVG_SPREAD, "NQ");
|
||
let zn_tracker = PortfolioTracker::new(ZN_CAPITAL, AVG_SPREAD, "ZN");
|
||
|
||
let mut es = es_tracker;
|
||
let mut nq = nq_tracker;
|
||
let mut zn = zn_tracker;
|
||
|
||
let action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
|
||
|
||
// Execute same action on different symbols
|
||
es.execute_action(action, 5600.0, 1.0);
|
||
nq.execute_action(action, 18000.0, 1.0);
|
||
zn.execute_action(action, 110.0, 1.0);
|
||
|
||
// All portfolios should be ~$100K but with different cash values
|
||
let es_value = es.total_value(5600.0);
|
||
let nq_value = nq.total_value(18000.0);
|
||
let zn_value = zn.total_value(110.0);
|
||
|
||
assert!(
|
||
(es_value - ES_CAPITAL).abs() < 5000.0,
|
||
"ES portfolio should be ~$100K, got ${:.2}",
|
||
es_value
|
||
);
|
||
assert!(
|
||
(nq_value - NQ_CAPITAL).abs() < 10000.0,
|
||
"NQ portfolio should be ~$100K, got ${:.2}",
|
||
nq_value
|
||
);
|
||
assert!(
|
||
(zn_value - ZN_CAPITAL).abs() < 5000.0,
|
||
"ZN portfolio should be ~$100K, got ${:.2}",
|
||
zn_value
|
||
);
|
||
|
||
// Cash values should be DIFFERENT (multipliers are different)
|
||
let es_cash = es.cash_balance();
|
||
let nq_cash = nq.cash_balance();
|
||
let zn_cash = zn.cash_balance();
|
||
|
||
assert_ne!(
|
||
es_cash, nq_cash,
|
||
"ES and NQ should have different cash balances (different multipliers)"
|
||
);
|
||
assert_ne!(
|
||
nq_cash, zn_cash,
|
||
"NQ and ZN should have different cash balances (different multipliers)"
|
||
);
|
||
}
|