Skip to main content

nautilus_bitmex/websocket/
dispatch.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//! WebSocket message dispatch for the BitMEX execution client.
17//!
18//! Routes incoming [`BitmexWsMessage`] variants to the appropriate parsing and
19//! event emission paths. Tracked orders (submitted through this client) produce
20//! proper order events; untracked orders fall back to execution reports for
21//! downstream reconciliation.
22
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use ahash::AHashMap;
26use dashmap::DashMap;
27use nautilus_core::{UUID4, UnixNanos};
28use nautilus_live::ExecutionEventEmitter;
29use nautilus_model::{
30    enums::{OrderSide, OrderType},
31    events::{OrderAccepted, OrderEventAny, OrderFilled, OrderUpdated},
32    identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId, VenueOrderId},
33    instruments::{Instrument, InstrumentAny},
34    reports::FillReport,
35    types::Currency,
36};
37use parking_lot::Mutex;
38use ustr::Ustr;
39
40use crate::{
41    common::enums::{BitmexExecType, BitmexOrderType, BitmexPegPriceType},
42    http::parse::{InstrumentParseResult, parse_instrument_any},
43    websocket::{
44        enums::BitmexAction,
45        messages::{
46            BitmexExecutionMsg, BitmexTableMessage, BitmexWsMessage, OrderData, OrderRowCache,
47            ResolvedOrderData,
48        },
49        parse::{
50            ParsedOrderEvent, parse_execution_msg, parse_margin_account_state, parse_order_event,
51            parse_order_msg, parse_order_update_msg, parse_position_msg, parse_wallet_msg,
52        },
53    },
54};
55
56/// Maximum entries per generation before rotation.
57const DEDUP_GENERATION_CAPACITY: usize = 10_000;
58
59/// Order identity context stored at submission time, used by the WS dispatch
60/// task to produce proper order events without Cache access.
61///
62/// These fields are immutable for the lifetime of an order and are used to
63/// construct proper order events (`OrderAccepted`, `OrderFilled`, etc.) instead
64/// of execution reports.
65#[derive(Debug, Clone)]
66pub struct OrderIdentity {
67    pub instrument_id: InstrumentId,
68    pub strategy_id: StrategyId,
69    pub order_side: OrderSide,
70    pub order_type: OrderType,
71}
72
73/// Two-generation dedup set that avoids the duplicate-emission window caused
74/// by wholesale clearing. Holds a current and previous `AHashSet` behind a
75/// `Mutex`. When the current set fills up, `std::mem::swap` promotes it to
76/// previous and starts a fresh current, all under a single lock acquisition.
77/// Membership checks and removals also take the lock briefly.
78///
79/// The lock is held only for the duration of a hash-set insert (and sometimes
80/// a swap + clear), so contention is negligible.
81#[derive(Debug)]
82struct GenerationalDedupSet {
83    inner: Mutex<DedupInner>,
84}
85
86#[derive(Debug)]
87struct DedupInner {
88    current: ahash::AHashSet<ClientOrderId>,
89    previous: ahash::AHashSet<ClientOrderId>,
90}
91
92impl Default for GenerationalDedupSet {
93    fn default() -> Self {
94        Self {
95            inner: Mutex::new(DedupInner {
96                current: ahash::AHashSet::new(),
97                previous: ahash::AHashSet::new(),
98            }),
99        }
100    }
101}
102
103impl GenerationalDedupSet {
104    fn contains(&self, key: &ClientOrderId) -> bool {
105        let guard = self.inner.lock();
106        guard.current.contains(key) || guard.previous.contains(key)
107    }
108
109    fn insert(&self, key: ClientOrderId) {
110        let mut guard = self.inner.lock();
111        let inner = &mut *guard;
112        inner.current.insert(key);
113        if inner.current.len() >= DEDUP_GENERATION_CAPACITY {
114            inner.previous.clear();
115            std::mem::swap(&mut inner.current, &mut inner.previous);
116        }
117    }
118
119    fn remove(&self, key: &ClientOrderId) {
120        let mut guard = self.inner.lock();
121        guard.current.remove(key);
122        guard.previous.remove(key);
123    }
124}
125
126/// Shared state for WS dispatch event deduplication and order tracking.
127///
128/// Uses `DashMap` and mutex-guarded sets for concurrent access from the stream task
129/// and the main thread without mutex contention.
130#[derive(Debug)]
131pub struct WsDispatchState {
132    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
133    order_rows: Mutex<OrderRowCache>,
134    emitted_accepted: GenerationalDedupSet,
135    triggered_orders: GenerationalDedupSet,
136    filled_orders: GenerationalDedupSet,
137    tombstoned: GenerationalDedupSet,
138    pub margin_subscribed: AtomicBool,
139}
140
141impl Default for WsDispatchState {
142    fn default() -> Self {
143        Self {
144            order_identities: DashMap::new(),
145            order_rows: Mutex::new(OrderRowCache::default()),
146            emitted_accepted: GenerationalDedupSet::default(),
147            triggered_orders: GenerationalDedupSet::default(),
148            filled_orders: GenerationalDedupSet::default(),
149            tombstoned: GenerationalDedupSet::default(),
150            margin_subscribed: AtomicBool::new(false),
151        }
152    }
153}
154
155impl WsDispatchState {
156    pub(crate) fn accepted_contains(&self, cid: &ClientOrderId) -> bool {
157        self.emitted_accepted.contains(cid)
158    }
159
160    pub(crate) fn filled_contains(&self, cid: &ClientOrderId) -> bool {
161        self.filled_orders.contains(cid)
162    }
163
164    pub(crate) fn triggered_contains(&self, cid: &ClientOrderId) -> bool {
165        self.triggered_orders.contains(cid)
166    }
167
168    pub(crate) fn insert_accepted(&self, cid: ClientOrderId) {
169        self.emitted_accepted.insert(cid);
170    }
171
172    pub(crate) fn insert_filled(&self, cid: ClientOrderId) {
173        self.filled_orders.insert(cid);
174    }
175
176    pub(crate) fn insert_triggered(&self, cid: ClientOrderId) {
177        self.triggered_orders.insert(cid);
178    }
179
180    pub(crate) fn remove_triggered(&self, cid: &ClientOrderId) {
181        self.triggered_orders.remove(cid);
182    }
183
184    pub(crate) fn remove_filled(&self, cid: &ClientOrderId) {
185        self.filled_orders.remove(cid);
186    }
187
188    pub(crate) fn remove_accepted(&self, cid: &ClientOrderId) {
189        self.emitted_accepted.remove(cid);
190    }
191
192    /// Returns `true` if the order has been tombstoned by the HTTP cancel path.
193    pub(crate) fn is_tombstoned(&self, cid: &ClientOrderId) -> bool {
194        self.tombstoned.contains(cid)
195    }
196
197    /// Tombstones an order so the WS dispatch silently drops all subsequent
198    /// messages for it. Call after the HTTP path has already sent a terminal
199    /// report (cancel, expire, reject). The tombstone prevents stale WS
200    /// messages (Accepted, Triggered) that are still queued from being
201    /// processed as untracked orders and re-activating a closed order.
202    pub(crate) fn tombstone_order(&self, cid: &ClientOrderId) {
203        self.tombstoned.insert(*cid);
204        self.order_identities.remove(cid);
205        self.remove_accepted(cid);
206        self.remove_triggered(cid);
207        self.remove_filled(cid);
208    }
209}
210
211/// Top-level dispatch for all BitMEX WebSocket messages on the execution stream.
212#[expect(clippy::too_many_arguments)]
213pub fn dispatch_ws_message(
214    ts_init: UnixNanos,
215    message: BitmexWsMessage,
216    emitter: &ExecutionEventEmitter,
217    state: &WsDispatchState,
218    instruments_by_symbol: &mut AHashMap<Ustr, InstrumentAny>,
219    order_type_cache: &mut AHashMap<ClientOrderId, OrderType>,
220    order_symbol_cache: &mut AHashMap<ClientOrderId, Ustr>,
221    account_id: AccountId,
222) {
223    match message {
224        BitmexWsMessage::Table(table_msg) => match table_msg {
225            BitmexTableMessage::Order { action, data } => {
226                dispatch_order_messages(
227                    state.order_rows_apply(action, data),
228                    emitter,
229                    state,
230                    instruments_by_symbol,
231                    order_type_cache,
232                    order_symbol_cache,
233                    account_id,
234                    ts_init,
235                );
236            }
237            BitmexTableMessage::Execution { data, .. } => {
238                dispatch_execution_messages(
239                    data,
240                    emitter,
241                    state,
242                    instruments_by_symbol,
243                    order_symbol_cache,
244                    account_id,
245                    ts_init,
246                );
247            }
248            BitmexTableMessage::Position { data, .. } => {
249                for pos_msg in data {
250                    let Some(instrument) = instruments_by_symbol.get(&pos_msg.symbol) else {
251                        log::error!(
252                            "Instrument cache miss: position dropped for symbol={}, account={}",
253                            pos_msg.symbol,
254                            pos_msg.account,
255                        );
256                        continue;
257                    };
258                    let mut report = parse_position_msg(&pos_msg, instrument, ts_init);
259                    report.account_id = account_id;
260                    emitter.send_position_report(report);
261                }
262            }
263            BitmexTableMessage::Wallet { data, .. } => {
264                if !state.margin_subscribed.load(Ordering::Relaxed) {
265                    for wallet_msg in data {
266                        let mut acct_state = parse_wallet_msg(&wallet_msg, ts_init);
267                        acct_state.account_id = account_id;
268                        emitter.send_account_state(acct_state);
269                    }
270                }
271            }
272            BitmexTableMessage::Margin { data, .. } => {
273                state.margin_subscribed.store(true, Ordering::Relaxed);
274
275                for margin_msg in data {
276                    let mut acct_state = parse_margin_account_state(&margin_msg, ts_init);
277                    acct_state.account_id = account_id;
278                    emitter.send_account_state(acct_state);
279                }
280            }
281            BitmexTableMessage::Instrument { action, data } => {
282                if matches!(action, BitmexAction::Partial | BitmexAction::Insert) {
283                    for msg in data {
284                        match msg.try_into() {
285                            Ok(http_inst) => match parse_instrument_any(&http_inst, ts_init) {
286                                InstrumentParseResult::Ok(boxed) => {
287                                    let inst = *boxed;
288                                    let symbol = inst.symbol().inner();
289                                    instruments_by_symbol.insert(symbol, inst);
290                                }
291                                InstrumentParseResult::Unsupported { .. }
292                                | InstrumentParseResult::Inactive { .. } => {}
293                                InstrumentParseResult::Failed { symbol, error, .. } => {
294                                    log::warn!("Failed to parse instrument {symbol}: {error}");
295                                }
296                            },
297                            Err(e) => {
298                                log::debug!("Skipping instrument (missing required fields): {e}");
299                            }
300                        }
301                    }
302                }
303            }
304            BitmexTableMessage::OrderBookL2 { .. }
305            | BitmexTableMessage::OrderBookL2_25 { .. }
306            | BitmexTableMessage::OrderBook10 { .. }
307            | BitmexTableMessage::Quote { .. }
308            | BitmexTableMessage::Trade { .. }
309            | BitmexTableMessage::TradeBin1m { .. }
310            | BitmexTableMessage::TradeBin5m { .. }
311            | BitmexTableMessage::TradeBin1h { .. }
312            | BitmexTableMessage::TradeBin1d { .. }
313            | BitmexTableMessage::Funding { .. } => {
314                log::debug!("Ignoring BitMEX data message on execution stream");
315            }
316            _ => {
317                log::warn!("Unhandled table message type on execution stream");
318            }
319        },
320        BitmexWsMessage::Reconnected => {
321            state.order_rows_clear();
322            order_type_cache.clear();
323            order_symbol_cache.clear();
324            log::info!("BitMEX execution websocket reconnected");
325        }
326        BitmexWsMessage::Authenticated => {
327            log::debug!("BitMEX execution websocket authenticated");
328        }
329    }
330}
331
332impl WsDispatchState {
333    pub(crate) fn order_rows_apply(
334        &self,
335        action: BitmexAction,
336        data: Vec<OrderData>,
337    ) -> Vec<ResolvedOrderData> {
338        self.order_rows.lock().apply(action, data)
339    }
340
341    pub(crate) fn order_rows_clear(&self) {
342        self.order_rows.lock().clear();
343    }
344}
345
346/// Dispatches order messages, routing tracked orders to events and untracked
347/// orders to reports.
348#[expect(clippy::too_many_arguments)]
349fn dispatch_order_messages(
350    data: Vec<ResolvedOrderData>,
351    emitter: &ExecutionEventEmitter,
352    state: &WsDispatchState,
353    instruments_by_symbol: &AHashMap<Ustr, InstrumentAny>,
354    order_type_cache: &mut AHashMap<ClientOrderId, OrderType>,
355    order_symbol_cache: &mut AHashMap<ClientOrderId, Ustr>,
356    account_id: AccountId,
357    ts_init: UnixNanos,
358) {
359    for order_data in data {
360        let order_data = match order_data {
361            ResolvedOrderData::Terminal(order_msg) => {
362                let tracked = order_msg.cl_ord_id.as_ref().is_some_and(|cl_ord_id| {
363                    state
364                        .order_identities
365                        .contains_key(&ClientOrderId::new(cl_ord_id))
366                });
367
368                if !tracked {
369                    log::debug!(
370                        "Skipping terminal update for untracked order: order_id={}",
371                        order_msg.order_id,
372                    );
373                    continue;
374                }
375                ResolvedOrderData::Full(order_msg)
376            }
377            order_data => order_data,
378        };
379
380        match order_data {
381            ResolvedOrderData::Full(order_msg) => {
382                let Some(instrument) = instruments_by_symbol.get(&order_msg.symbol) else {
383                    log::error!(
384                        "Instrument cache miss: order dropped for symbol={}, order_id={}",
385                        order_msg.symbol,
386                        order_msg.order_id,
387                    );
388                    continue;
389                };
390
391                let client_order_id = order_msg.cl_ord_id.map(ClientOrderId::new);
392
393                // Update caches before tombstone check so execution messages
394                // that arrive later can still resolve the symbol
395                if let Some(ref cid) = client_order_id {
396                    if let Some(ord_type) = &order_msg.ord_type {
397                        let order_type: OrderType = if *ord_type == BitmexOrderType::Pegged
398                            && order_msg.peg_price_type == Some(BitmexPegPriceType::TrailingStopPeg)
399                        {
400                            if order_msg.price.is_some() {
401                                OrderType::TrailingStopLimit
402                            } else {
403                                OrderType::TrailingStopMarket
404                            }
405                        } else {
406                            (*ord_type).into()
407                        };
408                        order_type_cache.insert(*cid, order_type);
409                    }
410                    order_symbol_cache.insert(*cid, order_msg.symbol);
411                }
412
413                // Skip tombstoned orders (already handled by HTTP cancel path)
414                if let Some(ref cid) = client_order_id
415                    && state.is_tombstoned(cid)
416                {
417                    log::debug!("Skipping tombstoned order {cid}");
418                    continue;
419                }
420
421                let identity = client_order_id
422                    .and_then(|cid| state.order_identities.get(&cid).map(|r| (cid, r.clone())));
423
424                if let Some((cid, ident)) = identity {
425                    // Tracked order: produce order events
426                    if let Some(event) = parse_order_event(
427                        &order_msg,
428                        cid,
429                        account_id,
430                        emitter.trader_id(),
431                        ident.strategy_id,
432                        ts_init,
433                    ) {
434                        let venue_order_id = VenueOrderId::new(order_msg.order_id.to_string());
435                        dispatch_parsed_order_event(
436                            event,
437                            cid,
438                            account_id,
439                            venue_order_id,
440                            &ident,
441                            emitter,
442                            state,
443                            ts_init,
444                        );
445                    }
446
447                    // Clean up caches on terminal status
448                    if order_msg.ord_status.is_terminal() {
449                        order_type_cache.remove(&cid);
450                        order_symbol_cache.remove(&cid);
451                    }
452                } else {
453                    // Untracked order: fall back to report
454                    match parse_order_msg(&order_msg, instrument, order_type_cache, ts_init) {
455                        Ok(mut report) => {
456                            if report.order_status.is_closed()
457                                && let Some(cid) = report.client_order_id
458                            {
459                                order_type_cache.remove(&cid);
460                                order_symbol_cache.remove(&cid);
461                            }
462                            report.account_id = account_id;
463                            emitter.send_order_status_report(report);
464                        }
465                        Err(e) => {
466                            log::error!(
467                                "Failed to parse order report: error={e}, symbol={}, order_id={}",
468                                order_msg.symbol,
469                                order_msg.order_id,
470                            );
471                        }
472                    }
473                }
474            }
475            ResolvedOrderData::Update(msg) => {
476                let Some(symbol) = msg.symbol else {
477                    log::warn!(
478                        "Order update missing cached symbol: order_id={}",
479                        msg.order_id,
480                    );
481                    continue;
482                };
483                let Some(instrument) = instruments_by_symbol.get(&symbol) else {
484                    log::error!(
485                        "Instrument cache miss: order update dropped for symbol={}, order_id={}",
486                        symbol,
487                        msg.order_id,
488                    );
489                    continue;
490                };
491
492                // Populate cache for execution message routing
493                if let Some(cl_ord_id) = &msg.cl_ord_id {
494                    let client_order_id = ClientOrderId::new(cl_ord_id);
495                    order_symbol_cache.insert(client_order_id, symbol);
496                }
497
498                let identity = msg.cl_ord_id.as_ref().and_then(|cl| {
499                    let cid = ClientOrderId::new(cl);
500                    state.order_identities.get(&cid).map(|r| (cid, r.clone()))
501                });
502
503                if let Some((cid, ident)) = identity {
504                    // Tracked: enrich with identity context
505                    if let Some(event) =
506                        parse_order_update_msg(&msg, instrument, account_id, ts_init)
507                    {
508                        let enriched = OrderUpdated::new(
509                            emitter.trader_id(),
510                            ident.strategy_id,
511                            event.instrument_id,
512                            cid,
513                            event.quantity,
514                            event.event_id,
515                            event.ts_event,
516                            event.ts_init,
517                            false,
518                            event.venue_order_id,
519                            Some(account_id),
520                            event.price,
521                            event.trigger_price,
522                            event.protection_price,
523                            false, // is_quote_quantity
524                        );
525                        ensure_accepted_emitted(
526                            cid,
527                            account_id,
528                            enriched
529                                .venue_order_id
530                                .unwrap_or_else(|| VenueOrderId::new(msg.order_id.to_string())),
531                            &ident,
532                            emitter,
533                            state,
534                            ts_init,
535                        );
536                        emitter.send_order_event(OrderEventAny::Updated(enriched));
537                    } else {
538                        log::warn!(
539                            "Skipped order update (insufficient data): order_id={}, price={:?}",
540                            msg.order_id,
541                            msg.price,
542                        );
543                    }
544                } else {
545                    log::debug!(
546                        "Skipping order update for untracked order: order_id={}",
547                        msg.order_id,
548                    );
549                }
550            }
551            ResolvedOrderData::Terminal(_) => unreachable!("terminal order update was normalized"),
552        }
553    }
554}
555
556/// Dispatches execution (fill) messages, routing tracked orders to
557/// `OrderFilled` events and untracked orders to `FillReport`.
558fn dispatch_execution_messages(
559    data: Vec<BitmexExecutionMsg>,
560    emitter: &ExecutionEventEmitter,
561    state: &WsDispatchState,
562    instruments_by_symbol: &AHashMap<Ustr, InstrumentAny>,
563    order_symbol_cache: &AHashMap<ClientOrderId, Ustr>,
564    account_id: AccountId,
565    ts_init: UnixNanos,
566) {
567    for exec_msg in data {
568        let symbol_opt = if let Some(sym) = &exec_msg.symbol {
569            Some(*sym)
570        } else if let Some(cl_ord_id) = &exec_msg.cl_ord_id {
571            let client_order_id = ClientOrderId::new(cl_ord_id);
572            order_symbol_cache.get(&client_order_id).copied()
573        } else {
574            None
575        };
576
577        let Some(symbol) = symbol_opt else {
578            if let Some(cl_ord_id) = &exec_msg.cl_ord_id {
579                if exec_msg.exec_type == Some(BitmexExecType::Trade) {
580                    log::warn!(
581                        "Execution missing symbol and not in cache: \
582                        cl_ord_id={cl_ord_id}, exec_id={:?}",
583                        exec_msg.exec_id,
584                    );
585                } else {
586                    log::debug!(
587                        "Execution missing symbol and not in cache: \
588                        cl_ord_id={cl_ord_id}, exec_type={:?}",
589                        exec_msg.exec_type,
590                    );
591                }
592            } else if exec_msg.exec_type == Some(BitmexExecType::CancelReject) {
593                log::debug!(
594                    "CancelReject missing symbol/clOrdID (expected with redundant cancels): \
595                    exec_id={:?}, order_id={:?}",
596                    exec_msg.exec_id,
597                    exec_msg.order_id,
598                );
599            } else {
600                log::warn!(
601                    "Execution missing both symbol and clOrdID: \
602                    exec_id={:?}, order_id={:?}, exec_type={:?}",
603                    exec_msg.exec_id,
604                    exec_msg.order_id,
605                    exec_msg.exec_type,
606                );
607            }
608            continue;
609        };
610
611        let Some(instrument) = instruments_by_symbol.get(&symbol) else {
612            log::error!(
613                "Instrument cache miss: execution dropped for symbol={}, exec_id={:?}, exec_type={:?}",
614                symbol,
615                exec_msg.exec_id,
616                exec_msg.exec_type,
617            );
618            continue;
619        };
620
621        let Some(mut fill) = parse_execution_msg(exec_msg, instrument, ts_init) else {
622            continue;
623        };
624        fill.account_id = account_id;
625
626        let identity = fill
627            .client_order_id
628            .and_then(|cid| state.order_identities.get(&cid).map(|r| (cid, r.clone())));
629
630        if let Some((cid, ident)) = identity {
631            // Tracked: produce OrderFilled event
632            let venue_order_id = fill.venue_order_id;
633            ensure_accepted_emitted(
634                cid,
635                fill.account_id,
636                venue_order_id,
637                &ident,
638                emitter,
639                state,
640                ts_init,
641            );
642            state.insert_filled(cid);
643            state.remove_triggered(&cid);
644            let filled = fill_report_to_order_filled(
645                &fill,
646                emitter.trader_id(),
647                &ident,
648                instrument.quote_currency(),
649            );
650            emitter.send_order_event(OrderEventAny::Filled(filled));
651        } else {
652            // Untracked: forward as FillReport
653            emitter.send_fill_report(fill);
654        }
655    }
656}
657
658/// Dispatches a parsed order event with lifecycle synthesis and deduplication.
659///
660/// Guarantees the `Submitted -> Accepted -> ...` lifecycle by synthesizing
661/// `OrderAccepted` before any other event when one has not yet been emitted.
662#[expect(clippy::too_many_arguments, clippy::needless_pass_by_value)]
663fn dispatch_parsed_order_event(
664    event: ParsedOrderEvent,
665    client_order_id: ClientOrderId,
666    account_id: AccountId,
667    venue_order_id: VenueOrderId,
668    identity: &OrderIdentity,
669    emitter: &ExecutionEventEmitter,
670    state: &WsDispatchState,
671    ts_init: UnixNanos,
672) {
673    let is_terminal;
674
675    match event {
676        ParsedOrderEvent::Accepted(e) => {
677            if state.accepted_contains(&client_order_id)
678                || state.filled_contains(&client_order_id)
679                || state.triggered_contains(&client_order_id)
680            {
681                log::debug!("Skipping duplicate Accepted for {client_order_id}");
682                return;
683            }
684            state.insert_accepted(client_order_id);
685            is_terminal = false;
686            emitter.send_order_event(OrderEventAny::Accepted(e));
687        }
688        ParsedOrderEvent::Triggered(e) => {
689            if state.filled_contains(&client_order_id) {
690                log::debug!("Skipping stale Triggered for {client_order_id} (already filled)");
691                return;
692            }
693            ensure_accepted_emitted(
694                client_order_id,
695                account_id,
696                venue_order_id,
697                identity,
698                emitter,
699                state,
700                ts_init,
701            );
702            state.insert_triggered(client_order_id);
703            is_terminal = false;
704            emitter.send_order_event(OrderEventAny::Triggered(e));
705        }
706        ParsedOrderEvent::Canceled(e) => {
707            ensure_accepted_emitted(
708                client_order_id,
709                account_id,
710                venue_order_id,
711                identity,
712                emitter,
713                state,
714                ts_init,
715            );
716            state.remove_triggered(&client_order_id);
717            state.remove_filled(&client_order_id);
718            is_terminal = true;
719            emitter.send_order_event(OrderEventAny::Canceled(e));
720        }
721        ParsedOrderEvent::Expired(e) => {
722            ensure_accepted_emitted(
723                client_order_id,
724                account_id,
725                venue_order_id,
726                identity,
727                emitter,
728                state,
729                ts_init,
730            );
731            state.remove_triggered(&client_order_id);
732            state.remove_filled(&client_order_id);
733            is_terminal = true;
734            emitter.send_order_event(OrderEventAny::Expired(e));
735        }
736        ParsedOrderEvent::Rejected(e) => {
737            state.remove_triggered(&client_order_id);
738            state.remove_filled(&client_order_id);
739            is_terminal = true;
740            emitter.send_order_event(OrderEventAny::Rejected(e));
741        }
742    }
743
744    if is_terminal {
745        state.order_identities.remove(&client_order_id);
746        state.remove_accepted(&client_order_id);
747    }
748}
749
750/// Synthesizes and emits `OrderAccepted` if one has not yet been emitted for
751/// this order. Handles fast-filling orders that skip the `New` state.
752fn ensure_accepted_emitted(
753    client_order_id: ClientOrderId,
754    account_id: AccountId,
755    venue_order_id: VenueOrderId,
756    identity: &OrderIdentity,
757    emitter: &ExecutionEventEmitter,
758    state: &WsDispatchState,
759    ts_init: UnixNanos,
760) {
761    if state.accepted_contains(&client_order_id) {
762        return;
763    }
764    state.insert_accepted(client_order_id);
765    let accepted = OrderAccepted::new(
766        emitter.trader_id(),
767        identity.strategy_id,
768        identity.instrument_id,
769        client_order_id,
770        venue_order_id,
771        account_id,
772        UUID4::new(),
773        ts_init,
774        ts_init,
775        false,
776    );
777    emitter.send_order_event(OrderEventAny::Accepted(accepted));
778}
779
780/// Converts a [`FillReport`] into an [`OrderFilled`] event using tracked identity.
781pub(crate) fn fill_report_to_order_filled(
782    report: &FillReport,
783    trader_id: TraderId,
784    identity: &OrderIdentity,
785    quote_currency: Currency,
786) -> OrderFilled {
787    OrderFilled::new(
788        trader_id,
789        identity.strategy_id,
790        report.instrument_id,
791        report
792            .client_order_id
793            .expect("tracked order has client_order_id"),
794        report.venue_order_id,
795        report.account_id,
796        report.trade_id,
797        identity.order_side,
798        identity.order_type,
799        report.last_qty,
800        report.last_px,
801        quote_currency,
802        report.liquidity_side,
803        UUID4::new(),
804        report.ts_event,
805        report.ts_init,
806        false,
807        report.venue_position_id,
808        Some(report.commission),
809        None,
810    )
811}