//! DQN Action Replay Backtesting Binary //! //! Runs backtesting by replaying pre-computed DQN actions against historical market data. //! This is a simplified standalone implementation that simulates trading without the full //! backtesting infrastructure (to avoid circular dependencies with the backtesting crate). //! //! # Features //! - Loads DQN actions from CSV (exported by evaluate_dqn_main_orchestrator) //! - Simulates trading with simple position tracking //! - Computes basic performance metrics (total return, trades, action distribution) //! //! # Usage //! ```bash //! # Basic usage with defaults //! cargo run -p ml --example backtest_dqn_replay --release -- \ //! --actions-csv /tmp/dqn_actions_wave3.csv \ //! --parquet-file test_data/ES_FUT_unseen.parquet //! //! # Custom trading parameters //! cargo run -p ml --example backtest_dqn_replay --release -- \ //! --actions-csv /tmp/dqn_actions_wave3.csv \ //! --parquet-file test_data/ES_FUT_unseen.parquet \ //! --initial-capital 250000 \ //! --commission-rate 0.05 //! ``` //! //! # CSV Format //! Expected CSV columns: timestamp,action,q_buy,q_sell,q_hold,open,high,low,close,volume //! Actions: 0=BUY, 1=SELL, 2=HOLD //! //! # Output //! - Console report with detailed metrics //! - Action distribution analysis //! - Performance summary (return, trades, PnL) //! //! # Design Reference //! See `/tmp/backtesting_pipeline_design.md` for complete architecture use anyhow::{Context, Result}; use clap::Parser; use ml::backtesting::action_loader::load_actions_from_csv; use ml::data_loaders::parquet_utils::load_parquet_data_with_timestamps; use std::path::PathBuf; use tracing::{info, warn}; use tracing_subscriber; /// CLI arguments for DQN replay backtesting #[derive(Parser, Debug)] #[command( name = "backtest_dqn_replay", about = "Backtest DQN trading decisions via action replay", long_about = "Load pre-computed DQN actions from CSV and simulate trading to evaluate performance." )] struct Args { /// Path to DQN actions CSV (from evaluate_dqn_main_orchestrator) #[arg(long, default_value = "/tmp/dqn_actions_wave3.csv")] actions_csv: PathBuf, /// Path to OHLCV Parquet file (same data used for DQN evaluation) #[arg(long, default_value = "test_data/ES_FUT_unseen.parquet")] parquet_file: PathBuf, /// Initial trading capital (USD) #[arg(long, default_value_t = 100000.0)] initial_capital: f64, /// Commission rate (percentage, e.g., 0.01 = 0.01%) #[arg(long, default_value_t = 0.01)] commission_rate: f64, /// Verbose logging #[arg(short, long)] verbose: bool, } /// Position state for tracking holdings #[derive(Debug, Clone, Copy, PartialEq)] enum PositionState { Flat, // No position Long, // Long position Short, // Short position } /// Trade record for performance tracking #[derive(Debug, Clone)] struct Trade { entry_price: f64, exit_price: f64, side: PositionState, pnl: f64, } /// Simple backtesting simulator struct SimpleBacktester { initial_capital: f64, current_capital: f64, position: PositionState, entry_price: f64, commission_rate: f64, trades: Vec, action_counts: [usize; 3], // BUY, SELL, HOLD } impl SimpleBacktester { fn new(initial_capital: f64, commission_rate: f64) -> Self { Self { initial_capital, current_capital: initial_capital, position: PositionState::Flat, entry_price: 0.0, commission_rate, trades: Vec::new(), action_counts: [0, 0, 0], } } /// Process a single action fn process_action(&mut self, action: usize, price: f64) { // Track action distribution if action <= 2 { self.action_counts[action] += 1; } match action { 0 => self.handle_buy(price), // BUY 1 => self.handle_sell(price), // SELL 2 => {}, // HOLD - no action _ => warn!("Invalid action: {}", action), } } fn handle_buy(&mut self, price: f64) { match self.position { PositionState::Flat => { // Open long position self.position = PositionState::Long; self.entry_price = price; let commission = self.current_capital * (self.commission_rate / 100.0); self.current_capital -= commission; }, PositionState::Short => { // Close short position let pnl = (self.entry_price - price) / self.entry_price * self.initial_capital; let commission = self.current_capital * (self.commission_rate / 100.0); self.current_capital += pnl - commission; self.trades.push(Trade { entry_price: self.entry_price, exit_price: price, side: PositionState::Short, pnl: pnl - commission, }); // Open long position self.position = PositionState::Long; self.entry_price = price; }, PositionState::Long => { // Already long, hold }, } } fn handle_sell(&mut self, price: f64) { match self.position { PositionState::Flat => { // Open short position self.position = PositionState::Short; self.entry_price = price; let commission = self.current_capital * (self.commission_rate / 100.0); self.current_capital -= commission; }, PositionState::Long => { // Close long position let pnl = (price - self.entry_price) / self.entry_price * self.initial_capital; let commission = self.current_capital * (self.commission_rate / 100.0); self.current_capital += pnl - commission; self.trades.push(Trade { entry_price: self.entry_price, exit_price: price, side: PositionState::Long, pnl: pnl - commission, }); // Open short position self.position = PositionState::Short; self.entry_price = price; }, PositionState::Short => { // Already short, hold }, } } /// Close any open position at final price fn finalize(&mut self, final_price: f64) { if self.position != PositionState::Flat { let pnl = match self.position { PositionState::Long => { (final_price - self.entry_price) / self.entry_price * self.initial_capital }, PositionState::Short => { (self.entry_price - final_price) / self.entry_price * self.initial_capital }, PositionState::Flat => 0.0, }; let commission = self.current_capital * (self.commission_rate / 100.0); self.current_capital += pnl - commission; self.trades.push(Trade { entry_price: self.entry_price, exit_price: final_price, side: self.position, pnl: pnl - commission, }); } } /// Get performance metrics fn get_metrics(&self) -> PerformanceMetrics { let total_return = (self.current_capital - self.initial_capital) / self.initial_capital; let total_trades = self.trades.len(); let winning_trades = self.trades.iter().filter(|t| t.pnl > 0.0).count(); let win_rate = if total_trades > 0 { winning_trades as f64 / total_trades as f64 } else { 0.0 }; PerformanceMetrics { total_return, final_capital: self.current_capital, total_pnl: self.current_capital - self.initial_capital, total_trades, win_rate, action_counts: self.action_counts, } } } /// Performance metrics summary #[derive(Debug)] struct PerformanceMetrics { total_return: f64, final_capital: f64, total_pnl: f64, total_trades: usize, win_rate: f64, action_counts: [usize; 3], } fn main() -> Result<()> { let args = Args::parse(); // Initialize logging if args.verbose { tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .init(); } else { tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); } info!("=== DQN Action Replay Backtesting ==="); info!("Actions CSV: {}", args.actions_csv.display()); info!("Parquet file: {}", args.parquet_file.display()); info!("Initial capital: ${}", args.initial_capital); info!("Commission rate: {}%", args.commission_rate); // Step 1: Load DQN actions from CSV info!("Loading DQN actions from CSV..."); let actions = load_actions_from_csv(&args.actions_csv).map_err(|e| { anyhow::anyhow!( "Failed to load actions from {}: {}", args.actions_csv.display(), e ) })?; info!("Loaded {} DQN actions", actions.len()); // Step 2: Load OHLCV data from Parquet info!("Loading OHLCV data from Parquet..."); let (_features, timestamps, ohlcv_bars) = load_parquet_data_with_timestamps(&args.parquet_file, 0).with_context(|| { format!( "Failed to load Parquet data from {}", args.parquet_file.display() ) })?; info!("Loaded {} OHLCV bars", ohlcv_bars.len()); // Validate alignment if actions.is_empty() { anyhow::bail!("No actions loaded from CSV"); } if ohlcv_bars.is_empty() { anyhow::bail!("No OHLCV data loaded from Parquet"); } if actions.len() > ohlcv_bars.len() { anyhow::bail!( "Action count ({}) exceeds OHLCV bars ({}). Warmup mismatch?", actions.len(), ohlcv_bars.len() ); } info!( "Data time range: {} to {}", timestamps.first().unwrap(), timestamps.last().unwrap() ); // Step 3: Run backtest simulation info!("Running backtest simulation..."); let mut backtester = SimpleBacktester::new(args.initial_capital, args.commission_rate); for (i, action_record) in actions.iter().enumerate() { if i < ohlcv_bars.len() { let bar = &ohlcv_bars[i]; backtester.process_action(action_record.action as usize, bar.close); } } // Close any open position at final price if !ohlcv_bars.is_empty() { backtester.finalize(ohlcv_bars.last().unwrap().close); } // Step 4: Print results let metrics = backtester.get_metrics(); info!(""); info!("=== Backtesting Complete ==="); info!("Total actions processed: {}", actions.len()); info!(""); info!("Action Distribution:"); info!( " BUY: {} ({:.1}%)", metrics.action_counts[0], 100.0 * (metrics.action_counts[0] as f64) / (actions.len() as f64) ); info!( " SELL: {} ({:.1}%)", metrics.action_counts[1], 100.0 * (metrics.action_counts[1] as f64) / (actions.len() as f64) ); info!( " HOLD: {} ({:.1}%)", metrics.action_counts[2], 100.0 * (metrics.action_counts[2] as f64) / (actions.len() as f64) ); info!(""); info!("Performance Metrics:"); info!(" Total trades: {}", metrics.total_trades); info!(" Win rate: {:.2}%", metrics.win_rate * 100.0); info!(" Total return: {:.2}%", metrics.total_return * 100.0); info!(" Final capital: ${:.2}", metrics.final_capital); info!(" Total PnL: ${:.2}", metrics.total_pnl); // Warn if 0% BUY signals if metrics.action_counts[0] == 0 { info!(""); warn!("⚠️ WARNING: 0% BUY signals detected!"); warn!(" This may indicate a model bias or specific market conditions."); warn!(" Review /tmp/backtesting_pipeline_design.md for interpretation guidance."); } Ok(()) }