nautilus_derive/websocket/
dispatch.rs1use std::sync::Mutex;
28
29use ahash::AHashMap;
30use nautilus_common::cache::fifo::FifoCache;
31use nautilus_core::MUTEX_POISONED;
32use nautilus_model::{
33 enums::{OrderSide, OrderType},
34 identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId, VenueOrderId},
35};
36
37pub const TRADE_DEDUP_CAPACITY: usize = 4_096;
40
41pub const ORDER_DEDUP_CAPACITY: usize = 1_024;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct OrderIdentity {
50 pub instrument_id: InstrumentId,
51 pub strategy_id: StrategyId,
52 pub order_side: OrderSide,
53 pub order_type: OrderType,
54}
55
56#[derive(Debug, Default)]
64pub struct WsDispatchState {
65 order_identities: Mutex<AHashMap<ClientOrderId, OrderIdentity>>,
66 emitted_accepted: Mutex<FifoCache<ClientOrderId, ORDER_DEDUP_CAPACITY>>,
67 filled_orders: Mutex<FifoCache<ClientOrderId, ORDER_DEDUP_CAPACITY>>,
68 emitted_trades: Mutex<FifoCache<TradeId, TRADE_DEDUP_CAPACITY>>,
69 bound_venue_order_ids: Mutex<AHashMap<ClientOrderId, VenueOrderId>>,
70 pending_modifies: Mutex<AHashMap<ClientOrderId, VenueOrderId>>,
71}
72
73impl WsDispatchState {
74 #[must_use]
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
82 pub fn register_identity(&self, client_order_id: ClientOrderId, identity: OrderIdentity) {
83 self.order_identities
84 .lock()
85 .expect(MUTEX_POISONED)
86 .insert(client_order_id, identity);
87 }
88
89 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
92 #[must_use]
93 pub fn identity(&self, client_order_id: &ClientOrderId) -> Option<OrderIdentity> {
94 self.order_identities
95 .lock()
96 .expect(MUTEX_POISONED)
97 .get(client_order_id)
98 .copied()
99 }
100
101 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
105 pub fn forget(&self, client_order_id: &ClientOrderId) {
106 self.order_identities
107 .lock()
108 .expect(MUTEX_POISONED)
109 .remove(client_order_id);
110 self.emitted_accepted
111 .lock()
112 .expect(MUTEX_POISONED)
113 .remove(client_order_id);
114 self.bound_venue_order_ids
115 .lock()
116 .expect(MUTEX_POISONED)
117 .remove(client_order_id);
118 self.pending_modifies
119 .lock()
120 .expect(MUTEX_POISONED)
121 .remove(client_order_id);
122 }
123
124 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
126 pub fn record_venue_order_id(
127 &self,
128 client_order_id: ClientOrderId,
129 venue_order_id: VenueOrderId,
130 ) {
131 self.bound_venue_order_ids
132 .lock()
133 .expect(MUTEX_POISONED)
134 .insert(client_order_id, venue_order_id);
135 }
136
137 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
139 #[must_use]
140 pub fn bound_venue_order_id(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
141 self.bound_venue_order_ids
142 .lock()
143 .expect(MUTEX_POISONED)
144 .get(client_order_id)
145 .copied()
146 }
147
148 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
151 pub fn mark_pending_modify(
152 &self,
153 client_order_id: ClientOrderId,
154 old_venue_order_id: VenueOrderId,
155 ) {
156 self.pending_modifies
157 .lock()
158 .expect(MUTEX_POISONED)
159 .insert(client_order_id, old_venue_order_id);
160 }
161
162 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
164 pub fn clear_pending_modify(&self, client_order_id: &ClientOrderId) {
165 self.pending_modifies
166 .lock()
167 .expect(MUTEX_POISONED)
168 .remove(client_order_id);
169 }
170
171 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
173 #[must_use]
174 pub fn pending_modify(&self, client_order_id: &ClientOrderId) -> Option<VenueOrderId> {
175 self.pending_modifies
176 .lock()
177 .expect(MUTEX_POISONED)
178 .get(client_order_id)
179 .copied()
180 }
181
182 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
185 #[must_use]
186 pub fn contains_accepted(&self, client_order_id: &ClientOrderId) -> bool {
187 self.emitted_accepted
188 .lock()
189 .expect(MUTEX_POISONED)
190 .contains(client_order_id)
191 }
192
193 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
196 pub fn mark_accepted(&self, client_order_id: ClientOrderId) -> bool {
197 let mut cache = self.emitted_accepted.lock().expect(MUTEX_POISONED);
198 if cache.contains(&client_order_id) {
199 return true;
200 }
201 cache.add(client_order_id);
202 false
203 }
204
205 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
208 #[must_use]
209 pub fn contains_filled(&self, client_order_id: &ClientOrderId) -> bool {
210 self.filled_orders
211 .lock()
212 .expect(MUTEX_POISONED)
213 .contains(client_order_id)
214 }
215
216 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
218 pub fn mark_filled(&self, client_order_id: ClientOrderId) {
219 let mut cache = self.filled_orders.lock().expect(MUTEX_POISONED);
220 if !cache.contains(&client_order_id) {
221 cache.add(client_order_id);
222 }
223 }
224
225 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
228 pub fn check_and_insert_trade(&self, trade_id: TradeId) -> bool {
229 let mut cache = self.emitted_trades.lock().expect(MUTEX_POISONED);
230 if cache.contains(&trade_id) {
231 return true;
232 }
233 cache.add(trade_id);
234 false
235 }
236
237 #[allow(clippy::missing_panics_doc, reason = "mutex poisoning is not expected")]
240 #[must_use]
241 pub fn contains_trade(&self, trade_id: &TradeId) -> bool {
242 self.emitted_trades
243 .lock()
244 .expect(MUTEX_POISONED)
245 .contains(trade_id)
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use nautilus_model::{
252 enums::{OrderSide, OrderType},
253 identifiers::{ClientOrderId, InstrumentId, StrategyId, TradeId, VenueOrderId},
254 };
255 use rstest::rstest;
256
257 use super::*;
258
259 fn sample_identity() -> OrderIdentity {
260 OrderIdentity {
261 instrument_id: InstrumentId::from("ETH-PERP.DERIVE"),
262 strategy_id: StrategyId::from("S-1"),
263 order_side: OrderSide::Buy,
264 order_type: OrderType::Limit,
265 }
266 }
267
268 #[rstest]
269 fn test_register_and_identity_roundtrip() {
270 let state = WsDispatchState::new();
271 let cid = ClientOrderId::from("STRAT-O-1");
272 let identity = sample_identity();
273
274 assert!(state.identity(&cid).is_none());
275 state.register_identity(cid, identity);
276 assert_eq!(state.identity(&cid), Some(identity));
277
278 state.forget(&cid);
279 assert!(state.identity(&cid).is_none());
280 }
281
282 #[rstest]
283 fn test_mark_accepted_dedupes_second_call() {
284 let state = WsDispatchState::new();
285 let cid = ClientOrderId::from("STRAT-O-1");
286
287 assert!(!state.mark_accepted(cid));
288 assert!(state.contains_accepted(&cid));
289 assert!(state.mark_accepted(cid));
290 }
291
292 #[rstest]
293 fn test_check_and_insert_trade_returns_true_on_duplicate() {
294 let state = WsDispatchState::new();
295 let trade_id = TradeId::new("T-1");
296
297 assert!(!state.check_and_insert_trade(trade_id));
298 assert!(state.contains_trade(&trade_id));
299 assert!(state.check_and_insert_trade(trade_id));
300 }
301
302 #[rstest]
303 fn test_forget_clears_accepted_marker() {
304 let state = WsDispatchState::new();
305 let cid = ClientOrderId::from("STRAT-O-1");
306
307 state.mark_accepted(cid);
308 state.forget(&cid);
309 assert!(!state.contains_accepted(&cid));
310 }
311
312 #[rstest]
313 fn test_bound_venue_order_id_records_and_advances() {
314 let state = WsDispatchState::new();
315 let cid = ClientOrderId::from("STRAT-O-1");
316 let voi1 = VenueOrderId::from("voi-1");
317 let voi2 = VenueOrderId::from("voi-2");
318
319 assert!(state.bound_venue_order_id(&cid).is_none());
320 state.record_venue_order_id(cid, voi1);
321 assert_eq!(state.bound_venue_order_id(&cid), Some(voi1));
322 state.record_venue_order_id(cid, voi2);
324 assert_eq!(state.bound_venue_order_id(&cid), Some(voi2));
325 }
326
327 #[rstest]
328 fn test_pending_modify_marker_set_and_cleared() {
329 let state = WsDispatchState::new();
330 let cid = ClientOrderId::from("STRAT-O-1");
331 let old_voi = VenueOrderId::from("voi-1");
332
333 assert!(state.pending_modify(&cid).is_none());
334 state.mark_pending_modify(cid, old_voi);
335 assert_eq!(state.pending_modify(&cid), Some(old_voi));
336 state.clear_pending_modify(&cid);
337 assert!(state.pending_modify(&cid).is_none());
338 }
339
340 #[rstest]
341 fn test_forget_clears_bound_and_pending() {
342 let state = WsDispatchState::new();
343 let cid = ClientOrderId::from("STRAT-O-1");
344
345 state.record_venue_order_id(cid, VenueOrderId::from("voi-1"));
346 state.mark_pending_modify(cid, VenueOrderId::from("voi-1"));
347 state.forget(&cid);
348 assert!(state.bound_venue_order_id(&cid).is_none());
349 assert!(state.pending_modify(&cid).is_none());
350 }
351}