//! 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, /// Current position exposure for factored evaluation (-1.0 to +1.0) pub current_exposure: f64, /// Entry price for current exposure-based position pub exposure_entry_price: f32, /// Bar index where current exposure was first entered pub exposure_entry_bar: usize, /// Override fee rate for all trades (None = use action's `transaction_cost()`) fee_rate_override: Option, /// Running equity (`initial_capital` + sum of all trade `PnLs`) pub running_equity: f32, /// True when running equity hit zero — no further trades are executed pub margin_called: bool, } impl EvaluationEngine { /// Create new evaluation engine /// /// # Arguments /// * `initial_capital` - Starting capital for backtest /// * `kelly_fraction` - Position sizing multiplier (default: 1.0 = full size) pub const 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, current_exposure: 0.0, exposure_entry_price: 0.0, exposure_entry_bar: 0, fee_rate_override: None, running_equity: initial_capital, margin_called: false, } } /// Create evaluation engine with explicit fee rate override (in decimal, e.g. 0.00001 for 0.1 bps) pub const fn new_with_fee_rate(initial_capital: f32, kelly_fraction: f64, fee_rate: f64) -> Self { Self { current_position: None, trades: Vec::new(), initial_capital, action_counts: [0, 0, 0], kelly_fraction, current_exposure: 0.0, exposure_entry_price: 0.0, exposure_entry_bar: 0, fee_rate_override: Some(fee_rate), running_equity: initial_capital, margin_called: false, } } /// Create new evaluation engine with default Kelly fraction (1.0 = full size) pub const 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) { if self.margin_called { return; } // 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_owned(), PositionDirection::Short => "short".to_owned(), }, pnl: net_pnl, // Store NET P&L (after costs) }; self.trades.push(trade); self.running_equity += net_pnl; if self.running_equity <= 0.0 { self.margin_called = true; } } } /// Process a bar using the full 45-action factored space. /// /// Tracks continuous exposure (-1.0 to +1.0) and generates trades on /// any exposure change (including partial: Long100->Long50 = sell 0.5). /// Transaction costs use the `FactoredAction`'s order type. pub fn process_bar_factored( &mut self, bar_idx: usize, bar: &OHLCVBarF32, action: &crate::action_space::FactoredAction, ) { // Margin call: equity depleted — force-flat and stop trading if self.margin_called { return; } let target = action.target_exposure(); // -1.0, -0.5, 0.0, +0.5, +1.0 let delta = target - self.current_exposure; // Update legacy action counts for compatibility with metrics if target > 0.0 { self.action_counts[0] += 1; // buy } else if target < 0.0 { self.action_counts[2] += 1; // sell } else { self.action_counts[1] += 1; // hold } const EPSILON: f64 = 1e-6; if delta.abs() < EPSILON { return; // No position change } // Record trade for the exposure change let fee_rate = self.fee_rate_override.unwrap_or_else(|| action.transaction_cost()); // PnL from the portion being closed (if reducing or reversing) let closing_size = if self.current_exposure.abs() > EPSILON && delta.signum() != self.current_exposure.signum() { // Closing part (or all) of existing position self.current_exposure.abs().min(delta.abs()) } else { 0.0 }; if closing_size > EPSILON { let price_diff = bar.close - self.exposure_entry_price; let direction_sign = if self.current_exposure > 0.0 { 1.0_f32 } else { -1.0_f32 }; let gross_pnl = price_diff * direction_sign * (closing_size * self.kelly_fraction) as f32; let tx_cost = (self.exposure_entry_price.abs() + bar.close.abs()) as f64 * 0.5 * closing_size * self.kelly_fraction * fee_rate; let net_pnl = gross_pnl - tx_cost as f32; self.trades.push(Trade { entry_bar_idx: self.exposure_entry_bar, exit_bar_idx: bar_idx, entry_price: self.exposure_entry_price, exit_price: bar.close, direction: if self.current_exposure > 0.0 { "long".to_owned() } else { "short".to_owned() }, pnl: net_pnl, }); self.running_equity += net_pnl; // Margin call: equity depleted — record but stop further trading if self.running_equity <= 0.0 { self.margin_called = true; self.current_exposure = 0.0; return; } } // Update exposure state if target.abs() > EPSILON { // Opening or adjusting -- reset entry if crossing zero or first entry if self.current_exposure.abs() < EPSILON || target.signum() != self.current_exposure.signum() { self.exposure_entry_price = bar.close; self.exposure_entry_bar = bar_idx; } } self.current_exposure = target; } /// Close any remaining factored exposure at end of backtest pub fn close_factored_position(&mut self, bar_idx: usize, bar: &OHLCVBarF32) { const EPSILON: f64 = 1e-6; if self.current_exposure.abs() < EPSILON || self.margin_called { return; } let price_diff = bar.close - self.exposure_entry_price; let direction_sign = if self.current_exposure > 0.0 { 1.0_f32 } else { -1.0_f32 }; let size = self.current_exposure.abs() * self.kelly_fraction; let gross_pnl = price_diff * direction_sign * size as f32; let close_fee = self.fee_rate_override.unwrap_or(0.0015); let tx_cost = bar.close.abs() as f64 * size * close_fee; let net_pnl = gross_pnl - tx_cost as f32; self.trades.push(Trade { entry_bar_idx: self.exposure_entry_bar, exit_bar_idx: bar_idx, entry_price: self.exposure_entry_price, exit_price: bar.close, direction: if self.current_exposure > 0.0 { "long".to_owned() } else { "short".to_owned() }, pnl: net_pnl, }); self.running_equity += net_pnl; self.current_exposure = 0.0; if self.running_equity <= 0.0 { self.margin_called = true; } } /// 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, } #[cfg(test)] #[allow(clippy::unnecessary_map_or)] mod tests { use super::*; use crate::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency}; fn bar(close: f32) -> OHLCVBarF32 { OHLCVBarF32 { timestamp: 0, open: close, high: close, low: close, close, volume: 0.0, } } fn market_action(exposure: ExposureLevel) -> FactoredAction { FactoredAction::new(exposure, OrderType::Market, Urgency::Normal) } #[test] fn factored_same_exposure_no_trade() { let mut engine = EvaluationEngine::new(10000.0); let b = bar(100.0); engine.process_bar_factored(0, &b, &market_action(ExposureLevel::Long100)); engine.process_bar_factored(1, &b, &market_action(ExposureLevel::Long100)); assert_eq!( engine.trades.len(), 0, "Same exposure should generate no trades" ); } #[test] fn factored_partial_close_generates_trade() { let mut engine = EvaluationEngine::new(10000.0); engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100)); engine.process_bar_factored(1, &bar(110.0), &market_action(ExposureLevel::Long50)); assert_eq!( engine.trades.len(), 1, "Long100->Long50 should generate 1 trade" ); assert!( engine.trades.first().map_or(false, |t| t.pnl > 0.0), "Price went up on long = profit" ); assert!((engine.current_exposure - 0.5).abs() < 1e-6); } #[test] fn factored_reversal_generates_trade() { let mut engine = EvaluationEngine::new(10000.0); engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100)); engine.process_bar_factored(1, &bar(105.0), &market_action(ExposureLevel::Short100)); assert!( !engine.trades.is_empty(), "Reversal should generate at least 1 trade" ); assert!((engine.current_exposure - (-1.0)).abs() < 1e-6); } #[test] fn factored_flat_from_long_closes() { let mut engine = EvaluationEngine::new(10000.0); engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100)); engine.process_bar_factored(1, &bar(95.0), &market_action(ExposureLevel::Flat)); assert_eq!(engine.trades.len(), 1); assert!( engine.trades.first().map_or(false, |t| t.pnl < 0.0), "Price went down on long = loss" ); assert!(engine.current_exposure.abs() < 1e-6); } #[test] fn factored_close_at_end() { let mut engine = EvaluationEngine::new(10000.0); engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Short50)); engine.close_factored_position(1, &bar(90.0)); assert_eq!(engine.trades.len(), 1); assert!( engine.trades.first().map_or(false, |t| t.pnl > 0.0), "Price down on short = profit" ); assert!(engine.current_exposure.abs() < 1e-6); } #[test] fn factored_all_buy_still_one_trade_at_close() { let mut engine = EvaluationEngine::new(10000.0); for i in 0..100 { engine.process_bar_factored( i, &bar(100.0 + i as f32), &market_action(ExposureLevel::Long100), ); } assert_eq!(engine.trades.len(), 0); engine.close_factored_position(100, &bar(200.0)); assert_eq!(engine.trades.len(), 1); } #[test] fn factored_alternating_generates_many_trades() { let mut engine = EvaluationEngine::new(10000.0); for i in 0..10 { let action = if i % 2 == 0 { market_action(ExposureLevel::Long100) } else { market_action(ExposureLevel::Short100) }; engine.process_bar_factored(i, &bar(100.0), &action); } assert!( engine.trades.len() >= 9, "Alternating should generate many trades: got {}", engine.trades.len() ); } }