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;
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() {
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() {
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() {
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() {
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() {
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() {
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();
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}"
);
}