#![cfg(feature = "examples")]
use ahash::AHashMap;
use nautilus_backtest::{config::BacktestEngineConfig, engine::BacktestEngine};
use nautilus_execution::models::{fee::FeeModelAny, fill::FillModelAny};
use nautilus_model::{
data::{Data, QuoteTick},
enums::{AccountType, BookType, OmsType},
identifiers::{InstrumentId, Venue},
instruments::{CryptoPerpetual, Instrument, InstrumentAny, stubs::crypto_perpetual_ethusdt},
types::{Money, Price, Quantity},
};
use nautilus_trading::examples::strategies::{GridMarketMaker, GridMarketMakerConfig};
use rstest::*;
fn create_engine() -> BacktestEngine {
let config = BacktestEngineConfig::default();
let mut engine = BacktestEngine::new(config).unwrap();
engine
.add_venue(
Venue::from("BINANCE"),
OmsType::Netting,
AccountType::Margin,
BookType::L1_MBP,
vec![Money::from("1_000_000 USDT")],
None,
None,
AHashMap::new(),
None,
vec![],
FillModelAny::default(),
FeeModelAny::default(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
engine
}
fn quote(instrument_id: InstrumentId, bid: &str, ask: &str, ts: u64) -> Data {
Data::Quote(QuoteTick::new(
instrument_id,
Price::from(bid),
Price::from(ask),
Quantity::from("1.000"),
Quantity::from("1.000"),
ts.into(),
ts.into(),
))
}
#[rstest]
fn test_generates_orders(crypto_perpetual_ethusdt: CryptoPerpetual) {
let mut engine = create_engine();
let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
let instrument_id = instrument.id();
engine.add_instrument(&instrument).unwrap();
let config = GridMarketMakerConfig::new(instrument_id, Quantity::from("10.0"))
.with_trade_size(Quantity::from("0.100"))
.with_num_levels(3)
.with_grid_step_bps(10)
.with_skew_factor(0.01)
.with_requote_threshold_bps(5);
engine.add_strategy(GridMarketMaker::new(config)).unwrap();
let spread = 0.10;
let mut quotes = Vec::new();
let base_ts: u64 = 1_000_000_000;
let interval: u64 = 1_000_000_000;
let mut tick: u64 = 0;
let add_quote = |quotes: &mut Vec<Data>, mid: f64, tick: &mut u64| {
let bid = format!("{:.2}", mid - spread / 2.0);
let ask = format!("{:.2}", mid + spread / 2.0);
quotes.push(quote(instrument_id, &bid, &ask, base_ts + *tick * interval));
*tick += 1;
};
for _ in 0..5 {
add_quote(&mut quotes, 1000.0, &mut tick);
}
for i in 0..20 {
add_quote(&mut quotes, 1000.0 + (i as f64 * 0.5), &mut tick);
}
for i in 0..20 {
add_quote(&mut quotes, 1009.5 - (i as f64 * 0.5), &mut tick);
}
let total_quotes = quotes.len();
engine.add_data(quotes, None, true, true);
engine.run(None, None, None, false).unwrap();
let bt_result = engine.get_result();
assert_eq!(bt_result.iterations, total_quotes);
assert!(
bt_result.total_orders >= 6,
"Expected limit orders from grid market maker, was {}",
bt_result.total_orders
);
}
#[rstest]
fn test_skips_requote_within_threshold(crypto_perpetual_ethusdt: CryptoPerpetual) {
let mut engine = create_engine();
let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
let instrument_id = instrument.id();
engine.add_instrument(&instrument).unwrap();
let config = GridMarketMakerConfig::new(instrument_id, Quantity::from("10.0"))
.with_trade_size(Quantity::from("0.100"))
.with_num_levels(3)
.with_grid_step_bps(10)
.with_skew_factor(0.01)
.with_requote_threshold_bps(50);
engine.add_strategy(GridMarketMaker::new(config)).unwrap();
let quotes: Vec<Data> = (0..10u64)
.map(|i| {
let mid = 1000.0 + (i as f64 * 0.1);
quote(
instrument_id,
&format!("{:.2}", mid - 0.05),
&format!("{:.2}", mid + 0.05),
1_000_000_000 + i * 1_000_000_000,
)
})
.collect();
engine.add_data(quotes, None, true, true);
engine.run(None, None, None, false).unwrap();
let bt_result = engine.get_result();
assert_eq!(bt_result.iterations, 10);
assert_eq!(
bt_result.total_orders, 6,
"Expected exactly 6 orders from single initial quote, was {}",
bt_result.total_orders
);
}
#[rstest]
fn test_enforces_max_position_across_levels(crypto_perpetual_ethusdt: CryptoPerpetual) {
let mut engine = create_engine();
let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt);
let instrument_id = instrument.id();
engine.add_instrument(&instrument).unwrap();
let config = GridMarketMakerConfig::new(instrument_id, Quantity::from("0.150"))
.with_trade_size(Quantity::from("0.100"))
.with_num_levels(3)
.with_grid_step_bps(10)
.with_requote_threshold_bps(5);
engine.add_strategy(GridMarketMaker::new(config)).unwrap();
let quotes = vec![quote(instrument_id, "999.95", "1000.05", 1_000_000_000)];
engine.add_data(quotes, None, true, true);
engine.run(None, None, None, false).unwrap();
let bt_result = engine.get_result();
assert_eq!(
bt_result.total_orders, 2,
"Expected 2 orders (1 buy + 1 sell) due to max_position limit, was {}",
bt_result.total_orders
);
}