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)
234 lines
8.6 KiB
Rust
234 lines
8.6 KiB
Rust
//! Test circuit breaker functionality in DQN training
|
|
//!
|
|
//! Verifies that the circuit breaker correctly halts training when portfolio
|
|
//! drawdown exceeds configured limits, preventing data corruption from
|
|
//! catastrophic losses.
|
|
|
|
use anyhow::Result;
|
|
use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer};
|
|
use ml::trainers::TargetUpdateMode;
|
|
use ml::MLError;
|
|
|
|
#[tokio::test]
|
|
async fn test_circuit_breaker_triggers_on_catastrophic_loss() -> Result<()> {
|
|
// Create hyperparameters with circuit breaker enabled
|
|
let hyperparams = DQNHyperparameters {
|
|
learning_rate: 0.0001,
|
|
batch_size: 32,
|
|
gamma: 0.99,
|
|
epsilon_start: 1.0,
|
|
epsilon_end: 0.01,
|
|
epsilon_decay: 0.995,
|
|
buffer_size: 10000,
|
|
min_replay_size: 100,
|
|
epochs: 100, // Long enough to potentially trigger circuit breaker
|
|
checkpoint_frequency: 10,
|
|
early_stopping_enabled: false, // Disable early stopping to isolate circuit breaker
|
|
q_value_floor: 0.0, // Disable Q-value floor to isolate circuit breaker
|
|
min_loss_improvement_pct: 0.0,
|
|
plateau_window: 30,
|
|
min_epochs_before_stopping: 100, // Prevent early stopping
|
|
hold_penalty: -0.001,
|
|
use_huber_loss: true,
|
|
huber_delta: 1.0,
|
|
use_double_dqn: true,
|
|
gradient_clip_norm: Some(10.0),
|
|
hold_penalty_weight: 0.01,
|
|
movement_threshold: 0.02,
|
|
enable_preprocessing: false, // Disable for simpler test
|
|
preprocessing_window: 50,
|
|
preprocessing_clip_sigma: 5.0,
|
|
tau: 0.005,
|
|
target_update_mode: TargetUpdateMode::Soft,
|
|
target_update_frequency: 1,
|
|
warmup_steps: 0,
|
|
entropy_coefficient: 0.01,
|
|
|
|
// WAVE 16S-P2: Circuit breaker configuration
|
|
enable_circuit_breaker: true,
|
|
max_drawdown_pct: 20.0, // Set very low threshold for test (20% vs 50% default)
|
|
circuit_breaker_checkpoint: false, // Disable checkpoint for test
|
|
};
|
|
|
|
// Create trainer
|
|
let mut trainer = DQNTrainer::new(hyperparams)?;
|
|
|
|
// Use small parquet file for fast test
|
|
// Try multiple possible locations (cargo test runs from workspace root)
|
|
let parquet_file = if std::path::Path::new("test_data/ES_FUT_small.parquet").exists() {
|
|
"test_data/ES_FUT_small.parquet"
|
|
} else if std::path::Path::new("../test_data/ES_FUT_small.parquet").exists() {
|
|
"../test_data/ES_FUT_small.parquet"
|
|
} else {
|
|
panic!("❌ Test data file not found. Expected test_data/ES_FUT_small.parquet");
|
|
};
|
|
|
|
// Checkpoint callback that doesn't save anything (for testing)
|
|
let checkpoint_callback = |_epoch: usize, _data: Vec<u8>, _is_best: bool| -> Result<String> {
|
|
Ok(format!("test_checkpoint_epoch_{}", _epoch))
|
|
};
|
|
|
|
// Train and expect circuit breaker to trigger
|
|
let result = trainer.train_from_parquet(parquet_file, checkpoint_callback).await;
|
|
|
|
// Verify that training either:
|
|
// 1. Completes successfully (if no catastrophic loss occurs), OR
|
|
// 2. Triggers circuit breaker (if drawdown exceeds 20%)
|
|
match result {
|
|
Ok(_metrics) => {
|
|
// Training completed without hitting circuit breaker
|
|
println!("✅ Training completed successfully (no circuit breaker trigger)");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
// Check if error is circuit breaker
|
|
if let Some(ml_error) = e.downcast_ref::<MLError>() {
|
|
if let MLError::CircuitBreakerTriggered {
|
|
drawdown_pct,
|
|
peak_value,
|
|
current_value,
|
|
epoch,
|
|
} = ml_error
|
|
{
|
|
println!("✅ Circuit breaker triggered as expected!");
|
|
println!(" • Drawdown: {:.2}%", drawdown_pct);
|
|
println!(" • Peak portfolio: ${:.0}", peak_value);
|
|
println!(" • Current portfolio: ${:.0}", current_value);
|
|
println!(" • Epoch: {}", epoch);
|
|
|
|
// Verify drawdown exceeds threshold
|
|
assert!(
|
|
*drawdown_pct > 20.0,
|
|
"Circuit breaker should trigger when drawdown > 20%, got {:.2}%",
|
|
drawdown_pct
|
|
);
|
|
|
|
// Verify peak value is reasonable (should start at $100,000)
|
|
assert!(
|
|
*peak_value >= 100_000.0,
|
|
"Peak portfolio should be at least initial capital ($100,000), got ${:.0}",
|
|
peak_value
|
|
);
|
|
|
|
// Verify current value is below peak
|
|
assert!(
|
|
current_value < peak_value,
|
|
"Current portfolio (${:.0}) should be less than peak (${:.0})",
|
|
current_value, peak_value
|
|
);
|
|
|
|
Ok(())
|
|
} else {
|
|
// Different ML error - propagate it
|
|
Err(e)
|
|
}
|
|
} else {
|
|
// Non-ML error - propagate it
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_circuit_breaker_disabled() -> Result<()> {
|
|
// Create hyperparameters with circuit breaker DISABLED
|
|
let hyperparams = DQNHyperparameters {
|
|
learning_rate: 0.0001,
|
|
batch_size: 32,
|
|
gamma: 0.99,
|
|
epsilon_start: 1.0,
|
|
epsilon_end: 0.01,
|
|
epsilon_decay: 0.995,
|
|
buffer_size: 10000,
|
|
min_replay_size: 100,
|
|
epochs: 10, // Short test
|
|
checkpoint_frequency: 10,
|
|
early_stopping_enabled: false,
|
|
q_value_floor: 0.0,
|
|
min_loss_improvement_pct: 0.0,
|
|
plateau_window: 30,
|
|
min_epochs_before_stopping: 100,
|
|
hold_penalty: -0.001,
|
|
use_huber_loss: true,
|
|
huber_delta: 1.0,
|
|
use_double_dqn: true,
|
|
gradient_clip_norm: Some(10.0),
|
|
hold_penalty_weight: 0.01,
|
|
movement_threshold: 0.02,
|
|
enable_preprocessing: false,
|
|
preprocessing_window: 50,
|
|
preprocessing_clip_sigma: 5.0,
|
|
tau: 0.005,
|
|
target_update_mode: TargetUpdateMode::Soft,
|
|
target_update_frequency: 1,
|
|
warmup_steps: 0,
|
|
entropy_coefficient: 0.01,
|
|
|
|
// WAVE 16S-P2: Circuit breaker DISABLED
|
|
enable_circuit_breaker: false,
|
|
max_drawdown_pct: 20.0, // Threshold is ignored when disabled
|
|
circuit_breaker_checkpoint: false,
|
|
};
|
|
|
|
// Create trainer
|
|
let mut trainer = DQNTrainer::new(hyperparams)?;
|
|
|
|
// Use small parquet file for fast test
|
|
// Try multiple possible locations (cargo test runs from workspace root)
|
|
let parquet_file = if std::path::Path::new("test_data/ES_FUT_small.parquet").exists() {
|
|
"test_data/ES_FUT_small.parquet"
|
|
} else if std::path::Path::new("../test_data/ES_FUT_small.parquet").exists() {
|
|
"../test_data/ES_FUT_small.parquet"
|
|
} else {
|
|
panic!("❌ Test data file not found. Expected test_data/ES_FUT_small.parquet");
|
|
};
|
|
|
|
// Checkpoint callback
|
|
let checkpoint_callback = |_epoch: usize, _data: Vec<u8>, _is_best: bool| -> Result<String> {
|
|
Ok(format!("test_checkpoint_epoch_{}", _epoch))
|
|
};
|
|
|
|
// Train - should never trigger circuit breaker since it's disabled
|
|
let result = trainer.train_from_parquet(parquet_file, checkpoint_callback).await;
|
|
|
|
match result {
|
|
Ok(_metrics) => {
|
|
println!("✅ Training completed successfully (circuit breaker disabled)");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
// If circuit breaker triggers when disabled, test fails
|
|
if let Some(ml_error) = e.downcast_ref::<MLError>() {
|
|
if matches!(ml_error, MLError::CircuitBreakerTriggered { .. }) {
|
|
panic!("❌ Circuit breaker triggered when it should be disabled!");
|
|
}
|
|
}
|
|
// Other errors are acceptable (early stopping, etc.)
|
|
println!("⚠️ Training ended with non-circuit-breaker error (acceptable)");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_circuit_breaker_configuration_defaults() {
|
|
// Test that conservative defaults have correct circuit breaker settings
|
|
let hyperparams = DQNHyperparameters::conservative();
|
|
|
|
assert!(
|
|
hyperparams.enable_circuit_breaker,
|
|
"Circuit breaker should be enabled by default"
|
|
);
|
|
|
|
assert_eq!(
|
|
hyperparams.max_drawdown_pct, 50.0,
|
|
"Default max drawdown should be 50%"
|
|
);
|
|
|
|
assert!(
|
|
hyperparams.circuit_breaker_checkpoint,
|
|
"Circuit breaker checkpoint should be enabled by default"
|
|
);
|
|
}
|