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:
@@ -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;
|
||||
|
||||
215
ml/src/paper_trading/broker.rs
Normal file
215
ml/src/paper_trading/broker.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
4
ml/src/paper_trading/mod.rs
Normal file
4
ml/src/paper_trading/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! Paper trading simulation -- simulated fills and P&L tracking.
|
||||
|
||||
pub mod broker;
|
||||
pub mod pnl_tracker;
|
||||
170
ml/src/paper_trading/pnl_tracker.rs
Normal file
170
ml/src/paper_trading/pnl_tracker.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user