- 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>
494 lines
14 KiB
Rust
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(())
|
|
}
|