Final cleanup: - 61 test files + 5 example files: candle imports replaced - 8 testing/integration files: migrated to cudarc/ml-core types - 3 services/trading_service test files: migrated - Root Cargo.toml: candle-core, candle-nn removed from [workspace.dependencies] - crates/ml/Cargo.toml: candle-nn dependency removed - testing/e2e/Cargo.toml: candle-core dependency removed Zero active candle_core/candle_nn/candle_optimisers code references remain. Zero candle dependency declarations in any Cargo.toml. Remaining "candle" strings are exclusively in doc comments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
622 lines
20 KiB
Rust
622 lines
20 KiB
Rust
#![allow(unexpected_cfgs)]
|
|
#![cfg(feature = "__trading_service_integration")]
|
|
//! TDD Integration Tests for Adaptive Strategy ML Integration
|
|
//!
|
|
//! Phase: RED (Failing Tests) → GREEN (Minimal Implementation) → REFACTOR (Quality)
|
|
//!
|
|
//! Tests cover:
|
|
//! - ML-enabled strategy creation
|
|
//! - ML signal generation
|
|
//! - Ensemble voting from 4 models (DQN, PPO, MAMBA2, TFT)
|
|
//! - Fallback to rule-based on ML failure
|
|
//! - Hybrid strategy (ML + rule-based ensemble)
|
|
//! - Performance tracking (accuracy, predictions)
|
|
|
|
use ml_core::native_types::NativeDevice;
|
|
use ml::ensemble::{AdaptiveMLEnsemble, MarketRegime};
|
|
use ml::ModelPrediction;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
// ============================================================================
|
|
// TEST 1: ML-Enabled Strategy Creation (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_adaptive_strategy_with_ml_enabled() {
|
|
// Arrange: Create ML configuration
|
|
let ml_config = create_test_ml_config();
|
|
|
|
// Act: Create adaptive strategy with ML
|
|
let result = create_strategy_with_ml(ml_config).await;
|
|
|
|
// Assert: Strategy should be created successfully
|
|
assert!(
|
|
result.is_ok(),
|
|
"Strategy creation failed: {:?}",
|
|
result.err()
|
|
);
|
|
let strategy = result.unwrap();
|
|
|
|
assert!(strategy.has_ml_enabled(), "ML should be enabled");
|
|
assert_eq!(
|
|
strategy.ml_models_loaded(),
|
|
4,
|
|
"Should load 4 models (DQN, PPO, MAMBA2, TFT)"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 2: ML Signal Generation (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ml_signal_generation() {
|
|
// Arrange: Create strategy with ML
|
|
let strategy = create_test_strategy_with_ml().await.unwrap();
|
|
|
|
// Generate 50 OHLCV bars (enough for technical indicators)
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Act: Generate signal from ML models
|
|
let result = strategy.generate_signal(&market_data).await;
|
|
|
|
// Assert: Should generate valid ML signal
|
|
assert!(
|
|
result.is_ok(),
|
|
"Signal generation failed: {:?}",
|
|
result.err()
|
|
);
|
|
let signal = result.unwrap();
|
|
|
|
assert!(
|
|
signal.action.is_some(),
|
|
"Should have an action (Buy/Sell/Hold)"
|
|
);
|
|
assert!(
|
|
signal.confidence >= 0.0 && signal.confidence <= 1.0,
|
|
"Confidence should be in [0, 1], got {}",
|
|
signal.confidence
|
|
);
|
|
assert_eq!(signal.source, SignalSource::ML, "Source should be ML");
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 3: Ensemble Voting from 4 Models (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ensemble_voting() {
|
|
// Arrange: Create strategy with all 4 models
|
|
let strategy = create_test_strategy_with_ml().await.unwrap();
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Act: Generate signal (should collect votes from all models)
|
|
let result = strategy.generate_signal(&market_data).await;
|
|
|
|
// Assert: Ensemble voting should work
|
|
assert!(result.is_ok());
|
|
let signal = result.unwrap();
|
|
|
|
assert!(signal.model_votes.is_some(), "Should have model votes");
|
|
let votes = signal.model_votes.unwrap();
|
|
assert_eq!(votes.len(), 4, "Should have votes from 4 models");
|
|
|
|
// Verify all model types are present
|
|
let model_names: Vec<String> = votes.iter().map(|(name, _, _)| name.clone()).collect();
|
|
assert!(model_names.contains(&"DQN".to_string()));
|
|
assert!(model_names.contains(&"PPO".to_string()));
|
|
assert!(model_names.contains(&"MAMBA2".to_string()));
|
|
assert!(model_names.contains(&"TFT".to_string()));
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 4: Fallback to Rule-Based on ML Failure (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_fallback_to_rule_based_on_ml_failure() {
|
|
// Arrange: Create strategy with ML
|
|
let mut strategy = create_test_strategy_with_ml().await.unwrap();
|
|
|
|
// Simulate ML failure by disabling ML
|
|
strategy.disable_ml().await;
|
|
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Act: Generate signal (should fallback to rule-based)
|
|
let result = strategy.generate_signal(&market_data).await;
|
|
|
|
// Assert: Should fallback successfully
|
|
assert!(result.is_ok(), "Fallback failed: {:?}", result.err());
|
|
let signal = result.unwrap();
|
|
|
|
assert_eq!(
|
|
signal.source,
|
|
SignalSource::RuleBased,
|
|
"Should fallback to rule-based"
|
|
);
|
|
assert!(
|
|
signal.action.is_some(),
|
|
"Should still generate signal from rules"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 5: Hybrid Strategy (ML + Rule-Based Ensemble) (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_hybrid_strategy_ml_plus_rules() {
|
|
// Arrange: Create strategy with ML
|
|
let strategy = create_test_strategy_with_ml().await.unwrap();
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Act: Generate hybrid signal (ML + rules)
|
|
let result = strategy.generate_signal_hybrid(&market_data).await;
|
|
|
|
// Assert: Hybrid signal should combine both sources
|
|
assert!(
|
|
result.is_ok(),
|
|
"Hybrid signal generation failed: {:?}",
|
|
result.err()
|
|
);
|
|
let signal = result.unwrap();
|
|
|
|
assert_eq!(
|
|
signal.source,
|
|
SignalSource::Hybrid,
|
|
"Source should be Hybrid"
|
|
);
|
|
assert!(signal.ml_confidence.is_some(), "Should have ML confidence");
|
|
assert!(
|
|
signal.rule_confidence.is_some(),
|
|
"Should have rule confidence"
|
|
);
|
|
|
|
// Verify weighted average (70% ML, 30% rules)
|
|
let ml_conf = signal.ml_confidence.unwrap();
|
|
let rule_conf = signal.rule_confidence.unwrap();
|
|
let expected_conf = ml_conf * 0.7 + rule_conf * 0.3;
|
|
|
|
assert!(
|
|
(signal.confidence - expected_conf).abs() < 0.01,
|
|
"Confidence should be weighted average: expected {}, got {}",
|
|
expected_conf,
|
|
signal.confidence
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 6: ML Performance Tracking (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ml_performance_tracking() {
|
|
// Arrange: Create strategy with ML
|
|
let mut strategy = create_test_strategy_with_ml().await.unwrap();
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Act: Generate signal and record outcome
|
|
let signal = strategy.generate_signal(&market_data).await.unwrap();
|
|
strategy
|
|
.record_outcome(&signal, Outcome::Correct)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Assert: Performance stats should be tracked
|
|
let stats = strategy.get_ml_performance_stats().await;
|
|
|
|
assert_eq!(stats.total_predictions, 1, "Should have 1 prediction");
|
|
assert_eq!(
|
|
stats.correct_predictions, 1,
|
|
"Should have 1 correct prediction"
|
|
);
|
|
assert_eq!(stats.accuracy, 1.0, "Accuracy should be 100%");
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 7: ML Confidence Thresholds (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ml_confidence_thresholds() {
|
|
// Arrange: Create strategy with custom confidence threshold
|
|
let mut ml_config = create_test_ml_config();
|
|
ml_config.min_confidence = 0.8;
|
|
let strategy = create_strategy_with_ml(ml_config).await.unwrap();
|
|
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Act: Generate signal
|
|
let result = strategy.generate_signal(&market_data).await;
|
|
|
|
// Assert: Should only generate signals above threshold
|
|
assert!(result.is_ok());
|
|
let signal = result.unwrap();
|
|
|
|
if signal.action.is_some() {
|
|
assert!(
|
|
signal.confidence >= 0.8,
|
|
"Signal confidence {} should be >= 0.8",
|
|
signal.confidence
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 8: Model Weight Adjustment (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_model_weight_adjustment() {
|
|
// Arrange: Create strategy and record multiple outcomes
|
|
let mut strategy = create_test_strategy_with_ml().await.unwrap();
|
|
let market_data = generate_test_ohlcv_data(50);
|
|
|
|
// Record 10 predictions (8 correct, 2 incorrect)
|
|
for i in 0..10 {
|
|
let signal = strategy.generate_signal(&market_data).await.unwrap();
|
|
let outcome = if i < 8 {
|
|
Outcome::Correct
|
|
} else {
|
|
Outcome::Incorrect
|
|
};
|
|
strategy.record_outcome(&signal, outcome).await.unwrap();
|
|
}
|
|
|
|
// Act: Get model weights (should be adjusted based on performance)
|
|
let weights = strategy.get_model_weights().await;
|
|
|
|
// Assert: Weights should sum to ~1.0 and reflect performance
|
|
let total_weight: f64 = weights.values().sum();
|
|
assert!(
|
|
(total_weight - 1.0).abs() < 0.01,
|
|
"Weights should sum to 1.0, got {}",
|
|
total_weight
|
|
);
|
|
|
|
// Higher performing models should have higher weights
|
|
// (This is a basic check - actual implementation may vary)
|
|
assert!(weights.len() == 4, "Should have 4 model weights");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions and Types (to be implemented)
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MLInferenceConfig {
|
|
pub checkpoint_dir: PathBuf,
|
|
pub device: Device,
|
|
pub models_enabled: Vec<String>,
|
|
pub min_confidence: f64,
|
|
}
|
|
|
|
impl Default for MLInferenceConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
checkpoint_dir: PathBuf::from("ml/checkpoints"),
|
|
device: Device::Cpu,
|
|
models_enabled: vec![
|
|
"DQN".to_string(),
|
|
"PPO".to_string(),
|
|
"MAMBA2".to_string(),
|
|
"TFT".to_string(),
|
|
],
|
|
min_confidence: 0.6,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum SignalSource {
|
|
ML,
|
|
RuleBased,
|
|
Hybrid,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Action {
|
|
Buy,
|
|
Sell,
|
|
Hold,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TradingSignal {
|
|
pub action: Option<Action>,
|
|
pub confidence: f64,
|
|
pub source: SignalSource,
|
|
pub model_votes: Option<Vec<(String, usize, f32)>>, // (model_name, action_index, confidence)
|
|
pub ml_confidence: Option<f64>,
|
|
pub rule_confidence: Option<f64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MLPerformanceStats {
|
|
pub total_predictions: usize,
|
|
pub correct_predictions: usize,
|
|
pub accuracy: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum Outcome {
|
|
Correct,
|
|
Incorrect,
|
|
}
|
|
|
|
/// Adaptive Strategy with ML Integration (wrapper around AdaptiveMLEnsemble)
|
|
pub struct AdaptiveStrategyML {
|
|
ensemble: AdaptiveMLEnsemble,
|
|
ml_enabled: bool,
|
|
models_loaded: usize,
|
|
performance_stats: MLPerformanceStats,
|
|
model_weights: HashMap<String, f64>,
|
|
}
|
|
|
|
impl AdaptiveStrategyML {
|
|
pub fn has_ml_enabled(&self) -> bool {
|
|
self.ml_enabled
|
|
}
|
|
|
|
pub fn ml_models_loaded(&self) -> usize {
|
|
self.models_loaded
|
|
}
|
|
|
|
pub async fn generate_signal(
|
|
&self,
|
|
market_data: &[(f64, f64, f64, f64, f64)],
|
|
) -> Result<TradingSignal, String> {
|
|
if !self.ml_enabled {
|
|
return Err("ML is disabled".to_string());
|
|
}
|
|
|
|
// Update regime based on latest price
|
|
if let Some((_, _, _, close, volume)) = market_data.last() {
|
|
self.ensemble
|
|
.update_regime(*close, *volume)
|
|
.await
|
|
.map_err(|e| format!("Regime update failed: {}", e))?;
|
|
}
|
|
|
|
// Create predictions from all 6 models (mock predictions for now)
|
|
let predictions = vec![
|
|
ModelPrediction::new("DQN".to_string(), 0.5, 0.8),
|
|
ModelPrediction::new("PPO".to_string(), 0.6, 0.85),
|
|
ModelPrediction::new("TFT".to_string(), 0.4, 0.75),
|
|
ModelPrediction::new("MAMBA-2".to_string(), 0.55, 0.8),
|
|
ModelPrediction::new("Liquid".to_string(), 0.45, 0.7),
|
|
ModelPrediction::new("TLOB".to_string(), 0.3, 0.65),
|
|
];
|
|
|
|
// Get ensemble decision
|
|
let decision = self
|
|
.ensemble
|
|
.predict(predictions)
|
|
.await
|
|
.map_err(|e| format!("Prediction failed: {}", e))?;
|
|
|
|
// Convert to trading signal
|
|
let action = if decision.signal > 0.2 {
|
|
Some(Action::Buy)
|
|
} else if decision.signal < -0.2 {
|
|
Some(Action::Sell)
|
|
} else {
|
|
Some(Action::Hold)
|
|
};
|
|
|
|
let model_votes = Some(vec![
|
|
("DQN".to_string(), 0, 0.8),
|
|
("PPO".to_string(), 0, 0.85),
|
|
("TFT".to_string(), 1, 0.75),
|
|
("MAMBA-2".to_string(), 0, 0.8),
|
|
]);
|
|
|
|
Ok(TradingSignal {
|
|
action,
|
|
confidence: decision.confidence,
|
|
source: SignalSource::ML,
|
|
model_votes,
|
|
ml_confidence: Some(decision.confidence),
|
|
rule_confidence: None,
|
|
})
|
|
}
|
|
|
|
pub async fn generate_signal_hybrid(
|
|
&self,
|
|
market_data: &[(f64, f64, f64, f64, f64)],
|
|
) -> Result<TradingSignal, String> {
|
|
// Generate ML signal
|
|
let ml_signal = self.generate_signal(market_data).await?;
|
|
|
|
// Generate rule-based signal (simple moving average)
|
|
let rule_signal = self.generate_rule_signal(market_data);
|
|
|
|
// Combine signals (70% ML, 30% rules)
|
|
let ml_conf = ml_signal.confidence;
|
|
let rule_conf = rule_signal.confidence;
|
|
let hybrid_conf = ml_conf * 0.7 + rule_conf * 0.3;
|
|
|
|
Ok(TradingSignal {
|
|
action: ml_signal.action,
|
|
confidence: hybrid_conf,
|
|
source: SignalSource::Hybrid,
|
|
model_votes: ml_signal.model_votes,
|
|
ml_confidence: Some(ml_conf),
|
|
rule_confidence: Some(rule_conf),
|
|
})
|
|
}
|
|
|
|
fn generate_rule_signal(&self, market_data: &[(f64, f64, f64, f64, f64)]) -> TradingSignal {
|
|
// Simple moving average crossover
|
|
if market_data.len() < 20 {
|
|
return TradingSignal {
|
|
action: Some(Action::Hold),
|
|
confidence: 0.5,
|
|
source: SignalSource::RuleBased,
|
|
model_votes: None,
|
|
ml_confidence: None,
|
|
rule_confidence: Some(0.5),
|
|
};
|
|
}
|
|
|
|
let short_ma: f64 = market_data
|
|
.iter()
|
|
.rev()
|
|
.take(5)
|
|
.map(|(_, _, _, c, _)| c)
|
|
.sum::<f64>()
|
|
/ 5.0;
|
|
let long_ma: f64 = market_data
|
|
.iter()
|
|
.rev()
|
|
.take(20)
|
|
.map(|(_, _, _, c, _)| c)
|
|
.sum::<f64>()
|
|
/ 20.0;
|
|
|
|
let action = if short_ma > long_ma * 1.01 {
|
|
Some(Action::Buy)
|
|
} else if short_ma < long_ma * 0.99 {
|
|
Some(Action::Sell)
|
|
} else {
|
|
Some(Action::Hold)
|
|
};
|
|
|
|
let confidence = ((short_ma - long_ma).abs() / long_ma).min(1.0);
|
|
|
|
TradingSignal {
|
|
action,
|
|
confidence,
|
|
source: SignalSource::RuleBased,
|
|
model_votes: None,
|
|
ml_confidence: None,
|
|
rule_confidence: Some(confidence),
|
|
}
|
|
}
|
|
|
|
pub async fn disable_ml(&mut self) {
|
|
self.ml_enabled = false;
|
|
}
|
|
|
|
pub async fn record_outcome(
|
|
&mut self,
|
|
signal: &TradingSignal,
|
|
outcome: Outcome,
|
|
) -> Result<(), String> {
|
|
self.performance_stats.total_predictions += 1;
|
|
if outcome == Outcome::Correct {
|
|
self.performance_stats.correct_predictions += 1;
|
|
}
|
|
self.performance_stats.accuracy = self.performance_stats.correct_predictions as f64
|
|
/ self.performance_stats.total_predictions as f64;
|
|
|
|
// Record outcome for each model in the ensemble
|
|
if let Some(votes) = &signal.model_votes {
|
|
for (model_name, _, _) in votes {
|
|
let return_value = if outcome == Outcome::Correct {
|
|
0.01
|
|
} else {
|
|
-0.01
|
|
};
|
|
self.ensemble
|
|
.record_outcome(model_name, return_value)
|
|
.await
|
|
.map_err(|e| format!("Failed to record outcome: {}", e))?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_ml_performance_stats(&self) -> MLPerformanceStats {
|
|
self.performance_stats.clone()
|
|
}
|
|
|
|
pub async fn get_model_weights(&self) -> HashMap<String, f64> {
|
|
self.model_weights.clone()
|
|
}
|
|
}
|
|
|
|
/// Helper: Create test ML configuration
|
|
fn create_test_ml_config() -> MLInferenceConfig {
|
|
MLInferenceConfig::default()
|
|
}
|
|
|
|
/// Helper: Create strategy with ML integration (uses real AdaptiveMLEnsemble)
|
|
async fn create_strategy_with_ml(config: MLInferenceConfig) -> Result<AdaptiveStrategyML, String> {
|
|
// Create real adaptive ensemble
|
|
let ensemble = AdaptiveMLEnsemble::new(None);
|
|
|
|
// Register all 6 models
|
|
ensemble
|
|
.register_models()
|
|
.await
|
|
.map_err(|e| format!("Failed to register models: {}", e))?;
|
|
|
|
Ok(AdaptiveStrategyML {
|
|
ensemble,
|
|
ml_enabled: true,
|
|
models_loaded: config.models_enabled.len(),
|
|
performance_stats: MLPerformanceStats {
|
|
total_predictions: 0,
|
|
correct_predictions: 0,
|
|
accuracy: 0.0,
|
|
},
|
|
model_weights: vec![
|
|
("DQN".to_string(), 0.167),
|
|
("PPO".to_string(), 0.167),
|
|
("TFT".to_string(), 0.167),
|
|
("MAMBA-2".to_string(), 0.166),
|
|
("Liquid".to_string(), 0.166),
|
|
("TLOB".to_string(), 0.167),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
/// Helper: Create test strategy with default ML config
|
|
async fn create_test_strategy_with_ml() -> Result<AdaptiveStrategyML, String> {
|
|
create_strategy_with_ml(create_test_ml_config()).await
|
|
}
|
|
|
|
/// Helper: Generate test OHLCV data
|
|
fn generate_test_ohlcv_data(count: usize) -> Vec<(f64, f64, f64, f64, f64)> {
|
|
// Generate synthetic OHLCV bars (open, high, low, close, volume)
|
|
let mut data = Vec::new();
|
|
let mut price = 100.0;
|
|
|
|
for _ in 0..count {
|
|
let open = price;
|
|
let high = price + 0.5;
|
|
let low = price - 0.3;
|
|
let close = price + 0.1;
|
|
let volume = 10000.0;
|
|
|
|
data.push((open, high, low, close, volume));
|
|
price = close; // Next bar starts at previous close
|
|
}
|
|
|
|
data
|
|
}
|
|
|
|
// ============================================================================
|
|
// Compilation Check (ensures types are correct)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_compilation() {
|
|
// This test just ensures the file compiles
|
|
assert!(true);
|
|
}
|