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>
559 lines
17 KiB
Rust
559 lines
17 KiB
Rust
#![allow(unexpected_cfgs)]
|
|
#![cfg(feature = "__trading_service_integration")]
|
|
//! TDD Integration Tests for Paper Trading ML Integration
|
|
//!
|
|
//! Mission: Integrate ML predictions with paper trading executor using strict TDD methodology
|
|
//! Phase: RED (Failing Tests) → GREEN (Minimal Implementation) → REFACTOR (Quality)
|
|
//!
|
|
//! Tests cover:
|
|
//! - Paper trading with ML signals
|
|
//! - ML signal to order conversion
|
|
//! - Position sizing based on confidence
|
|
//! - ML prediction tracking in PostgreSQL
|
|
//! - Risk limits override ML signals
|
|
//! - Fallback to rule-based on ML failure
|
|
//! - Performance feedback loop
|
|
|
|
use ml_core::native_types::NativeDevice;
|
|
use common::{CommonError, OrderSide, OrderType};
|
|
use sqlx::PgPool;
|
|
use std::path::PathBuf;
|
|
use uuid::Uuid;
|
|
|
|
// ============================================================================
|
|
// Helper Functions (Test Infrastructure)
|
|
// ============================================================================
|
|
|
|
/// Create test database pool
|
|
async fn get_test_db_pool() -> PgPool {
|
|
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
|
|
"postgresql://foxhunt:foxhunt_dev_password@localhost:5432/foxhunt".to_string()
|
|
});
|
|
|
|
PgPool::connect(&database_url)
|
|
.await
|
|
.expect("Failed to connect to test database")
|
|
}
|
|
|
|
/// Create test ensemble coordinator with 3 models (DQN, PPO, MAMBA2)
|
|
fn create_test_ensemble() -> std::sync::Arc<trading_service::EnsembleCoordinator> {
|
|
use std::sync::Arc;
|
|
|
|
let coordinator = Arc::new(trading_service::EnsembleCoordinator::new());
|
|
|
|
// Note: In real usage, models would be loaded and registered with the coordinator
|
|
// For tests, we create a minimal ensemble coordinator without loaded models
|
|
|
|
coordinator
|
|
}
|
|
|
|
/// Create test paper trading executor with ML
|
|
async fn create_test_executor_with_ml(pool: PgPool) -> trading_service::PaperTradingExecutor {
|
|
let ensemble = create_test_ensemble();
|
|
|
|
trading_service::PaperTradingExecutor::new_with_ml(pool, ensemble)
|
|
.await
|
|
.expect("Failed to create executor with ML")
|
|
}
|
|
|
|
/// Load test OHLCV data (50 bars)
|
|
fn load_test_ohlcv_data(_symbol: &str, num_bars: usize) -> Vec<(f64, f64, f64, f64, f64)> {
|
|
// Generate synthetic OHLCV data with realistic pattern
|
|
let mut data = Vec::new();
|
|
let mut base_price = 4500.0; // ES.FUT starting price
|
|
|
|
for i in 0..num_bars {
|
|
let trend = (i as f64 * 0.1).sin(); // Add sine wave trend
|
|
let open = base_price + trend * 10.0;
|
|
let high = open + (i as f64 % 5.0) + 5.0;
|
|
let low = open - (i as f64 % 3.0) - 3.0;
|
|
let close = open + trend * 5.0;
|
|
let volume = 1000.0 + (i as f64 * 10.0);
|
|
|
|
data.push((open, high, low, close, volume));
|
|
base_price = close; // Next bar starts from previous close
|
|
}
|
|
|
|
data
|
|
}
|
|
|
|
/// Trading signal structure
|
|
#[derive(Debug, Clone)]
|
|
struct TradingSignal {
|
|
action: Option<Action>,
|
|
confidence: f64,
|
|
source: SignalSource,
|
|
model_votes: Option<Vec<(String, usize, f32)>>,
|
|
}
|
|
|
|
/// Action enum
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum Action {
|
|
Buy,
|
|
Sell,
|
|
Hold,
|
|
}
|
|
|
|
/// Signal source
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum SignalSource {
|
|
ML,
|
|
RuleBased,
|
|
}
|
|
|
|
/// Order structure for testing
|
|
#[derive(Debug, Clone)]
|
|
struct Order {
|
|
id: Uuid,
|
|
symbol: String,
|
|
side: OrderSide,
|
|
quantity: i32,
|
|
order_type: OrderType,
|
|
price: Option<i64>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 1: Paper Trading with ML Signals (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_paper_trading_with_ml_signals() {
|
|
// Arrange: Create executor with ML
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool).await;
|
|
|
|
// Generate market data
|
|
let market_data = load_test_ohlcv_data("ES.FUT", 50);
|
|
|
|
// Act: Generate ML signal
|
|
let result = executor.generate_ml_signal(&market_data).await;
|
|
|
|
// Assert: Should generate valid ML signal
|
|
assert!(
|
|
result.is_ok(),
|
|
"ML signal generation failed: {:?}",
|
|
result.err()
|
|
);
|
|
let signal = result.unwrap();
|
|
|
|
assert!(signal.action.is_some(), "ML signal should have an action");
|
|
assert_eq!(signal.source, SignalSource::ML, "Source should be ML");
|
|
assert!(
|
|
signal.confidence >= 0.0 && signal.confidence <= 1.0,
|
|
"Confidence should be in [0, 1], got {}",
|
|
signal.confidence
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 2: ML Signal to Order Conversion (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ml_signal_to_order_conversion() {
|
|
// Arrange: Create executor and signal
|
|
let pool = get_test_db_pool().await;
|
|
let executor = create_test_executor_with_ml(pool).await;
|
|
|
|
let signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.85,
|
|
source: SignalSource::ML,
|
|
model_votes: Some(vec![
|
|
("DQN".to_string(), 0, 0.9),
|
|
("PPO".to_string(), 0, 0.8),
|
|
]),
|
|
};
|
|
|
|
// Act: Convert signal to order
|
|
let result = executor.convert_signal_to_order(&signal, "ES.FUT").await;
|
|
|
|
// Assert: Order should be created correctly
|
|
assert!(
|
|
result.is_ok(),
|
|
"Signal to order conversion failed: {:?}",
|
|
result.err()
|
|
);
|
|
let order = result.unwrap();
|
|
|
|
assert_eq!(order.side, OrderSide::Buy, "Order side should be Buy");
|
|
assert!(order.quantity > 0, "Quantity should be positive");
|
|
assert_eq!(
|
|
order.order_type,
|
|
OrderType::Market,
|
|
"Should be market order"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 3: Position Sizing Based on Confidence (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_position_sizing_based_on_confidence() {
|
|
// Arrange: Create executor
|
|
let pool = get_test_db_pool().await;
|
|
let executor = create_test_executor_with_ml(pool).await;
|
|
|
|
// High confidence signal (0.9)
|
|
let high_conf_signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.9,
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
// Low confidence signal (0.6)
|
|
let low_conf_signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.6,
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
// Act: Convert both to orders
|
|
let high_conf_order = executor
|
|
.convert_signal_to_order(&high_conf_signal, "ES.FUT")
|
|
.await
|
|
.expect("High confidence order failed");
|
|
|
|
let low_conf_order = executor
|
|
.convert_signal_to_order(&low_conf_signal, "ES.FUT")
|
|
.await
|
|
.expect("Low confidence order failed");
|
|
|
|
// Assert: Higher confidence should result in larger position
|
|
assert!(
|
|
high_conf_order.quantity > low_conf_order.quantity,
|
|
"High confidence ({}) should have larger position than low confidence ({})",
|
|
high_conf_order.quantity,
|
|
low_conf_order.quantity
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 4: ML Prediction Tracking in PostgreSQL (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ml_prediction_tracking() {
|
|
// Arrange: Create executor
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool.clone()).await;
|
|
|
|
let signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.85,
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
// Act: Execute ML signal (should track prediction)
|
|
let result = executor.execute_ml_signal(&signal, "ES.FUT").await;
|
|
|
|
assert!(
|
|
result.is_ok(),
|
|
"ML signal execution failed: {:?}",
|
|
result.err()
|
|
);
|
|
let order = result.unwrap();
|
|
|
|
// Assert: Prediction should be stored in ml_predictions table
|
|
let prediction = sqlx::query!(
|
|
r#"
|
|
SELECT id, predicted_action, confidence, symbol
|
|
FROM ml_predictions
|
|
WHERE order_id = $1
|
|
ORDER BY prediction_timestamp DESC
|
|
LIMIT 1
|
|
"#,
|
|
order.id
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.expect("Failed to query predictions");
|
|
|
|
assert!(
|
|
prediction.is_some(),
|
|
"Prediction should be stored in database"
|
|
);
|
|
|
|
let pred = prediction.unwrap();
|
|
assert_eq!(
|
|
pred.predicted_action, 0,
|
|
"Predicted action should be 0 (Buy)"
|
|
);
|
|
assert!(
|
|
(pred.confidence - 0.85).abs() < 0.01,
|
|
"Confidence should be 0.85"
|
|
);
|
|
assert_eq!(pred.symbol, "ES.FUT", "Symbol should be ES.FUT");
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 5: Risk Limits Override ML Signals (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_risk_limits_override_ml_signals() {
|
|
// Arrange: Create executor and set position limit
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool).await;
|
|
|
|
// Simulate position limit reached
|
|
executor
|
|
.set_position_limit("ES.FUT", 0)
|
|
.await
|
|
.expect("Failed to set position limit");
|
|
|
|
let signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.95, // High confidence, but should be rejected
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
// Act: Try to execute ML signal
|
|
let result = executor.execute_ml_signal(&signal, "ES.FUT").await;
|
|
|
|
// Assert: Should reject due to position limit
|
|
assert!(result.is_err(), "Should reject when position limit reached");
|
|
|
|
let error = result.unwrap_err();
|
|
let error_msg = error.to_string();
|
|
assert!(
|
|
error_msg.contains("Position limit") || error_msg.contains("position"),
|
|
"Error should mention position limit, got: {}",
|
|
error_msg
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 6: 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 executor
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool).await;
|
|
|
|
// Disable ML to simulate failure
|
|
executor.disable_ml().await;
|
|
|
|
let market_data = load_test_ohlcv_data("ES.FUT", 50);
|
|
|
|
// Act: Generate signal (should fallback to rule-based)
|
|
let result = executor.generate_signal(&market_data).await;
|
|
|
|
// Assert: Should fallback successfully
|
|
assert!(
|
|
result.is_ok(),
|
|
"Fallback to rule-based failed: {:?}",
|
|
result.err()
|
|
);
|
|
let signal = result.unwrap();
|
|
|
|
assert_eq!(
|
|
signal.source,
|
|
SignalSource::RuleBased,
|
|
"Should fallback to rule-based"
|
|
);
|
|
assert!(
|
|
signal.action.is_some(),
|
|
"Rule-based should still generate signal"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 7: ML Performance Feedback Loop (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ml_performance_feedback_loop() {
|
|
// Arrange: Create executor and execute signal
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool.clone()).await;
|
|
|
|
let signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.85,
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
let order = executor
|
|
.execute_ml_signal(&signal, "ES.FUT")
|
|
.await
|
|
.expect("ML signal execution failed");
|
|
|
|
// Act: Record outcome (simulate profitable trade)
|
|
let result = executor.record_outcome(order.id, 100.0).await;
|
|
|
|
// Assert: Outcome should be recorded
|
|
assert!(
|
|
result.is_ok(),
|
|
"Recording outcome failed: {:?}",
|
|
result.err()
|
|
);
|
|
|
|
// Verify outcome in database
|
|
let prediction = sqlx::query!(
|
|
r#"
|
|
SELECT actual_action, pnl, outcome_recorded_at
|
|
FROM ml_predictions
|
|
WHERE order_id = $1
|
|
"#,
|
|
order.id
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("Failed to fetch prediction");
|
|
|
|
assert!(
|
|
prediction.actual_action.is_some(),
|
|
"Actual action should be recorded"
|
|
);
|
|
assert!(prediction.pnl.is_some(), "PnL should be recorded");
|
|
assert!(
|
|
prediction.outcome_recorded_at.is_some(),
|
|
"Outcome timestamp should be set"
|
|
);
|
|
assert!(
|
|
(prediction.pnl.unwrap() - 100.0).abs() < 0.01,
|
|
"PnL should be $100"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 8: Confidence Threshold Filtering (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_confidence_threshold_filtering() {
|
|
// Arrange: Create executor
|
|
let pool = get_test_db_pool().await;
|
|
let executor = create_test_executor_with_ml(pool).await;
|
|
|
|
// Very low confidence signal (below trading threshold)
|
|
let low_conf_signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.4, // Below 0.6 threshold
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
// Act: Try to convert to order
|
|
let result = executor
|
|
.convert_signal_to_order(&low_conf_signal, "ES.FUT")
|
|
.await;
|
|
|
|
// Assert: Should reject low confidence signals
|
|
assert!(result.is_err(), "Should reject low confidence signals");
|
|
|
|
let error = result.unwrap_err();
|
|
assert!(
|
|
error.to_string().contains("Confidence too low")
|
|
|| error.to_string().contains("confidence"),
|
|
"Error should mention confidence threshold"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 9: Multi-Symbol ML Trading (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_multi_symbol_ml_trading() {
|
|
// Arrange: Create executor
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool).await;
|
|
|
|
let symbols = vec!["ES.FUT", "NQ.FUT", "ZN.FUT"];
|
|
|
|
// Act: Execute ML signals for multiple symbols
|
|
for symbol in &symbols {
|
|
let signal = TradingSignal {
|
|
action: Some(Action::Buy),
|
|
confidence: 0.8,
|
|
source: SignalSource::ML,
|
|
model_votes: None,
|
|
};
|
|
|
|
let result = executor.execute_ml_signal(&signal, symbol).await;
|
|
assert!(result.is_ok(), "ML signal for {} failed", symbol);
|
|
}
|
|
|
|
// Assert: All symbols should have executed trades
|
|
let position_summary = executor.get_position_summary().await;
|
|
|
|
for symbol in &symbols {
|
|
assert!(
|
|
position_summary.contains_key(*symbol),
|
|
"Position should exist for {}",
|
|
symbol
|
|
);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEST 10: Ensemble Agreement Weighting (RED)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
#[ignore = "RED phase - will fail until implementation exists"]
|
|
async fn test_ensemble_agreement_weighting() {
|
|
// Arrange: Create executor
|
|
let pool = get_test_db_pool().await;
|
|
let mut executor = create_test_executor_with_ml(pool).await;
|
|
|
|
let market_data = load_test_ohlcv_data("ES.FUT", 50);
|
|
|
|
// Act: Generate signal with ensemble voting
|
|
let result = executor.generate_ml_signal(&market_data).await;
|
|
|
|
assert!(result.is_ok());
|
|
let signal = result.unwrap();
|
|
|
|
// Assert: Should have model votes with agreement weighting
|
|
assert!(signal.model_votes.is_some(), "Should have model votes");
|
|
let votes = signal.model_votes.unwrap();
|
|
|
|
// Check that confidence reflects ensemble agreement
|
|
let agreement_ratio = calculate_agreement_ratio(&votes);
|
|
|
|
// If all models agree, confidence should be high
|
|
if agreement_ratio > 0.8 {
|
|
assert!(
|
|
signal.confidence > 0.7,
|
|
"High agreement should result in high confidence, got {}",
|
|
signal.confidence
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Helper: Calculate agreement ratio from model votes
|
|
fn calculate_agreement_ratio(votes: &[(String, usize, f32)]) -> f64 {
|
|
if votes.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
// Count votes for most common action
|
|
let mut action_counts = std::collections::HashMap::new();
|
|
for (_, action, _) in votes {
|
|
*action_counts.entry(action).or_insert(0) += 1;
|
|
}
|
|
|
|
let max_count = action_counts.values().max().copied().unwrap_or(0);
|
|
max_count as f64 / votes.len() as f64
|
|
}
|