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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-21 14:26:00 +01:00
parent dc3952ce22
commit ed320c936b
4 changed files with 390 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<Fill>,
}
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<Fill> {
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);
}
}

View File

@@ -0,0 +1,4 @@
//! Paper trading simulation -- simulated fills and P&L tracking.
pub mod broker;
pub mod pnl_tracker;

View File

@@ -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<f64>,
returns: VecDeque<f64>,
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::<f64>() / n;
let variance: f64 =
self.returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (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);
}
}