Skip to main content

nautilus_trading/examples/strategies/grid_mm/
strategy.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Grid market making strategy implementation.
17
18use 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, StrategyId},
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
39/// Grid market making strategy with inventory-based skewing.
40///
41/// Places a symmetric grid of limit buy and sell orders around the mid-price.
42/// Orders persist across ticks and are only replaced when the mid-price moves
43/// by at least `requote_threshold_bps`. The grid is shifted by a skew proportional
44/// to the current net position to discourage inventory buildup.
45pub 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    /// Creates a new [`GridMarketMaker`] instance from config.
57    #[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    ) -> Vec<(OrderSide, Price)> {
91        let instrument = self
92            .instrument
93            .as_ref()
94            .expect("instrument should be resolved in on_start");
95        let mid_f64 = mid.as_f64();
96        let skew_f64 = self.config.skew_factor * net_position;
97        let pct = self.config.grid_step_bps as f64 / 10_000.0;
98        let trade_size = self
99            .trade_size
100            .expect("trade_size should be resolved in on_start")
101            .as_decimal();
102        let max_pos = self.config.max_position.as_decimal();
103        let mut projected_long = worst_long;
104        let mut projected_short = worst_short;
105        let mut orders = Vec::new();
106
107        for level in 1..=self.config.num_levels {
108            let buy_f64 = mid_f64 * (1.0 - pct).powi(level as i32) - skew_f64;
109            let sell_f64 = mid_f64 * (1.0 + pct).powi(level as i32) - skew_f64;
110            // next_bid_price floors to the nearest valid bid tick (<=buy_f64),
111            // next_ask_price ceils to the nearest valid ask tick (>=sell_f64),
112            // preventing self-cross on coarse-tick instruments.
113            let buy_price = instrument.next_bid_price(buy_f64, 0);
114            let sell_price = instrument.next_ask_price(sell_f64, 0);
115
116            if let Some(buy_price) = buy_price
117                && projected_long + trade_size <= max_pos
118            {
119                orders.push((OrderSide::Buy, buy_price));
120                projected_long += trade_size;
121            }
122
123            if let Some(sell_price) = sell_price
124                && projected_short - trade_size >= -max_pos
125            {
126                orders.push((OrderSide::Sell, sell_price));
127                projected_short -= trade_size;
128            }
129        }
130
131        orders
132    }
133}
134
135nautilus_strategy!(GridMarketMaker, {
136    fn on_order_rejected(&mut self, event: OrderRejected) {
137        self.pending_self_cancels.remove(&event.client_order_id);
138        // Reset so the next quote tick can retry placing the full grid
139        self.last_quoted_mid = None;
140    }
141
142    fn on_order_expired(&mut self, event: OrderExpired) {
143        self.pending_self_cancels.remove(&event.client_order_id);
144        // GTD expiry means the grid is gone; reset so re-quoting is not suppressed
145        self.last_quoted_mid = None;
146    }
147});
148
149impl Debug for GridMarketMaker {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct(stringify!(GridMarketMaker))
152            .field("config", &self.config)
153            .field("trade_size", &self.trade_size)
154            .finish()
155    }
156}
157
158impl DataActor for GridMarketMaker {
159    fn on_start(&mut self) -> anyhow::Result<()> {
160        let instrument_id = self.config.instrument_id;
161        let (instrument, size_precision, min_quantity) = {
162            let cache = self.cache();
163            let instrument = cache
164                .instrument(&instrument_id)
165                .ok_or_else(|| anyhow::anyhow!("Instrument {instrument_id} not found in cache"))?;
166            (
167                instrument.clone(),
168                instrument.size_precision(),
169                instrument.min_quantity(),
170            )
171        };
172        self.price_precision = Some(instrument.price_precision());
173        self.instrument = Some(instrument);
174
175        // Resolve trade_size from instrument when not explicitly provided
176        if self.trade_size.is_none() {
177            self.trade_size =
178                Some(min_quantity.unwrap_or_else(|| Quantity::new(1.0, size_precision)));
179        }
180
181        self.subscribe_quotes(instrument_id, None, None);
182        Ok(())
183    }
184
185    fn on_stop(&mut self) -> anyhow::Result<()> {
186        let instrument_id = self.config.instrument_id;
187        self.cancel_all_orders(instrument_id, None, None)?;
188        self.close_all_positions(instrument_id, None, None, None, None, None, None)?;
189        self.unsubscribe_quotes(instrument_id, None, None);
190        Ok(())
191    }
192
193    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
194        // f64 division by 2 is exact in IEEE 754
195        let mid_f64 = (quote.bid_price.as_f64() + quote.ask_price.as_f64()) / 2.0;
196        let mid = Price::new(
197            mid_f64,
198            self.price_precision
199                .expect("price_precision should be resolved in on_start"),
200        );
201
202        let instrument_id = self.config.instrument_id;
203        let strategy_id = StrategyId::from(self.actor_id.inner().as_str());
204
205        // Always requote when the grid is empty, even if mid is within threshold
206        let has_resting = {
207            let cache = self.cache();
208            let inst = Some(&instrument_id);
209            let sid = Some(&strategy_id);
210            !cache.orders_open(None, inst, sid, None, None).is_empty()
211                || !cache
212                    .orders_inflight(None, inst, sid, None, None)
213                    .is_empty()
214        };
215
216        if !self.should_requote(mid) && has_resting {
217            return Ok(());
218        }
219
220        log::info!(
221            "Requoting grid: mid={mid}, last_mid={:?}, instrument={instrument_id}",
222            self.last_quoted_mid,
223        );
224
225        if self.config.on_cancel_resubmit {
226            let inst = Some(&instrument_id);
227            let strategy = Some(&strategy_id);
228            let ids: Vec<ClientOrderId> = {
229                let cache = self.cache();
230                cache
231                    .orders_open(None, inst, strategy, None, None)
232                    .iter()
233                    .chain(
234                        cache
235                            .orders_inflight(None, inst, strategy, None, None)
236                            .iter(),
237                    )
238                    .map(|o| o.client_order_id())
239                    .collect()
240            };
241            self.pending_self_cancels.extend(ids);
242        }
243
244        self.cancel_all_orders(instrument_id, None, None)?;
245
246        // Compute worst-case per-side exposure for max_position checks,
247        // since cancels are async and pending orders may still fill
248        let (net_position, worst_long, worst_short) = {
249            let instrument_id = Some(&instrument_id);
250            let strategy = Some(&strategy_id);
251            let cache = self.cache();
252
253            let mut position_qty = 0.0_f64;
254            let mut position_dec = Decimal::ZERO;
255
256            for p in cache.positions_open(None, instrument_id, strategy, None, None) {
257                position_qty += p.signed_qty;
258                position_dec += p.quantity.as_decimal()
259                    * if p.signed_qty < 0.0 {
260                        Decimal::NEGATIVE_ONE
261                    } else {
262                        Decimal::ONE
263                    };
264            }
265
266            let mut pending_buy_dec = Decimal::ZERO;
267            let mut pending_sell_dec = Decimal::ZERO;
268            let mut seen = AHashSet::new();
269
270            // Deduplicate open/inflight (can overlap during state transitions)
271            for order in cache
272                .orders_open(None, instrument_id, strategy, None, None)
273                .iter()
274                .chain(
275                    cache
276                        .orders_inflight(None, instrument_id, strategy, None, None)
277                        .iter(),
278                )
279            {
280                if !seen.insert(order.client_order_id()) {
281                    continue;
282                }
283                let qty = order.leaves_qty().as_decimal();
284                match order.order_side() {
285                    OrderSide::Buy => pending_buy_dec += qty,
286                    _ => pending_sell_dec += qty,
287                }
288            }
289
290            (
291                position_qty,
292                position_dec + pending_buy_dec,
293                position_dec - pending_sell_dec,
294            )
295        };
296
297        let grid = self.grid_orders(mid, net_position, worst_long, worst_short);
298
299        // Don't advance the requote anchor when no orders are placed,
300        // otherwise the strategy can stall with zero resting orders
301        if grid.is_empty() {
302            return Ok(());
303        }
304
305        let trade_size = self
306            .trade_size
307            .expect("trade_size should be resolved in on_start");
308
309        let (tif, expire_time) = match self.config.expire_time_secs {
310            Some(secs) => {
311                let now_ns = self.core.clock().timestamp_ns();
312                let expire_ns = now_ns + secs * 1_000_000_000;
313                (Some(TimeInForce::Gtd), Some(expire_ns))
314            }
315            None => (None, None),
316        };
317
318        for (side, price) in grid {
319            let order = self.core.order_factory().limit(
320                instrument_id,
321                side,
322                trade_size,
323                price,
324                tif,
325                expire_time,
326                Some(true), // post_only
327                None,
328                None,
329                None,
330                None,
331                None,
332                None,
333                None,
334                None,
335                None,
336            );
337            self.submit_order(order, None, None)?;
338        }
339
340        self.last_quoted_mid = Some(mid);
341        Ok(())
342    }
343
344    fn on_order_filled(&mut self, event: &OrderFilled) -> anyhow::Result<()> {
345        // Only discard once fully filled; partial fills must keep the ID so a
346        // subsequent self-cancel is not misclassified as external.
347        let closed = {
348            let cache = self.cache();
349            cache
350                .order(&event.client_order_id)
351                .is_some_and(|o| o.is_closed())
352        };
353
354        if closed {
355            self.pending_self_cancels.remove(&event.client_order_id);
356        }
357        Ok(())
358    }
359
360    fn on_order_canceled(&mut self, event: &OrderCanceled) -> anyhow::Result<()> {
361        if self.pending_self_cancels.remove(&event.client_order_id) {
362            return Ok(());
363        }
364
365        if self.config.on_cancel_resubmit {
366            // Reset so the next incoming quote triggers a full grid resubmission
367            self.last_quoted_mid = None;
368        }
369        Ok(())
370    }
371
372    fn on_reset(&mut self) -> anyhow::Result<()> {
373        self.instrument = None;
374        self.trade_size = self.config.trade_size;
375        self.price_precision = None;
376        self.last_quoted_mid = None;
377        self.pending_self_cancels.clear();
378        Ok(())
379    }
380}