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,
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    ) -> 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            // next_bid_price floors to the nearest valid bid tick (<=buy_f64),
110            // next_ask_price ceils to the nearest valid ask tick (>=sell_f64),
111            // preventing self-cross on coarse-tick instruments.
112            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        // Reset so the next quote tick can retry placing the full grid
138        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        // GTD expiry means the grid is gone; reset so re-quoting is not suppressed
144        self.last_quoted_mid = None;
145    }
146
147    fn on_order_filled(&mut self, event: &OrderFilled) {
148        // Only discard once fully filled; partial fills must keep the ID so a
149        // subsequent self-cancel is not misclassified as external.
150        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            // Reset so the next incoming quote triggers a full grid resubmission
169            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        // Resolve trade_size from instrument when not explicitly provided
197        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        // Always requote when the grid is empty, even if mid is within threshold
225        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        // Compute worst-case per-side exposure for max_position checks,
260        // since cancels are async and pending orders may still fill
261        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            // Deduplicate open/inflight (can overlap during state transitions)
284            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        // Don't advance the requote anchor when no orders are placed,
307        // otherwise the strategy can stall with zero resting orders
308        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), // 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}