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

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"
);
}