1use 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::CompositeMarketMakerConfig;
35use crate::{
36 nautilus_strategy,
37 strategy::{Strategy, StrategyCore},
38};
39
40pub struct CompositeMarketMaker {
50 pub(super) core: StrategyCore,
51 pub(super) config: CompositeMarketMakerConfig,
52 pub(super) instrument: Option<InstrumentAny>,
53 pub(super) trade_size: Option<Quantity>,
54 pub(super) price_precision: Option<u8>,
55 pub(super) last_quoted_anchor: Option<Price>,
56 pub(super) last_quoted_residual: Option<f64>,
57 pub(super) signal_baseline: Option<f64>,
58 pub(super) last_signal: Option<f64>,
59 pub(super) pending_self_cancels: AHashSet<ClientOrderId>,
60}
61
62impl CompositeMarketMaker {
63 #[must_use]
65 pub fn new(config: CompositeMarketMakerConfig) -> Self {
66 let signal_baseline = config.signal_baseline;
67 Self {
68 core: StrategyCore::new(config.base.clone()),
69 instrument: None,
70 trade_size: config.trade_size,
71 config,
72 price_precision: None,
73 last_quoted_anchor: None,
74 last_quoted_residual: None,
75 signal_baseline,
76 last_signal: None,
77 pending_self_cancels: AHashSet::new(),
78 }
79 }
80
81 pub(super) fn should_requote_on_anchor(&self, anchor: Price) -> bool {
82 match self.last_quoted_anchor {
83 Some(last_anchor) => {
84 let last_f64 = last_anchor.as_f64();
85 if last_f64 == 0.0 {
86 return true;
87 }
88 let threshold = self.config.requote_threshold_bps as f64 / 10_000.0;
89 (anchor.as_f64() - last_f64).abs() / last_f64 >= threshold
90 }
91 None => true,
92 }
93 }
94
95 pub(super) fn should_requote_on_residual(&self, residual: f64, anchor: Price) -> bool {
96 if self.config.signal_skew_factor == 0.0 {
97 return false;
98 }
99 let anchor_f64 = anchor.as_f64();
100 if anchor_f64 == 0.0 {
101 return false;
102 }
103
104 match self.last_quoted_residual {
105 Some(last) => {
106 let price_delta = (residual - last).abs() * self.config.signal_skew_factor.abs();
110 let threshold = self.config.requote_threshold_bps as f64 / 10_000.0;
111 price_delta / anchor_f64 >= threshold
112 }
113 None => true,
114 }
115 }
116
117 pub(super) fn should_requote(&self, anchor: Price, residual: f64) -> bool {
118 self.should_requote_on_anchor(anchor) || self.should_requote_on_residual(residual, anchor)
119 }
120
121 pub(super) fn signal_residual(&self) -> f64 {
122 match (self.last_signal, self.signal_baseline) {
123 (Some(signal), Some(baseline)) if baseline != 0.0 => signal / baseline - 1.0,
124 _ => 0.0,
125 }
126 }
127
128 pub(super) fn compute_quotes(
129 &self,
130 anchor: Price,
131 signal_residual: f64,
132 net_position: f64,
133 worst_long: Decimal,
134 worst_short: Decimal,
135 ) -> anyhow::Result<Vec<(OrderSide, Price)>> {
136 let Some(instrument) = self.instrument.as_ref() else {
137 anyhow::bail!("Cannot compute quotes: instrument is not resolved");
138 };
139 let Some(trade_size) = self.trade_size else {
140 anyhow::bail!("Cannot compute quotes: trade_size is not resolved");
141 };
142 let trade_size = trade_size.as_decimal();
143 let max_pos = self.config.max_position.as_decimal();
144
145 let anchor_f64 = anchor.as_f64();
146 let half_spread = anchor_f64 * (self.config.half_spread_bps as f64 / 10_000.0);
147 let inventory_shift = self.config.inventory_skew_factor * net_position;
148 let signal_shift = self.config.signal_skew_factor * signal_residual;
149 let total_shift = signal_shift - inventory_shift;
151
152 let bid_f64 = anchor_f64 - half_spread + total_shift;
153 let ask_f64 = anchor_f64 + half_spread + total_shift;
154 let bid_price = instrument.next_bid_price(bid_f64, 0);
158 let ask_price = instrument.next_ask_price(ask_f64, 0);
159
160 let crossed = match (bid_price, ask_price) {
163 (Some(bp), Some(ap)) => bp >= ap,
164 _ => false,
165 };
166
167 if crossed {
168 return Ok(Vec::new());
169 }
170
171 let mut orders = Vec::new();
172
173 if let Some(price) = bid_price
174 && worst_long + trade_size <= max_pos
175 {
176 orders.push((OrderSide::Buy, price));
177 }
178
179 if let Some(price) = ask_price
180 && worst_short - trade_size >= -max_pos
181 {
182 orders.push((OrderSide::Sell, price));
183 }
184
185 Ok(orders)
186 }
187}
188
189nautilus_strategy!(CompositeMarketMaker, {
190 fn on_order_rejected(&mut self, event: OrderRejected) {
191 self.pending_self_cancels.remove(&event.client_order_id);
192 self.last_quoted_anchor = None;
193 self.last_quoted_residual = None;
194 }
195
196 fn on_order_expired(&mut self, event: OrderExpired) {
197 self.pending_self_cancels.remove(&event.client_order_id);
198 self.last_quoted_anchor = None;
199 self.last_quoted_residual = None;
200 }
201
202 fn on_order_filled(&mut self, event: &OrderFilled) {
203 let closed = {
204 let cache = self.cache();
205 cache
206 .order(&event.client_order_id)
207 .is_some_and(|o| o.is_closed())
208 };
209
210 if closed {
211 self.pending_self_cancels.remove(&event.client_order_id);
212 }
213 }
214
215 fn on_order_canceled(&mut self, event: &OrderCanceled) {
216 if self.pending_self_cancels.remove(&event.client_order_id) {
217 return;
218 }
219
220 if self.config.on_cancel_resubmit {
221 self.last_quoted_anchor = None;
222 self.last_quoted_residual = None;
223 }
224 }
225});
226
227impl Debug for CompositeMarketMaker {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 f.debug_struct(stringify!(CompositeMarketMaker))
230 .field("config", &self.config)
231 .field("trade_size", &self.trade_size)
232 .field("signal_baseline", &self.signal_baseline)
233 .field("last_signal", &self.last_signal)
234 .finish()
235 }
236}
237
238impl DataActor for CompositeMarketMaker {
239 fn on_start(&mut self) -> anyhow::Result<()> {
240 let instrument_id = self.config.instrument_id;
241 let signal_instrument_id = self.config.signal_instrument_id;
242
243 let (instrument, size_precision, min_quantity) = {
244 let cache = self.cache();
245 let instrument = cache.try_instrument(&instrument_id)?;
246 let size_precision = instrument.size_precision();
247 let min_quantity = instrument.min_quantity();
248 (instrument, size_precision, min_quantity)
249 };
250 self.price_precision = Some(instrument.price_precision());
251 self.instrument = Some(instrument);
252
253 if self.trade_size.is_none() {
254 self.trade_size =
255 Some(min_quantity.unwrap_or_else(|| Quantity::new(1.0, size_precision)));
256 }
257
258 self.subscribe_quotes(instrument_id, None, None);
259 self.subscribe_quotes(signal_instrument_id, None, None);
260 Ok(())
261 }
262
263 fn on_stop(&mut self) -> anyhow::Result<()> {
264 let instrument_id = self.config.instrument_id;
265 let signal_instrument_id = self.config.signal_instrument_id;
266 self.cancel_all_orders(instrument_id, None, None, true, None)?;
267 self.close_all_positions(instrument_id, None, None, None, None, None, None, None)?;
268 self.unsubscribe_quotes(instrument_id, None, None);
269 self.unsubscribe_quotes(signal_instrument_id, None, None);
270 Ok(())
271 }
272
273 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
274 if quote.instrument_id == self.config.signal_instrument_id {
275 let signal_mid = f64::midpoint(quote.bid_price.as_f64(), quote.ask_price.as_f64());
276 self.last_signal = Some(signal_mid);
277 if self.signal_baseline.is_none() {
278 self.signal_baseline = Some(signal_mid);
279 }
280 return Ok(());
281 }
282
283 if quote.instrument_id != self.config.instrument_id {
284 return Ok(());
285 }
286
287 let anchor_f64 = f64::midpoint(quote.bid_price.as_f64(), quote.ask_price.as_f64());
288 let price_precision = self.price_precision.ok_or_else(|| {
289 anyhow::anyhow!("Cannot handle quote: price_precision is not resolved")
290 })?;
291 let anchor = Price::new(anchor_f64, price_precision);
292
293 let signal_residual = self.signal_residual();
294 let instrument_id = self.config.instrument_id;
295 let strategy_id = self.strategy_id().expect("Strategy must be registered");
296
297 let has_resting = {
298 let cache = self.cache();
299 let inst = Some(&instrument_id);
300 let sid = Some(&strategy_id);
301 cache.orders_open_count(None, inst, sid, None, None) > 0
302 || cache.orders_inflight_count(None, inst, sid, None, None) > 0
303 };
304
305 if !self.should_requote(anchor, signal_residual) && has_resting {
306 return Ok(());
307 }
308
309 log::info!(
310 "Requoting: anchor={anchor}, last_anchor={:?}, residual={signal_residual:.6}, last_residual={:?}, instrument={instrument_id}",
311 self.last_quoted_anchor,
312 self.last_quoted_residual,
313 );
314
315 if self.config.on_cancel_resubmit {
316 let inst = Some(&instrument_id);
317 let strategy = Some(&strategy_id);
318 let ids: Vec<ClientOrderId> = {
319 let cache = self.cache();
320 let open = cache.orders_open(None, inst, strategy, None, None);
321 let inflight = cache.orders_inflight(None, inst, strategy, None, None);
322 open.iter()
323 .chain(inflight.iter())
324 .map(Order::client_order_id)
325 .collect()
326 };
327 self.pending_self_cancels.extend(ids);
328 }
329
330 self.cancel_all_orders(instrument_id, None, None, true, None)?;
331
332 let (net_position, worst_long, worst_short) = {
333 let instrument_id = Some(&instrument_id);
334 let strategy = Some(&strategy_id);
335 let cache = self.cache();
336
337 let mut position_qty = 0.0_f64;
338 let mut position_dec = Decimal::ZERO;
339
340 for p in cache.positions_open(None, instrument_id, strategy, None, None) {
341 position_qty += p.signed_qty;
342 position_dec += p.quantity.as_decimal()
343 * if p.signed_qty < 0.0 {
344 Decimal::NEGATIVE_ONE
345 } else {
346 Decimal::ONE
347 };
348 }
349
350 let mut pending_buy_dec = Decimal::ZERO;
351 let mut pending_sell_dec = Decimal::ZERO;
352 let mut seen = AHashSet::new();
353
354 let open = cache.orders_open(None, instrument_id, strategy, None, None);
355 let inflight = cache.orders_inflight(None, instrument_id, strategy, None, None);
356 for order in open.iter().chain(inflight.iter()) {
357 if !seen.insert(order.client_order_id()) {
358 continue;
359 }
360 let qty = order.leaves_qty().as_decimal();
361 match order.order_side() {
362 OrderSide::Buy => pending_buy_dec += qty,
363 _ => pending_sell_dec += qty,
364 }
365 }
366
367 (
368 position_qty,
369 position_dec + pending_buy_dec,
370 position_dec - pending_sell_dec,
371 )
372 };
373
374 let quotes = self.compute_quotes(
375 anchor,
376 signal_residual,
377 net_position,
378 worst_long,
379 worst_short,
380 )?;
381
382 if quotes.is_empty() {
383 return Ok(());
384 }
385
386 let trade_size = self
387 .trade_size
388 .ok_or_else(|| anyhow::anyhow!("Cannot handle quote: trade_size is not resolved"))?;
389
390 let (tif, expire_time) = match self.config.expire_time_secs {
391 Some(secs) => {
392 let expire_ns = self.clock().timestamp_ns() + DurationNanos::try_from_secs(secs)?;
393 (Some(TimeInForce::Gtd), Some(expire_ns))
394 }
395 None => (None, None),
396 };
397
398 for (side, price) in quotes {
399 let order = self.order().limit(
400 instrument_id,
401 side,
402 trade_size,
403 price,
404 tif,
405 expire_time,
406 Some(true), None,
408 None,
409 None,
410 None,
411 None,
412 None,
413 None,
414 None,
415 None,
416 );
417 self.submit_order(order, None, None, None)?;
418 }
419
420 self.last_quoted_anchor = Some(anchor);
421 self.last_quoted_residual = Some(signal_residual);
422 Ok(())
423 }
424
425 fn on_reset(&mut self) -> anyhow::Result<()> {
426 self.instrument = None;
427 self.trade_size = self.config.trade_size;
428 self.price_precision = None;
429 self.last_quoted_anchor = None;
430 self.last_quoted_residual = None;
431 self.signal_baseline = self.config.signal_baseline;
432 self.last_signal = None;
433 self.pending_self_cancels.clear();
434 Ok(())
435 }
436}