Skip to content

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: SimulatedClock advances only when next_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