Major Changes: - Migrated from 3-action TradingAction to 45-action FactoredAction - 45 actions: 5 exposure × 3 order types × 3 urgency levels - Absolute exposure model (target positions -1.0 to +1.0) - Transaction cost differentiation (Market 0.15%, LimitMaker 0.05%, IoC 0.10%) - Fixed action diversity threshold (1.11% → 0.5% for 45-action space) Bug Fixes: - Bug #15: Incomplete FactoredAction integration (code existed but unused) - Bug #16: Runtime crash in action diversity checking (hardcoded 3-action match) Code Changes (13 files, ~464 lines): - ml/src/dqn/action_space.rs: Core FactoredAction + 4 helper methods - ml/src/trainers/dqn.rs: Action diversity refactored (3→45 dynamic) - ml/src/dqn/reward.rs: calculate_reward() signature updated - ml/src/dqn/portfolio_tracker.rs: execute_action() absolute exposure - ml/src/dqn/dqn.rs: WorkingDQN action selection migrated - ml/tests/*.rs: 9 test files updated with FactoredAction assertions Test Results: - 1-epoch smoke test: 100% action diversity (45/45 actions, 80.2s) - 10-epoch production: 87.8% readiness (79/90 scorecard, 14.0 min) - Loss convergence: 96.9% reduction (119K → 3.6K) - Action diversity: 100% → 44% (healthy specialization) - Checkpoint reliability: 12/12 files saved (100%) - DQN tests: 195/195 passing (100%) - ML baseline: 1,514/1,515 passing (99.93%) Production Status: ✅ CERTIFIED (87.8% readiness) Go/No-Go: ✅ GO FOR 100-EPOCH PRODUCTION TRAINING 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
14 KiB
RECOMMENDED TEST ADDITIONS: Preventing Future Feature-Flag Bugs
Quick Start Guide
Step 1: Add These 3 Critical Tests IMMEDIATELY (2 hours)
These tests will expose the current bug and prevent similar issues:
// Add to ml/src/trainers/dqn.rs in the #[cfg(test)] mod tests block
/// CRITICAL TEST #1: Force epsilon > 0 to test exploration path
#[tokio::test]
async fn test_batched_action_selection_with_exploration() {
let mut hyperparams = create_test_params();
hyperparams.epsilon_start = 0.5;
hyperparams.epsilon_end = 0.5;
let trainer = DQNTrainer::new(hyperparams).unwrap();
// Force 50% exploration
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(0.5).unwrap();
}
let batch_size = 100;
let mut states = Vec::with_capacity(batch_size);
for i in 0..batch_size {
let mut feature_vec = [0.0; 128];
feature_vec[0] = 4000.0 + (i as f64 * 10.0);
feature_vec[1] = 4010.0 + (i as f64 * 10.0);
feature_vec[2] = 3990.0 + (i as f64 * 10.0);
feature_vec[3] = 4005.0 + (i as f64 * 10.0);
feature_vec[4] = 1000.0;
for j in 5..128 { feature_vec[j] = (j as f64) * 0.1; }
let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)).unwrap();
states.push(state);
}
// THIS WILL FAIL with current bug (action_idx can be 0-44, TradingAction only accepts 0-2)
let actions_result = trainer.select_actions_batch(&states).await;
assert!(
actions_result.is_ok(),
"Exploration path should not crash: {:?}",
actions_result.err()
);
let actions = actions_result.unwrap();
assert_eq!(actions.len(), batch_size);
}
/// CRITICAL TEST #2: Force 100% exploration to maximize coverage
#[tokio::test]
async fn test_full_exploration_action_range() {
let mut hyperparams = create_test_params();
hyperparams.epsilon_start = 1.0;
hyperparams.epsilon_end = 1.0;
let trainer = DQNTrainer::new(hyperparams).unwrap();
// Force 100% exploration
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(1.0).unwrap();
}
let batch_size = 1000; // Large batch for statistical coverage
let mut states = Vec::with_capacity(batch_size);
for i in 0..batch_size {
let mut feature_vec = [0.0; 128];
feature_vec[0] = 4000.0 + (i as f64 * 10.0);
feature_vec[1] = 4010.0 + (i as f64 * 10.0);
feature_vec[2] = 3990.0 + (i as f64 * 10.0);
feature_vec[3] = 4005.0 + (i as f64 * 10.0);
feature_vec[4] = 1000.0;
for j in 5..128 { feature_vec[j] = (j as f64) * 0.1; }
let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)).unwrap();
states.push(state);
}
// With 1000 samples and 100% exploration, will hit all action_idx values 0-44
// THIS WILL DEFINITELY FAIL with current bug
let actions_result = trainer.select_actions_batch(&states).await;
assert!(
actions_result.is_ok(),
"Full exploration should not crash with any action_idx: {:?}",
actions_result.err()
);
}
/// CRITICAL TEST #3: Feature-specific validation
#[tokio::test]
#[cfg(feature = "factored-actions")]
async fn test_factored_actions_feature_validation() {
let mut hyperparams = create_test_params();
hyperparams.epsilon_start = 0.5;
hyperparams.epsilon_end = 0.5;
let trainer = DQNTrainer::new(hyperparams).unwrap();
// Force exploration
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(0.5).unwrap();
}
let batch_size = 500;
let mut states = Vec::with_capacity(batch_size);
for i in 0..batch_size {
let mut feature_vec = [0.0; 128];
feature_vec[0] = 4000.0 + (i as f64 * 10.0);
feature_vec[1] = 4010.0 + (i as f64 * 10.0);
feature_vec[2] = 3990.0 + (i as f64 * 10.0);
feature_vec[3] = 4005.0 + (i as f64 * 10.0);
feature_vec[4] = 1000.0;
for j in 5..128 { feature_vec[j] = (j as f64) * 0.1; }
let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)).unwrap();
states.push(state);
}
let actions_result = trainer.select_actions_batch(&states).await;
// This test is ONLY for factored-actions feature
// If feature is enabled, we expect FactoredAction conversion, not TradingAction
assert!(
actions_result.is_ok(),
"Factored-actions feature should handle 45-action space: {:?}",
actions_result.err()
);
let actions = actions_result.unwrap();
assert_eq!(actions.len(), batch_size);
// TODO: Once refactored to return FactoredAction, add validation:
// for action in actions.iter() {
// let factored = action.as_factored().expect("Should be FactoredAction");
// assert!(factored.exposure_level() <= 4);
// assert!(factored.order_type() <= 2);
// assert!(factored.urgency() <= 2);
// }
}
Step 2: Run Tests to Verify Bug Detection
# This should FAIL with current code (proves tests work)
cargo test --package ml --lib trainers::dqn::tests::test_batched_action_selection_with_exploration
# This should also FAIL
cargo test --package ml --lib trainers::dqn::tests::test_full_exploration_action_range
# This should also FAIL (factored-actions only)
cargo test --package ml --lib trainers::dqn::tests::test_factored_actions_feature_validation
Expected output:
thread 'trainers::dqn::tests::test_batched_action_selection_with_exploration' panicked at ml/src/trainers/dqn.rs:XXXX:
Exploration path should not crash: Some(Invalid action index: 29
Step 3: Fix the Bug
After confirming tests detect the bug, fix the code (see FACTORED_ACTIONS_BUG_FIX_PLAN.md).
Step 4: Verify Tests Pass After Fix
# All 3 new tests should pass
cargo test --package ml --lib trainers::dqn::tests -- --nocapture
Test Matrix for CI/CD (1 hour)
Add to .gitlab-ci.yml:
test-dqn-default:
stage: test
script:
- cargo test -p ml --lib trainers::dqn::tests
allow_failure: false
test-dqn-no-factored:
stage: test
script:
- cargo test -p ml --lib trainers::dqn::tests --no-default-features --features cuda
allow_failure: false
test-dqn-factored-explicit:
stage: test
script:
- cargo test -p ml --lib trainers::dqn::tests --features cuda,factored-actions
allow_failure: false
Additional Recommended Tests (Medium Priority)
Test 4: Action Diversity Validation (1 hour)
#[tokio::test]
#[cfg(not(feature = "factored-actions"))]
async fn test_exploration_action_diversity() {
let mut hyperparams = create_test_params();
hyperparams.epsilon_start = 1.0;
hyperparams.epsilon_end = 1.0;
let trainer = DQNTrainer::new(hyperparams).unwrap();
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(1.0).unwrap();
}
let batch_size = 3000;
let mut states = Vec::with_capacity(batch_size);
for i in 0..batch_size {
let mut feature_vec = [0.0; 128];
feature_vec[0] = 4000.0 + (i as f64 * 10.0);
feature_vec[1] = 4010.0 + (i as f64 * 10.0);
feature_vec[2] = 3990.0 + (i as f64 * 10.0);
feature_vec[3] = 4005.0 + (i as f64 * 10.0);
feature_vec[4] = 1000.0;
for j in 5..128 { feature_vec[j] = (j as f64) * 0.1; }
let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)).unwrap();
states.push(state);
}
let actions = trainer.select_actions_batch(&states).await.unwrap();
// Count action distribution
let mut buy_count = 0;
let mut sell_count = 0;
let mut hold_count = 0;
for action in actions.iter() {
match action {
TradingAction::Buy => buy_count += 1,
TradingAction::Sell => sell_count += 1,
TradingAction::Hold => hold_count += 1,
}
}
// With 3000 samples and 3 actions, expect ~1000 of each
// Use 20% tolerance (800-1200 range)
let expected = 1000;
let tolerance = 200;
assert!(
buy_count >= expected - tolerance && buy_count <= expected + tolerance,
"Expected ~{} Buy actions, got {} (distribution: Buy={}, Sell={}, Hold={})",
expected, buy_count, buy_count, sell_count, hold_count
);
assert!(
sell_count >= expected - tolerance && sell_count <= expected + tolerance,
"Expected ~{} Sell actions, got {} (distribution: Buy={}, Sell={}, Hold={})",
expected, sell_count, buy_count, sell_count, hold_count
);
assert!(
hold_count >= expected - tolerance && hold_count <= expected + tolerance,
"Expected ~{} Hold actions, got {} (distribution: Buy={}, Sell={}, Hold={})",
expected, hold_count, buy_count, sell_count, hold_count
);
println!("✅ Action diversity validated: Buy={}, Sell={}, Hold={}",
buy_count, sell_count, hold_count);
}
Test 5: Epsilon Boundary Conditions (30 min)
#[tokio::test]
async fn test_epsilon_boundary_conditions() {
// Test epsilon=0.0 (pure exploitation)
let mut hyperparams = create_test_params();
hyperparams.epsilon_start = 0.0;
hyperparams.epsilon_end = 0.0;
let trainer = DQNTrainer::new(hyperparams).unwrap();
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(0.0).unwrap();
}
let batch_size = 10;
let mut states = Vec::with_capacity(batch_size);
for i in 0..batch_size {
let mut feature_vec = [0.0; 128];
feature_vec[0] = 4000.0 + (i as f64 * 10.0);
feature_vec[1] = 4010.0 + (i as f64 * 10.0);
feature_vec[2] = 3990.0 + (i as f64 * 10.0);
feature_vec[3] = 4005.0 + (i as f64 * 10.0);
feature_vec[4] = 1000.0;
for j in 5..128 { feature_vec[j] = (j as f64) * 0.1; }
let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)).unwrap();
states.push(state);
}
let actions_0 = trainer.select_actions_batch(&states).await.unwrap();
assert_eq!(actions_0.len(), batch_size);
// Test epsilon=1.0 (pure exploration)
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(1.0).unwrap();
}
let actions_1 = trainer.select_actions_batch(&states).await.unwrap();
assert_eq!(actions_1.len(), batch_size);
println!("✅ Epsilon boundary conditions validated (0.0 and 1.0)");
}
Property-Based Testing (Optional, 3-4 hours)
For maximum robustness, add property-based tests:
// Add to ml/Cargo.toml [dev-dependencies]
proptest = "1.5"
// Add to ml/src/trainers/dqn.rs
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_action_selection_never_panics(
epsilon in 0.0..=1.0f64,
batch_size in 1..=100usize
) {
tokio::runtime::Runtime::new().unwrap().block_on(async {
let mut hyperparams = create_test_params();
hyperparams.epsilon_start = epsilon;
hyperparams.epsilon_end = epsilon;
let trainer = DQNTrainer::new(hyperparams).unwrap();
{
let mut agent = trainer.agent.write().await;
agent.set_epsilon(epsilon).unwrap();
}
let mut states = Vec::with_capacity(batch_size);
for i in 0..batch_size {
let mut feature_vec = [0.0; 128];
feature_vec[0] = 4000.0 + (i as f64 * 10.0);
feature_vec[1] = 4010.0 + (i as f64 * 10.0);
feature_vec[2] = 3990.0 + (i as f64 * 10.0);
feature_vec[3] = 4005.0 + (i as f64 * 10.0);
feature_vec[4] = 1000.0;
for j in 5..128 { feature_vec[j] = (j as f64) * 0.1; }
let close_price = rust_decimal::Decimal::try_from(feature_vec[3])
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = trainer.feature_vector_to_state(&feature_vec, Some(close_price)).unwrap();
states.push(state);
}
// Property: Action selection should never panic for any epsilon or batch_size
let result = trainer.select_actions_batch(&states).await;
prop_assert!(result.is_ok(), "Action selection panicked: {:?}", result.err());
let actions = result.unwrap();
prop_assert_eq!(actions.len(), batch_size);
});
}
}
}
Summary of Effort
| Priority | Tests | Effort | Benefit |
|---|---|---|---|
| CRITICAL (Add NOW) | 3 tests | 2 hours | Catches current bug + prevents recurrence |
| HIGH (Add this week) | CI/CD matrix | 1 hour | Prevents feature-flag bugs |
| MEDIUM (Add this sprint) | 2 tests | 1.5 hours | Improves coverage |
| OPTIONAL (Add if time) | Property tests | 3-4 hours | Maximum robustness |
| TOTAL | 5-6 tests + CI | 4.5-7.5 hours | Production-ready test suite |
Validation Checklist
After adding tests, verify:
- Tests FAIL with current code (proves detection works)
- Tests PASS after bug fix (proves fix works)
- Tests run in CI/CD pipeline (prevents regression)
- Tests cover both feature flags (factored-actions ON/OFF)
- Tests cover epsilon boundaries (0.0, 0.5, 1.0)
- Test output includes clear error messages
- Tests are documented with comments explaining purpose
Next Steps
- Copy tests from this document to
ml/src/trainers/dqn.rs - Run tests to confirm they FAIL (proves detection)
- Fix the bug (see FACTORED_ACTIONS_BUG_FIX_PLAN.md)
- Re-run tests to confirm they PASS (proves fix)
- Add CI/CD test matrix (prevents regression)
- Optional: Add property-based tests (maximum robustness)
Total time investment: 4.5-7.5 hours Benefit: Never ship a feature-flag bug to production again