//! End-to-end integration tests for the backtesting vertical slice. //! //! Validates the full pipeline: synthetic data -> model loading -> replay -> prediction. //! No real .dbn files, no PostgreSQL, no trained checkpoints required. // Integration tests live outside the crate, so the crate-level deny lints don't apply. // Tests are allowed to use .unwrap(). use backtesting::dbn_converter::processed_to_market_event; use backtesting::dbn_replay::DbnReplayEngine; use backtesting::model_loader::{load_models_for_backtest, BacktestMLModel, ModelSpec}; use backtesting::replay_engine::ReplayEvent; use chrono::{DateTime, TimeDelta, Utc}; use common::{OrderSide, Price, Quantity, Symbol}; use data::providers::databento::dbn_parser::ProcessedMessage; use ml::dqn::dqn::DQNConfig; use ml::ensemble::adapters::dqn::DqnInferenceAdapter; use ml::features::ProductionFeatureExtractorAdapter; use ml::{get_global_registry, Features, MLModel, ModelType}; use rust_decimal::Decimal; use trading_engine::timing::{HardwareTimestamp, TimingSource}; use trading_engine::types::events::MarketEvent; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Build a `HardwareTimestamp` from a `DateTime`. fn ts_from_dt(dt: DateTime) -> HardwareTimestamp { #[allow(clippy::cast_sign_loss)] let nanos = dt.timestamp_nanos_opt().unwrap_or(0) as u64; HardwareTimestamp { cycles: 0, nanos, source: TimingSource::SystemClock, validation_passed: true, } } /// Create a synthetic `MarketEvent::Trade`. fn make_trade_event(symbol: &str, price_f64: f64, volume: f64, ts: DateTime) -> MarketEvent { MarketEvent::Trade { symbol: Symbol::new(symbol.to_string()), price: Price::from_f64(price_f64).unwrap_or(Price::ZERO), size: Quantity::from_f64(volume).unwrap_or(Quantity::ZERO), timestamp: ts, side: Some(OrderSide::Buy), venue: None, trade_id: None, } } /// Wrap a `MarketEvent` into a `ReplayEvent` with a given sequence and timestamp. fn make_replay_event(event: MarketEvent, sequence: u64, ts: DateTime) -> ReplayEvent { ReplayEvent { event, original_timestamp: ts, replay_timestamp: ts, source_id: "synthetic".to_string(), sequence, } } // --------------------------------------------------------------------------- // Test 1: DBN converter roundtrip // --------------------------------------------------------------------------- #[test] fn test_dbn_converter_roundtrip() { let now = Utc::now(); let hw_ts = ts_from_dt(now); let price = Price::from_f64(150.25).unwrap(); let msg = ProcessedMessage::Trade { symbol: "AAPL".to_string(), timestamp: hw_ts, price, size: Decimal::new(200, 0), // 200 shares side: OrderSide::Buy, trade_id: Some("T42".to_string()), conditions: vec![], }; let event = processed_to_market_event(&msg); assert!(event.is_some(), "Trade ProcessedMessage should convert to MarketEvent"); let event = event.unwrap(); if let MarketEvent::Trade { symbol, price: p, size, side, trade_id, .. } = &event { assert_eq!(symbol.as_str(), "AAPL"); assert_eq!(*p, price); assert!( (size.to_f64() - 200.0).abs() < f64::EPSILON, "size should be 200, got {}", size.to_f64() ); assert_eq!(*side, Some(OrderSide::Buy)); assert_eq!(*trade_id, Some("T42".to_string())); } else { panic!("Expected MarketEvent::Trade variant"); } } // --------------------------------------------------------------------------- // Test 2: Replay engine streams events in timestamp order // --------------------------------------------------------------------------- #[tokio::test] async fn test_replay_engine_streams_events() { let base = DateTime::from_timestamp(1_700_000_000, 0).unwrap(); // Create 10 synthetic events with trending price 100 -> 109 let events: Vec = (0..10) .map(|i| { let ts = base + TimeDelta::seconds(i as i64); let price = 100.0 + i as f64; let event = make_trade_event("SYNTH", price, 50.0, ts); make_replay_event(event, i as u64, ts) }) .collect(); let engine = DbnReplayEngine::from_events(events); assert_eq!(engine.event_count(), 10); let mut rx = engine.start_replay(); let mut received = Vec::new(); while let Some(ev) = rx.recv().await { received.push(ev); } assert_eq!(received.len(), 10, "Should receive all 10 events"); // Verify chronological order for pair in received.windows(2) { let a = pair.first().unwrap(); let b = pair.get(1).unwrap(); assert!( a.original_timestamp <= b.original_timestamp, "Events not in chronological order: {:?} > {:?}", a.original_timestamp, b.original_timestamp, ); } // Verify price trend if let MarketEvent::Trade { price: first_price, .. } = &received.first().unwrap().event { if let MarketEvent::Trade { price: last_price, .. } = &received.last().unwrap().event { assert!( last_price > first_price, "Price should trend upward: first={}, last={}", first_price, last_price ); } } } // --------------------------------------------------------------------------- // Test 3: DQN model loads with random weights and predicts // --------------------------------------------------------------------------- #[tokio::test] async fn test_model_loads_and_predicts() { let config = DQNConfig { state_dim: 51, num_actions: 45, hidden_dims: vec![64, 64], ..Default::default() }; let adapter = DqnInferenceAdapter::new(config).unwrap(); let model = BacktestMLModel::new(Box::new(adapter), ModelType::DQN); assert!(model.is_ready(), "Model should be ready after creation"); assert_eq!(model.name(), "DQN"); assert_eq!(model.model_type(), ModelType::DQN); // Build a 51-dim feature vector let features = Features::new( vec![0.1; 51], (0..51).map(|i| format!("f{i}")).collect(), ); let prediction = model.predict(&features).await.unwrap(); // Direction must be in [-1, 1] assert!( prediction.value >= -1.0 && prediction.value <= 1.0, "direction {} out of [-1, 1]", prediction.value, ); // Confidence must be in [0, 1] assert!( prediction.confidence >= 0.0 && prediction.confidence <= 1.0, "confidence {} out of [0, 1]", prediction.confidence, ); // Model id should be "DQN" assert_eq!(prediction.model_id, "DQN"); } // --------------------------------------------------------------------------- // Test 4: Production feature extractor produces exactly 51 dimensions // --------------------------------------------------------------------------- #[test] fn test_production_features_51_dim() { use common::ml_strategy::ProductionFeatureExtractor225; let mut extractor = ProductionFeatureExtractorAdapter::new(); // Feed 55 price updates (past the warmup period of 50) for i in 0..55 { let price = 100.0 + i as f64 * 0.1; let volume = 1000.0; let timestamp = Utc::now(); extractor .update(price, volume, timestamp) .unwrap_or_else(|e| { panic!("Production extractor update failed at step {}: {}", i, e) }); } let features = extractor .extract_features() .unwrap_or_else(|e| panic!("extract_features failed: {}", e)); assert_eq!( features.len(), 51, "Production extractor should produce exactly 51 features, got {}", features.len() ); // Validate all features are finite for (i, val) in features.iter().enumerate() { assert!( val.is_finite(), "Feature {} should be finite, found {}", i, val ); } } // --------------------------------------------------------------------------- // Test 5: Full pipeline -- events -> feature extractor -> model prediction // --------------------------------------------------------------------------- #[tokio::test] async fn test_full_pipeline_synthetic_backtest() { use common::ml_strategy::ProductionFeatureExtractor225; // --- Step 1: Register a DQN model in the global registry --- let specs = vec![ModelSpec { model_type: "DQN".to_string(), checkpoint_path: String::new(), // random weights weight: 1.0, }]; load_models_for_backtest(&specs) .await .unwrap_or_else(|e| panic!("load_models_for_backtest failed: {}", e)); let registry = get_global_registry(); let model = registry.get("DQN").await; assert!(model.is_some(), "DQN should be registered in the global registry"); // --- Step 2: Create 100 synthetic ReplayEvents with trending prices --- let base = DateTime::from_timestamp(1_700_000_000, 0).unwrap(); let events: Vec = (0..100) .map(|i| { let ts = base + TimeDelta::seconds(i as i64); let price = 100.0 + (i as f64); // 100 -> 199 let volume = 500.0 + (i as f64) * 2.0; let event = make_trade_event("SYNTH", price, volume, ts); make_replay_event(event, i as u64, ts) }) .collect(); // --- Step 3: Create replay engine and stream events --- let engine = DbnReplayEngine::from_events(events); assert_eq!(engine.event_count(), 100); let mut rx = engine.start_replay(); // --- Step 4: Consume events, update feature extractor, run predictions --- let mut extractor = ProductionFeatureExtractorAdapter::new(); let mut events_processed: u64 = 0; let mut predictions_made: u64 = 0; let warmup_period: u64 = 55; // extractor needs ~50 bars for warmup while let Some(replay_event) = rx.recv().await { events_processed += 1; // Extract price and volume from the MarketEvent if let MarketEvent::Trade { price, size, timestamp, .. } = &replay_event.event { let price_f64 = price.to_decimal().unwrap_or(Decimal::ZERO); let volume_f64 = size.to_f64(); // Update the feature extractor let price_f64_val: f64 = price_f64.try_into().unwrap_or(0.0); let _ = extractor.update(price_f64_val, volume_f64, *timestamp); // After warmup, extract features and predict if events_processed >= warmup_period { match extractor.extract_features() { Ok(feat_values) if feat_values.len() == 51 => { // Verify exactly 51 dimensions assert_eq!(feat_values.len(), 51, "Feature vector should be 51-dim"); let feature_names: Vec = (0..51) .map(|i| format!("prod_feature_{}", i)) .collect(); let features = Features { values: feat_values, names: feature_names, timestamp: timestamp.timestamp_micros() as u64, symbol: Some("SYNTH".to_string()), }; // Run prediction through the global registry let results = registry .predict_selected(&["DQN".to_string()], &features) .await; for result in results { match result { Ok(pred) => { predictions_made += 1; // Validate prediction bounds assert!( pred.value >= -1.0 && pred.value <= 1.0, "Prediction value {} out of [-1, 1]", pred.value ); assert!( pred.confidence >= 0.0 && pred.confidence <= 1.0, "Prediction confidence {} out of [0, 1]", pred.confidence ); } Err(e) => { panic!("DQN prediction failed: {:?}", e); } } } } Ok(feat) => { // Feature extractor not yet warmed up, fewer than 51 features eprintln!( "Event {}: extractor returned {} features (expected 51, still warming up)", events_processed, feat.len() ); } Err(e) => { // Expected during warmup eprintln!( "Event {}: extractor not ready yet: {}", events_processed, e ); } } } } } // --- Step 5: Verify results --- assert_eq!(events_processed, 100, "Should have processed all 100 events"); assert!( predictions_made > 0, "Should have made at least one prediction after warmup (made {})", predictions_made, ); println!( "Full pipeline test passed: {} events processed, {} predictions made", events_processed, predictions_made ); }