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)
161 lines
5.4 KiB
Rust
161 lines
5.4 KiB
Rust
use ml::dqn::portfolio_tracker::PortfolioTracker;
|
||
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
|
||
|
||
#[test]
|
||
fn test_position_size_respects_max_limit() {
|
||
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
|
||
|
||
// Try to build massive 10,000 contract position (Wave 16R bug)
|
||
for i in 0..100 {
|
||
let long_action = FactoredAction::new(
|
||
ExposureLevel::Long100, // Maximum long exposure
|
||
OrderType::Market,
|
||
Urgency::Aggressive
|
||
);
|
||
|
||
tracker.execute_action(
|
||
long_action,
|
||
5000.0, // ES futures price
|
||
200.0 // Max position parameter (should be enforced)
|
||
);
|
||
|
||
// Debug: Check position after each iteration
|
||
if i % 20 == 0 {
|
||
let features = tracker.get_raw_portfolio_features(5000.0);
|
||
println!("Iteration {}: position = {:.1}, portfolio = ${:.0}",
|
||
i, features[1], features[0]);
|
||
}
|
||
}
|
||
|
||
// CRITICAL: Position must be clamped to max_position limit
|
||
let features = tracker.get_raw_portfolio_features(5000.0);
|
||
let final_position = features[1]; // Position size from features
|
||
println!("FINAL: position = {:.1}, portfolio = ${:.0}", final_position, features[0]);
|
||
|
||
assert!(
|
||
final_position.abs() <= 200.0,
|
||
"Position size {} exceeds max_position limit 200! \
|
||
Bug #15 (unbounded position sizing) still exists!",
|
||
final_position
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_position_prevents_portfolio_explosion() {
|
||
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
|
||
let initial_capital = 100_000.0;
|
||
|
||
// Build position to limit
|
||
for _ in 0..50 {
|
||
let action = FactoredAction::new(
|
||
ExposureLevel::Long100,
|
||
OrderType::Market,
|
||
Urgency::Aggressive
|
||
);
|
||
tracker.execute_action(action, 5000.0, 100.0);
|
||
}
|
||
|
||
let features = tracker.get_raw_portfolio_features(5000.0);
|
||
let portfolio_value = features[0];
|
||
let normalized_value = portfolio_value / initial_capital;
|
||
|
||
// Portfolio should stay in realistic range (not $50M)
|
||
assert!(
|
||
portfolio_value < 500_000.0,
|
||
"Portfolio value ${} is catastrophic! Expected <$500K. \
|
||
Position explosion indicates Bug #15 persists.",
|
||
portfolio_value
|
||
);
|
||
|
||
assert!(
|
||
normalized_value < 5.0,
|
||
"Normalized value {} is catastrophic! Expected <5.0. \
|
||
This will cause reward explosion and gradient collapse.",
|
||
normalized_value
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_negative_position_also_clamped() {
|
||
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
|
||
|
||
// Try to build massive short position
|
||
for _ in 0..100 {
|
||
let short_action = FactoredAction::new(
|
||
ExposureLevel::Short100, // Maximum short exposure
|
||
OrderType::Market,
|
||
Urgency::Aggressive
|
||
);
|
||
|
||
tracker.execute_action(
|
||
short_action,
|
||
5000.0, // ES futures price
|
||
150.0 // Max position parameter
|
||
);
|
||
}
|
||
|
||
let features = tracker.get_raw_portfolio_features(5000.0);
|
||
let final_position = features[1];
|
||
assert!(
|
||
final_position >= -150.0,
|
||
"Short position {} exceeds max_position limit -150! \
|
||
Position clipping should work both ways.",
|
||
final_position
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_low_price_creates_catastrophic_positions() {
|
||
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
|
||
|
||
// CRITICAL: Simulate low price scenario (e.g., penny stock or data bug)
|
||
// At price=$10, max_position = $100K / $10 = 10,000 contracts!
|
||
let low_price = 10.0;
|
||
let max_position_uncapped = 100_000.0 / low_price; // = 10,000 contracts
|
||
|
||
println!("Testing low price scenario: price=${:.2}, max_position={:.1}",
|
||
low_price, max_position_uncapped);
|
||
|
||
// Execute Long100 action with uncapped max_position
|
||
let long_action = FactoredAction::new(
|
||
ExposureLevel::Long100,
|
||
OrderType::Market,
|
||
Urgency::Aggressive
|
||
);
|
||
|
||
tracker.execute_action(long_action, low_price, max_position_uncapped);
|
||
|
||
let features = tracker.get_raw_portfolio_features(low_price);
|
||
let final_position = features[1];
|
||
let portfolio_value = features[0];
|
||
|
||
println!("After Long100: position={:.1} contracts, portfolio=${:.0}",
|
||
final_position, portfolio_value);
|
||
|
||
// THIS IS THE BUG: Position can be 10,000 contracts!
|
||
// At $10/contract, that's $100K exposure (OK)
|
||
// But if price moves to $5000 (ES futures), portfolio = 10,000 × $5000 = $50M (CATASTROPHIC)
|
||
|
||
// Demonstrate the explosion when price changes
|
||
let es_price = 5000.0;
|
||
let catastrophic_portfolio = tracker.get_raw_portfolio_features(es_price)[0];
|
||
|
||
println!("Price moves to ${:.2}: portfolio=${:.0} (CATASTROPHIC!)",
|
||
es_price, catastrophic_portfolio);
|
||
|
||
assert!(
|
||
final_position.abs() <= 200.0,
|
||
"Position size {} is catastrophic at low price! \
|
||
Should be clamped to reasonable limit (200 contracts), not based on price. \
|
||
Bug #15: max_position = capital/price creates unbounded positions at low prices!",
|
||
final_position
|
||
);
|
||
|
||
// Verify portfolio stays under $2M even at high prices (200 contracts × $5K = $1M position)
|
||
assert!(
|
||
catastrophic_portfolio < 2_000_000.0,
|
||
"Portfolio value ${:.0} is still catastrophic! Expected <$2M after position clamping.",
|
||
catastrophic_portfolio
|
||
);
|
||
}
|