Files
foxhunt/ml/tests/meta_labeling_secondary_test.rs
jgrusewski aac0597cd2 feat(ml): DQN Option B checkpoint fix + TFT OOM investigation
- Fixed DQN early stopping checkpoint naming bug (Option B)
  - Added is_final: bool parameter to checkpoint callback signature
  - Trainer now distinguishes final checkpoints from regular epoch checkpoints
  - Final checkpoints use 'dqn_final_epoch{N}' naming convention
  - Regular checkpoints use 'dqn_epoch_{N}' naming convention

- Completed comprehensive TFT OOM investigation
  - Spawned 3 parallel agents for memory analysis
  - Identified 16.4GB memory leak (29.7x over expected 525-550MB)
  - Root causes: Attention cache bloat (960MB), gradient accumulation bug, detached tensors
  - Recommended fixes: Disable cache during training, explicit tensor drops
  - Created TFT_MEMORY_ANALYSIS.md, TFT_MEMORY_LEAK_ANALYSIS.md

- DQN 100-epoch training VERIFIED on Runpod RTX A4000
  - Training completed successfully: 100/100 epochs
  - Final checkpoint created: dqn_final_epoch100.safetensors
  - Training speed: 4.8 sec/epoch (3.5x faster than baseline)
  - Option B fix working perfectly

- Deployed RTX 4090 pod for TFT testing
  - Pod ID: 6244yzm9hadnog
  - 24GB VRAM to bypass OOM issue
  - EUR-IS-1 datacenter, $0.59/hr

Files modified:
- ml/examples/train_dqn.rs (checkpoint callback signature)
- ml/src/trainers/dqn.rs (callback signature + is_final parameter)
- CLAUDE.md (compacted to ~11k chars)

Generated reports:
- TFT_MEMORY_ANALYSIS.md (15-section memory breakdown)
- TFT_MEMORY_QUICK_SUMMARY.md (executive summary)
- TFT_MEMORY_LEAK_ANALYSIS.md (5 critical leaks identified)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 23:49:24 +02:00

494 lines
14 KiB
Rust

