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
148impl Debug for GridMarketMaker {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct(stringify!(GridMarketMaker))
151            .field("config", &self.config)
152            .field("trade_size", &self.trade_size)
153            .finish()
154    }
155}
156
157impl DataActor for GridMarketMaker {
158    fn on_start(&mut self) -> anyhow::Result<()> {
159        let instrument_id = self.config.instrument_id;
160        let (instrument, size_precision, min_quantity) = {
161            let cache = self.cache();
162            let instrument = cache.try_instrument(&instrument_id)?;
163            let size_precision = instrument.size_precision();
164            let min_quantity = instrument.min_quantity();
165            (instrument, size_precision, min_quantity)
166        };
167        self.price_precision = Some(instrument.price_precision());
168        self.instrument = Some(instrument);
169
170        // Resolve trade_size from instrument when not explicitly provided
171        if self.trade_size.is_none() {
172            self.trade_size =
173                Some(min_quantity.unwrap_or_else(|| Quantity::new(1.0, size_precision)));
174        }
175
176        self.subscribe_quotes(instrument_id, None, None);
177        Ok(())
178    }
179
180    fn on_stop(&mut self) -> anyhow::Result<()> {
181        let instrument_id = self.config.instrument_id;
182        self.cancel_all_orders(instrument_id, None, None, None)?;
183        self.close_all_positions(instrument_id, None, None, None, None, None, None)?;
184        self.unsubscribe_quotes(instrument_id, None, None);
185        Ok(())
186    }
187
188    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
189        let mid_f64 = f64::midpoint(quote.bid_price.as_f64(), quote.ask_price.as_f64());
190        let price_precision = self.price_precision.ok_or_else(|| {
191            anyhow::anyhow!("Cannot handle quote: price_precision is not resolved")
192        })?;
193        let mid = Price::new(mid_f64, price_precision);
194
195        let instrument_id = self.config.instrument_id;
196        let strategy_id = self.strategy_id().expect("Strategy must be registered");
197
198        // Always requote when the grid is empty, even if mid is within threshold
199        let has_resting = {
200            let cache = self.cache();
201            let inst = Some(&instrument_id);
202            let sid = Some(&strategy_id);
203            cache.orders_open_count(None, inst, sid, None, None) > 0
204                || cache.orders_inflight_count(None, inst, sid, None, None) > 0
205        };
206
207        if !self.should_requote(mid) && has_resting {
208            return Ok(());
209        }
210
211        log::info!(
212            "Requoting grid: mid={mid}, last_mid={:?}, instrument={instrument_id}",
213            self.last_quoted_mid,
214        );
215
216        if self.config.on_cancel_resubmit {
217            let inst = Some(&instrument_id);
218            let strategy = Some(&strategy_id);
219            let ids: Vec<ClientOrderId> = {
220                let cache = self.cache();
221                let open = cache.orders_open(None, inst, strategy, None, None);
222                let inflight = cache.orders_inflight(None, inst, strategy, None, None);
223                open.iter()
224                    .chain(inflight.iter())
225                    .map(|o| o.client_order_id())
226                    .collect()
227            };
228            self.pending_self_cancels.extend(ids);
229        }
230
231        self.cancel_all_orders(instrument_id, None, None, None)?;
232
233        // Compute worst-case per-side exposure for max_position checks,
234        // since cancels are async and pending orders may still fill
235        let (net_position, worst_long, worst_short) = {
236            let instrument_id = Some(&instrument_id);
237            let strategy = Some(&strategy_id);
238            let cache = self.cache();
239
240            let mut position_qty = 0.0_f64;
241            let mut position_dec = Decimal::ZERO;
242
243            for p in cache.positions_open(None, instrument_id, strategy, None, None) {
244                position_qty += p.signed_qty;
245                position_dec += p.quantity.as_decimal()
246                    * if p.signed_qty < 0.0 {
247                        Decimal::NEGATIVE_ONE
248                    } else {
249                        Decimal::ONE
250                    };
251            }
252
253            let mut pending_buy_dec = Decimal::ZERO;
254            let mut pending_sell_dec = Decimal::ZERO;
255            let mut seen = AHashSet::new();
256
257            // Deduplicate open/inflight (can overlap during state transitions)
258            let open = cache.orders_open(None, instrument_id, strategy, None, None);
259            let inflight = cache.orders_inflight(None, instrument_id, strategy, None, None);
260            for order in open.iter().chain(inflight.iter()) {
261                if !seen.insert(order.client_order_id()) {
262                    continue;
263                }
264                let qty = order.leaves_qty().as_decimal();
265                match order.order_side() {
266                    OrderSide::Buy => pending_buy_dec += qty,
267                    _ => pending_sell_dec += qty,
268                }
269            }
270
271            (
272                position_qty,
273                position_dec + pending_buy_dec,
274                position_dec - pending_sell_dec,
275            )
276        };
277
278        let grid = self.grid_orders(mid, net_position, worst_long, worst_short)?;
279
280        // Don't advance the requote anchor when no orders are placed,
281        // otherwise the strategy can stall with zero resting orders
282        if grid.is_empty() {
283            return Ok(());
284        }
285
286        let trade_size = self
287            .trade_size
288            .ok_or_else(|| anyhow::anyhow!("Cannot handle quote: trade_size is not resolved"))?;
289
290        let (tif, expire_time) = match self.config.expire_time_secs {
291            Some(secs) => {
292                let now_ns = self.clock().timestamp_ns();
293                let expire_ns = now_ns + secs * 1_000_000_000;
294                (Some(TimeInForce::Gtd), Some(expire_ns))
295            }
296            None => (None, None),
297        };
298
299        for (side, price) in grid {
300            let order = self.order().limit(
301                instrument_id,
302                side,
303                trade_size,
304                price,
305                tif,
306                expire_time,
307                Some(true), // post_only
308                None,
309                None,
310                None,
311                None,
312                None,
313                None,
314                None,
315                None,
316                None,
317            );
318            self.submit_order(order, None, None, None)?;
319        }
320
321        self.last_quoted_mid = Some(mid);
322        Ok(())
323    }
324
325    fn on_order_filled(&mut self, event: &OrderFilled) -> anyhow::Result<()> {
326        // Only discard once fully filled; partial fills must keep the ID so a
327        // subsequent self-cancel is not misclassified as external.
328        let closed = {
329            let cache = self.cache();
330            cache
331                .order(&event.client_order_id)
332                .is_some_and(|o| o.is_closed())
333        };
334
335        if closed {
336            self.pending_self_cancels.remove(&event.client_order_id);
337        }
338        Ok(())
339    }
340
341    fn on_order_canceled(&mut self, event: &OrderCanceled) -> anyhow::Result<()> {
342        if self.pending_self_cancels.remove(&event.client_order_id) {
343            return Ok(());
344        }
345
346        if self.config.on_cancel_resubmit {
347            // Reset so the next incoming quote triggers a full grid resubmission
348            self.last_quoted_mid = None;
349        }
350        Ok(())
351    }
352
353    fn on_reset(&mut self) -> anyhow::Result<()> {
354        self.instrument = None;
355        self.trade_size = self.config.trade_size;
356        self.price_precision = None;
357        self.last_quoted_mid = None;
358        self.pending_self_cancels.clear();
359        Ok(())
360    }
361}