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

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 (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: StringSymbol, HardwareTimestampDateTime<Utc>, DecimalQuantity
  • 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]:

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 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:

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 (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:
    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

#[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 (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:

#[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:

    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

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

#[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() 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

#[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:

  • PositionTracker at strategy_tester.rsupdate_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

#[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) 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:

// 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"