Skip to main content

nautilus_bybit/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 Bybit execution client.
17//!
18//! Routes incoming [`BybitWsMessage`] 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 anyhow::Context;
27use dashmap::{DashMap, DashSet};
28use nautilus_core::{UUID4, UnixNanos, time::AtomicTime};
29use nautilus_live::ExecutionEventEmitter;
30use nautilus_model::{
31    enums::{LiquiditySide, OrderSide, OrderType},
32    events::{
33        OrderAccepted, OrderCanceled, OrderEventAny, OrderFilled, OrderTriggered, OrderUpdated,
34    },
35    identifiers::{
36        AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, VenueOrderId,
37    },
38    instruments::{Instrument, InstrumentAny},
39    orders::TRIGGERABLE_ORDER_TYPES,
40    types::{Money, Price, Quantity},
41};
42use rust_decimal::Decimal;
43use ustr::Ustr;
44
45use super::{
46    messages::{
47        BybitWsAccountExecution, BybitWsAccountExecutionFast, BybitWsAccountOrder, BybitWsMessage,
48    },
49    parse::{
50        parse_millis_i64, parse_ws_account_state, parse_ws_fill_report_fast,
51        parse_ws_position_status_report,
52    },
53};
54use crate::{
55    common::{
56        enums::BybitOrderStatus,
57        parse::{
58            make_bybit_symbol, parse_millis_timestamp, parse_price_with_precision,
59            parse_quantity_with_precision,
60        },
61    },
62    http::error::is_bybit_ambiguous_order_error_code,
63};
64
65const DEDUP_CAPACITY: usize = 10_000;
66
67const BYBIT_OP_ORDER_CREATE: &str = "order.create";
68const BYBIT_OP_ORDER_AMEND: &str = "order.amend";
69const BYBIT_OP_ORDER_CANCEL: &str = "order.cancel";
70const BYBIT_OP_ORDER_CREATE_BATCH: &str = "order.create-batch";
71const BYBIT_OP_ORDER_AMEND_BATCH: &str = "order.amend-batch";
72const BYBIT_OP_ORDER_CANCEL_BATCH: &str = "order.cancel-batch";
73
74/// Order identity context stored at submission time, used by the WS dispatch
75/// task to produce proper order events without Cache access.
76#[derive(Debug, Clone)]
77pub struct OrderIdentity {
78    pub instrument_id: InstrumentId,
79    pub strategy_id: StrategyId,
80    pub order_side: OrderSide,
81    pub order_type: OrderType,
82    pub venue_position_id: Option<PositionId>,
83}
84
85/// Tracks which type of WS request is pending for a given req_id.
86#[derive(Debug, Clone, Copy)]
87pub enum PendingOperation {
88    Place,
89    Cancel,
90    Amend,
91}
92
93/// Shared state for cross-stream event deduplication between the private
94/// and trade WebSocket dispatch loops.
95pub type PendingRequestData = (
96    Vec<ClientOrderId>,
97    Vec<Option<VenueOrderId>>,
98    PendingOperation,
99);
100
101/// Snapshot of an order's price, quantity, and trigger price at last dispatch.
102/// Used to detect modifications when Bybit sends back an order with the same
103/// status but changed fields.
104#[derive(Debug, Clone)]
105pub struct OrderStateSnapshot {
106    pub quantity: Quantity,
107    pub price: Option<Price>,
108    pub trigger_price: Option<Price>,
109}
110
111#[derive(Debug)]
112pub struct WsDispatchState {
113    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
114    pub pending_requests: DashMap<String, PendingRequestData>,
115    pub order_snapshots: DashMap<ClientOrderId, OrderStateSnapshot>,
116    pub emitted_accepted: DashSet<ClientOrderId>,
117    pub triggered_orders: DashSet<ClientOrderId>,
118    pub filled_orders: DashSet<ClientOrderId>,
119    clearing: AtomicBool,
120}
121
122impl Default for WsDispatchState {
123    fn default() -> Self {
124        Self {
125            order_identities: DashMap::new(),
126            pending_requests: DashMap::new(),
127            order_snapshots: DashMap::new(),
128            emitted_accepted: DashSet::default(),
129            triggered_orders: DashSet::default(),
130            filled_orders: DashSet::default(),
131            clearing: AtomicBool::new(false),
132        }
133    }
134}
135
136impl WsDispatchState {
137    fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
138        if set.len() >= DEDUP_CAPACITY
139            && self
140                .clearing
141                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
142                .is_ok()
143        {
144            set.clear();
145            self.clearing.store(false, Ordering::Release);
146        }
147    }
148
149    fn insert_accepted(&self, cid: ClientOrderId) {
150        self.evict_if_full(&self.emitted_accepted);
151        self.emitted_accepted.insert(cid);
152    }
153
154    fn insert_filled(&self, cid: ClientOrderId) {
155        self.evict_if_full(&self.filled_orders);
156        self.filled_orders.insert(cid);
157    }
158
159    fn insert_triggered(&self, cid: ClientOrderId) {
160        self.evict_if_full(&self.triggered_orders);
161        self.triggered_orders.insert(cid);
162    }
163}
164
165/// Dispatches a WebSocket message with cross-stream deduplication.
166///
167/// For orders with a tracked identity (submitted through this client), produces
168/// proper order events (OrderAccepted, OrderCanceled, OrderFilled, etc.).
169/// For untracked orders (external or pre-existing), falls back to execution
170/// reports for downstream reconciliation.
171pub fn dispatch_ws_message(
172    message: &BybitWsMessage,
173    emitter: &ExecutionEventEmitter,
174    state: &WsDispatchState,
175    account_id: AccountId,
176    instruments: &AHashMap<Ustr, InstrumentAny>,
177    clock: &AtomicTime,
178) {
179    match message {
180        BybitWsMessage::AccountOrder(msg) => {
181            let ts_init = clock.get_time_ns();
182
183            for order in &msg.data {
184                let symbol = make_bybit_symbol(order.symbol, order.category);
185                let Some(instrument) = instruments.get(&symbol) else {
186                    log::warn!("No instrument for order update: {symbol}");
187                    continue;
188                };
189                dispatch_order_update(order, instrument, emitter, state, account_id, ts_init);
190            }
191        }
192        BybitWsMessage::AccountExecution(msg) => {
193            let ts_init = clock.get_time_ns();
194
195            for exec in &msg.data {
196                let symbol = make_bybit_symbol(exec.symbol, exec.category);
197                let Some(instrument) = instruments.get(&symbol) else {
198                    log::warn!("No instrument for execution update: {symbol}");
199                    continue;
200                };
201                dispatch_execution_fill(exec, instrument, emitter, state, account_id, ts_init);
202            }
203        }
204        BybitWsMessage::AccountExecutionFast(msg) => {
205            let ts_init = clock.get_time_ns();
206
207            for exec in &msg.data {
208                let symbol = make_bybit_symbol(exec.symbol, exec.category);
209                let Some(instrument) = instruments.get(&symbol) else {
210                    log::warn!("No instrument for fast-execution update: {symbol}");
211                    continue;
212                };
213                dispatch_execution_fill_fast(exec, instrument, emitter, state, account_id, ts_init);
214            }
215        }
216        BybitWsMessage::AccountWallet(msg) => {
217            let ts_init = clock.get_time_ns();
218            let ts_event = parse_millis_i64(msg.creation_time, "wallet.creation_time")
219                .unwrap_or_else(|e| {
220                    log::warn!("Failed to parse wallet creation_time, using ts_init: {e}");
221                    ts_init
222                });
223
224            for wallet in &msg.data {
225                match parse_ws_account_state(wallet, account_id, ts_event, ts_init) {
226                    Ok(state) => emitter.send_account_state(state),
227                    Err(e) => log::error!("Failed to parse account state: {e}"),
228                }
229            }
230        }
231        BybitWsMessage::AccountPosition(msg) => {
232            let ts_init = clock.get_time_ns();
233
234            for position in &msg.data {
235                let symbol = make_bybit_symbol(position.symbol, position.category);
236                let Some(instrument) = instruments.get(&symbol) else {
237                    log::warn!("No instrument for position update: {symbol}");
238                    continue;
239                };
240
241                match parse_ws_position_status_report(position, account_id, instrument, ts_init) {
242                    Ok(report) => emitter.send_position_report(report),
243                    Err(e) => log::error!("Failed to parse position status report: {e}"),
244                }
245            }
246        }
247        BybitWsMessage::OrderResponse(resp) => {
248            let ts_init = clock.get_time_ns();
249            dispatch_order_response(resp, emitter, state, ts_init);
250        }
251        BybitWsMessage::Error(e) => {
252            log::warn!("WebSocket error: code={} message={}", e.code, e.message);
253        }
254        BybitWsMessage::Reconnected => {
255            log::info!("WebSocket reconnected");
256        }
257        BybitWsMessage::Auth(_)
258        | BybitWsMessage::Orderbook(_)
259        | BybitWsMessage::Trade(_)
260        | BybitWsMessage::Kline(_)
261        | BybitWsMessage::TickerLinear(_)
262        | BybitWsMessage::TickerOption(_) => {}
263    }
264}
265
266/// Dispatches a single order status update.
267///
268/// Tracked orders produce lifecycle events (OrderAccepted, OrderTriggered,
269/// OrderCanceled, OrderRejected). Untracked orders fall back to
270/// `OrderStatusReport` for reconciliation.
271fn dispatch_order_update(
272    order: &BybitWsAccountOrder,
273    instrument: &InstrumentAny,
274    emitter: &ExecutionEventEmitter,
275    state: &WsDispatchState,
276    account_id: AccountId,
277    ts_init: UnixNanos,
278) {
279    let client_order_id = if order.order_link_id.is_empty() {
280        None
281    } else {
282        Some(ClientOrderId::new(order.order_link_id.as_str()))
283    };
284
285    let identity = client_order_id
286        .as_ref()
287        .and_then(|cid| state.order_identities.get(cid).map(|r| r.clone()));
288
289    if let (Some(client_order_id), Some(identity)) = (client_order_id, identity) {
290        let venue_order_id = VenueOrderId::new(order.order_id.as_str());
291
292        match order.order_status {
293            BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
294                let snapshot = parse_order_snapshot(order, instrument);
295
296                if state.emitted_accepted.contains(&client_order_id)
297                    || state.filled_orders.contains(&client_order_id)
298                    || state.triggered_orders.contains(&client_order_id)
299                {
300                    if let Some(snapshot) = snapshot
301                        && is_snapshot_updated(&snapshot, &client_order_id, state)
302                    {
303                        let updated = OrderUpdated::new(
304                            emitter.trader_id(),
305                            identity.strategy_id,
306                            identity.instrument_id,
307                            client_order_id,
308                            snapshot.quantity,
309                            UUID4::new(),
310                            ts_init,
311                            ts_init,
312                            false,
313                            Some(venue_order_id),
314                            Some(account_id),
315                            snapshot.price,
316                            snapshot.trigger_price,
317                            None,
318                            false,
319                        );
320                        state.order_snapshots.insert(client_order_id, snapshot);
321                        emitter.send_order_event(OrderEventAny::Updated(updated));
322                        return;
323                    }
324                    log::debug!("Skipping duplicate Accepted for {client_order_id}");
325                    return;
326                }
327
328                state.insert_accepted(client_order_id);
329
330                // BBO orders resolve their limit price venue-side: emit
331                // OrderUpdated after OrderAccepted when the seed diverges.
332                let venue_differs_from_submitted = snapshot
333                    .as_ref()
334                    .is_some_and(|s| is_snapshot_updated(s, &client_order_id, state));
335
336                if let Some(snapshot) = snapshot.as_ref() {
337                    state
338                        .order_snapshots
339                        .insert(client_order_id, snapshot.clone());
340                }
341
342                let accepted = OrderAccepted::new(
343                    emitter.trader_id(),
344                    identity.strategy_id,
345                    identity.instrument_id,
346                    client_order_id,
347                    venue_order_id,
348                    account_id,
349                    UUID4::new(),
350                    ts_init,
351                    ts_init,
352                    false,
353                );
354                emitter.send_order_event(OrderEventAny::Accepted(accepted));
355
356                if venue_differs_from_submitted && let Some(snapshot) = snapshot {
357                    let updated = OrderUpdated::new(
358                        emitter.trader_id(),
359                        identity.strategy_id,
360                        identity.instrument_id,
361                        client_order_id,
362                        snapshot.quantity,
363                        UUID4::new(),
364                        ts_init,
365                        ts_init,
366                        false,
367                        Some(venue_order_id),
368                        Some(account_id),
369                        snapshot.price,
370                        snapshot.trigger_price,
371                        None,
372                        false,
373                    );
374                    emitter.send_order_event(OrderEventAny::Updated(updated));
375                }
376            }
377            BybitOrderStatus::Triggered => {
378                if state.filled_orders.contains(&client_order_id) {
379                    log::debug!("Skipping stale Triggered for {client_order_id} (already filled)");
380                    return;
381                }
382
383                if !TRIGGERABLE_ORDER_TYPES.contains(&identity.order_type) {
384                    log::debug!(
385                        "Skipping OrderTriggered for {} order {client_order_id}: market-style stops have no TRIGGERED state",
386                        identity.order_type,
387                    );
388                    return;
389                }
390
391                ensure_accepted_emitted(
392                    client_order_id,
393                    account_id,
394                    venue_order_id,
395                    &identity,
396                    emitter,
397                    state,
398                    ts_init,
399                );
400                state.insert_triggered(client_order_id);
401                let triggered = OrderTriggered::new(
402                    emitter.trader_id(),
403                    identity.strategy_id,
404                    identity.instrument_id,
405                    client_order_id,
406                    UUID4::new(),
407                    ts_init,
408                    ts_init,
409                    false,
410                    Some(venue_order_id),
411                    Some(account_id),
412                );
413                emitter.send_order_event(OrderEventAny::Triggered(triggered));
414            }
415            BybitOrderStatus::Rejected => {
416                let filled_qty = parse_quantity_with_precision(
417                    &order.cum_exec_qty,
418                    instrument.size_precision(),
419                    "order.cumExecQty",
420                )
421                .unwrap_or_default();
422
423                if filled_qty.is_positive() {
424                    // Partially filled then rejected - treat as canceled
425                    ensure_accepted_emitted(
426                        client_order_id,
427                        account_id,
428                        venue_order_id,
429                        &identity,
430                        emitter,
431                        state,
432                        ts_init,
433                    );
434                    let canceled = OrderCanceled::new(
435                        emitter.trader_id(),
436                        identity.strategy_id,
437                        identity.instrument_id,
438                        client_order_id,
439                        UUID4::new(),
440                        ts_init,
441                        ts_init,
442                        false,
443                        Some(venue_order_id),
444                        Some(account_id),
445                    );
446                    cleanup_terminal(client_order_id, state);
447                    emitter.send_order_event(OrderEventAny::Canceled(canceled));
448                } else {
449                    let reason = if order.reject_reason.is_empty() {
450                        Ustr::from("Order rejected by venue")
451                    } else {
452                        order.reject_reason
453                    };
454                    state.order_identities.remove(&client_order_id);
455                    state.order_snapshots.remove(&client_order_id);
456                    emitter.emit_order_rejected_event(
457                        identity.strategy_id,
458                        identity.instrument_id,
459                        client_order_id,
460                        reason.as_str(),
461                        ts_init,
462                        false,
463                    );
464                }
465            }
466            BybitOrderStatus::PartiallyFilled => {
467                // Fills arrive on the execution channel; no event needed here.
468                // Ensure accepted was emitted so the fill has a valid prior state.
469                ensure_accepted_emitted(
470                    client_order_id,
471                    account_id,
472                    venue_order_id,
473                    &identity,
474                    emitter,
475                    state,
476                    ts_init,
477                );
478
479                // A successful amend on a partially filled order keeps the
480                // PartiallyFilled status. Detect price/qty/trigger changes and
481                // emit OrderUpdated so PendingUpdate resolves.
482                if let Some(snapshot) = parse_order_snapshot(order, instrument)
483                    && is_snapshot_updated(&snapshot, &client_order_id, state)
484                {
485                    let updated = OrderUpdated::new(
486                        emitter.trader_id(),
487                        identity.strategy_id,
488                        identity.instrument_id,
489                        client_order_id,
490                        snapshot.quantity,
491                        UUID4::new(),
492                        ts_init,
493                        ts_init,
494                        false,
495                        Some(venue_order_id),
496                        Some(account_id),
497                        snapshot.price,
498                        snapshot.trigger_price,
499                        None,
500                        false,
501                    );
502                    state.order_snapshots.insert(client_order_id, snapshot);
503                    emitter.send_order_event(OrderEventAny::Updated(updated));
504                }
505            }
506            BybitOrderStatus::Filled => {
507                // Fills arrive on the execution channel; no event needed here.
508                // Ensure accepted was emitted so the fill has a valid prior state.
509                ensure_accepted_emitted(
510                    client_order_id,
511                    account_id,
512                    venue_order_id,
513                    &identity,
514                    emitter,
515                    state,
516                    ts_init,
517                );
518
519                // Reconcile seed against venue values before the fill (BBO
520                // orders may land directly on Filled).
521                if let Some(snapshot) = parse_order_snapshot(order, instrument)
522                    && is_snapshot_updated(&snapshot, &client_order_id, state)
523                {
524                    let updated = OrderUpdated::new(
525                        emitter.trader_id(),
526                        identity.strategy_id,
527                        identity.instrument_id,
528                        client_order_id,
529                        snapshot.quantity,
530                        UUID4::new(),
531                        ts_init,
532                        ts_init,
533                        false,
534                        Some(venue_order_id),
535                        Some(account_id),
536                        snapshot.price,
537                        snapshot.trigger_price,
538                        None,
539                        false,
540                    );
541                    state.order_snapshots.insert(client_order_id, snapshot);
542                    emitter.send_order_event(OrderEventAny::Updated(updated));
543                }
544                // Identity cleaned up in dispatch_execution_fill when leaves_qty
545                // reaches zero, since there is no guaranteed ordering between
546                // the order and execution topics.
547            }
548            BybitOrderStatus::Canceled
549            | BybitOrderStatus::PartiallyFilledCanceled
550            | BybitOrderStatus::Deactivated => {
551                ensure_accepted_emitted(
552                    client_order_id,
553                    account_id,
554                    venue_order_id,
555                    &identity,
556                    emitter,
557                    state,
558                    ts_init,
559                );
560                let canceled = OrderCanceled::new(
561                    emitter.trader_id(),
562                    identity.strategy_id,
563                    identity.instrument_id,
564                    client_order_id,
565                    UUID4::new(),
566                    ts_init,
567                    ts_init,
568                    false,
569                    Some(venue_order_id),
570                    Some(account_id),
571                );
572                cleanup_terminal(client_order_id, state);
573                emitter.send_order_event(OrderEventAny::Canceled(canceled));
574            }
575        }
576    } else {
577        // Untracked order: fall back to report for reconciliation
578        match super::parse::parse_ws_order_status_report(order, instrument, account_id, ts_init) {
579            Ok(report) => emitter.send_order_status_report(report),
580            Err(e) => log::error!("Failed to parse order status report: {e}"),
581        }
582    }
583}
584
585/// Dispatches a single execution (fill) message.
586///
587/// Tracked orders are parsed directly to [`OrderFilled`]. Untracked orders
588/// fall back to [`FillReport`] for reconciliation.
589fn dispatch_execution_fill(
590    exec: &BybitWsAccountExecution,
591    instrument: &InstrumentAny,
592    emitter: &ExecutionEventEmitter,
593    state: &WsDispatchState,
594    account_id: AccountId,
595    ts_init: UnixNanos,
596) {
597    if exec.exec_type.is_exchange_generated() {
598        log::warn!(
599            "Exchange-generated execution: exec_type={:?}, symbol={}, order_id={}, order_link_id={}, side={:?}, qty={}, price={}",
600            exec.exec_type,
601            exec.symbol,
602            exec.order_id,
603            exec.order_link_id,
604            exec.side,
605            exec.exec_qty,
606            exec.exec_price,
607        );
608    }
609
610    let client_order_id = if exec.order_link_id.is_empty() {
611        None
612    } else {
613        Some(ClientOrderId::new(exec.order_link_id.as_str()))
614    };
615
616    let identity = client_order_id
617        .as_ref()
618        .and_then(|cid| state.order_identities.get(cid).map(|r| r.clone()));
619
620    if let (Some(client_order_id), Some(identity)) = (client_order_id, identity) {
621        let venue_order_id = VenueOrderId::new(exec.order_id.as_str());
622
623        ensure_accepted_emitted(
624            client_order_id,
625            account_id,
626            venue_order_id,
627            &identity,
628            emitter,
629            state,
630            ts_init,
631        );
632
633        match parse_order_filled(exec, instrument, &identity, emitter, account_id, ts_init) {
634            Ok(filled) => {
635                state.insert_filled(client_order_id);
636                state.triggered_orders.remove(&client_order_id);
637                emitter.send_order_event(OrderEventAny::Filled(filled));
638
639                if exec.leaves_qty == "0" {
640                    cleanup_terminal(client_order_id, state);
641                }
642            }
643            Err(e) => log::error!("Failed to parse OrderFilled for {client_order_id}: {e}"),
644        }
645    } else {
646        // Untracked: fall back to FillReport for reconciliation
647        match super::parse::parse_ws_fill_report(exec, account_id, instrument, ts_init) {
648            Ok(report) => emitter.send_fill_report(report),
649            Err(e) => log::error!("Failed to parse fill report: {e}"),
650        }
651    }
652}
653
654/// Dispatches a single fast-execution (fill) message.
655///
656/// The fast channel lacks fee and exec-type fields, so all fills route to
657/// [`FillReport`] with zero commission; liquidity side is derived from the
658/// payload's `isMaker` flag. Subscribe to the standard `execution` channel
659/// for full fill metadata (e.g. fees).
660fn dispatch_execution_fill_fast(
661    exec: &BybitWsAccountExecutionFast,
662    instrument: &InstrumentAny,
663    emitter: &ExecutionEventEmitter,
664    state: &WsDispatchState,
665    account_id: AccountId,
666    ts_init: UnixNanos,
667) {
668    let client_order_id = if exec.order_link_id.is_empty() {
669        None
670    } else {
671        Some(ClientOrderId::new(exec.order_link_id.as_str()))
672    };
673
674    let mut venue_position_id = None;
675
676    if let Some(cid) = client_order_id.as_ref()
677        && let Some(identity) = state.order_identities.get(cid).map(|r| r.clone())
678    {
679        venue_position_id = identity.venue_position_id;
680        let venue_order_id = VenueOrderId::new(exec.order_id.as_str());
681        ensure_accepted_emitted(
682            *cid,
683            account_id,
684            venue_order_id,
685            &identity,
686            emitter,
687            state,
688            ts_init,
689        );
690    }
691
692    match parse_ws_fill_report_fast(exec, account_id, instrument, venue_position_id, ts_init) {
693        Ok(report) => emitter.send_fill_report(report),
694        Err(e) => log::error!("Failed to parse fast fill report: {e}"),
695    }
696}
697
698/// Parses a Bybit execution message directly into an [`OrderFilled`] event.
699fn parse_order_filled(
700    exec: &BybitWsAccountExecution,
701    instrument: &InstrumentAny,
702    identity: &OrderIdentity,
703    emitter: &ExecutionEventEmitter,
704    account_id: AccountId,
705    ts_init: UnixNanos,
706) -> anyhow::Result<OrderFilled> {
707    let client_order_id = ClientOrderId::new(exec.order_link_id.as_str());
708    let venue_order_id = VenueOrderId::new(exec.order_id.as_str());
709    let trade_id =
710        TradeId::new_checked(exec.exec_id.as_str()).context("invalid execId in Bybit execution")?;
711
712    let last_qty = parse_quantity_with_precision(
713        &exec.exec_qty,
714        instrument.size_precision(),
715        "execution.execQty",
716    )?;
717    let last_px = parse_price_with_precision(
718        &exec.exec_price,
719        instrument.price_precision(),
720        "execution.execPrice",
721    )?;
722
723    let liquidity_side = if exec.is_maker {
724        LiquiditySide::Maker
725    } else {
726        LiquiditySide::Taker
727    };
728
729    let fee_decimal: Decimal = exec
730        .exec_fee
731        .parse()
732        .with_context(|| format!("failed to parse execFee='{}'", exec.exec_fee))?;
733    let commission_currency = instrument.quote_currency();
734    let commission = Money::from_decimal(fee_decimal, commission_currency).with_context(|| {
735        format!(
736            "failed to create commission from execFee='{}'",
737            exec.exec_fee
738        )
739    })?;
740
741    let ts_event = parse_millis_timestamp(&exec.exec_time, "execution.execTime")?;
742
743    Ok(OrderFilled::new(
744        emitter.trader_id(),
745        identity.strategy_id,
746        identity.instrument_id,
747        client_order_id,
748        venue_order_id,
749        account_id,
750        trade_id,
751        identity.order_side,
752        identity.order_type,
753        last_qty,
754        last_px,
755        commission_currency,
756        liquidity_side,
757        UUID4::new(),
758        ts_event,
759        ts_init,
760        false,
761        identity.venue_position_id,
762        Some(commission),
763    ))
764}
765
766/// Handles a Bybit WS order command response.
767fn dispatch_order_response(
768    resp: &super::messages::BybitWsOrderResponse,
769    emitter: &ExecutionEventEmitter,
770    state: &WsDispatchState,
771    ts_init: UnixNanos,
772) {
773    if resp.ret_code == 0 {
774        // Check for per-order failures in batch retExtInfo even on success
775        let pending = resp
776            .req_id
777            .as_ref()
778            .and_then(|rid| state.pending_requests.remove(rid))
779            .map(|(_, v)| v);
780
781        if let Some((cids, voids, pending_op)) = pending {
782            let batch_errors = resp.extract_batch_errors();
783            let data_array = resp.data.as_array();
784
785            for (idx, error) in batch_errors.iter().enumerate() {
786                if error.code == 0 {
787                    continue;
788                }
789
790                if is_bybit_ambiguous_order_error_code(error.code) {
791                    log::warn!(
792                        "Ambiguous batch order item failure at index {idx}: code={}, msg={}; awaiting reconciliation",
793                        error.code,
794                        error.msg,
795                    );
796                    continue;
797                }
798
799                // Extract orderLinkId from the corresponding data entry
800                let cid = data_array
801                    .and_then(|arr| arr.get(idx))
802                    .and_then(extract_order_link_id_from_data)
803                    .or_else(|| cids.get(idx).copied());
804
805                let Some(cid) = cid else {
806                    log::warn!(
807                        "Batch error at index {idx} without correlation: code={}, msg={}",
808                        error.code,
809                        error.msg,
810                    );
811                    continue;
812                };
813
814                let Some(identity) = state.order_identities.get(&cid).map(|r| r.clone()) else {
815                    log::warn!(
816                        "Batch error for untracked order: client_order_id={cid}, msg={}",
817                        error.msg,
818                    );
819                    continue;
820                };
821
822                let stored_void = voids.get(idx).and_then(|v| *v);
823
824                emit_rejection_for_op(
825                    &pending_op,
826                    cid,
827                    &identity,
828                    stored_void,
829                    &error.msg,
830                    emitter,
831                    state,
832                    ts_init,
833                );
834            }
835        }
836        return;
837    }
838
839    // Remove the pending request entry (if any) to get client_order_ids and op
840    let pending = resp
841        .req_id
842        .as_ref()
843        .and_then(|rid| state.pending_requests.remove(rid))
844        .map(|(_, v)| v);
845
846    let is_batch_response = is_batch_order_op(resp.op.as_str())
847        || pending.as_ref().is_some_and(|(cids, _, _)| cids.len() > 1);
848
849    if is_batch_response {
850        let order_count = pending.as_ref().map_or(0, |(cids, _, _)| cids.len());
851        log::warn!(
852            "Ambiguous batch order response failure for {order_count} orders: op={}, ret_code={}, ret_msg={}; awaiting reconciliation",
853            resp.op,
854            resp.ret_code,
855            resp.ret_msg,
856        );
857        return;
858    }
859
860    if is_bybit_ambiguous_order_error_code(resp.ret_code) {
861        log::warn!(
862            "Ambiguous order response failure: op={}, ret_code={}, ret_msg={}; awaiting reconciliation",
863            resp.op,
864            resp.ret_code,
865            resp.ret_msg,
866        );
867        return;
868    }
869
870    let effective_op = pending
871        .as_ref()
872        .map(|(_, _, op)| *op)
873        .or_else(|| pending_op_from_str(resp.op.as_str()))
874        .unwrap_or_else(|| {
875            log::warn!("Unknown order operation '{}', defaulting to Place", resp.op);
876            PendingOperation::Place
877        });
878
879    // Single-order rejection path
880    let client_order_id = extract_order_link_id_from_data(&resp.data).or_else(|| {
881        pending
882            .as_ref()
883            .and_then(|(cids, _, _)| cids.first().copied())
884    });
885
886    let stored_venue_order_id = pending
887        .as_ref()
888        .and_then(|(_, voids, _)| voids.first().and_then(|v| *v));
889
890    let Some(client_order_id) = client_order_id else {
891        log::warn!(
892            "Order response error without correlation: op={}, ret_code={}, ret_msg={}, req_id={:?}",
893            resp.op,
894            resp.ret_code,
895            resp.ret_msg,
896            resp.req_id,
897        );
898        return;
899    };
900
901    let Some(identity) = state
902        .order_identities
903        .get(&client_order_id)
904        .map(|r| r.clone())
905    else {
906        log::warn!(
907            "Order response error for untracked order: op={}, client_order_id={client_order_id}, ret_msg={}",
908            resp.op,
909            resp.ret_msg,
910        );
911        return;
912    };
913
914    let venue_order_id = extract_venue_order_id_from_data(&resp.data).or(stored_venue_order_id);
915
916    emit_rejection_for_op(
917        &effective_op,
918        client_order_id,
919        &identity,
920        venue_order_id,
921        &resp.ret_msg,
922        emitter,
923        state,
924        ts_init,
925    );
926}
927
928/// Emits the appropriate rejection event based on the pending operation type.
929#[expect(clippy::too_many_arguments)]
930fn emit_rejection_for_op(
931    pending_op: &PendingOperation,
932    client_order_id: ClientOrderId,
933    identity: &OrderIdentity,
934    venue_order_id: Option<VenueOrderId>,
935    reason: &str,
936    emitter: &ExecutionEventEmitter,
937    state: &WsDispatchState,
938    ts_init: UnixNanos,
939) {
940    match pending_op {
941        PendingOperation::Place => {
942            state.order_identities.remove(&client_order_id);
943            state.order_snapshots.remove(&client_order_id);
944            emitter.emit_order_rejected_event(
945                identity.strategy_id,
946                identity.instrument_id,
947                client_order_id,
948                reason,
949                ts_init,
950                false,
951            );
952        }
953        PendingOperation::Cancel => {
954            emitter.emit_order_cancel_rejected_event(
955                identity.strategy_id,
956                identity.instrument_id,
957                client_order_id,
958                venue_order_id,
959                reason,
960                ts_init,
961            );
962        }
963        PendingOperation::Amend => {
964            emitter.emit_order_modify_rejected_event(
965                identity.strategy_id,
966                identity.instrument_id,
967                client_order_id,
968                venue_order_id,
969                reason,
970                ts_init,
971            );
972        }
973    }
974}
975
976/// Maps an operation string to a `PendingOperation`.
977fn pending_op_from_str(op: &str) -> Option<PendingOperation> {
978    match op {
979        BYBIT_OP_ORDER_CREATE => Some(PendingOperation::Place),
980        BYBIT_OP_ORDER_CANCEL => Some(PendingOperation::Cancel),
981        BYBIT_OP_ORDER_AMEND => Some(PendingOperation::Amend),
982        _ => None,
983    }
984}
985
986fn is_batch_order_op(op: &str) -> bool {
987    matches!(
988        op,
989        BYBIT_OP_ORDER_CREATE_BATCH | BYBIT_OP_ORDER_AMEND_BATCH | BYBIT_OP_ORDER_CANCEL_BATCH
990    )
991}
992
993/// Parses an order snapshot from a WS order message for modification detection.
994fn parse_order_snapshot(
995    order: &BybitWsAccountOrder,
996    instrument: &InstrumentAny,
997) -> Option<OrderStateSnapshot> {
998    let quantity =
999        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty").ok()?;
1000
1001    let price = if !order.price.is_empty() && order.price != "0" {
1002        parse_price_with_precision(&order.price, instrument.price_precision(), "order.price").ok()
1003    } else {
1004        None
1005    };
1006
1007    let trigger_price = if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1008        parse_price_with_precision(
1009            &order.trigger_price,
1010            instrument.price_precision(),
1011            "order.triggerPrice",
1012        )
1013        .ok()
1014    } else {
1015        None
1016    };
1017
1018    Some(OrderStateSnapshot {
1019        quantity,
1020        price,
1021        trigger_price,
1022    })
1023}
1024
1025/// Returns whether the incoming snapshot differs from the stored snapshot.
1026fn is_snapshot_updated(
1027    snapshot: &OrderStateSnapshot,
1028    client_order_id: &ClientOrderId,
1029    state: &WsDispatchState,
1030) -> bool {
1031    let Some(previous) = state.order_snapshots.get(client_order_id) else {
1032        return false;
1033    };
1034
1035    if let (Some(prev_price), Some(new_price)) = (previous.price, snapshot.price)
1036        && prev_price != new_price
1037    {
1038        return true;
1039    }
1040
1041    if let (Some(prev_trigger), Some(new_trigger)) =
1042        (previous.trigger_price, snapshot.trigger_price)
1043        && prev_trigger != new_trigger
1044    {
1045        return true;
1046    }
1047
1048    previous.quantity != snapshot.quantity
1049}
1050
1051/// Synthesizes and emits `OrderAccepted` if one has not yet been emitted for
1052/// this order. Handles fast-filling orders that skip the `New` state on Bybit.
1053fn ensure_accepted_emitted(
1054    client_order_id: ClientOrderId,
1055    account_id: AccountId,
1056    venue_order_id: VenueOrderId,
1057    identity: &OrderIdentity,
1058    emitter: &ExecutionEventEmitter,
1059    state: &WsDispatchState,
1060    ts_init: UnixNanos,
1061) {
1062    if state.emitted_accepted.contains(&client_order_id) {
1063        return;
1064    }
1065    state.insert_accepted(client_order_id);
1066    let accepted = OrderAccepted::new(
1067        emitter.trader_id(),
1068        identity.strategy_id,
1069        identity.instrument_id,
1070        client_order_id,
1071        venue_order_id,
1072        account_id,
1073        UUID4::new(),
1074        ts_init,
1075        ts_init,
1076        false,
1077    );
1078    emitter.send_order_event(OrderEventAny::Accepted(accepted));
1079}
1080
1081/// Removes a terminal order from all tracking sets.
1082fn cleanup_terminal(client_order_id: ClientOrderId, state: &WsDispatchState) {
1083    state.order_identities.remove(&client_order_id);
1084    state.order_snapshots.remove(&client_order_id);
1085    state.emitted_accepted.remove(&client_order_id);
1086    state.triggered_orders.remove(&client_order_id);
1087    state.filled_orders.remove(&client_order_id);
1088}
1089
1090/// Tries to extract `orderLinkId` from the response data Value.
1091fn extract_order_link_id_from_data(data: &serde_json::Value) -> Option<ClientOrderId> {
1092    data.get("orderLinkId")
1093        .and_then(|v| v.as_str())
1094        .filter(|s| !s.is_empty())
1095        .map(ClientOrderId::new)
1096}
1097
1098/// Tries to extract `orderId` from the response data Value.
1099fn extract_venue_order_id_from_data(data: &serde_json::Value) -> Option<VenueOrderId> {
1100    data.get("orderId")
1101        .and_then(|v| v.as_str())
1102        .filter(|s| !s.is_empty())
1103        .map(VenueOrderId::new)
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use ahash::AHashMap;
1109    use nautilus_common::messages::{ExecutionEvent, execution::ExecutionReport};
1110    use nautilus_core::{
1111        UnixNanos,
1112        time::{AtomicTime, get_atomic_clock_realtime},
1113    };
1114    use nautilus_live::emitter::ExecutionEventEmitter;
1115    use nautilus_model::{
1116        enums::{AccountType, OrderSide, OrderType},
1117        events::OrderEventAny,
1118        identifiers::{
1119            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TraderId, VenueOrderId,
1120        },
1121        instruments::{Instrument, InstrumentAny},
1122    };
1123    use rstest::rstest;
1124    use ustr::Ustr;
1125
1126    use super::*;
1127    use crate::{
1128        common::{
1129            enums::{BybitOrderSide, BybitProductType},
1130            parse::parse_linear_instrument,
1131            testing::load_test_json,
1132        },
1133        http::models::{BybitFeeRate, BybitInstrumentLinearResponse},
1134        websocket::messages::{
1135            BybitWsAccountExecutionFastMsg, BybitWsMessage, BybitWsOrderResponse,
1136        },
1137    };
1138
1139    fn sample_fee_rate(
1140        symbol: &str,
1141        taker: &str,
1142        maker: &str,
1143        base_coin: Option<&str>,
1144    ) -> BybitFeeRate {
1145        BybitFeeRate {
1146            symbol: Ustr::from(symbol),
1147            taker_fee_rate: taker.to_string(),
1148            maker_fee_rate: maker.to_string(),
1149            base_coin: base_coin.map(Ustr::from),
1150        }
1151    }
1152
1153    fn linear_instrument() -> InstrumentAny {
1154        let json = load_test_json("http_get_instruments_linear.json");
1155        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1156        let instrument = &response.result.list[0];
1157        let fee_rate = sample_fee_rate("BTCUSDT", "0.00055", "0.0001", Some("BTC"));
1158        let ts = UnixNanos::new(1_700_000_000_000_000_000);
1159        parse_linear_instrument(instrument, &fee_rate, ts, ts).unwrap()
1160    }
1161
1162    fn build_instruments(instruments: &[InstrumentAny]) -> AHashMap<Ustr, InstrumentAny> {
1163        let mut map = AHashMap::new();
1164        for inst in instruments {
1165            map.insert(inst.id().symbol.inner(), inst.clone());
1166        }
1167        map
1168    }
1169
1170    fn test_account_id() -> AccountId {
1171        AccountId::from("BYBIT-001")
1172    }
1173
1174    fn create_emitter() -> (
1175        ExecutionEventEmitter,
1176        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
1177    ) {
1178        let clock = get_atomic_clock_realtime();
1179        let trader_id = TraderId::from("TESTER-001");
1180        let account_id = test_account_id();
1181        let mut emitter =
1182            ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
1183        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1184        emitter.set_sender(tx);
1185        (emitter, rx)
1186    }
1187
1188    fn default_identity() -> OrderIdentity {
1189        OrderIdentity {
1190            instrument_id: InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
1191            strategy_id: StrategyId::from("S-001"),
1192            order_side: OrderSide::Buy,
1193            order_type: OrderType::Limit,
1194            venue_position_id: None,
1195        }
1196    }
1197
1198    #[rstest]
1199    fn test_dispatch_tracked_canceled_order_emits_accepted_then_canceled() {
1200        let instrument = linear_instrument();
1201        let instruments = build_instruments(std::slice::from_ref(&instrument));
1202        let (emitter, mut rx) = create_emitter();
1203        let clock = get_atomic_clock_realtime();
1204        let state = WsDispatchState::default();
1205
1206        // Fixture has orderStatus=Cancelled
1207        let json = load_test_json("ws_account_order.json");
1208        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1209            serde_json::from_str(&json).unwrap();
1210
1211        if let Some(order) = msg.data.first()
1212            && !order.order_link_id.is_empty()
1213        {
1214            let cid = ClientOrderId::new(order.order_link_id.as_str());
1215            state.order_identities.insert(cid, default_identity());
1216        }
1217
1218        let ws_msg = BybitWsMessage::AccountOrder(msg);
1219        dispatch_ws_message(
1220            &ws_msg,
1221            &emitter,
1222            &state,
1223            test_account_id(),
1224            &instruments,
1225            clock,
1226        );
1227
1228        // First: synthesized Accepted
1229        let event1 = rx.try_recv().unwrap();
1230        assert!(
1231            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(ref a)) if a.strategy_id == StrategyId::from("S-001")),
1232            "Expected Accepted, found {event1:?}"
1233        );
1234
1235        // Second: Canceled (from Cancelled status)
1236        let event2 = rx.try_recv().unwrap();
1237        assert!(
1238            matches!(event2, ExecutionEvent::Order(OrderEventAny::Canceled(_))),
1239            "Expected Canceled, found {event2:?}"
1240        );
1241    }
1242
1243    #[rstest]
1244    fn test_dispatch_untracked_order_emits_report() {
1245        let instrument = linear_instrument();
1246        let instruments = build_instruments(std::slice::from_ref(&instrument));
1247        let (emitter, mut rx) = create_emitter();
1248        let clock = get_atomic_clock_realtime();
1249        let state = WsDispatchState::default();
1250
1251        let json = load_test_json("ws_account_order.json");
1252        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1253            serde_json::from_str(&json).unwrap();
1254
1255        // No identity registered → untracked
1256        let ws_msg = BybitWsMessage::AccountOrder(msg);
1257        dispatch_ws_message(
1258            &ws_msg,
1259            &emitter,
1260            &state,
1261            test_account_id(),
1262            &instruments,
1263            clock,
1264        );
1265
1266        let event = rx.try_recv().unwrap();
1267        assert!(matches!(
1268            event,
1269            ExecutionEvent::Report(ExecutionReport::Order(_))
1270        ));
1271    }
1272
1273    #[rstest]
1274    fn test_dispatch_tracked_execution_emits_order_filled() {
1275        let instrument = linear_instrument();
1276        let instruments = build_instruments(std::slice::from_ref(&instrument));
1277        let (emitter, mut rx) = create_emitter();
1278        let clock = get_atomic_clock_realtime();
1279        let state = WsDispatchState::default();
1280
1281        let json = load_test_json("ws_account_execution.json");
1282        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1283            serde_json::from_str(&json).unwrap();
1284
1285        // Register identity for the execution's orderLinkId
1286        if let Some(exec) = msg.data.first()
1287            && !exec.order_link_id.is_empty()
1288        {
1289            let cid = ClientOrderId::new(exec.order_link_id.as_str());
1290            state.order_identities.insert(cid, default_identity());
1291        }
1292
1293        let ws_msg = BybitWsMessage::AccountExecution(msg);
1294        dispatch_ws_message(
1295            &ws_msg,
1296            &emitter,
1297            &state,
1298            test_account_id(),
1299            &instruments,
1300            clock,
1301        );
1302
1303        // First event should be synthesized Accepted
1304        let event1 = rx.try_recv().unwrap();
1305        assert!(
1306            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1307            "Expected Accepted, found {event1:?}"
1308        );
1309
1310        // Second event should be OrderFilled
1311        let event2 = rx.try_recv().unwrap();
1312        match event2 {
1313            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1314                assert_eq!(filled.strategy_id, StrategyId::from("S-001"));
1315                assert_eq!(filled.order_side, OrderSide::Buy);
1316                assert_eq!(filled.order_type, OrderType::Limit);
1317            }
1318            other => panic!("Expected Filled event, found {other:?}"),
1319        }
1320    }
1321
1322    #[rstest]
1323    fn test_dispatch_tracked_execution_preserves_venue_position_id() {
1324        let instrument = linear_instrument();
1325        let instruments = build_instruments(std::slice::from_ref(&instrument));
1326        let (emitter, mut rx) = create_emitter();
1327        let clock = get_atomic_clock_realtime();
1328        let state = WsDispatchState::default();
1329
1330        let json = load_test_json("ws_account_execution.json");
1331        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1332            serde_json::from_str(&json).unwrap();
1333        let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
1334
1335        if let Some(exec) = msg.data.first()
1336            && !exec.order_link_id.is_empty()
1337        {
1338            let cid = ClientOrderId::new(exec.order_link_id.as_str());
1339            state.order_identities.insert(
1340                cid,
1341                OrderIdentity {
1342                    venue_position_id: Some(venue_position_id),
1343                    ..default_identity()
1344                },
1345            );
1346        }
1347
1348        let ws_msg = BybitWsMessage::AccountExecution(msg);
1349        dispatch_ws_message(
1350            &ws_msg,
1351            &emitter,
1352            &state,
1353            test_account_id(),
1354            &instruments,
1355            clock,
1356        );
1357
1358        let _accepted = rx.try_recv().unwrap();
1359        let event = rx.try_recv().unwrap();
1360        match event {
1361            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1362                assert_eq!(filled.position_id, Some(venue_position_id));
1363            }
1364            other => panic!("Expected Filled event, found {other:?}"),
1365        }
1366    }
1367
1368    fn fast_execution_msg(is_maker: bool, order_link_id: &str) -> BybitWsAccountExecutionFastMsg {
1369        BybitWsAccountExecutionFastMsg {
1370            topic: Ustr::from("execution.fast"),
1371            id: String::new(),
1372            creation_time: 1_716_800_399_338,
1373            data: vec![BybitWsAccountExecutionFast {
1374                category: BybitProductType::Linear,
1375                symbol: Ustr::from("BTCUSDT"),
1376                exec_id: "fast-1".to_string(),
1377                exec_price: "50000.0".to_string(),
1378                exec_qty: "0.5".to_string(),
1379                order_id: Ustr::from("ord-1"),
1380                order_link_id: Ustr::from(order_link_id),
1381                side: BybitOrderSide::Buy,
1382                exec_time: "1716800399334".to_string(),
1383                is_maker,
1384                seq: 42,
1385            }],
1386        }
1387    }
1388
1389    #[rstest]
1390    fn test_dispatch_tracked_fast_execution_preserves_venue_position_id() {
1391        let instrument = linear_instrument();
1392        let instruments = build_instruments(std::slice::from_ref(&instrument));
1393        let (emitter, mut rx) = create_emitter();
1394        let clock = get_atomic_clock_realtime();
1395        let state = WsDispatchState::default();
1396        let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
1397
1398        // Taker fast fill (orderLinkId populated) so the identity lookup hits.
1399        let msg = fast_execution_msg(false, "link-1");
1400        let cid = ClientOrderId::new(msg.data[0].order_link_id.as_str());
1401        state.order_identities.insert(
1402            cid,
1403            OrderIdentity {
1404                venue_position_id: Some(venue_position_id),
1405                ..default_identity()
1406            },
1407        );
1408
1409        let ws_msg = BybitWsMessage::AccountExecutionFast(msg);
1410        dispatch_ws_message(
1411            &ws_msg,
1412            &emitter,
1413            &state,
1414            test_account_id(),
1415            &instruments,
1416            clock,
1417        );
1418
1419        // First event: synthesized OrderAccepted from the identity hit.
1420        let event1 = rx.try_recv().unwrap();
1421        assert!(
1422            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1423            "Expected Accepted, found {event1:?}",
1424        );
1425
1426        // Second event: FillReport carrying the hedge venue_position_id.
1427        let event2 = rx.try_recv().unwrap();
1428        match event2 {
1429            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
1430                assert_eq!(report.venue_position_id, Some(venue_position_id));
1431                assert_eq!(report.client_order_id, Some(cid));
1432            }
1433            other => panic!("Expected FillReport, found {other:?}"),
1434        }
1435    }
1436
1437    #[rstest]
1438    fn test_dispatch_untracked_fast_execution_emits_fill_report_without_position() {
1439        let instrument = linear_instrument();
1440        let instruments = build_instruments(std::slice::from_ref(&instrument));
1441        let (emitter, mut rx) = create_emitter();
1442        let clock = get_atomic_clock_realtime();
1443        let state = WsDispatchState::default();
1444
1445        // Maker fast fill: orderLinkId is empty per venue docs, so no identity lookup.
1446        let msg = fast_execution_msg(true, "");
1447        let ws_msg = BybitWsMessage::AccountExecutionFast(msg);
1448        dispatch_ws_message(
1449            &ws_msg,
1450            &emitter,
1451            &state,
1452            test_account_id(),
1453            &instruments,
1454            clock,
1455        );
1456
1457        // No OrderAccepted is synthesized for untracked fills.
1458        let event = rx.try_recv().unwrap();
1459        match event {
1460            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
1461                assert_eq!(report.client_order_id, None);
1462                assert_eq!(report.venue_position_id, None);
1463                assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1464                assert_eq!(report.commission.as_f64(), 0.0);
1465            }
1466            other => panic!("Expected FillReport, found {other:?}"),
1467        }
1468    }
1469
1470    #[rstest]
1471    fn test_dispatch_untracked_execution_emits_fill_report() {
1472        let instrument = linear_instrument();
1473        let instruments = build_instruments(std::slice::from_ref(&instrument));
1474        let (emitter, mut rx) = create_emitter();
1475        let clock = get_atomic_clock_realtime();
1476        let state = WsDispatchState::default();
1477
1478        let json = load_test_json("ws_account_execution.json");
1479        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1480            serde_json::from_str(&json).unwrap();
1481
1482        // No identity registered → untracked
1483        let ws_msg = BybitWsMessage::AccountExecution(msg);
1484        dispatch_ws_message(
1485            &ws_msg,
1486            &emitter,
1487            &state,
1488            test_account_id(),
1489            &instruments,
1490            clock,
1491        );
1492
1493        let event = rx.try_recv().unwrap();
1494        assert!(matches!(
1495            event,
1496            ExecutionEvent::Report(ExecutionReport::Fill(_))
1497        ));
1498    }
1499
1500    #[rstest]
1501    fn test_dispatch_wallet_emits_account_state() {
1502        let instruments = AHashMap::new();
1503        let (emitter, mut rx) = create_emitter();
1504        let clock = get_atomic_clock_realtime();
1505        let state = WsDispatchState::default();
1506
1507        let json = load_test_json("ws_account_wallet.json");
1508        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1509            serde_json::from_str(&json).unwrap();
1510        let ws_msg = BybitWsMessage::AccountWallet(msg);
1511
1512        dispatch_ws_message(
1513            &ws_msg,
1514            &emitter,
1515            &state,
1516            test_account_id(),
1517            &instruments,
1518            clock,
1519        );
1520
1521        let event = rx.try_recv().unwrap();
1522        assert!(matches!(event, ExecutionEvent::Account(_)));
1523    }
1524
1525    #[rstest]
1526    fn test_dispatch_data_message_ignored() {
1527        let instruments = AHashMap::new();
1528        let (emitter, mut rx) = create_emitter();
1529        let clock = get_atomic_clock_realtime();
1530        let state = WsDispatchState::default();
1531
1532        let json = load_test_json("ws_public_trade.json");
1533        let msg: crate::websocket::messages::BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
1534        let ws_msg = BybitWsMessage::Trade(msg);
1535
1536        dispatch_ws_message(
1537            &ws_msg,
1538            &emitter,
1539            &state,
1540            test_account_id(),
1541            &instruments,
1542            clock,
1543        );
1544
1545        rx.try_recv().unwrap_err();
1546    }
1547
1548    #[rstest]
1549    fn test_accepted_dedup_prevents_duplicate() {
1550        let instrument = linear_instrument();
1551        let instruments = build_instruments(std::slice::from_ref(&instrument));
1552        let (emitter, mut rx) = create_emitter();
1553        let clock = get_atomic_clock_realtime();
1554        let state = WsDispatchState::default();
1555
1556        // Fixture has orderStatus=Cancelled. Patch to New for this dedup test.
1557        let json = load_test_json("ws_account_order.json");
1558        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1559        value["data"][0]["orderStatus"] = serde_json::Value::String("New".to_string());
1560        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1561            serde_json::from_value(value).unwrap();
1562
1563        if let Some(order) = msg.data.first()
1564            && !order.order_link_id.is_empty()
1565        {
1566            let cid = ClientOrderId::new(order.order_link_id.as_str());
1567            state.order_identities.insert(cid, default_identity());
1568        }
1569
1570        let ws_msg = BybitWsMessage::AccountOrder(msg.clone());
1571        dispatch_ws_message(
1572            &ws_msg,
1573            &emitter,
1574            &state,
1575            test_account_id(),
1576            &instruments,
1577            clock,
1578        );
1579
1580        let event = rx.try_recv().unwrap();
1581        assert!(matches!(
1582            event,
1583            ExecutionEvent::Order(OrderEventAny::Accepted(_))
1584        ));
1585
1586        // Dispatch the same message again: dedup should suppress the duplicate
1587        let ws_msg2 = BybitWsMessage::AccountOrder(msg);
1588        dispatch_ws_message(
1589            &ws_msg2,
1590            &emitter,
1591            &state,
1592            test_account_id(),
1593            &instruments,
1594            clock,
1595        );
1596
1597        rx.try_recv().unwrap_err();
1598    }
1599
1600    fn new_order_value() -> serde_json::Value {
1601        let json = load_test_json("ws_account_order.json");
1602        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1603        value["data"][0]["orderStatus"] = serde_json::Value::String("New".to_string());
1604        value
1605    }
1606
1607    struct DispatchTestContext {
1608        instruments: AHashMap<Ustr, InstrumentAny>,
1609        emitter: ExecutionEventEmitter,
1610        rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
1611        clock: &'static AtomicTime,
1612        state: WsDispatchState,
1613    }
1614
1615    impl DispatchTestContext {
1616        fn new() -> Self {
1617            let instrument = linear_instrument();
1618            let instruments = build_instruments(std::slice::from_ref(&instrument));
1619            let (emitter, rx) = create_emitter();
1620            let clock = get_atomic_clock_realtime();
1621            let state = WsDispatchState::default();
1622            Self {
1623                instruments,
1624                emitter,
1625                rx,
1626                clock,
1627                state,
1628            }
1629        }
1630
1631        fn accept_order(&mut self, value: &serde_json::Value) {
1632            let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1633                serde_json::from_value(value.clone()).unwrap();
1634
1635            if let Some(order) = msg.data.first()
1636                && !order.order_link_id.is_empty()
1637                && !self
1638                    .state
1639                    .order_identities
1640                    .contains_key(&ClientOrderId::new(order.order_link_id.as_str()))
1641            {
1642                let cid = ClientOrderId::new(order.order_link_id.as_str());
1643                self.state.order_identities.insert(cid, default_identity());
1644            }
1645
1646            self.dispatch_value(value);
1647
1648            let event = self.rx.try_recv().unwrap();
1649            assert!(
1650                matches!(event, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1651                "Expected Accepted, found {event:?}"
1652            );
1653        }
1654
1655        fn dispatch_value(&self, value: &serde_json::Value) {
1656            let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1657                serde_json::from_value(value.clone()).unwrap();
1658            let ws_msg = BybitWsMessage::AccountOrder(msg);
1659            dispatch_ws_message(
1660                &ws_msg,
1661                &self.emitter,
1662                &self.state,
1663                test_account_id(),
1664                &self.instruments,
1665                self.clock,
1666            );
1667        }
1668
1669        fn recv_updated(&mut self) -> OrderUpdated {
1670            let event = self.rx.try_recv().unwrap();
1671            match event {
1672                ExecutionEvent::Order(OrderEventAny::Updated(updated)) => updated,
1673                other => panic!("Expected Updated event, found {other:?}"),
1674            }
1675        }
1676    }
1677
1678    #[rstest]
1679    fn test_dispatch_order_updated_on_price_change() {
1680        let mut ctx = DispatchTestContext::new();
1681        let value = new_order_value();
1682        ctx.accept_order(&value);
1683
1684        let mut amended = value;
1685        amended["data"][0]["price"] = serde_json::Value::String("31000".to_string());
1686        ctx.dispatch_value(&amended);
1687
1688        let updated = ctx.recv_updated();
1689        assert_eq!(updated.client_order_id, ClientOrderId::from("client-1"));
1690        assert_eq!(updated.price, Some(Price::from("31000.00")));
1691        assert_eq!(updated.quantity, Quantity::from("0.010"));
1692        assert_eq!(updated.trigger_price, None);
1693        assert!(updated.venue_order_id.is_some());
1694    }
1695
1696    #[rstest]
1697    fn test_dispatch_order_updated_on_quantity_change() {
1698        let mut ctx = DispatchTestContext::new();
1699        let value = new_order_value();
1700        ctx.accept_order(&value);
1701
1702        let mut amended = value;
1703        amended["data"][0]["qty"] = serde_json::Value::String("0.020".to_string());
1704        ctx.dispatch_value(&amended);
1705
1706        let updated = ctx.recv_updated();
1707        assert_eq!(updated.quantity, Quantity::from("0.020"));
1708        assert_eq!(updated.price, Some(Price::from("30000.00")));
1709    }
1710
1711    #[rstest]
1712    fn test_dispatch_order_updated_on_trigger_price_change() {
1713        let mut ctx = DispatchTestContext::new();
1714        let mut value = new_order_value();
1715        value["data"][0]["triggerPrice"] = serde_json::Value::String("29000".to_string());
1716        ctx.accept_order(&value);
1717
1718        let mut amended = value;
1719        amended["data"][0]["triggerPrice"] = serde_json::Value::String("28000".to_string());
1720        ctx.dispatch_value(&amended);
1721
1722        let updated = ctx.recv_updated();
1723        assert_eq!(updated.trigger_price, Some(Price::from("28000.00")));
1724        assert_eq!(updated.price, Some(Price::from("30000.00")));
1725    }
1726
1727    #[rstest]
1728    fn test_dispatch_dedup_suppresses_identical_after_snapshot() {
1729        let mut ctx = DispatchTestContext::new();
1730        let value = new_order_value();
1731        ctx.accept_order(&value);
1732
1733        ctx.dispatch_value(&value);
1734
1735        assert!(
1736            ctx.rx.try_recv().is_err(),
1737            "Expected no event for identical redelivery"
1738        );
1739    }
1740
1741    #[rstest]
1742    fn test_dispatch_order_updated_stores_snapshot_for_subsequent_change() {
1743        let mut ctx = DispatchTestContext::new();
1744        let value = new_order_value();
1745        ctx.accept_order(&value);
1746
1747        let mut amended1 = value.clone();
1748        amended1["data"][0]["price"] = serde_json::Value::String("31000".to_string());
1749        ctx.dispatch_value(&amended1);
1750        let _result = ctx.recv_updated();
1751
1752        let mut amended2 = value;
1753        amended2["data"][0]["price"] = serde_json::Value::String("32000".to_string());
1754        ctx.dispatch_value(&amended2);
1755
1756        let updated = ctx.recv_updated();
1757        assert_eq!(updated.price, Some(Price::from("32000.00")));
1758    }
1759
1760    #[rstest]
1761    fn test_dispatch_accepted_with_seeded_snapshot_emits_updated_for_bbo() {
1762        let mut ctx = DispatchTestContext::new();
1763        let value = new_order_value();
1764        let cid = ClientOrderId::from("client-1");
1765        ctx.state.order_identities.insert(cid, default_identity());
1766        ctx.state.order_snapshots.insert(
1767            cid,
1768            OrderStateSnapshot {
1769                quantity: Quantity::from("0.010"),
1770                price: Some(Price::from("29000.00")),
1771                trigger_price: None,
1772            },
1773        );
1774
1775        ctx.dispatch_value(&value);
1776
1777        let accepted = ctx.rx.try_recv().unwrap();
1778        assert!(
1779            matches!(accepted, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1780            "Expected Accepted first, found {accepted:?}"
1781        );
1782
1783        let updated = ctx.recv_updated();
1784        assert_eq!(updated.client_order_id, cid);
1785        assert_eq!(updated.price, Some(Price::from("30000.00")));
1786        assert_eq!(updated.quantity, Quantity::from("0.010"));
1787        assert!(updated.venue_order_id.is_some());
1788    }
1789
1790    #[rstest]
1791    fn test_emit_rejection_for_place_clears_snapshot() {
1792        let ctx = DispatchTestContext::new();
1793        let cid = ClientOrderId::from("client-1");
1794        let identity = default_identity();
1795        ctx.state.order_identities.insert(cid, identity.clone());
1796        ctx.state.order_snapshots.insert(
1797            cid,
1798            OrderStateSnapshot {
1799                quantity: Quantity::from("0.010"),
1800                price: Some(Price::from("30000.00")),
1801                trigger_price: None,
1802            },
1803        );
1804
1805        emit_rejection_for_op(
1806            &PendingOperation::Place,
1807            cid,
1808            &identity,
1809            None,
1810            "rejected",
1811            &ctx.emitter,
1812            &ctx.state,
1813            UnixNanos::from(1u64),
1814        );
1815
1816        assert!(!ctx.state.order_identities.contains_key(&cid));
1817        assert!(!ctx.state.order_snapshots.contains_key(&cid));
1818    }
1819
1820    fn order_response(
1821        op: &str,
1822        ret_code: i64,
1823        ret_msg: &str,
1824        req_id: &str,
1825        data: serde_json::Value,
1826        ret_ext_info: Option<serde_json::Value>,
1827    ) -> BybitWsOrderResponse {
1828        BybitWsOrderResponse {
1829            op: Ustr::from(op),
1830            conn_id: Some("test-conn-id".to_string()),
1831            ret_code,
1832            ret_msg: ret_msg.to_string(),
1833            data,
1834            req_id: Some(req_id.to_string()),
1835            header: None,
1836            ret_ext_info,
1837        }
1838    }
1839
1840    #[rstest]
1841    fn test_dispatch_single_cancel_rejection_emits_cancel_rejected() {
1842        let mut ctx = DispatchTestContext::new();
1843        let cid = ClientOrderId::from("cancel-reject-1");
1844        let venue_order_id = VenueOrderId::from("venue-cancel-1");
1845        ctx.state.order_identities.insert(cid, default_identity());
1846        ctx.state.pending_requests.insert(
1847            "req-cancel".to_string(),
1848            (
1849                vec![cid],
1850                vec![Some(venue_order_id)],
1851                PendingOperation::Cancel,
1852            ),
1853        );
1854
1855        let response = order_response(
1856            BYBIT_OP_ORDER_CANCEL,
1857            110001,
1858            "Order does not exist.",
1859            "req-cancel",
1860            serde_json::json!({
1861                "orderId": venue_order_id.to_string(),
1862                "orderLinkId": cid.to_string(),
1863            }),
1864            None,
1865        );
1866
1867        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
1868
1869        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
1870        assert!(
1871            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected)) if rejected.client_order_id == cid),
1872            "Expected CancelRejected for {cid}, found {event:?}"
1873        );
1874        assert!(
1875            ctx.rx.try_recv().is_err(),
1876            "Expected no extra events for single cancel rejection"
1877        );
1878    }
1879
1880    #[rstest]
1881    fn test_dispatch_single_modify_rejection_emits_modify_rejected() {
1882        let mut ctx = DispatchTestContext::new();
1883        let cid = ClientOrderId::from("modify-reject-1");
1884        let venue_order_id = VenueOrderId::from("venue-modify-1");
1885        ctx.state.order_identities.insert(cid, default_identity());
1886        ctx.state.pending_requests.insert(
1887            "req-modify".to_string(),
1888            (
1889                vec![cid],
1890                vec![Some(venue_order_id)],
1891                PendingOperation::Amend,
1892            ),
1893        );
1894
1895        let response = order_response(
1896            BYBIT_OP_ORDER_AMEND,
1897            110003,
1898            "Order price exceeds allowable range.",
1899            "req-modify",
1900            serde_json::json!({
1901                "orderId": venue_order_id.to_string(),
1902                "orderLinkId": cid.to_string(),
1903            }),
1904            None,
1905        );
1906
1907        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
1908
1909        let event = ctx.rx.try_recv().expect("expected ModifyRejected event");
1910        assert!(
1911            matches!(event, ExecutionEvent::Order(OrderEventAny::ModifyRejected(ref rejected)) if rejected.client_order_id == cid),
1912            "Expected ModifyRejected for {cid}, found {event:?}"
1913        );
1914        assert!(
1915            ctx.rx.try_recv().is_err(),
1916            "Expected no extra events for single modify rejection"
1917        );
1918    }
1919
1920    #[rstest]
1921    fn test_dispatch_single_cancel_rate_limit_keeps_outcome_unresolved() {
1922        let mut ctx = DispatchTestContext::new();
1923        let cid = ClientOrderId::from("cancel-rate-limit-1");
1924        let venue_order_id = VenueOrderId::from("venue-cancel-rate-limit-1");
1925        ctx.state.order_identities.insert(cid, default_identity());
1926        ctx.state.pending_requests.insert(
1927            "req-cancel-rate-limit".to_string(),
1928            (
1929                vec![cid],
1930                vec![Some(venue_order_id)],
1931                PendingOperation::Cancel,
1932            ),
1933        );
1934
1935        let response = order_response(
1936            BYBIT_OP_ORDER_CANCEL,
1937            10429,
1938            "System level frequency protection.",
1939            "req-cancel-rate-limit",
1940            serde_json::json!({
1941                "orderId": venue_order_id.to_string(),
1942                "orderLinkId": cid.to_string(),
1943            }),
1944            None,
1945        );
1946
1947        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
1948
1949        assert!(
1950            ctx.rx.try_recv().is_err(),
1951            "Expected no CancelRejected event for rate-limit response"
1952        );
1953        assert!(ctx.state.order_identities.contains_key(&cid));
1954    }
1955
1956    #[rstest]
1957    fn test_dispatch_single_modify_server_error_keeps_outcome_unresolved() {
1958        let mut ctx = DispatchTestContext::new();
1959        let cid = ClientOrderId::from("modify-server-error-1");
1960        let venue_order_id = VenueOrderId::from("venue-modify-server-error-1");
1961        ctx.state.order_identities.insert(cid, default_identity());
1962        ctx.state.pending_requests.insert(
1963            "req-modify-server-error".to_string(),
1964            (
1965                vec![cid],
1966                vec![Some(venue_order_id)],
1967                PendingOperation::Amend,
1968            ),
1969        );
1970
1971        let response = order_response(
1972            BYBIT_OP_ORDER_AMEND,
1973            10016,
1974            "Internal server error.",
1975            "req-modify-server-error",
1976            serde_json::json!({
1977                "orderId": venue_order_id.to_string(),
1978                "orderLinkId": cid.to_string(),
1979            }),
1980            None,
1981        );
1982
1983        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
1984
1985        assert!(
1986            ctx.rx.try_recv().is_err(),
1987            "Expected no ModifyRejected event for server error response"
1988        );
1989        assert!(ctx.state.order_identities.contains_key(&cid));
1990    }
1991
1992    #[rstest]
1993    fn test_dispatch_batch_cancel_top_level_failure_does_not_emit_cancel_rejected() {
1994        let mut ctx = DispatchTestContext::new();
1995        let cid_1 = ClientOrderId::from("batch-cancel-1");
1996        let cid_2 = ClientOrderId::from("batch-cancel-2");
1997        ctx.state.order_identities.insert(cid_1, default_identity());
1998        ctx.state.order_identities.insert(cid_2, default_identity());
1999        ctx.state.pending_requests.insert(
2000            "req-batch-cancel".to_string(),
2001            (
2002                vec![cid_1, cid_2],
2003                vec![
2004                    Some(VenueOrderId::from("venue-batch-1")),
2005                    Some(VenueOrderId::from("venue-batch-2")),
2006                ],
2007                PendingOperation::Cancel,
2008            ),
2009        );
2010
2011        let response = order_response(
2012            BYBIT_OP_ORDER_CANCEL_BATCH,
2013            10016,
2014            "Internal server error.",
2015            "req-batch-cancel",
2016            serde_json::json!({}),
2017            None,
2018        );
2019
2020        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2021
2022        assert!(
2023            ctx.rx.try_recv().is_err(),
2024            "Expected no CancelRejected events for ambiguous batch failure"
2025        );
2026        assert!(ctx.state.order_identities.contains_key(&cid_1));
2027        assert!(ctx.state.order_identities.contains_key(&cid_2));
2028    }
2029
2030    #[rstest]
2031    fn test_dispatch_single_item_batch_cancel_top_level_failure_does_not_emit_cancel_rejected() {
2032        let mut ctx = DispatchTestContext::new();
2033        let cid = ClientOrderId::from("batch-cancel-single");
2034        ctx.state.order_identities.insert(cid, default_identity());
2035        ctx.state.pending_requests.insert(
2036            "req-batch-cancel-single".to_string(),
2037            (
2038                vec![cid],
2039                vec![Some(VenueOrderId::from("venue-batch-single"))],
2040                PendingOperation::Cancel,
2041            ),
2042        );
2043
2044        let response = order_response(
2045            BYBIT_OP_ORDER_CANCEL_BATCH,
2046            10016,
2047            "Internal server error.",
2048            "req-batch-cancel-single",
2049            serde_json::json!({}),
2050            None,
2051        );
2052
2053        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2054
2055        assert!(
2056            ctx.rx.try_recv().is_err(),
2057            "Expected no CancelRejected event for ambiguous one-item batch failure"
2058        );
2059        assert!(ctx.state.order_identities.contains_key(&cid));
2060    }
2061
2062    #[rstest]
2063    fn test_dispatch_batch_cancel_per_item_error_emits_only_failed_item() {
2064        let mut ctx = DispatchTestContext::new();
2065        let cid_1 = ClientOrderId::from("batch-cancel-ok");
2066        let cid_2 = ClientOrderId::from("batch-cancel-reject");
2067        ctx.state.order_identities.insert(cid_1, default_identity());
2068        ctx.state.order_identities.insert(cid_2, default_identity());
2069        ctx.state.pending_requests.insert(
2070            "req-batch-mixed".to_string(),
2071            (
2072                vec![cid_1, cid_2],
2073                vec![
2074                    Some(VenueOrderId::from("venue-batch-ok")),
2075                    Some(VenueOrderId::from("venue-batch-reject")),
2076                ],
2077                PendingOperation::Cancel,
2078            ),
2079        );
2080
2081        let response = order_response(
2082            BYBIT_OP_ORDER_CANCEL_BATCH,
2083            0,
2084            "OK",
2085            "req-batch-mixed",
2086            serde_json::json!({
2087                "list": [
2088                    {"orderId": "venue-batch-ok", "orderLinkId": cid_1.to_string()},
2089                    {"orderId": "venue-batch-reject", "orderLinkId": cid_2.to_string()}
2090                ]
2091            }),
2092            Some(serde_json::json!({
2093                "list": [
2094                    {"code": 0, "msg": "OK"},
2095                    {"code": 170213, "msg": "Order does not exist."}
2096                ]
2097            })),
2098        );
2099
2100        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2101
2102        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2103        assert!(
2104            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected)) if rejected.client_order_id == cid_2),
2105            "Expected CancelRejected for failed batch item {cid_2}, found {event:?}"
2106        );
2107        assert!(
2108            ctx.rx.try_recv().is_err(),
2109            "Expected no CancelRejected event for successful batch item"
2110        );
2111    }
2112
2113    #[rstest]
2114    fn test_dispatch_batch_cancel_per_item_rate_limit_keeps_outcome_unresolved() {
2115        let mut ctx = DispatchTestContext::new();
2116        let cid = ClientOrderId::from("batch-cancel-rate-limit");
2117        let venue_order_id = VenueOrderId::from("venue-batch-rate-limit");
2118        ctx.state.order_identities.insert(cid, default_identity());
2119        ctx.state.pending_requests.insert(
2120            "req-batch-rate-limit".to_string(),
2121            (
2122                vec![cid],
2123                vec![Some(venue_order_id)],
2124                PendingOperation::Cancel,
2125            ),
2126        );
2127
2128        let response = order_response(
2129            BYBIT_OP_ORDER_CANCEL_BATCH,
2130            0,
2131            "OK",
2132            "req-batch-rate-limit",
2133            serde_json::json!({
2134                "list": [
2135                    {"orderId": venue_order_id.to_string(), "orderLinkId": cid.to_string()}
2136                ]
2137            }),
2138            Some(serde_json::json!({
2139                "list": [
2140                    {"code": 10006, "msg": "Too many visits."}
2141                ]
2142            })),
2143        );
2144
2145        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2146
2147        assert!(
2148            ctx.rx.try_recv().is_err(),
2149            "Expected no CancelRejected event for per-item rate-limit response"
2150        );
2151        assert!(ctx.state.order_identities.contains_key(&cid));
2152    }
2153
2154    #[rstest]
2155    fn test_dispatch_filled_with_seeded_snapshot_emits_updated_for_bbo() {
2156        let mut ctx = DispatchTestContext::new();
2157        let mut value = new_order_value();
2158        value["data"][0]["orderStatus"] = serde_json::Value::String("Filled".to_string());
2159        value["data"][0]["cumExecQty"] = serde_json::Value::String("0.010".to_string());
2160        let cid = ClientOrderId::from("client-1");
2161        ctx.state.order_identities.insert(cid, default_identity());
2162        ctx.state.order_snapshots.insert(
2163            cid,
2164            OrderStateSnapshot {
2165                quantity: Quantity::from("0.010"),
2166                price: Some(Price::from("29000.00")),
2167                trigger_price: None,
2168            },
2169        );
2170
2171        ctx.dispatch_value(&value);
2172
2173        let accepted_event = ctx.rx.try_recv().unwrap();
2174        let accepted = match accepted_event {
2175            ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) => accepted,
2176            other => panic!("Expected Accepted first, found {other:?}"),
2177        };
2178        assert_eq!(accepted.client_order_id, cid);
2179        assert!(!accepted.venue_order_id.to_string().is_empty());
2180
2181        let updated = ctx.recv_updated();
2182        assert_eq!(updated.client_order_id, cid);
2183        assert_eq!(updated.price, Some(Price::from("30000.00")));
2184        assert_eq!(updated.quantity, Quantity::from("0.010"));
2185        assert_eq!(updated.trigger_price, None);
2186        assert_eq!(updated.venue_order_id, Some(accepted.venue_order_id));
2187
2188        let stored = ctx.state.order_snapshots.get(&cid).unwrap();
2189        assert_eq!(stored.price, Some(Price::from("30000.00")));
2190    }
2191
2192    #[rstest]
2193    fn test_dispatch_accepted_with_matching_seeded_snapshot_no_updated() {
2194        let mut ctx = DispatchTestContext::new();
2195        let value = new_order_value();
2196        let cid = ClientOrderId::from("client-1");
2197        ctx.state.order_identities.insert(cid, default_identity());
2198        ctx.state.order_snapshots.insert(
2199            cid,
2200            OrderStateSnapshot {
2201                quantity: Quantity::from("0.010"),
2202                price: Some(Price::from("30000.00")),
2203                trigger_price: None,
2204            },
2205        );
2206
2207        ctx.dispatch_value(&value);
2208
2209        let accepted = ctx.rx.try_recv().unwrap();
2210        assert!(matches!(
2211            accepted,
2212            ExecutionEvent::Order(OrderEventAny::Accepted(_))
2213        ));
2214
2215        assert!(
2216            ctx.rx.try_recv().is_err(),
2217            "Expected no OrderUpdated when seed matches venue snapshot"
2218        );
2219    }
2220
2221    #[rstest]
2222    #[case::price_changed(
2223        Some(Price::from("100.00")),
2224        None,
2225        Quantity::from("1.000"),
2226        Some(Price::from("200.00")),
2227        None,
2228        Quantity::from("1.000"),
2229        true
2230    )]
2231    #[case::trigger_changed(
2232        None,
2233        Some(Price::from("100.00")),
2234        Quantity::from("1.000"),
2235        None,
2236        Some(Price::from("90.00")),
2237        Quantity::from("1.000"),
2238        true
2239    )]
2240    #[case::qty_changed(
2241        Some(Price::from("100.00")),
2242        None,
2243        Quantity::from("1.000"),
2244        Some(Price::from("100.00")),
2245        None,
2246        Quantity::from("2.000"),
2247        true
2248    )]
2249    #[case::no_change(
2250        Some(Price::from("100.00")),
2251        None,
2252        Quantity::from("1.000"),
2253        Some(Price::from("100.00")),
2254        None,
2255        Quantity::from("1.000"),
2256        false
2257    )]
2258    fn test_is_snapshot_updated(
2259        #[case] prev_price: Option<Price>,
2260        #[case] prev_trigger: Option<Price>,
2261        #[case] prev_qty: Quantity,
2262        #[case] new_price: Option<Price>,
2263        #[case] new_trigger: Option<Price>,
2264        #[case] new_qty: Quantity,
2265        #[case] expected: bool,
2266    ) {
2267        let state = WsDispatchState::default();
2268        let cid = ClientOrderId::from("test-1");
2269        state.order_snapshots.insert(
2270            cid,
2271            OrderStateSnapshot {
2272                quantity: prev_qty,
2273                price: prev_price,
2274                trigger_price: prev_trigger,
2275            },
2276        );
2277
2278        let new_snapshot = OrderStateSnapshot {
2279            quantity: new_qty,
2280            price: new_price,
2281            trigger_price: new_trigger,
2282        };
2283        assert_eq!(is_snapshot_updated(&new_snapshot, &cid, &state), expected);
2284    }
2285
2286    #[rstest]
2287    fn test_is_snapshot_updated_no_previous() {
2288        let state = WsDispatchState::default();
2289        let cid = ClientOrderId::from("test-1");
2290
2291        let new_snapshot = OrderStateSnapshot {
2292            quantity: Quantity::from("1.000"),
2293            price: Some(Price::from("100.00")),
2294            trigger_price: None,
2295        };
2296        assert!(!is_snapshot_updated(&new_snapshot, &cid, &state));
2297    }
2298
2299    #[rstest]
2300    #[case::limit_order("30000", "0", Some(Price::from("30000.00")), None)]
2301    #[case::conditional("0", "29000", None, Some(Price::from("29000.00")))]
2302    #[case::both(
2303        "30000",
2304        "29000",
2305        Some(Price::from("30000.00")),
2306        Some(Price::from("29000.00"))
2307    )]
2308    fn test_parse_order_snapshot(
2309        #[case] price: &str,
2310        #[case] trigger: &str,
2311        #[case] expected_price: Option<Price>,
2312        #[case] expected_trigger: Option<Price>,
2313    ) {
2314        let instrument = linear_instrument();
2315        let json = load_test_json("ws_account_order.json");
2316        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2317        value["data"][0]["price"] = serde_json::Value::String(price.to_string());
2318        value["data"][0]["triggerPrice"] = serde_json::Value::String(trigger.to_string());
2319        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2320            serde_json::from_value(value).unwrap();
2321
2322        let snapshot = parse_order_snapshot(&msg.data[0], &instrument).unwrap();
2323        assert_eq!(snapshot.price, expected_price);
2324        assert_eq!(snapshot.trigger_price, expected_trigger);
2325        assert_eq!(snapshot.quantity, Quantity::from("0.010"));
2326    }
2327
2328    #[rstest]
2329    fn test_parse_order_snapshot_invalid_qty_returns_none() {
2330        let instrument = linear_instrument();
2331        let json = load_test_json("ws_account_order.json");
2332        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2333        value["data"][0]["qty"] = serde_json::Value::String(String::new());
2334        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2335            serde_json::from_value(value).unwrap();
2336
2337        assert!(parse_order_snapshot(&msg.data[0], &instrument).is_none());
2338    }
2339
2340    #[rstest]
2341    fn test_dispatch_order_updated_on_partially_filled_price_change() {
2342        let mut ctx = DispatchTestContext::new();
2343        let value = new_order_value();
2344        ctx.accept_order(&value);
2345
2346        let mut amended = value;
2347        amended["data"][0]["orderStatus"] =
2348            serde_json::Value::String("PartiallyFilled".to_string());
2349        amended["data"][0]["cumExecQty"] = serde_json::Value::String("0.005".to_string());
2350        amended["data"][0]["price"] = serde_json::Value::String("31000".to_string());
2351        ctx.dispatch_value(&amended);
2352
2353        let updated = ctx.recv_updated();
2354        assert_eq!(updated.client_order_id, ClientOrderId::from("client-1"));
2355        assert_eq!(updated.price, Some(Price::from("31000.00")));
2356    }
2357}