nautilus_trading/examples/strategies/grid_mm/
strategy.rs1use std::fmt::Debug;
19
20use ahash::AHashSet;
21use nautilus_common::actor::DataActor;
22use nautilus_model::{
23 data::QuoteTick,
24 enums::{OrderSide, TimeInForce},
25 events::{OrderCanceled, OrderExpired, OrderFilled, OrderRejected},
26 identifiers::ClientOrderId,
27 instruments::{Instrument, InstrumentAny},
28 orders::Order,
29 types::{Price, Quantity},
30};
31use rust_decimal::Decimal;
32
33use super::config::GridMarketMakerConfig;
34use crate::{
35 nautilus_strategy,
36 strategy::{Strategy, StrategyCore},
37};
38
39pub struct GridMarketMaker {
46 pub(super) core: StrategyCore,
47 pub(super) config: GridMarketMakerConfig,
48 pub(super) instrument: Option<InstrumentAny>,
49 pub(super) trade_size: Option<Quantity>,
50 pub(super) price_precision: Option<u8>,
51 pub(super) last_quoted_mid: Option<Price>,
52 pub(super) pending_self_cancels: AHashSet<ClientOrderId>,
53}
54
55impl GridMarketMaker {
56 #[must_use]
58 pub fn new(config: GridMarketMakerConfig) -> Self {
59 Self {
60 core: StrategyCore::new(config.base.clone()),
61 instrument: None,
62 trade_size: config.trade_size,
63 config,
64 price_precision: None,
65 last_quoted_mid: None,
66 pending_self_cancels: AHashSet::new(),
67 }
68 }
69
70 pub(super) fn should_requote(&self, mid: Price) -> bool {
71 match self.last_quoted_mid {
72 Some(last_mid) => {
73 let last_f64 = last_mid.as_f64();
74 if last_f64 == 0.0 {
75 return true;
76 }
77 let threshold = self.config.requote_threshold_bps as f64 / 10_000.0;
78 (mid.as_f64() - last_f64).abs() / last_f64 >= threshold
79 }
80 None => true,
81 }
82 }
83
84 pub(super) fn grid_orders(
85 &self,
86 mid: Price,
87 net_position: f64,
88 worst_long: Decimal,
89 worst_short: Decimal,
90 ) -> anyhow::Result<Vec<(OrderSide, Price)>> {
91 let Some(instrument) = self.instrument.as_ref() else {
92 anyhow::bail!("Cannot compute grid orders: instrument is not resolved");
93 };
94 let mid_f64 = mid.as_f64();
95 let skew_f64 = self.config.skew_factor * net_position;
96 let pct = self.config.grid_step_bps as f64 / 10_000.0;
97 let Some(trade_size) = self.trade_size else {
98 anyhow::bail!("Cannot compute grid orders: trade_size is not resolved");
99 };
100 let trade_size = trade_size.as_decimal();
101 let max_pos = self.config.max_position.as_decimal();
102 let mut projected_long = worst_long;
103 let mut projected_short = worst_short;
104 let mut orders = Vec::new();
105
106 for level in 1..=self.config.num_levels {
107 let buy_f64 = mid_f64 * (1.0 - pct).powi(level as i32) - skew_f64;
108 let sell_f64 = mid_f64 * (1.0 + pct).powi(level as i32) - skew_f64;
109 let buy_price = instrument.next_bid_price(buy_f64, 0);
113 let sell_price = instrument.next_ask_price(sell_f64, 0);
114
115 if let Some(buy_price) = buy_price
116 && projected_long + trade_size <= max_pos
117 {
118 orders.push((OrderSide::Buy, buy_price));
119 projected_long += trade_size;
120 }
121
122 if let Some(sell_price) = sell_price
123 && projected_short - trade_size >= -max_pos
124 {
125 orders.push((OrderSide::Sell, sell_price));
126 projected_short -= trade_size;
127 }
128 }
129
130 Ok(orders)
131 }
132}
133
134nautilus_strategy!(GridMarketMaker, {
135 fn on_order_rejected(&mut self, event: OrderRejected) {
136 self.pending_self_cancels.remove(&event.client_order_id);
137 self.last_quoted_mid = None;
139 }
140
141 fn on_order_expired(&mut self, event: OrderExpired) {
142 self.pending_self_cancels.remove(&event.client_order_id);
143 self.last_quoted_mid = None;
145 }
146
147 fn on_order_filled(&mut self, event: &OrderFilled) {
148 let closed = {
151 let cache = self.cache();
152 cache
153 .order(&event.client_order_id)
154 .is_some_and(|o| o.is_closed())
155 };
156
157 if closed {
158 self.pending_self_cancels.remove(&event.client_order_id);
159 }
160 }
161
162 fn on_order_canceled(&mut self, event: &OrderCanceled) {
163 if self.pending_self_cancels.remove(&event.client_order_id) {
164 return;
165 }
166
167 if self.config.on_cancel_resubmit {
168 self.last_quoted_mid = None;
170 }
171 }
172});
173
174impl Debug for GridMarketMaker {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.debug_struct(stringify!(GridMarketMaker))
177 .field("config", &self.config)
178 .field("trade_size", &self.trade_size)
179 .finish()
180 }
181}
182
183impl DataActor for GridMarketMaker {
184 fn on_start(&mut self) -> anyhow::Result<()> {
185 let instrument_id = self.config.instrument_id;
186 let (instrument, size_precision, min_quantity) = {
187 let cache = self.cache();
188 let instrument = cache.try_instrument(&instrument_id)?;
189 let size_precision = instrument.size_precision();
190 let min_quantity = instrument.min_quantity();
191 (instrument, size_precision, min_quantity)
192 };
193 self.price_precision = Some(instrument.price_precision());
194 self.instrument = Some(instrument);
195
196 if self.trade_size.is_none() {
198 self.trade_size =
199 Some(min_quantity.unwrap_or_else(|| Quantity::new(1.0, size_precision)));
200 }
201
202 self.subscribe_quotes(instrument_id, None, None);
203 Ok(())
204 }
205
206 fn on_stop(&mut self) -> anyhow::Result<()> {
207 let instrument_id = self.config.instrument_id;
208 self.cancel_all_orders(instrument_id, None, None, true, None)?;
209 self.close_all_positions(instrument_id, None, None, None, None, None, None, None)?;
210 self.unsubscribe_quotes(instrument_id, None, None);
211 Ok(())
212 }
213
214 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
215 let mid_f64 = f64::midpoint(quote.bid_price.as_f64(), quote.ask_price.as_f64());
216 let price_precision = self.price_precision.ok_or_else(|| {
217 anyhow::anyhow!("Cannot handle quote: price_precision is not resolved")
218 })?;
219 let mid = Price::new(mid_f64, price_precision);
220
221 let instrument_id = self.config.instrument_id;
222 let strategy_id = self.strategy_id().expect("Strategy must be registered");
223
224 let has_resting = {
226 let cache = self.cache();
227 let inst = Some(&instrument_id);
228 let sid = Some(&strategy_id);
229 cache.orders_open_count(None, inst, sid, None, None) > 0
230 || cache.orders_inflight_count(None, inst, sid, None, None) > 0
231 };
232
233 if !self.should_requote(mid) && has_resting {
234 return Ok(());
235 }
236
237 log::info!(
238 "Requoting grid: mid={mid}, last_mid={:?}, instrument={instrument_id}",
239 self.last_quoted_mid,
240 );
241
242 if self.config.on_cancel_resubmit {
243 let inst = Some(&instrument_id);
244 let strategy = Some(&strategy_id);
245 let ids: Vec<ClientOrderId> = {
246 let cache = self.cache();
247 let open = cache.orders_open(None, inst, strategy, None, None);
248 let inflight = cache.orders_inflight(None, inst, strategy, None, None);
249 open.iter()
250 .chain(inflight.iter())
251 .map(Order::client_order_id)
252 .collect()
253 };
254 self.pending_self_cancels.extend(ids);
255 }
256
257 self.cancel_all_orders(instrument_id, None, None, true, None)?;
258
259 let (net_position, worst_long, worst_short) = {
262 let instrument_id = Some(&instrument_id);
263 let strategy = Some(&strategy_id);
264 let cache = self.cache();
265
266 let mut position_qty = 0.0_f64;
267 let mut position_dec = Decimal::ZERO;
268
269 for p in cache.positions_open(None, instrument_id, strategy, None, None) {
270 position_qty += p.signed_qty;
271 position_dec += p.quantity.as_decimal()
272 * if p.signed_qty < 0.0 {
273 Decimal::NEGATIVE_ONE
274 } else {
275 Decimal::ONE
276 };
277 }
278
279 let mut pending_buy_dec = Decimal::ZERO;
280 let mut pending_sell_dec = Decimal::ZERO;
281 let mut seen = AHashSet::new();
282
283 let open = cache.orders_open(None, instrument_id, strategy, None, None);
285 let inflight = cache.orders_inflight(None, instrument_id, strategy, None, None);
286 for order in open.iter().chain(inflight.iter()) {
287 if !seen.insert(order.client_order_id()) {
288 continue;
289 }
290 let qty = order.leaves_qty().as_decimal();
291 match order.order_side() {
292 OrderSide::Buy => pending_buy_dec += qty,
293 _ => pending_sell_dec += qty,
294 }
295 }
296
297 (
298 position_qty,
299 position_dec + pending_buy_dec,
300 position_dec - pending_sell_dec,
301 )
302 };
303
304 let grid = self.grid_orders(mid, net_position, worst_long, worst_short)?;
305
306 if grid.is_empty() {
309 return Ok(());
310 }
311
312 let trade_size = self
313 .trade_size
314 .ok_or_else(|| anyhow::anyhow!("Cannot handle quote: trade_size is not resolved"))?;
315
316 let (tif, expire_time) = match self.config.expire_time_secs {
317 Some(secs) => {
318 let now_ns = self.clock().timestamp_ns();
319 let expire_ns = now_ns + secs * 1_000_000_000;
320 (Some(TimeInForce::Gtd), Some(expire_ns))
321 }
322 None => (None, None),
323 };
324
325 for (side, price) in grid {
326 let order = self.order().limit(
327 instrument_id,
328 side,
329 trade_size,
330 price,
331 tif,
332 expire_time,
333 Some(true), None,
335 None,
336 None,
337 None,
338 None,
339 None,
340 None,
341 None,
342 None,
343 );
344 self.submit_order(order, None, None, None)?;
345 }
346
347 self.last_quoted_mid = Some(mid);
348 Ok(())
349 }
350
351 fn on_reset(&mut self) -> anyhow::Result<()> {
352 self.instrument = None;
353 self.trade_size = self.config.trade_size;
354 self.price_precision = None;
355 self.last_quoted_mid = None;
356 self.pending_self_cancels.clear();
357 Ok(())
358 }
359}