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>
29 KiB
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 ModelInferenceAdapters → 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(adddatadependency) - Create:
backtesting/src/dbn_converter.rs - Modify:
backtesting/src/lib.rs(add module declaration)
Context:
ProcessedMessageis defined atdata/src/providers/databento/dbn_parser.rs:749MarketEventis defined attrading_engine/src/types/events.rs:93ProcessedMessageuses:String(symbol),HardwareTimestamp(timestamp),Price(price),Decimal(size),OrderSide(side)MarketEventuses: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.rsto findHardwareTimestamp's conversion method (likelyraw()returning nanoseconds u64) - Read
common/src/types.rsto findSymbol::new(),Quantity::from_f64()orQuantity::from_decimal()constructors
Step 1: Add data dependency to backtesting
In backtesting/Cargo.toml, add under [dependencies]:
data = { path = "../data" }
Step 2: Write the failing test
Create backtesting/src/dbn_converter.rs with tests:
#[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
//! 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 convertHardwareTimestamptoDateTime<Utc>or nanoscommon/src/types.rs(or whereverSymbol,Quantityare defined) — constructorsdata/src/providers/databento/dbn_parser.rslines 749-828 — ProcessedMessage field types
Step 4: Register module
In backtesting/src/lib.rs, add:
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
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(atbacktesting/src/replay_engine.rs:146) only supports CSV. Rather than modifying it, create a newDbnReplayEnginethat provides the samempsc::UnboundedReceiver<ReplayEvent>interface.ReplayEventis atbacktesting/src/replay_engine.rs:132: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 fromMarketReplay. We'll create an alternative constructor or a standalone function.DbnParser::new()atdata/src/providers/databento/dbn_parser.rs— callparse_batch(&bytes)on raw file bytes.
Step 1: Write the failing test
#[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
//! 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:
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
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(insidestrategy_runner.rs) produces 3-5 features: returns + volatility + RSI - Production
ProductionFeatureExtractorAdapteratml/src/features/production_adapter.rs:63produces 51 features - API:
update(&mut self, price: f64, volume: f64, timestamp: DateTime<Utc>)thenextract_features(&mut self) -> Result<Vec<f64>> - Needs warmup of ~50 bars. Before warmup complete,
extract_features()returns an error or short vector. - The
AdaptiveStrategyRunnercallsself.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:
#[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
-
Replace the
feature_extractor: Arc<FeatureExtractor>field with a production extractor:feature_extractor: Arc<RwLock<ProductionFeatureExtractorAdapter>>, -
In
on_market_event(), when aMarketEvent::Tradearrives:- 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: ... }
- Call
-
Keep the old local
FeatureExtractoras a fallback (behind a config flag or just remove it).
IMPORTANT: Read these files before implementing:
ml/src/features/production_adapter.rs— full API ofProductionFeatureExtractorAdapterml/src/features/extraction.rs— whatFeatureExtractor::extract_current_features()returnsbacktesting/src/strategy_runner.rslines 250-290 — current extract_features flow- Check if
ProductionFeatureExtractorAdapterisSend + Sync(needed forArc)
Step 3: Verify
Run: SQLX_OFFLINE=true cargo test -p backtesting --lib strategy_runner -- --nocapture
Step 4: Commit
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(atml/src/lib.rs:1410) storesArc<dyn MLModel> MLModeltrait (atml/src/lib.rs:1368) requires:name(),model_type(),predict(),get_confidence(),get_metadata()- We already have
ModelInferenceAdapterimplementations (DQN, PPO, TFT, Mamba2) from the ensemble work - We need a wrapper that implements
MLModelusing aModelInferenceAdapterinternally MLModel::predict()isasync fn,ModelInferenceAdapter::predict()is sync — just call sync from asyncMLModelrequiresSend + Sync + Debug
Step 1: Write the failing test
#[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
//! 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:
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
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()returnsArc<ModelRegistry>— starts emptyModelRegistry::register(&self, model: Arc<dyn MLModel>)atml/src/lib.rs:1445- DQN loading:
DQN::new(config)thenload_from_safetensors(path)atml/src/dqn/dqn.rs:2532 - PPO loading:
PPO::load_checkpoint(actor_path, critic_path, config, device)atml/src/ppo/ppo.rs:1671 - We reuse the
DqnInferenceAdapter::from_checkpoint()andPpoInferenceAdapterfrom our ensemble adapters
Step 1: Write the failing test
#[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:
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
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:
PositionTrackeratstrategy_tester.rs—update_position()body isOk(()),record_trade()body is emptyPositionTrackerhas fields for positions and trades (read the struct definition at ~line 750-780)PerformanceMetrics(insidePerformanceTracker) hastotal_realized_pnl,winning_trades,total_trades- The
PerformanceTrackerhas asnapshots: 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:
PositionTrackerstruct definition and all its fieldsupdate_position()method (currently stub)record_trade()method (currently stub)PerformanceTrackerstruct andPerformanceMetrics- How
execute_order()calls position tracker and performance tracker - How
process_pending_orders()handles fills
Step 2: Write the failing test
#[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():
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():
pub fn record_trade(&mut self, trade: TradeRecord) {
self.trades.push(trade);
}
Add a get_trades() accessor:
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
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) hastotal_pnl,winning_trades,total_trades- These fields are never updated in the current code
finalize()at line ~1002 buildsStrategyResultbut returnstrades: vec![]andperformance_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:
PerformanceTrackerstruct (line ~183) — all fieldson_market_event()— where trades are generatedgenerate_signal()— where TradingSignal is createdfinalize()— where StrategyResult is built- Any existing place where
performance_trackeris written to
Step 2: Add trade recording to signal generation
When generate_signal() produces a signal that gets executed, update the performance tracker:
// 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:
trades: vec![],
performance_timeline: vec![],
with:
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
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
//! 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
git add backtesting/tests/dbn_backtest_integration.rs
git commit -m "test(backtesting): add end-to-end DBN backtest integration test"