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