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>
437 lines
14 KiB
Markdown
437 lines
14 KiB
Markdown
# 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:
|
|
|
|
```rust
|
|
// 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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`:
|
|
|
|
```yaml
|
|
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)
|
|
|
|
```rust
|
|
#[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)
|
|
|
|
```rust
|
|
#[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:
|
|
|
|
```rust
|
|
// 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
|
|
|
|
1. **Copy tests from this document to `ml/src/trainers/dqn.rs`**
|
|
2. **Run tests to confirm they FAIL** (proves detection)
|
|
3. **Fix the bug** (see FACTORED_ACTIONS_BUG_FIX_PLAN.md)
|
|
4. **Re-run tests to confirm they PASS** (proves fix)
|
|
5. **Add CI/CD test matrix** (prevents regression)
|
|
6. **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
|