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_core::DurationNanos;
23use nautilus_model::{
24    data::QuoteTick,
25    enums::{OrderSide, TimeInForce},
26    events::{OrderCanceled, OrderExpired, OrderFilled, OrderRejected},
27    identifiers::ClientOrderId,
28    instruments::{Instrument, InstrumentAny},
29    orders::Order,
30    types::{Price, Quantity},
31};
32use rust_decimal::Decimal;
33
34use super::config::GridMarketMakerConfig;
35use crate::{
36    nautilus_strategy,
37    strategy::{Strategy, StrategyCore},
38};
39
40/// Grid market making strategy with inventory-based skewing.
41///
42/// Places a symmetric grid of limit buy and sell orders around the mid-price.
43/// Orders persist across ticks and are only replaced when the mid-price moves
44/// by at least `requote_threshold_bps`. The grid is shifted by a skew proportional
45/// to the current net position to discourage inventory buildup.
46pub struct GridMarketMaker {
47    pub(super) core: StrategyCore,
48    pub(super) config: GridMarketMakerConfig,
49    pub(super) instrument: Option<InstrumentAny>,
50    pub(super) trade_size: Option<Quantity>,
51    pub(super) price_precision: Option<u8>,
52    pub(super) last_quoted_mid: Option<Price>,
53    pub(super) pending_self_cancels: AHashSet<ClientOrderId>,
54}
55
56impl GridMarketMaker {
57    /// Creates a new [`GridMarketMaker`] instance from config.
58    #[must_use]
59    pub fn new(config: GridMarketMakerConfig) -> Self {
60        Self {
61            core: StrategyCore::new(config.base.clone()),
62            instrument: None,
63            trade_size: config.trade_size,
64            config,
65            price_precision: None,
66            last_quoted_mid: None,
67            pending_self_cancels: AHashSet::new(),
68        }
69    }
70
71    pub(super) fn should_requote(&self, mid: Price) -> bool {
72        match self.last_quoted_mid {
73            Some(last_mid) => {
74                let last_f64 = last_mid.as_f64();
75                if last_f64 == 0.0 {
76                    return true;
77                }
78                let threshold = self.config.requote_threshold_bps as f64 / 10_000.0;
79                (mid.as_f64() - last_f64).abs() / last_f64 >= threshold
80            }
81            None => true,
82        }
83    }
84
85    pub(super) fn grid_orders(
86        &self,
87        mid: Price,
88        net_position: f64,
89        worst_long: Decimal,
90        worst_short: Decimal,
91    ) -> anyhow::Result<Vec<(OrderSide, Price)>> {
92        let Some(instrument) = self.instrument.as_ref() else {
93            anyhow::bail!("Cannot compute grid orders: instrument is not resolved");
94        };
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 Some(trade_size) = self.trade_size else {
99            anyhow::bail!("Cannot compute grid orders: trade_size is not resolved");
100        };
101        let trade_size = trade_size.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        Ok(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    fn on_order_filled(&mut self, event: &OrderFilled) {
149        // Only discard once fully filled; partial fills must keep the ID so a
150        // subsequent self-cancel is not misclassified as external.
151        let closed = {
152            let cache = self.cache();
153            cache
154                .order(&event.client_order_id)
155                .is_some_and(|o| o.is_closed())
156        };
157
158        if closed {
159            self.pending_self_cancels.remove(&event.client_order_id);
160        }
161    }
162
163    fn on_order_canceled(&mut self, event: &OrderCanceled) {
164        if self.pending_self_cancels.remove(&event.client_order_id) {
165            return;
166        }
167
168        if self.config.on_cancel_resubmit {
169            // Reset so the next incoming quote triggers a full grid resubmission
170            self.last_quoted_mid = None;
171        }
172    }
173});
174
175impl Debug for GridMarketMaker {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct(stringify!(GridMarketMaker))
178            .field("config", &self.config)
179            .field("trade_size", &self.trade_size)
180            .finish()
181    }
182}
183
184impl DataActor for GridMarketMaker {
185    fn on_start(&mut self) -> anyhow::Result<()> {
186        let instrument_id = self.config.instrument_id;
187        let (instrument, size_precision, min_quantity) = {
188            let cache = self.cache();
189            let instrument = cache.try_instrument(&instrument_id)?;
190            let size_precision = instrument.size_precision();
191            let min_quantity = instrument.min_quantity();
192            (instrument, size_precision, min_quantity)
193        };
194        self.price_precision = Some(instrument.price_precision());
195        self.instrument = Some(instrument);
196
197        // Resolve trade_size from instrument when not explicitly provided
198        if self.trade_size.is_none() {
199            self.trade_size =
200                Some(min_quantity.unwrap_or_else(|| Quantity::new(1.0, size_precision)));
201        }
202
203        self.subscribe_quotes(instrument_id, None, None);
204        Ok(())
205    }
206
207    fn on_stop(&mut self) -> anyhow::Result<()> {
208        let instrument_id = self.config.instrument_id;
209        self.cancel_all_orders(instrument_id, None, None, true, None)?;
210        self.close_all_positions(instrument_id, None, None, None, None, None, None, None)?;
211        self.unsubscribe_quotes(instrument_id, None, None);
212        Ok(())
213    }
214
215    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
216        let mid_f64 = f64::midpoint(quote.bid_price.as_f64(), quote.ask_price.as_f64());
217        let price_precision = self.price_precision.ok_or_else(|| {
218            anyhow::anyhow!("Cannot handle quote: price_precision is not resolved")
219        })?;
220        let mid = Price::new(mid_f64, price_precision);
221
222        let instrument_id = self.config.instrument_id;
223        let strategy_id = self.strategy_id().expect("Strategy must be registered");
224
225        // Always requote when the grid is empty, even if mid is within threshold
226        let has_resting = {
227            let cache = self.cache();
228            let inst = Some(&instrument_id);
229            let sid = Some(&strategy_id);
230            cache.orders_open_count(None, inst, sid, None, None) > 0
231                || cache.orders_inflight_count(None, inst, sid, None, None) > 0
232        };
233
234        if !self.should_requote(mid) && has_resting {
235            return Ok(());
236        }
237
238        log::info!(
239            "Requoting grid: mid={mid}, last_mid={:?}, instrument={instrument_id}",
240            self.last_quoted_mid,
241        );
242
243        if self.config.on_cancel_resubmit {
244            let inst = Some(&instrument_id);
245            let strategy = Some(&strategy_id);
246            let ids: Vec<ClientOrderId> = {
247                let cache = self.cache();
248                let open = cache.orders_open(None, inst, strategy, None, None);
249                let inflight = cache.orders_inflight(None, inst, strategy, None, None);
250                open.iter()
251                    .chain(inflight.iter())
252                    .map(Order::client_order_id)
253                    .collect()
254            };
255            self.pending_self_cancels.extend(ids);
256        }
257
258        self.cancel_all_orders(instrument_id, None, None, true, None)?;
259
260        // Compute worst-case per-side exposure for max_position checks,
261        // since cancels are async and pending orders may still fill
262        let (net_position, worst_long, worst_short) = {
263            let instrument_id = Some(&instrument_id);
264            let strategy = Some(&strategy_id);
265            let cache = self.cache();
266
267            let mut position_qty = 0.0_f64;
268            let mut position_dec = Decimal::ZERO;
269
270            for p in cache.positions_open(None, instrument_id, strategy, None, None) {
271                position_qty += p.signed_qty;
272                position_dec += p.quantity.as_decimal()
273                    * if p.signed_qty < 0.0 {
274                        Decimal::NEGATIVE_ONE
275                    } else {
276                        Decimal::ONE
277                    };
278            }
279
280            let mut pending_buy_dec = Decimal::ZERO;
281            let mut pending_sell_dec = Decimal::ZERO;
282            let mut seen = AHashSet::new();
283
284            // Deduplicate open/inflight (can overlap during state transitions)
285            let open = cache.orders_open(None, instrument_id, strategy, None, None);
286            let inflight = cache.orders_inflight(None, instrument_id, strategy, None, None);
287            for order in open.iter().chain(inflight.iter()) {
288                if !seen.insert(order.client_order_id()) {
289                    continue;
290                }
291                let qty = order.leaves_qty().as_decimal();
292                match order.order_side() {
293                    OrderSide::Buy => pending_buy_dec += qty,
294                    _ => pending_sell_dec += qty,
295                }
296            }
297
298            (
299                position_qty,
300                position_dec + pending_buy_dec,
301                position_dec - pending_sell_dec,
302            )
303        };
304
305        let grid = self.grid_orders(mid, net_position, worst_long, worst_short)?;
306
307        // Don't advance the requote anchor when no orders are placed,
308        // otherwise the strategy can stall with zero resting orders
309        if grid.is_empty() {
310            return Ok(());
311        }
312
313        let trade_size = self
314            .trade_size
315            .ok_or_else(|| anyhow::anyhow!("Cannot handle quote: trade_size is not resolved"))?;
316
317        let (tif, expire_time) = match self.config.expire_time_secs {
318            Some(secs) => {
319                let expire_ns = self.clock().timestamp_ns() + DurationNanos::try_from_secs(secs)?;
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), // post_only
334                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}