//! Evaluation Engine for DQN Backtesting //! //! Tracks positions, executes trades based on DQN actions, and records trade history. use super::metrics::OHLCVBarF32; use serde::{Deserialize, Serialize}; /// Trading action from DQN model #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Buy = 0, Hold = 1, Sell = 2, } impl From for Action { fn from(action: usize) -> Self { match action { 0 => Action::Buy, 1 => Action::Hold, 2 => Action::Sell, _ => Action::Hold, // Default to hold for invalid actions } } } /// Open position #[derive(Debug, Clone)] pub struct Position { pub entry_bar_idx: usize, pub entry_price: f32, pub direction: PositionDirection, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PositionDirection { Long, Short, } /// Completed trade #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Trade { pub entry_bar_idx: usize, pub exit_bar_idx: usize, pub entry_price: f32, pub exit_price: f32, pub direction: String, pub pnl: f32, } /// Evaluation engine that processes DQN actions and tracks positions #[derive(Debug)] pub struct EvaluationEngine { pub current_position: Option, pub trades: Vec, pub initial_capital: f32, pub action_counts: [usize; 3], // [buy, hold, sell] /// Kelly fraction for position sizing (1.0 = full size, 0.5 = half size) pub kelly_fraction: f64, } impl EvaluationEngine { /// Create new evaluation engine /// /// # Arguments /// * `initial_capital` - Starting capital for backtest /// * `kelly_fraction` - Position sizing multiplier (default: 1.0 = full size) pub fn new_with_kelly(initial_capital: f32, kelly_fraction: f64) -> Self { Self { current_position: None, trades: Vec::new(), initial_capital, action_counts: [0, 0, 0], kelly_fraction, } } /// Create new evaluation engine with default Kelly fraction (1.0 = full size) pub fn new(initial_capital: f32) -> Self { Self::new_with_kelly(initial_capital, 1.0) } /// Process a single bar with DQN action /// /// # Arguments /// * `bar_idx` - Index of current bar in the dataset /// * `bar` - Current OHLCV bar /// * `action` - Action selected by DQN model pub fn process_bar(&mut self, bar_idx: usize, bar: &OHLCVBarF32, action: Action) { // Update action counts self.action_counts[action as usize] += 1; match action { Action::Buy => { // If no position or short position, open long if let Some(pos) = &self.current_position { if pos.direction == PositionDirection::Short { // Close short position self.close_position(bar_idx, bar); } } // Open new long position if self.current_position.is_none() { self.current_position = Some(Position { entry_bar_idx: bar_idx, entry_price: bar.close, direction: PositionDirection::Long, }); } }, Action::Sell => { // If no position or long position, open short if let Some(pos) = &self.current_position { if pos.direction == PositionDirection::Long { // Close long position self.close_position(bar_idx, bar); } } // Open new short position if self.current_position.is_none() { self.current_position = Some(Position { entry_bar_idx: bar_idx, entry_price: bar.close, direction: PositionDirection::Short, }); } }, Action::Hold => { // Do nothing, maintain current position }, } } /// Close current position and record trade pub fn close_position(&mut self, exit_bar_idx: usize, exit_bar: &OHLCVBarF32) { if let Some(pos) = self.current_position.take() { // Calculate base PnL (for 1 contract) let base_pnl = match pos.direction { PositionDirection::Long => { // Long: profit when price goes up exit_bar.close - pos.entry_price }, PositionDirection::Short => { // Short: profit when price goes down pos.entry_price - exit_bar.close }, }; // Apply Kelly scaling to gross PnL (Kelly fraction scales position size) // Example: Kelly=0.5 means half position, so gross PnL is also halved let kelly_scaled_gross_pnl = base_pnl * self.kelly_fraction as f32; // Calculate transaction costs (Bug #3 fix: Net P&L calculation) // Assumes market orders (0.15% fee) on both entry and exit const MARKET_ORDER_FEE: f32 = 0.0015; // 0.15% // Transaction costs scale with Kelly fraction (smaller position = smaller costs) let entry_cost = pos.entry_price * self.kelly_fraction as f32 * MARKET_ORDER_FEE; let exit_cost = exit_bar.close * self.kelly_fraction as f32 * MARKET_ORDER_FEE; let total_transaction_cost = entry_cost + exit_cost; // Net P&L = Gross P&L - Transaction Costs let net_pnl = kelly_scaled_gross_pnl - total_transaction_cost; let trade = Trade { entry_bar_idx: pos.entry_bar_idx, exit_bar_idx, entry_price: pos.entry_price, exit_price: exit_bar.close, direction: match pos.direction { PositionDirection::Long => "long".to_string(), PositionDirection::Short => "short".to_string(), }, pnl: net_pnl, // Store NET P&L (after costs) }; self.trades.push(trade); } } /// Get action distribution summary pub fn get_action_distribution(&self) -> ActionDistribution { let total = self.action_counts.iter().sum::(); let total_f64 = total as f64; ActionDistribution { buy_count: self.action_counts[0], hold_count: self.action_counts[1], sell_count: self.action_counts[2], buy_pct: if total > 0 { (self.action_counts[0] as f64 / total_f64) * 100.0 } else { 0.0 }, hold_pct: if total > 0 { (self.action_counts[1] as f64 / total_f64) * 100.0 } else { 0.0 }, sell_pct: if total > 0 { (self.action_counts[2] as f64 / total_f64) * 100.0 } else { 0.0 }, } } } /// Action distribution statistics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionDistribution { pub buy_count: usize, pub hold_count: usize, pub sell_count: usize, pub buy_pct: f64, pub hold_pct: f64, pub sell_pct: f64, }