rustrade-backtest 0.4.0

Deterministic backtest engine for rustrade Brains — same trait, same brain, replayed offline
Documentation
//! 0.2b: the backtest engine honours `Decision.order_kind` / `limit_price`.
//!
//! Market entries fill at the candle close; resting limits fill at their
//! limit price only when the candle's range crosses them; marketable
//! post-only orders are rejected. These mirror the live execution layer so
//! a limit brain backtests the way it trades.

use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use async_trait::async_trait;
use rustrade_backtest::{Backtest, BacktestConfig, FeeModel, SlippageModel};
use rustrade_core::{
    Brain, BrainHealth, Candle, Decision, MarketDataEvent, OrderKind, Position, Price,
    Result as CoreResult,
};
use rustrade_risk::SizingConfig;

/// Emits a fixed entry decision on the first event, a `Close` at
/// `close_at`, and holds otherwise.
struct ScriptBrain {
    entry: Decision,
    close_at: Option<usize>,
    seen: AtomicUsize,
}

impl ScriptBrain {
    fn new(entry: Decision, close_at: Option<usize>) -> Self {
        Self {
            entry,
            close_at,
            seen: AtomicUsize::new(0),
        }
    }
}

#[async_trait]
impl Brain for ScriptBrain {
    fn name(&self) -> &str {
        "script"
    }
    async fn on_event(&self, _e: &MarketDataEvent, _p: &Position) -> CoreResult<Decision> {
        let i = self.seen.fetch_add(1, Ordering::Relaxed);
        if i == 0 {
            Ok(self.entry.clone())
        } else if Some(i) == self.close_at {
            Ok(Decision::close())
        } else {
            Ok(Decision::hold())
        }
    }
    async fn health(&self) -> BrainHealth {
        BrainHealth::ok()
    }
}

fn ohlc(t: i64, open: f64, high: f64, low: f64, close: f64) -> Candle {
    Candle {
        time: t,
        open,
        high,
        low,
        close,
        volume: 1.0,
    }
}

fn cfg() -> BacktestConfig {
    BacktestConfig::builder()
        .symbol("BTCUSDT")
        .initial_cash(100_000.0)
        .slippage(SlippageModel::Zero)
        .fees(FeeModel::Zero)
        .sizing(SizingConfig {
            margin_per_trade: 1_000.0,
            leverage: 1,
            max_contracts: 1_000,
        })
        .build()
        .unwrap()
}

async fn run(
    entry: Decision,
    close_at: Option<usize>,
    candles: Vec<Candle>,
) -> rustrade_backtest::BacktestResult {
    Backtest::new(cfg(), Arc::new(ScriptBrain::new(entry, close_at)))
        .with_candles(candles)
        .run()
        .await
        .unwrap()
}

#[tokio::test]
async fn resting_limit_buy_fills_at_limit_price() {
    // c0 dips to 94 (< limit 95) so the resting buy fills at 95; close on
    // c2 at 100. The trade's entry price must be the limit, not the close.
    let candles = vec![
        ohlc(0, 100.0, 101.0, 94.0, 100.0),
        ohlc(60_000, 100.0, 100.0, 100.0, 100.0),
        ohlc(120_000, 100.0, 100.0, 100.0, 100.0),
    ];
    let entry = Decision::buy(1.0).with_limit_price(Price(95.0));
    let r = run(entry, Some(2), candles).await;

    assert_eq!(r.orders_filled, 2, "open + close should both fill");
    assert_eq!(r.trades.len(), 1);
    let t = &r.trades[0];
    assert!(
        (t.entry_price - 95.0).abs() < 1e-9,
        "entry at limit: {}",
        t.entry_price
    );
    assert!(
        (t.exit_price - 100.0).abs() < 1e-9,
        "exit at close: {}",
        t.exit_price
    );
}

#[tokio::test]
async fn market_buy_fills_at_close_not_limit() {
    // Same candles, but a market entry fills at the candle close (100).
    let candles = vec![
        ohlc(0, 100.0, 101.0, 94.0, 100.0),
        ohlc(60_000, 100.0, 100.0, 100.0, 100.0),
        ohlc(120_000, 100.0, 100.0, 100.0, 100.0),
    ];
    let r = run(Decision::buy(1.0), Some(2), candles).await;
    assert_eq!(r.trades.len(), 1);
    assert!((r.trades[0].entry_price - 100.0).abs() < 1e-9);
}

