Backtesting¶
Backtest strategies against historical market data with deterministic replay. Same results from the same data, every time.
Quick Start¶
# Backtest with date range
cargo run --manifest-path arbiter-engine/Cargo.toml -- \
--backtest \
--from 2026-01-01 \
--to 2026-01-21
# Backtest specific market
cargo run --manifest-path arbiter-engine/Cargo.toml -- \
--backtest \
--market "BTC-50K-2026" \
--from 2026-01-15 \
--to 2026-01-21
Recording Market Data¶
Before backtesting, record live market data:
# Record market data to SQLite
cargo run --manifest-path arbiter-engine/Cargo.toml -- \
--record-data \
--output data/market_history.db
Programmatic Recording¶
use arbiter_engine::history::{TradeStorage, MarketDataRecord};
let storage = TradeStorage::new("data/market_history.db")?;
// Record market snapshot
storage.record_market_data(&MarketDataRecord {
id: None,
timestamp: Utc::now(),
market_id: "BTC-50K-2026".to_string(),
best_bid: dec!(0.54),
best_ask: dec!(0.56),
bid_size: dec!(100),
ask_size: dec!(150),
mid_price: dec!(0.55),
})?;
Running Backtests¶
Using DataReplayer¶
use arbiter_engine::history::{TradeStorage, DataReplayer, ReplayConfig};
use arbiter_engine::clock::SimulatedClock;
use std::sync::Arc;
// Load historical data
let storage = Arc::new(TradeStorage::new("data/market_history.db")?);
// Configure replay
let config = ReplayConfig::new(
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")?.with_timezone(&Utc),
DateTime::parse_from_rfc3339("2026-01-21T00:00:00Z")?.with_timezone(&Utc),
)
.with_market("BTC-50K-2026") // Optional: filter to specific market
.with_speed(10.0); // 10x replay speed
// Create replayer with simulated clock
let clock = Arc::new(SimulatedClock::new(config.start_time));
let mut replayer = DataReplayer::new(storage, clock.clone(), config)?;
// Replay events
while replayer.state() != &ReplayState::Finished {
match replayer.next_event()? {
ReplayEvent::MarketData(data) => {
// Process market data, run strategy
println!("[{}] {} bid={} ask={}",
clock.now(),
data.market_id,
data.best_bid,
data.best_ask
);
}
ReplayEvent::EndOfData => break,
}
}
Replay Controls¶
// Pause replay
replayer.pause();
// Seek to specific time
replayer.seek(DateTime::parse_from_rfc3339("2026-01-15T12:00:00Z")?.with_timezone(&Utc));
// Resume
replayer.start();
// Reset to beginning
replayer.reset();
// Check progress
println!("Progress: {:.1}%", replayer.progress() * 100.0);
Performance Analysis¶
After backtesting, analyze results with PerformanceMetrics:
use arbiter_engine::analytics::PerformanceMetrics;
use rust_decimal_macros::dec;
let mut metrics = PerformanceMetrics::new()
.with_risk_free_rate(dec!(0.05)) // 5% annual risk-free rate
.with_periods_per_year(dec!(252)); // Daily trading
// Add trade results (PnL, return percentage)
metrics.add_trade(dec!(10), dec!(0.05)); // $10 profit, 5% return
metrics.add_trade(dec!(-5), dec!(-0.025)); // $5 loss, 2.5% return
metrics.add_trade(dec!(15), dec!(0.075)); // $15 profit, 7.5% return
// Calculate metrics
let sharpe = metrics.sharpe_ratio()?;
let sortino = metrics.sortino_ratio()?;
let max_dd = metrics.max_drawdown()?;
let calmar = metrics.calmar_ratio()?;
println!("Sharpe Ratio: {:.2}", sharpe);
println!("Sortino Ratio: {:.2}", sortino);
println!("Max Drawdown: {:.1}%", max_dd * dec!(100));
println!("Calmar Ratio: {:.2}", calmar);
Trade Statistics¶
let stats = metrics.trade_statistics()?;
println!("=== Trade Statistics ===");
println!("Total trades: {}", stats.total_trades);
println!("Win rate: {:.1}%", stats.win_rate * dec!(100));
println!("Profit factor: {:.2}", stats.profit_factor);
println!("Avg profit: ${}", stats.avg_profit);
println!("Avg loss: ${}", stats.avg_loss);
println!("Best trade: {:.1}%", stats.best_trade * dec!(100));
println!("Worst trade: {:.1}%", stats.worst_trade * dec!(100));
println!("Total PnL: ${}", stats.total_pnl);
Metrics Reference¶
| Metric | Description | Good Value |
|---|---|---|
| Sharpe Ratio | Risk-adjusted return (annualized) | > 1.0 |
| Sortino Ratio | Downside risk-adjusted return | > 1.5 |
| Max Drawdown | Largest peak-to-trough decline | < 20% |
| Calmar Ratio | Annualized return / max drawdown | > 1.0 |
| Win Rate | Percentage of profitable trades | > 50% |
| Profit Factor | Gross profit / gross loss | > 1.5 |
Example: Full Backtest¶
use arbiter_engine::{
clock::SimulatedClock,
history::{TradeStorage, DataReplayer, ReplayConfig, ReplayEvent, ReplayState},
position::{PositionTracker, FillRecord, MarketId},
simulation::{SimulatedExchangeClient, SimulationConfig, FidelityLevel},
analytics::PerformanceMetrics,
};
use rust_decimal_macros::dec;
use std::sync::Arc;
async fn run_backtest() -> Result<(), Box<dyn std::error::Error>> {
// Setup
let storage = Arc::new(TradeStorage::new("data/market_history.db")?);
let start = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")?.with_timezone(&Utc);
let end = DateTime::parse_from_rfc3339("2026-01-21T00:00:00Z")?.with_timezone(&Utc);
let clock = Arc::new(SimulatedClock::new(start));
let config = ReplayConfig::new(start, end);
let mut replayer = DataReplayer::new(Arc::clone(&storage), Arc::clone(&clock), config)?;
// Trading components
let sim_config = SimulationConfig::kalshi().with_fidelity(FidelityLevel::Realistic);
let client = SimulatedExchangeClient::new(Arc::clone(&clock), sim_config);
let tracker = PositionTracker::new(Arc::clone(&clock));
let mut metrics = PerformanceMetrics::new();
// Run backtest
while replayer.state() != &ReplayState::Finished {
match replayer.next_event()? {
ReplayEvent::MarketData(data) => {
// Update simulated exchange with current orderbook
client.update_orderbook(/* convert data to OrderBook */);
// Your strategy logic here
// if signal.is_buy() { ... }
// Track positions
tracker.update_unrealized_pnl(&MarketId::new(&data.market_id), data.mid_price);
}
ReplayEvent::EndOfData => break,
}
}
// Analyze results
let stats = metrics.trade_statistics()?;
println!("\n=== Backtest Results ===");
println!("Period: {} to {}", start, end);
println!("Total trades: {}", stats.total_trades);
println!("Win rate: {:.1}%", stats.win_rate * dec!(100));
println!("Sharpe: {:.2}", metrics.sharpe_ratio()?);
println!("Max DD: {:.1}%", metrics.max_drawdown()? * dec!(100));
println!("Total PnL: ${}", tracker.total_pnl());
Ok(())
}
Deterministic Replay¶
Backtest results are deterministic - same data produces identical results:
- Clock:
SimulatedClockadvances only whennext_event()is called - Data ordering: Events sorted by timestamp, ties broken by insertion order
- No randomness: Fill simulation uses exact order book state
This enables:
- Debugging specific trades by seeking to exact timestamps
- A/B testing strategy changes
- Regression testing after code changes
Data Storage¶
Market data is stored in SQLite with indexed queries:
-- Trades table
CREATE TABLE trades (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
market_id TEXT NOT NULL,
side TEXT NOT NULL,
price TEXT NOT NULL,
quantity TEXT NOT NULL,
fee TEXT NOT NULL,
venue TEXT NOT NULL,
order_id TEXT NOT NULL
);
-- Market data table
CREATE TABLE market_data (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
market_id TEXT NOT NULL,
best_bid TEXT NOT NULL,
best_ask TEXT NOT NULL,
bid_size TEXT NOT NULL,
ask_size TEXT NOT NULL,
mid_price TEXT NOT NULL
);
-- Indexes for efficient queries
CREATE INDEX idx_trades_timestamp ON trades(timestamp);
CREATE INDEX idx_market_data_timestamp ON market_data(timestamp);
Next Steps¶
- Paper Trading Guide - Live simulation
- ADR-014: Paper Trading Architecture - Technical details
- Performance Monitoring - Latency metrics