From ed320c936bfac1eec8914bb0a84cb8611256b9ac Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 21 Feb 2026 14:26:00 +0100 Subject: [PATCH] feat(paper_trading): add PaperBroker and PnLTracker for simulated trading PaperBroker: simulated fills with configurable slippage and commission. PnLTracker: rolling Sharpe ratio (252-day window), max drawdown, cumulative return. Co-Authored-By: Claude Opus 4.6 --- ml/src/lib.rs | 1 + ml/src/paper_trading/broker.rs | 215 ++++++++++++++++++++++++++++ ml/src/paper_trading/mod.rs | 4 + ml/src/paper_trading/pnl_tracker.rs | 170 ++++++++++++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 ml/src/paper_trading/broker.rs create mode 100644 ml/src/paper_trading/mod.rs create mode 100644 ml/src/paper_trading/pnl_tracker.rs diff --git a/ml/src/lib.rs b/ml/src/lib.rs index 3b40c4112..6a3ab3d73 100644 --- a/ml/src/lib.rs +++ b/ml/src/lib.rs @@ -955,6 +955,7 @@ pub mod liquid; pub mod mamba; pub mod memory_optimization; // Memory optimization utilities (lazy loading, quantization, precision) pub mod microstructure; +pub mod paper_trading; pub mod ppo; pub mod preprocessing; // Data preprocessing (log returns, normalization, outlier clipping) pub mod risk; diff --git a/ml/src/paper_trading/broker.rs b/ml/src/paper_trading/broker.rs new file mode 100644 index 000000000..141cba002 --- /dev/null +++ b/ml/src/paper_trading/broker.rs @@ -0,0 +1,215 @@ +//! Simulated broker for paper trading. + +use crate::ensemble::signal::{TradeAction, TradeSignal}; + +/// A simulated fill from the paper broker. +#[derive(Debug, Clone)] +pub struct Fill { + pub symbol: String, + pub side: TradeAction, + pub quantity: i64, + pub fill_price: f64, + pub commission: f64, + pub timestamp_us: i64, +} + +/// Paper broker that simulates order fills against market prices. +#[derive(Debug)] +pub struct PaperBroker { + cash: f64, + initial_cash: f64, + position: i64, // signed: positive = long, negative = short + avg_entry_price: f64, + realized_pnl: f64, + slippage_bps: f64, + commission_per_contract: f64, + fills: Vec, +} + +impl PaperBroker { + pub fn new(initial_cash: f64, slippage_bps: f64) -> Self { + Self { + cash: initial_cash, + initial_cash, + position: 0, + avg_entry_price: 0.0, + realized_pnl: 0.0, + slippage_bps, + commission_per_contract: 2.50, + fills: Vec::new(), + } + } + + /// Execute a trade signal at the given market price. + /// Returns a Fill if the signal is actionable (Buy or Sell). + pub fn execute_signal(&mut self, signal: &TradeSignal, market_price: f64) -> Option { + match signal.action { + TradeAction::Hold => None, + TradeAction::Buy => { + let slippage = market_price * self.slippage_bps; + let fill_price = market_price + slippage; + let quantity = 1_i64; + + // Close short or open long + if self.position < 0 { + // Closing short: realized P&L + let pnl = (self.avg_entry_price - fill_price) * (-self.position) as f64; + self.realized_pnl += pnl; + self.position = 0; + self.avg_entry_price = 0.0; + } else { + // Opening/adding long + let total_cost = + self.avg_entry_price * self.position as f64 + fill_price; + self.position += quantity; + if self.position > 0 { + self.avg_entry_price = total_cost / self.position as f64; + } + } + + self.cash -= self.commission_per_contract; + + let fill = Fill { + symbol: signal.symbol.clone(), + side: TradeAction::Buy, + quantity, + fill_price, + commission: self.commission_per_contract, + timestamp_us: signal.timestamp_us, + }; + self.fills.push(fill.clone()); + Some(fill) + } + TradeAction::Sell => { + let slippage = market_price * self.slippage_bps; + let fill_price = market_price - slippage; + let quantity = 1_i64; + + // Close long or open short + if self.position > 0 { + let pnl = (fill_price - self.avg_entry_price) * self.position as f64; + self.realized_pnl += pnl; + self.position = 0; + self.avg_entry_price = 0.0; + } else { + let total_cost = + self.avg_entry_price * (-self.position) as f64 + fill_price; + self.position -= quantity; + if self.position < 0 { + self.avg_entry_price = total_cost / (-self.position) as f64; + } + } + + self.cash -= self.commission_per_contract; + + let fill = Fill { + symbol: signal.symbol.clone(), + side: TradeAction::Sell, + quantity, + fill_price, + commission: self.commission_per_contract, + timestamp_us: signal.timestamp_us, + }; + self.fills.push(fill.clone()); + Some(fill) + } + } + } + + pub fn position_size(&self) -> i64 { + self.position + } + + pub fn unrealized_pnl(&self, current_price: f64) -> f64 { + if self.position > 0 { + (current_price - self.avg_entry_price) * self.position as f64 + } else if self.position < 0 { + (self.avg_entry_price - current_price) * (-self.position) as f64 + } else { + 0.0 + } + } + + pub fn realized_pnl(&self) -> f64 { + self.realized_pnl + } + + pub fn total_pnl(&self, current_price: f64) -> f64 { + self.realized_pnl + self.unrealized_pnl(current_price) + - (self.fills.len() as f64 * self.commission_per_contract) + + self.cash + - self.initial_cash + + (self.fills.len() as f64 * self.commission_per_contract) + } + + pub fn equity(&self, current_price: f64) -> f64 { + self.cash + self.unrealized_pnl(current_price) + } + + pub fn num_trades(&self) -> usize { + self.fills.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_signal(action: TradeAction, symbol: &str) -> TradeSignal { + TradeSignal { + action, + symbol: symbol.to_string(), + confidence: 0.9, + direction: match action { + TradeAction::Buy => 0.5, + TradeAction::Sell => -0.5, + TradeAction::Hold => 0.0, + }, + timestamp_us: 0, + } + } + + #[test] + fn test_buy_and_position() { + let mut broker = PaperBroker::new(100_000.0, 0.0001); + let signal = make_signal(TradeAction::Buy, "6E.FUT"); + let fill = broker.execute_signal(&signal, 1.0850); + assert!(fill.is_some()); + assert_eq!(broker.position_size(), 1); + let f = fill.as_ref().map(|f| f.fill_price > 1.0850); + assert_eq!(f, Some(true)); // slippage applied + } + + #[test] + fn test_hold_no_fill() { + let mut broker = PaperBroker::new(100_000.0, 0.0001); + let signal = make_signal(TradeAction::Hold, "6E.FUT"); + let fill = broker.execute_signal(&signal, 1.0850); + assert!(fill.is_none()); + assert_eq!(broker.position_size(), 0); + } + + #[test] + fn test_buy_then_sell_roundtrip() { + let mut broker = PaperBroker::new(100_000.0, 0.0001); + let buy = make_signal(TradeAction::Buy, "6E.FUT"); + broker.execute_signal(&buy, 1.0850); + assert_eq!(broker.position_size(), 1); + + let sell = make_signal(TradeAction::Sell, "6E.FUT"); + broker.execute_signal(&sell, 1.0860); + assert_eq!(broker.position_size(), 0); + // Should have some realized P&L (positive since we bought low, sold high) + assert!(broker.realized_pnl() > 0.0); + } + + #[test] + fn test_unrealized_pnl() { + let mut broker = PaperBroker::new(100_000.0, 0.0001); + let buy = make_signal(TradeAction::Buy, "6E.FUT"); + broker.execute_signal(&buy, 1.0850); + // Price went up + let upnl = broker.unrealized_pnl(1.0900); + assert!(upnl > 0.0); + } +} diff --git a/ml/src/paper_trading/mod.rs b/ml/src/paper_trading/mod.rs new file mode 100644 index 000000000..3b0f519f2 --- /dev/null +++ b/ml/src/paper_trading/mod.rs @@ -0,0 +1,4 @@ +//! Paper trading simulation -- simulated fills and P&L tracking. + +pub mod broker; +pub mod pnl_tracker; diff --git a/ml/src/paper_trading/pnl_tracker.rs b/ml/src/paper_trading/pnl_tracker.rs new file mode 100644 index 000000000..e1a7b3fa4 --- /dev/null +++ b/ml/src/paper_trading/pnl_tracker.rs @@ -0,0 +1,170 @@ +//! P&L tracking with rolling Sharpe ratio and max drawdown. + +use std::collections::VecDeque; + +/// Tracks equity curve, computes rolling Sharpe and max drawdown. +#[derive(Debug)] +pub struct PnLTracker { + equity_history: Vec, + returns: VecDeque, + window_size: usize, + peak_equity: f64, + max_drawdown: f64, +} + +impl PnLTracker { + pub fn new(window_size: usize) -> Self { + Self { + equity_history: Vec::new(), + returns: VecDeque::with_capacity(window_size.saturating_add(1)), + window_size, + peak_equity: 0.0, + max_drawdown: 0.0, + } + } + + /// Record a new equity value. + pub fn record_equity(&mut self, equity: f64) { + // Compute return from previous equity + if let Some(&prev) = self.equity_history.last() { + if prev.abs() > 1e-12 { + let ret = (equity - prev) / prev; + self.returns.push_back(ret); + while self.returns.len() > self.window_size { + self.returns.pop_front(); + } + } + } + + self.equity_history.push(equity); + + // Track peak and drawdown + if equity > self.peak_equity { + self.peak_equity = equity; + } + if self.peak_equity > 0.0 { + let dd = (self.peak_equity - equity) / self.peak_equity; + if dd > self.max_drawdown { + self.max_drawdown = dd; + } + } + } + + /// Record a raw return value directly. + pub fn record_return(&mut self, ret: f64) { + self.returns.push_back(ret); + while self.returns.len() > self.window_size { + self.returns.pop_front(); + } + // Update equity from returns + let prev_equity = self.equity_history.last().copied().unwrap_or(100_000.0); + let new_equity = prev_equity * (1.0 + ret); + self.record_equity_internal(new_equity); + } + + fn record_equity_internal(&mut self, equity: f64) { + self.equity_history.push(equity); + if equity > self.peak_equity { + self.peak_equity = equity; + } + if self.peak_equity > 0.0 { + let dd = (self.peak_equity - equity) / self.peak_equity; + if dd > self.max_drawdown { + self.max_drawdown = dd; + } + } + } + + /// Rolling annualized Sharpe ratio (assumes 252 trading days). + pub fn rolling_sharpe(&self) -> f64 { + if self.returns.len() < 2 { + return 0.0; + } + let n = self.returns.len() as f64; + let mean: f64 = self.returns.iter().sum::() / n; + let variance: f64 = + self.returns.iter().map(|r| (r - mean).powi(2)).sum::() / (n - 1.0); + let std_dev = variance.sqrt(); + if std_dev < 1e-12 { + return 0.0; + } + (mean / std_dev) * 252.0_f64.sqrt() + } + + /// Maximum drawdown as a fraction [0.0, 1.0]. + pub fn max_drawdown(&self) -> f64 { + self.max_drawdown.clamp(0.0, 1.0) + } + + /// Cumulative return since first equity observation. + pub fn cumulative_return(&self) -> f64 { + let first = self.equity_history.first().copied().unwrap_or(0.0); + let last = self.equity_history.last().copied().unwrap_or(0.0); + if first.abs() < 1e-12 { + return 0.0; + } + (last - first) / first + } + + pub fn num_observations(&self) -> usize { + self.equity_history.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sharpe_positive_returns() { + let mut tracker = PnLTracker::new(252); + for i in 0..10 { + tracker.record_return(0.001 + 0.0001 * (i as f64)); + } + let sharpe = tracker.rolling_sharpe(); + assert!(sharpe.is_finite()); + assert!( + sharpe > 0.0, + "Positive returns should yield positive Sharpe, got {}", + sharpe + ); + } + + #[test] + fn test_max_drawdown() { + let mut tracker = PnLTracker::new(252); + tracker.record_equity(100.0); + tracker.record_equity(110.0); // peak + tracker.record_equity(99.0); // drawdown: (110-99)/110 = 0.1 + + let dd = tracker.max_drawdown(); + assert!( + dd >= 0.09 && dd <= 0.11, + "Expected ~10% drawdown, got {}", + dd + ); + } + + #[test] + fn test_cumulative_return() { + let mut tracker = PnLTracker::new(252); + tracker.record_equity(100.0); + tracker.record_equity(105.0); + tracker.record_equity(110.0); + + let ret = tracker.cumulative_return(); + assert!( + (ret - 0.10).abs() < 0.01, + "Expected ~10% return, got {}", + ret + ); + } + + #[test] + fn test_empty_tracker() { + let tracker = PnLTracker::new(252); + assert_eq!(tracker.rolling_sharpe(), 0.0); + assert_eq!(tracker.max_drawdown(), 0.0); + assert_eq!(tracker.cumulative_return(), 0.0); + } +}