Files
foxhunt/docs/plans/2026-02-21-backtesting-vertical-slice-implementation.md
jgrusewski 4069eb473c docs: backtesting vertical slice implementation plan
8-task TDD plan to bridge 8 disconnected layers in the backtesting
pipeline: DBN converter, replay engine, 51-dim feature wiring,
model loader, registry startup, position tracking, PnL tracking,
and end-to-end integration test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:30:00 +01:00

872 lines
29 KiB
Markdown

# 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<Utc>` (timestamp), `OrderSide` (side)
- Key conversions needed: `String``Symbol`, `HardwareTimestamp``DateTime<Utc>`, `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<MarketEvent> {
match msg {
ProcessedMessage::Trade { symbol, timestamp, price, size, side, trade_id, .. } => {
// Convert HardwareTimestamp → DateTime<Utc> (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<Utc> */,
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<Utc>` 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<ReplayEvent>` interface.
- `ReplayEvent` is at `backtesting/src/replay_engine.rs:132`:
```rust
pub struct ReplayEvent {
pub event: MarketEvent,
pub original_timestamp: Timestamp, // = DateTime<Utc>
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<ReplayEvent>,
}
impl DbnReplayEngine {
/// Load a .dbn file and parse all events.
pub fn from_dbn_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let bytes = std::fs::read(path)?;
let parser = DbnParser::new()?;
let messages = parser.parse_batch(&bytes)?;
let mut events: Vec<ReplayEvent> = 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<ReplayEvent>) -> 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<ReplayEvent> {
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<Utc>)` then `extract_features(&mut self) -> Result<Vec<f64>>`
- 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<FeatureExtractor>` field with a production extractor:
```rust
feature_extractor: Arc<RwLock<ProductionFeatureExtractorAdapter>>,
```
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<dyn MLModel>`
- `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<dyn ModelInferenceAdapter>,
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<dyn ModelInferenceAdapter>, 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<ModelPrediction> {
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<ModelRegistry>` — starts empty
- `ModelRegistry::register(&self, model: Arc<dyn MLModel>)` 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<dyn ModelInferenceAdapter> = 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<PerformanceSnapshot>` 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<TradeRecord>` 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<TradeRecord>` 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"
```