#[tokio::test]
async fn limit_buy_does_not_fill_when_price_never_reaches_it() {
    // Price stays at/above 99 the whole time; a buy limit at 95 never fills.
    let candles = vec![
        ohlc(0, 100.0, 101.0, 99.0, 100.0),
        ohlc(60_000, 100.0, 101.0, 99.5, 100.0),
    ];
    let entry = Decision::buy(1.0).with_limit_price(Price(95.0));
    let r = run(entry, None, candles).await;

    assert_eq!(r.signals_emitted, 1, "the buy intent was emitted");
    assert_eq!(r.orders_filled, 0, "but never filled");
    assert_eq!(r.trades.len(), 0);
    assert_eq!(r.net_pnl, 0.0);
}

#[tokio::test]
async fn marketable_limit_buy_fills_at_open_as_taker() {
    // A buy limit above the market (105 >= open 100) is immediately
    // marketable: it fills at the open, not at the (higher) limit.
    let candles = vec![
        ohlc(0, 100.0, 106.0, 100.0, 103.0),
        ohlc(60_000, 103.0, 103.0, 103.0, 103.0),
    ];
    let entry = Decision::buy(1.0).with_limit_price(Price(105.0));
    let r = run(entry, Some(1), candles).await;
    assert_eq!(r.trades.len(), 1);
    assert!(
        (r.trades[0].entry_price - 100.0).abs() < 1e-9,
        "marketable limit fills at open: {}",
        r.trades[0].entry_price
    );
}

#[tokio::test]
async fn post_only_is_rejected_when_marketable() {
    // A post-only that would cross as taker (limit 105 >= open 100) must be
    // rejected — never filled.
    let candles = vec![
        ohlc(0, 100.0, 106.0, 100.0, 103.0),
        ohlc(60_000, 103.0, 103.0, 103.0, 103.0),
    ];
    let entry = Decision::buy(1.0)
        .with_limit_price(Price(105.0))
        .with_order_kind(OrderKind::PostOnly);
    let r = run(entry, None, candles).await;
    assert_eq!(r.signals_emitted, 1);
    assert_eq!(r.orders_filled, 0, "marketable post-only is rejected");
}

#[tokio::test]
async fn resting_limit_pays_maker_fee_via_makertaker() {
    // A resting limit open (maker) then market close (taker) under a
    // MakerTaker schedule charges maker on the open and taker on the close.
    // We observe the closing (taker) fee on the trade ledger; the maker
    // open fee is reflected in cash. Here we assert the closing fill used
    // the taker rate by comparing to a Zero-fee baseline.
    let candles = vec![
        ohlc(0, 100.0, 101.0, 94.0, 100.0),
        ohlc(60_000, 100.0, 100.0, 100.0, 100.0),
    ];
    let entry = Decision::buy(1.0).with_limit_price(Price(95.0));

    let with_fees = Backtest::new(
        BacktestConfig::builder()
            .symbol("BTCUSDT")
            .initial_cash(100_000.0)
            .slippage(SlippageModel::Zero)
            .fees(FeeModel::MakerTaker {
                maker: 0.0001,
                taker: 0.0010,
            })
            .sizing(SizingConfig {
                margin_per_trade: 1_000.0,
                leverage: 1,
                max_contracts: 1_000,
            })
            .build()
            .unwrap(),
        Arc::new(ScriptBrain::new(entry, Some(1))),
    )
    .with_candles(candles)
    .run()
    .await
    .unwrap();

    // Closing fill is a market (taker): 10 contracts × 100 × 10bps = 1.0.
    assert_eq!(with_fees.trades.len(), 1);
    let close_fee = with_fees.trades[0].fee;
    assert!(
        (close_fee - 1.0).abs() < 1e-9,
        "closing taker fee should be 1.0, got {close_fee}"
    );
}