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

257 lines
8.9 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.
//! 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)"
);
}