Files
foxhunt/ml/src/evaluation/engine.rs
jgrusewski 1934367bfa refactor(ml): consolidate 13 duplicate OHLCVBar definitions into single canonical type
Created ml/src/types/ohlcv.rs as the single source of truth for OHLCVBar
(DateTime<Utc> timestamp, f64 OHLCV fields). Replaced all 13 duplicate
definitions across features/, regime/, real_data_loader, and evaluation/
with imports from crate::types::OHLCVBar.

Key changes:
- New: ml/src/types/mod.rs + ohlcv.rs with canonical OHLCVBar
  (derives: Debug, Clone, Copy, PartialEq, Serialize, Deserialize + Default)
- Renamed: evaluation::metrics::OHLCVBar → OHLCVBarF32 (genuinely
  different type: f32 fields, i64 timestamp for compact backtesting)
- Eliminated all import aliases (ExtractionOHLCVBar, RegimeOHLCVBar,
  PriceOHLCVBar, VolumeOHLCVBar) in dbn_sequence_loader.rs and pipeline.rs
- Renamed regime::orchestrator::Bar → OHLCVBar (same fields, just aliased)
- Updated 39 files total (13 definitions removed, imports normalized)

1883 lib tests passing, compilation clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:14:42 +01:00

224 lines
7.3 KiB
Rust

//! 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<usize> 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<Position>,
pub trades: Vec<Trade>,
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::<usize>();
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,
}