# Backtesting Vertical Slice Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Connect the 8 disconnected backtesting pipeline layers so that `.dbn` files + trained model checkpoints produce real backtest PnL with trade-by-trade history. **Architecture:** DBN parser (exists in `data/`) → new converter → new replay engine → existing `StrategyTester` → modified `AdaptiveStrategyRunner` with production 51-dim features → `ModelRegistry` populated with real models via new `MLModel` wrappers around existing `ModelInferenceAdapter`s → fixed position tracking and PnL. **Tech Stack:** Rust, `dbn` crate (Databento parsing), `candle` (ML inference), `rust_decimal`, `chrono`, `tokio`, `mpsc` channels **Build command:** `SQLX_OFFLINE=true cargo check -p backtesting` **Test command:** `SQLX_OFFLINE=true cargo test -p backtesting --lib` **Clippy rules:** `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]` — use `.get()`, `?`, `.ok_or()` everywhere. --- ### Task 1: DBN → MarketEvent Converter **Files:** - Modify: `backtesting/Cargo.toml` (add `data` dependency) - Create: `backtesting/src/dbn_converter.rs` - Modify: `backtesting/src/lib.rs` (add module declaration) **Context:** - `ProcessedMessage` is defined at `data/src/providers/databento/dbn_parser.rs:749` - `MarketEvent` is defined at `trading_engine/src/types/events.rs:93` - `ProcessedMessage` uses: `String` (symbol), `HardwareTimestamp` (timestamp), `Price` (price), `Decimal` (size), `OrderSide` (side) - `MarketEvent` uses: `Symbol` (symbol), `Price` (price), `Quantity` (size), `DateTime` (timestamp), `OrderSide` (side) - Key conversions needed: `String` → `Symbol`, `HardwareTimestamp` → `DateTime`, `Decimal` → `Quantity` - Read `trading_engine/src/timing.rs` to find `HardwareTimestamp`'s conversion method (likely `raw()` returning nanoseconds u64) - Read `common/src/types.rs` to find `Symbol::new()`, `Quantity::from_f64()` or `Quantity::from_decimal()` constructors **Step 1: Add `data` dependency to backtesting** In `backtesting/Cargo.toml`, add under `[dependencies]`: ```toml data = { path = "../data" } ``` **Step 2: Write the failing test** Create `backtesting/src/dbn_converter.rs` with tests: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_convert_trade() { // Create a ProcessedMessage::Trade and verify it converts to MarketEvent::Trade // Must check: symbol, price, quantity, timestamp all map correctly } #[test] fn test_convert_ohlcv_to_bar() { // Create a ProcessedMessage::Ohlcv and verify it converts to MarketEvent::Bar } #[test] fn test_convert_quote() { // Create a ProcessedMessage::Quote and verify it converts to MarketEvent::Quote } } ``` **Step 3: Write the implementation** ```rust //! Converts Databento ProcessedMessage types to trading engine MarketEvent types. use data::providers::databento::dbn_parser::ProcessedMessage; use trading_engine::types::events::MarketEvent; // Import Symbol, Price, Quantity, DateTime, Utc from appropriate crates // Read common/src/types.rs for Symbol, Quantity constructors // Read trading_engine/src/timing.rs for HardwareTimestamp API use crate::MLResult; /// Convert a ProcessedMessage to a MarketEvent. /// /// Returns None for message types that don't map to market events (e.g., Status). pub fn processed_to_market_event(msg: &ProcessedMessage) -> Option { match msg { ProcessedMessage::Trade { symbol, timestamp, price, size, side, trade_id, .. } => { // Convert HardwareTimestamp → DateTime (read timing.rs for method) // Convert String → Symbol (read common types for constructor) // Convert Decimal size → Quantity (use Quantity::from_f64 or from_decimal) Some(MarketEvent::Trade { symbol: /* Symbol::new(symbol) or similar */, price: *price, size: /* convert size to Quantity */, timestamp: /* convert HardwareTimestamp to DateTime */, side: Some(*side), venue: None, trade_id: trade_id.clone(), }) } ProcessedMessage::Ohlcv { symbol, timestamp, open, high, low, close, volume } => { Some(MarketEvent::Bar { symbol: /* convert */, open: *open, high: *high, low: *low, close: *close, volume: /* convert Decimal to Quantity */, timestamp: /* convert */, interval: "1m".to_string(), venue: None, }) } ProcessedMessage::Quote { symbol, timestamp, bid, ask, bid_size, ask_size, .. } => { // Only convert if both bid and ask are present let bid_p = (*bid)?; let ask_p = (*ask)?; Some(MarketEvent::Quote { symbol: /* convert */, bid_price: bid_p, bid_size: /* convert bid_size.unwrap_or(Decimal::ZERO) */, ask_price: ask_p, ask_size: /* convert ask_size.unwrap_or(Decimal::ZERO) */, timestamp: /* convert */, venue: None, }) } ProcessedMessage::OrderBook { .. } => { // OrderBook updates need accumulation into full snapshot // For MVP, skip individual order book updates None } ProcessedMessage::Status { .. } => None, } } ``` IMPORTANT: You MUST read these files to determine exact constructor/conversion APIs: - `trading_engine/src/timing.rs` — how to convert `HardwareTimestamp` to `DateTime` or nanos - `common/src/types.rs` (or wherever `Symbol`, `Quantity` are defined) — constructors - `data/src/providers/databento/dbn_parser.rs` lines 749-828 — ProcessedMessage field types **Step 4: Register module** In `backtesting/src/lib.rs`, add: ```rust pub mod dbn_converter; ``` **Step 5: Verify** Run: `SQLX_OFFLINE=true cargo check -p backtesting` Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib dbn_converter::tests -- --nocapture` Expected: 3 tests pass **Step 6: Commit** ```bash git add backtesting/Cargo.toml backtesting/src/dbn_converter.rs backtesting/src/lib.rs git commit -m "feat(backtesting): add DBN ProcessedMessage to MarketEvent converter" ``` --- ### Task 2: DBN Replay Engine **Files:** - Create: `backtesting/src/dbn_replay.rs` - Modify: `backtesting/src/lib.rs` (add module + re-exports) **Context:** - `MarketReplay` (at `backtesting/src/replay_engine.rs:146`) only supports CSV. Rather than modifying it, create a new `DbnReplayEngine` that provides the same `mpsc::UnboundedReceiver` interface. - `ReplayEvent` is at `backtesting/src/replay_engine.rs:132`: ```rust pub struct ReplayEvent { pub event: MarketEvent, pub original_timestamp: Timestamp, // = DateTime pub replay_timestamp: Timestamp, pub source_id: String, pub sequence: u64, } ``` - `StrategyTester::run_test()` takes receiver from `MarketReplay`. We'll create an alternative constructor or a standalone function. - `DbnParser::new()` at `data/src/providers/databento/dbn_parser.rs` — call `parse_batch(&bytes)` on raw file bytes. **Step 1: Write the failing test** ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_dbn_replay_engine_empty_file() { // Create engine with empty bytes, verify 0 events } #[tokio::test] async fn test_dbn_replay_engine_sends_events() { // Create engine with synthetic trade events (construct ProcessedMessages directly) // Call start_replay(), receive events from channel, verify count and order } } ``` **Step 2: Write the implementation** ```rust //! DBN file replay engine for backtesting. //! //! Parses .dbn files and streams MarketEvents through an mpsc channel, //! compatible with the StrategyTester event loop. use std::path::Path; use chrono::Utc; use tokio::sync::mpsc; use data::providers::databento::dbn_parser::DbnParser; use crate::dbn_converter::processed_to_market_event; use crate::replay_engine::ReplayEvent; /// Replay engine that loads a .dbn file and streams events. pub struct DbnReplayEngine { /// Pre-parsed and converted events, sorted by timestamp events: Vec, } impl DbnReplayEngine { /// Load a .dbn file and parse all events. pub fn from_dbn_file(path: &Path) -> Result> { let bytes = std::fs::read(path)?; let parser = DbnParser::new()?; let messages = parser.parse_batch(&bytes)?; let mut events: Vec = Vec::new(); let mut sequence = 0u64; for msg in &messages { if let Some(market_event) = processed_to_market_event(msg) { let timestamp = /* extract timestamp from market_event */; events.push(ReplayEvent { event: market_event, original_timestamp: timestamp, replay_timestamp: timestamp, source_id: "dbn".to_string(), sequence, }); sequence += 1; } } // Sort by original_timestamp events.sort_by_key(|e| e.original_timestamp); Ok(Self { events }) } /// Create from pre-built events (for testing). pub fn from_events(events: Vec) -> Self { Self { events } } /// Number of events loaded. pub fn event_count(&self) -> usize { self.events.len() } /// Start streaming events through an mpsc channel. /// Returns the receiver. Events are sent at maximum speed (no delay). pub fn start_replay(&self) -> mpsc::UnboundedReceiver { let (tx, rx) = mpsc::unbounded_channel(); let events = self.events.clone(); tokio::spawn(async move { for event in events { if tx.send(event).is_err() { break; // Receiver dropped } } }); rx } } ``` **Step 3: Register module** In `backtesting/src/lib.rs`: ```rust pub mod dbn_replay; pub use dbn_replay::DbnReplayEngine; ``` **Step 4: Verify** Run: `SQLX_OFFLINE=true cargo check -p backtesting` Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib dbn_replay::tests -- --nocapture` **Step 5: Commit** ```bash git add backtesting/src/dbn_replay.rs backtesting/src/lib.rs git commit -m "feat(backtesting): add DBN replay engine for historical data" ``` --- ### Task 3: Wire Production Feature Extractor **Files:** - Modify: `backtesting/src/strategy_runner.rs` **Context:** - Current local `FeatureExtractor` (inside `strategy_runner.rs`) produces 3-5 features: returns + volatility + RSI - Production `ProductionFeatureExtractorAdapter` at `ml/src/features/production_adapter.rs:63` produces 51 features - API: `update(&mut self, price: f64, volume: f64, timestamp: DateTime)` then `extract_features(&mut self) -> Result>` - Needs warmup of ~50 bars. Before warmup complete, `extract_features()` returns an error or short vector. - The `AdaptiveStrategyRunner` calls `self.feature_extractor.extract_features(&market_state)` at line ~262 - We need to replace this with the production extractor - Import: `use ml::features::production_adapter::ProductionFeatureExtractorAdapter;` **Step 1: Write the failing test** Add to `strategy_runner.rs` tests: ```rust #[test] fn test_production_features_have_51_dimensions() { // Create ProductionFeatureExtractorAdapter // Feed 55 price updates (past warmup) // Extract features // Assert features.values.len() == 51 } ``` **Step 2: Modify AdaptiveStrategyRunner** 1. Replace the `feature_extractor: Arc` field with a production extractor: ```rust feature_extractor: Arc>, ``` 2. In `on_market_event()`, when a `MarketEvent::Trade` arrives: - Call `feature_extractor.write().update(price.to_f64(), volume_f64, timestamp)?` - Then `let values = feature_extractor.write().extract_features()?` - Build `Features { values, names: (0..values.len()).map(|i| format!("f{}", i)).collect(), timestamp: ..., symbol: ... }` 3. Keep the old local `FeatureExtractor` as a fallback (behind a config flag or just remove it). IMPORTANT: Read these files before implementing: - `ml/src/features/production_adapter.rs` — full API of `ProductionFeatureExtractorAdapter` - `ml/src/features/extraction.rs` — what `FeatureExtractor::extract_current_features()` returns - `backtesting/src/strategy_runner.rs` lines 250-290 — current extract_features flow - Check if `ProductionFeatureExtractorAdapter` is `Send + Sync` (needed for `Arc`) **Step 3: Verify** Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib strategy_runner -- --nocapture` **Step 4: Commit** ```bash git add backtesting/src/strategy_runner.rs git commit -m "feat(backtesting): wire production 51-dim feature extractor" ``` --- ### Task 4: MLModel Wrapper for Inference Adapters **Files:** - Create: `backtesting/src/model_loader.rs` - Modify: `backtesting/src/lib.rs` **Context:** - The global `ModelRegistry` (at `ml/src/lib.rs:1410`) stores `Arc` - `MLModel` trait (at `ml/src/lib.rs:1368`) requires: `name()`, `model_type()`, `predict()`, `get_confidence()`, `get_metadata()` - We already have `ModelInferenceAdapter` implementations (DQN, PPO, TFT, Mamba2) from the ensemble work - We need a wrapper that implements `MLModel` using a `ModelInferenceAdapter` internally - `MLModel::predict()` is `async fn`, `ModelInferenceAdapter::predict()` is sync — just call sync from async - `MLModel` requires `Send + Sync + Debug` **Step 1: Write the failing test** ```rust #[cfg(test)] mod tests { use super::*; use ml::dqn::dqn::DQNConfig; #[tokio::test] async fn test_backtest_ml_model_wraps_dqn() { let config = DQNConfig { state_dim: 51, num_actions: 45, hidden_dims: vec![64, 64], ..Default::default() }; let adapter = ml::ensemble::adapters::DqnInferenceAdapter::new(config).unwrap(); let model = BacktestMLModel::new(Box::new(adapter), ml::ModelType::DQN); assert_eq!(model.name(), "DQN"); assert!(model.is_ready()); let features = ml::Features::new( vec![0.5; 51], (0..51).map(|i| format!("f{}", i)).collect(), ); let pred = model.predict(&features).await.unwrap(); assert!(pred.value >= -1.0 && pred.value <= 1.0); assert!(pred.confidence >= 0.0 && pred.confidence <= 1.0); } } ``` **Step 2: Write the implementation** ```rust //! Model loading and MLModel wrapper for backtesting. //! //! Wraps ModelInferenceAdapter (from ensemble) to implement the MLModel trait //! required by the global ModelRegistry. use std::sync::Arc; use ml::{ Features, Feedback, MLModel, MLResult, ModelMetadata, ModelPrediction, ModelType, ensemble::inference_adapter::{FeatureVector, ModelInferenceAdapter}, }; /// Wraps a ModelInferenceAdapter to implement the MLModel trait. /// /// This bridges the ensemble inference adapters (DQN, PPO, TFT, Mamba2) /// with the global ModelRegistry used by predict_selected(). pub struct BacktestMLModel { adapter: Box, model_type: ModelType, } impl std::fmt::Debug for BacktestMLModel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BacktestMLModel") .field("name", &self.adapter.model_name()) .field("model_type", &self.model_type) .finish() } } impl BacktestMLModel { pub fn new(adapter: Box, model_type: ModelType) -> Self { Self { adapter, model_type } } } #[async_trait::async_trait] impl MLModel for BacktestMLModel { fn name(&self) -> &str { self.adapter.model_name() } fn model_type(&self) -> ModelType { self.model_type.clone() } async fn predict(&self, features: &Features) -> MLResult { let fv = FeatureVector { values: features.values.clone(), timestamp: features.timestamp as i64, }; let ensemble_pred = self.adapter.predict(&fv)?; Ok(ModelPrediction::new( ensemble_pred.model_name, ensemble_pred.direction, ensemble_pred.confidence, )) } fn get_confidence(&self) -> f64 { 0.8 // Default confidence for loaded model } fn get_metadata(&self) -> ModelMetadata { ModelMetadata { name: self.adapter.model_name().to_string(), version: "1.0.0".to_string(), model_type: self.model_type.clone(), // Fill remaining fields with defaults — read ModelMetadata definition } } fn validate_features(&self, features: &Features) -> MLResult<()> { if features.values.is_empty() { return Err(ml::MLError::ValidationError { message: "Empty feature vector".to_string(), }); } Ok(()) } } ``` IMPORTANT: Read `ml/src/lib.rs` lines 1340-1368 for `ModelMetadata` and `ModelType` definitions. Fill in all required fields. **Step 3: Register module** In `backtesting/src/lib.rs`: ```rust pub mod model_loader; pub use model_loader::BacktestMLModel; ``` **Step 4: Verify** Run: `SQLX_OFFLINE=true cargo check -p backtesting` Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib model_loader::tests -- --nocapture` **Step 5: Commit** ```bash git add backtesting/src/model_loader.rs backtesting/src/lib.rs git commit -m "feat(backtesting): add BacktestMLModel wrapper for inference adapters" ``` --- ### Task 5: Registry Startup — Load Models from Checkpoints **Files:** - Modify: `backtesting/src/model_loader.rs` (add loading functions) **Context:** - `get_global_registry()` returns `Arc` — starts empty - `ModelRegistry::register(&self, model: Arc)` at `ml/src/lib.rs:1445` - DQN loading: `DQN::new(config)` then `load_from_safetensors(path)` at `ml/src/dqn/dqn.rs:2532` - PPO loading: `PPO::load_checkpoint(actor_path, critic_path, config, device)` at `ml/src/ppo/ppo.rs:1671` - We reuse the `DqnInferenceAdapter::from_checkpoint()` and `PpoInferenceAdapter` from our ensemble adapters **Step 1: Write the failing test** ```rust #[tokio::test] async fn test_load_dqn_into_registry() { let config = DQNConfig { state_dim: 51, num_actions: 45, hidden_dims: vec![64, 64], ..Default::default() }; // Create a DQN with random weights (no checkpoint file needed for this test) let adapter = DqnInferenceAdapter::new(config).unwrap(); let model = BacktestMLModel::new(Box::new(adapter), ModelType::DQN); let registry = ml::get_global_registry(); registry.register(Arc::new(model)).await.unwrap(); let names = registry.get_model_names(); assert!(names.contains(&"DQN".to_string())); } ``` **Step 2: Write the loading API** Add to `backtesting/src/model_loader.rs`: ```rust use ml::dqn::dqn::DQNConfig; use ml::ppo::ppo::PPOConfig; use ml::ensemble::adapters::{DqnInferenceAdapter, PpoInferenceAdapter}; /// Model specification for loading pub struct ModelSpec { pub model_type: String, // "DQN", "PPO", "TFT", "MAMBA-2" pub checkpoint_path: String, // path to .safetensors file pub weight: f64, // ensemble weight } /// Load models from checkpoint files and register in the global registry. /// /// Call this before running a backtest to populate the ModelRegistry. pub async fn load_models_for_backtest(specs: &[ModelSpec]) -> MLResult<()> { let registry = ml::get_global_registry(); for spec in specs { let model: Box = match spec.model_type.as_str() { "DQN" => { let config = DQNConfig { state_dim: 51, num_actions: 45, hidden_dims: vec![128, 128], ..Default::default() }; Box::new(DqnInferenceAdapter::from_checkpoint(config, &spec.checkpoint_path)?) } "PPO" => { let config = PPOConfig { state_dim: 64, num_actions: 45, policy_hidden_dims: vec![128, 128], value_hidden_dims: vec![128, 128], ..Default::default() }; Box::new(PpoInferenceAdapter::new(config)?) // Note: PPO checkpoint loading requires actor+critic paths // This needs PpoInferenceAdapter::from_checkpoint() — extend if not available } // "TFT" and "MAMBA-2" can be added similarly using TftInferenceAdapter, Mamba2InferenceAdapter other => { tracing::warn!("Unknown model type: {}, skipping", other); continue; } }; let model_type = match spec.model_type.as_str() { "DQN" => ModelType::DQN, "PPO" => ModelType::PPO, _ => ModelType::Custom(spec.model_type.clone()), }; let ml_model = BacktestMLModel::new(model, model_type); registry.register(Arc::new(ml_model)).await?; tracing::info!("Loaded {} model from {}", spec.model_type, spec.checkpoint_path); } Ok(()) } ``` IMPORTANT: Read `ml/src/lib.rs` for `ModelType` enum variants. Check if `DQN`, `PPO` exist as variants or if you need `Custom(String)`. **Step 3: Verify** Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib model_loader -- --nocapture` **Step 4: Commit** ```bash git add backtesting/src/model_loader.rs git commit -m "feat(backtesting): add model checkpoint loading for registry startup" ``` --- ### Task 6: Fix PositionTracker **Files:** - Modify: `backtesting/src/strategy_tester.rs` **Context:** - `PositionTracker` at `strategy_tester.rs` — `update_position()` body is `Ok(())`, `record_trade()` body is empty - `PositionTracker` has fields for positions and trades (read the struct definition at ~line 750-780) - `PerformanceMetrics` (inside `PerformanceTracker`) has `total_realized_pnl`, `winning_trades`, `total_trades` - The `PerformanceTracker` has a `snapshots: Vec` for the timeline - These need to be updated when trades execute **Step 1: Read the current code** Read `backtesting/src/strategy_tester.rs` carefully — find: 1. `PositionTracker` struct definition and all its fields 2. `update_position()` method (currently stub) 3. `record_trade()` method (currently stub) 4. `PerformanceTracker` struct and `PerformanceMetrics` 5. How `execute_order()` calls position tracker and performance tracker 6. How `process_pending_orders()` handles fills **Step 2: Write the failing test** ```rust #[test] fn test_position_tracker_records_trade() { let mut tracker = PositionTracker::new(); let trade = TradeRecord { trade_id: "T001".to_string(), symbol: Symbol::new("ES"), side: OrderSide::Buy, entry_price: Price::from_f64(4500.0).unwrap(), exit_price: Price::from_f64(4510.0).unwrap(), quantity: Quantity::from_f64(1.0).unwrap(), entry_time: Utc::now(), exit_time: Utc::now(), pnl: Decimal::from(10), return_pct: Decimal::new(22, 4), // 0.0022 commission: Decimal::new(2, 0), }; tracker.record_trade(trade.clone()); assert_eq!(tracker.get_trades().len(), 1); } ``` **Step 3: Implement position tracking** In `update_position()`: ```rust pub fn update_position(&mut self, symbol: &Symbol, price: Price, quantity: i64, side: OrderSide) -> Result<()> { // Track entry: store (symbol, entry_price, quantity, side, entry_time) // Track exit: when opposite side or close signal, compute realized PnL // Update unrealized PnL based on current price Ok(()) } ``` In `record_trade()`: ```rust pub fn record_trade(&mut self, trade: TradeRecord) { self.trades.push(trade); } ``` Add a `get_trades()` accessor: ```rust pub fn get_trades(&self) -> &[TradeRecord] { &self.trades } ``` IMPORTANT: Read the full `PositionTracker` struct to understand its existing fields before modifying. Add a `trades: Vec` field if not already present. **Step 4: Verify** Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib strategy_tester -- --nocapture` **Step 5: Commit** ```bash git add backtesting/src/strategy_tester.rs git commit -m "fix(backtesting): implement PositionTracker position accounting and trade recording" ``` --- ### Task 7: Fix PnL Tracking in AdaptiveStrategyRunner **Files:** - Modify: `backtesting/src/strategy_runner.rs` **Context:** - `PerformanceTracker` (private struct in strategy_runner.rs, line ~183) has `total_pnl`, `winning_trades`, `total_trades` - These fields are never updated in the current code - `finalize()` at line ~1002 builds `StrategyResult` but returns `trades: vec![]` and `performance_timeline: vec![]` - Need to: (a) update PnL when trades execute, (b) populate trades list, (c) create performance snapshots **Step 1: Read the current code** Read `backtesting/src/strategy_runner.rs`: 1. `PerformanceTracker` struct (line ~183) — all fields 2. `on_market_event()` — where trades are generated 3. `generate_signal()` — where TradingSignal is created 4. `finalize()` — where StrategyResult is built 5. Any existing place where `performance_tracker` is written to **Step 2: Add trade recording to signal generation** When `generate_signal()` produces a signal that gets executed, update the performance tracker: ```rust // After a trade executes (in on_market_event or wherever the signal is consumed): let mut tracker = self.performance_tracker.write(); tracker.total_trades += 1; tracker.total_pnl += realized_pnl; if realized_pnl > Decimal::ZERO { tracker.winning_trades += 1; } ``` **Step 3: Collect trade records** Add a `trades: Vec` to `AdaptiveStrategyRunner` or to its `PerformanceTracker`. When signals execute, push `TradeRecord` entries. **Step 4: Fix finalize()** In `finalize()`, replace: ```rust trades: vec![], performance_timeline: vec![], ``` with: ```rust trades: self.get_trade_records(), performance_timeline: self.get_performance_timeline(), ``` **Step 5: Verify** Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib strategy_runner -- --nocapture` **Step 6: Commit** ```bash git add backtesting/src/strategy_runner.rs git commit -m "fix(backtesting): wire PnL tracking and trade history in AdaptiveStrategyRunner" ``` --- ### Task 8: End-to-End Integration Test **Files:** - Create: `backtesting/tests/dbn_backtest_integration.rs` **Context:** - This test validates the full pipeline: create synthetic data → load model → run backtest → verify PnL - Cannot depend on real .dbn files — create synthetic events directly - Cannot depend on real checkpoints — use random-weight models - Must work without PostgreSQL (SQLX_OFFLINE=true) **Step 1: Write the integration test** ```rust //! End-to-end integration test for the backtesting pipeline. //! //! Creates synthetic market events, loads a DQN model with random weights, //! runs a full backtest, and verifies the pipeline produces valid results. use backtesting::*; use ml::{get_global_registry, Features, ModelType}; use backtesting::model_loader::BacktestMLModel; use ml::ensemble::adapters::DqnInferenceAdapter; use ml::dqn::dqn::DQNConfig; use std::sync::Arc; #[tokio::test] async fn test_full_backtest_pipeline() { // 1. Create DQN model with random weights 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); // 2. Register in global registry let registry = get_global_registry(); registry.register(Arc::new(model)).await.unwrap(); // 3. Create synthetic ReplayEvents (trending price: 100 → 110) // Use DbnReplayEngine::from_events() with constructed MarketEvent::Trade events // 4. Create AdaptiveStrategyRunner with config targeting ["DQN"] // Set min_confidence low enough that random weights produce signals // 5. Run backtest event loop manually (feed events to strategy) // OR use StrategyTester if integration allows // 6. Call finalize() and check StrategyResult // Assert: total_trades > 0 (some signals should fire) // Assert: trades.len() == total_trades // Assert: final_value != initial_capital (some change occurred) // Assert: win_rate is computed (0.0 to 1.0) } ``` Note: This test uses `#[tokio::test]`. The exact event construction and strategy setup depend on the implementations from Tasks 1-7. The implementing agent should read those implementations and construct appropriate test data. **Step 2: Verify** Run: `SQLX_OFFLINE=true cargo test -p backtesting --test dbn_backtest_integration -- --nocapture` **Step 3: Run full backtesting test suite** Run: `SQLX_OFFLINE=true cargo test -p backtesting -- --nocapture` Expected: All tests pass, no regressions **Step 4: Run cargo check on full workspace** Run: `SQLX_OFFLINE=true cargo check -p backtesting -p ml -p data` Expected: Clean compilation **Step 5: Commit** ```bash git add backtesting/tests/dbn_backtest_integration.rs git commit -m "test(backtesting): add end-to-end DBN backtest integration test" ```