//! Meta-labeling Secondary Model Tests (TDD)
//!
//! Test suite for the secondary betting model that predicts whether to trade
//! given a primary signal. This model helps reduce false positives and optimize
//! position sizing based on confidence.
use approx::assert_relative_eq;
use ml::labeling::meta_labeling::secondary_model::{
PrimaryPrediction, SecondaryBettingModel, SecondaryModelConfig,
};
use ml::MLError;
/// Test: Basic model creation and initialization
#[test]
fn test_secondary_model_creation() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
assert!(model.is_ready());
assert_eq!(model.name(), "secondary_betting_model");
Ok(())
}
/// Test: Model configuration validation
#[test]
fn test_secondary_model_config_validation() {
// Valid configuration
let valid_config = SecondaryModelConfig {
min_confidence: 0.6,
max_confidence: 0.95,
min_bet_size: 0.01,
max_bet_size: 0.20,
use_ml_model: false, // Start with rule-based
};
assert!(valid_config.validate().is_ok());
// Invalid: min > max confidence
let invalid_config = SecondaryModelConfig {
min_confidence: 0.9,
max_confidence: 0.6,
min_bet_size: 0.01,
max_bet_size: 0.20,
use_ml_model: false,
};
assert!(invalid_config.validate().is_err());
// Invalid: negative bet size
let invalid_config = SecondaryModelConfig {
min_confidence: 0.6,
max_confidence: 0.95,
min_bet_size: -0.01,
max_bet_size: 0.20,
use_ml_model: false,
};
assert!(invalid_config.validate().is_err());
}
/// Test: High-confidence primary signal should result in trade
#[test]
fn test_high_confidence_signal_trades() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
// High-quality bullish signal
let primary = PrimaryPrediction {
direction: 1, // Buy
confidence: 0.85,
expected_return: 0.05, // 5% expected return
features: vec![1.0, 2.0, 3.0],
};
let features = vec![0.5, 0.3, 0.2]; // Market features (volatility, liquidity, etc.)
let decision = model.should_trade(&primary, &features)?;
assert!(decision.should_trade);
assert!(decision.bet_size > 0.0);
assert!(decision.bet_size <= config.max_bet_size);
assert!(decision.confidence >= config.min_confidence);
Ok(())
}
/// Test: Low-confidence primary signal should not trade
#[test]
fn test_low_confidence_signal_no_trade() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
// Low-quality signal
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.35, // Below threshold
expected_return: 0.01,
features: vec![1.0, 2.0, 3.0],
};
let features = vec![0.8, 0.2, 0.1]; // High volatility (risky)
let decision = model.should_trade(&primary, &features)?;
assert!(!decision.should_trade);
assert_eq!(decision.bet_size, 0.0);
Ok(())
}
/// Test: Position sizing based on confidence level
#[test]
fn test_position_sizing_scales_with_confidence() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let features = vec![0.5, 0.5, 0.5]; // Neutral market conditions
// Medium confidence signal
let medium_primary = PrimaryPrediction {
direction: 1,
confidence: 0.65,
expected_return: 0.03,
features: vec![1.0, 2.0, 3.0],
};
let medium_decision = model.should_trade(&medium_primary, &features)?;
// High confidence signal
let high_primary = PrimaryPrediction {
direction: 1,
confidence: 0.90,
expected_return: 0.05,
features: vec![1.0, 2.0, 3.0],
};
let high_decision = model.should_trade(&high_primary, &features)?;
// Higher confidence should lead to larger bet size
assert!(high_decision.bet_size > medium_decision.bet_size);
assert!(high_decision.confidence > medium_decision.confidence);
Ok(())
}
/// Test: Integration with primary model features
#[test]
fn test_feature_combination() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
// Primary prediction with features
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.8, 0.6, 0.4], // Strong technical indicators
};
// Market features
let market_features = vec![
0.3, // Low volatility
0.7, // High liquidity
0.5, // Medium momentum
];
let decision = model.should_trade(&primary, &market_features)?;
assert!(decision.should_trade);
// Combined features should boost confidence
assert!(decision.confidence > primary.confidence * 0.9);
Ok(())
}
/// Test: False positive reduction
#[test]
fn test_false_positive_reduction() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
// Primary says buy with moderate confidence
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.62, // Just above threshold
expected_return: 0.02,
features: vec![0.5, 0.5, 0.5],
};
// But market conditions are poor (high volatility, low liquidity)
let bad_market_features = vec![
0.9, // High volatility (risky)
0.2, // Low liquidity (execution risk)
0.1, // Weak momentum
];
let decision = model.should_trade(&primary, &bad_market_features)?;
// Secondary model should reject this trade despite primary saying buy
assert!(!decision.should_trade);
assert_eq!(decision.bet_size, 0.0);
Ok(())
}
/// Test: Negative expected return rejection
#[test]
fn test_negative_expected_return_rejected() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
// High confidence but negative expected return
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.80,
expected_return: -0.02, // Negative expected return
features: vec![1.0, 2.0, 3.0],
};
let features = vec![0.5, 0.5, 0.5];
let decision = model.should_trade(&primary, &features)?;
// Should not trade with negative expected return
assert!(!decision.should_trade);
Ok(())
}
/// Test: Risk-adjusted position sizing
#[test]
fn test_risk_adjusted_position_sizing() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.7, 0.6, 0.5],
};
// Low volatility (safer)
let low_vol_features = vec![0.2, 0.8, 0.6];
let low_vol_decision = model.should_trade(&primary, &low_vol_features)?;
// High volatility (riskier)
let high_vol_features = vec![0.9, 0.8, 0.6];
let high_vol_decision = model.should_trade(&primary, &high_vol_features)?;
// Lower volatility should allow larger position size
if low_vol_decision.should_trade && high_vol_decision.should_trade {
assert!(low_vol_decision.bet_size >= high_vol_decision.bet_size);
}
Ok(())
}
/// Test: Bet size clamping to configured limits
#[test]
fn test_bet_size_clamping() -> Result<(), MLError> {
let config = SecondaryModelConfig {
min_confidence: 0.5,
max_confidence: 0.95,
min_bet_size: 0.02,
max_bet_size: 0.15,
use_ml_model: false,
};
let model = SecondaryBettingModel::new(config.clone())?;
// Very high confidence primary signal
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.98, // Extremely high
expected_return: 0.10,
features: vec![1.0, 1.0, 1.0],
};
let features = vec![0.5, 0.5, 0.5];
let decision = model.should_trade(&primary, &features)?;
if decision.should_trade {
// Bet size should not exceed max
assert!(decision.bet_size <= config.max_bet_size);
assert!(decision.bet_size >= config.min_bet_size);
}
Ok(())
}
/// Test: Consistency across multiple calls
#[test]
fn test_prediction_consistency() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.7, 0.6, 0.5],
};
let features = vec![0.5, 0.5, 0.5];
// Make multiple predictions with same inputs
let decision1 = model.should_trade(&primary, &features)?;
let decision2 = model.should_trade(&primary, &features)?;
let decision3 = model.should_trade(&primary, &features)?;
// Results should be deterministic
assert_eq!(decision1.should_trade, decision2.should_trade);
assert_eq!(decision2.should_trade, decision3.should_trade);
assert_relative_eq!(decision1.bet_size, decision2.bet_size, epsilon = 1e-6);
assert_relative_eq!(decision2.bet_size, decision3.bet_size, epsilon = 1e-6);
Ok(())
}
/// Test: Performance latency target (<50μs)
#[test]
fn test_performance_latency_target() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.7, 0.6, 0.5],
};
let features = vec![0.5, 0.5, 0.5];
// Warm-up call
let _ = model.should_trade(&primary, &features)?;
// Measure latency
let start = std::time::Instant::now();
let _ = model.should_trade(&primary, &features)?;
let latency = start.elapsed();
// Should be under 50μs target
assert!(
latency.as_micros() < 50,
"Latency {}μs exceeds 50μs target",
latency.as_micros()
);
Ok(())
}
/// Test: Batch prediction throughput
#[test]
fn test_batch_prediction_throughput() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let batch_size = 1000;
let mut predictions = Vec::with_capacity(batch_size);
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.7, 0.6, 0.5],
};
let features = vec![0.5, 0.5, 0.5];
let start = std::time::Instant::now();
for _ in 0..batch_size {
let decision = model.should_trade(&primary, &features)?;
predictions.push(decision);
}
let duration = start.elapsed();
let throughput = batch_size as f64 / duration.as_secs_f64();
// Should achieve >10K predictions/second
assert!(
throughput > 10_000.0,
"Throughput {:.0} preds/s is below 10K target",
throughput
);
Ok(())
}
/// Test: Direction handling (buy vs sell)
#[test]
fn test_direction_handling() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let features = vec![0.5, 0.5, 0.5];
// Buy signal
let buy_primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.7, 0.6, 0.5],
};
// Sell signal (same confidence, negative expected return for short)
let sell_primary = PrimaryPrediction {
direction: -1,
confidence: 0.75,
expected_return: -0.04, // Profit from price decrease
features: vec![0.7, 0.6, 0.5],
};
let buy_decision = model.should_trade(&buy_primary, &features)?;
let sell_decision = model.should_trade(&sell_primary, &features)?;
// Both should be valid trade directions
assert!(buy_decision.should_trade || !buy_decision.should_trade); // Either is valid
assert!(sell_decision.should_trade || !sell_decision.should_trade);
Ok(())
}
/// Test: Edge case - zero confidence
#[test]
fn test_zero_confidence_handling() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config)?;
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.0, // No confidence
expected_return: 0.05,
features: vec![0.5, 0.5, 0.5],
};
let features = vec![0.5, 0.5, 0.5];
let decision = model.should_trade(&primary, &features)?;
// Should not trade with zero confidence
assert!(!decision.should_trade);
assert_eq!(decision.bet_size, 0.0);
Ok(())
}
/// Test: Edge case - empty features
#[test]
fn test_empty_features_handling() {
let config = SecondaryModelConfig::default();
let model = SecondaryBettingModel::new(config).unwrap();
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![],
};
let empty_features: Vec<f64> = vec![];
let result = model.should_trade(&primary, &empty_features);
// Should return error for empty features
assert!(result.is_err());
}
/// Test: Statistics tracking
#[test]
fn test_statistics_tracking() -> Result<(), MLError> {
let config = SecondaryModelConfig::default();
let mut model = SecondaryBettingModel::new(config)?;
let primary = PrimaryPrediction {
direction: 1,
confidence: 0.75,
expected_return: 0.04,
features: vec![0.7, 0.6, 0.5],
};
let features = vec![0.5, 0.5, 0.5];
// Make several predictions
for _ in 0..10 {
let _ = model.should_trade(&primary, &features)?;
}
let stats = model.get_statistics();
assert_eq!(stats.total_predictions, 10);
assert!(stats.total_trades <= 10);
assert!(stats.average_bet_size >= 0.0);
Ok(())
}