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::{
24    Arc,
25    atomic::{AtomicBool, Ordering},
26};
27
28use ahash::AHashMap;
29use anyhow::Context;
30use arc_swap::ArcSwapOption;
31use dashmap::{DashMap, DashSet};
32use nautilus_core::{UUID4, UnixNanos, time::AtomicTime};
33use nautilus_live::ExecutionEventEmitter;
34use nautilus_model::{
35    enums::{LiquiditySide, OrderSide, OrderType},
36    events::{
37        OrderAccepted, OrderCanceled, OrderEventAny, OrderFilled, OrderTriggered, OrderUpdated,
38    },
39    identifiers::{
40        AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TradeId, VenueOrderId,
41    },
42    instruments::{Instrument, InstrumentAny},
43    orders::TRIGGERABLE_ORDER_TYPES,
44    types::{Money, Price, Quantity},
45};
46use rust_decimal::Decimal;
47use ustr::Ustr;
48
49use super::{
50    messages::{
51        BybitWsAccountExecution, BybitWsAccountExecutionFast, BybitWsAccountOrder, BybitWsMessage,
52    },
53    parse::{
54        parse_millis_i64, parse_ws_account_state, parse_ws_fill_report_fast,
55        parse_ws_position_status_report,
56    },
57};
58use crate::{
59    common::{
60        enums::{BybitExecType, BybitOrderSide, BybitOrderStatus, BybitProductType},
61        parse::{
62            bybit_rejection_due_post_only, get_currency, make_bybit_symbol, parse_millis_timestamp,
63            parse_price_with_precision, parse_quantity_with_precision,
64        },
65    },
66    http::error::is_bybit_ambiguous_order_error_code,
67    repay::RepayRequest,
68};
69
70const DEDUP_CAPACITY: usize = 10_000;
71
72const BYBIT_OP_ORDER_CREATE: &str = "order.create";
73const BYBIT_OP_ORDER_AMEND: &str = "order.amend";
74const BYBIT_OP_ORDER_CANCEL: &str = "order.cancel";
75const BYBIT_OP_ORDER_CREATE_BATCH: &str = "order.create-batch";
76const BYBIT_OP_ORDER_AMEND_BATCH: &str = "order.amend-batch";
77const BYBIT_OP_ORDER_CANCEL_BATCH: &str = "order.cancel-batch";
78
79/// Order identity context stored at submission time, used by the WS dispatch
80/// task to produce proper order events without Cache access.
81#[derive(Debug, Clone)]
82pub struct OrderIdentity {
83    pub instrument_id: InstrumentId,
84    pub strategy_id: StrategyId,
85    pub order_side: OrderSide,
86    pub order_type: OrderType,
87    pub venue_position_id: Option<PositionId>,
88}
89
90/// Tracks which type of WS request is pending for a given req_id.
91#[derive(Debug, Clone, Copy)]
92pub enum PendingOperation {
93    Place,
94    Cancel,
95    Amend,
96}
97
98/// Shared state for cross-stream event deduplication between the private
99/// and trade WebSocket dispatch loops.
100pub type PendingRequestData = (
101    Vec<ClientOrderId>,
102    Vec<Option<VenueOrderId>>,
103    PendingOperation,
104);
105
106/// Snapshot of an order's price, quantity, and trigger price at last dispatch.
107/// Used to detect modifications when Bybit sends back an order with the same
108/// status but changed fields.
109#[derive(Debug, Clone)]
110pub struct OrderStateSnapshot {
111    pub quantity: Quantity,
112    pub price: Option<Price>,
113    pub trigger_price: Option<Price>,
114}
115
116#[derive(Clone, Copy, Debug)]
117struct SpotRepayFill {
118    quantity: Quantity,
119    base_fee: Decimal,
120}
121
122#[derive(Debug)]
123pub struct WsDispatchState {
124    pub order_identities: DashMap<ClientOrderId, OrderIdentity>,
125    pub pending_requests: DashMap<String, PendingRequestData>,
126    pub order_snapshots: DashMap<ClientOrderId, OrderStateSnapshot>,
127    pub emitted_accepted: DashSet<ClientOrderId>,
128    pub triggered_orders: DashSet<ClientOrderId>,
129    pub filled_orders: DashSet<ClientOrderId>,
130    spot_repay_fills: DashMap<ClientOrderId, SpotRepayFill>,
131    repay_tx: ArcSwapOption<tokio::sync::mpsc::UnboundedSender<RepayRequest>>,
132    clearing: AtomicBool,
133}
134
135impl Default for WsDispatchState {
136    fn default() -> Self {
137        Self {
138            order_identities: DashMap::new(),
139            pending_requests: DashMap::new(),
140            order_snapshots: DashMap::new(),
141            emitted_accepted: DashSet::default(),
142            triggered_orders: DashSet::default(),
143            filled_orders: DashSet::default(),
144            spot_repay_fills: DashMap::new(),
145            repay_tx: ArcSwapOption::empty(),
146            clearing: AtomicBool::new(false),
147        }
148    }
149}
150
151impl WsDispatchState {
152    fn evict_if_full(&self, set: &DashSet<ClientOrderId>) {
153        if set.len() >= DEDUP_CAPACITY
154            && self
155                .clearing
156                .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
157                .is_ok()
158        {
159            set.clear();
160            self.clearing.store(false, Ordering::Release);
161        }
162    }
163
164    fn insert_accepted(&self, cid: ClientOrderId) {
165        self.evict_if_full(&self.emitted_accepted);
166        self.emitted_accepted.insert(cid);
167    }
168
169    fn insert_filled(&self, cid: ClientOrderId) {
170        self.evict_if_full(&self.filled_orders);
171        self.filled_orders.insert(cid);
172    }
173
174    fn insert_triggered(&self, cid: ClientOrderId) {
175        self.evict_if_full(&self.triggered_orders);
176        self.triggered_orders.insert(cid);
177    }
178
179    pub(crate) fn set_repay_sender(&self, tx: tokio::sync::mpsc::UnboundedSender<RepayRequest>) {
180        self.repay_tx.store(Some(Arc::new(tx)));
181    }
182
183    pub(crate) fn clear_repay_sender(&self) {
184        self.repay_tx.store(None);
185    }
186
187    fn enqueue_repay(&self, req: RepayRequest) {
188        if let Some(tx) = self.repay_tx.load_full()
189            && let Err(e) = tx.send(req)
190        {
191            log::warn!("Failed to enqueue spot borrow repayment: {e}");
192        }
193    }
194}
195
196/// Dispatches a WebSocket message with cross-stream deduplication.
197///
198/// For orders with a tracked identity (submitted through this client), produces
199/// proper order events (OrderAccepted, OrderCanceled, OrderFilled, etc.).
200/// For untracked orders (external or pre-existing), falls back to execution
201/// reports for downstream reconciliation.
202pub fn dispatch_ws_message(
203    message: &BybitWsMessage,
204    emitter: &ExecutionEventEmitter,
205    state: &WsDispatchState,
206    account_id: AccountId,
207    instruments: &AHashMap<Ustr, InstrumentAny>,
208    clock: &AtomicTime,
209) {
210    match message {
211        BybitWsMessage::AccountOrder(msg) => {
212            let ts_init = clock.get_time_ns();
213
214            for order in &msg.data {
215                let symbol = make_bybit_symbol(order.symbol, order.category);
216                let Some(instrument) = instruments.get(&symbol) else {
217                    log::warn!("No instrument for order update: {symbol}");
218                    continue;
219                };
220                dispatch_order_update(order, instrument, emitter, state, account_id, ts_init);
221            }
222        }
223        BybitWsMessage::AccountExecution(msg) => {
224            let ts_init = clock.get_time_ns();
225
226            for exec in &msg.data {
227                let symbol = make_bybit_symbol(exec.symbol, exec.category);
228                let Some(instrument) = instruments.get(&symbol) else {
229                    log::warn!("No instrument for execution update: {symbol}");
230                    continue;
231                };
232                dispatch_execution_fill(exec, instrument, emitter, state, account_id, ts_init);
233            }
234        }
235        BybitWsMessage::AccountExecutionFast(msg) => {
236            let ts_init = clock.get_time_ns();
237
238            for exec in &msg.data {
239                let symbol = make_bybit_symbol(exec.symbol, exec.category);
240                let Some(instrument) = instruments.get(&symbol) else {
241                    log::warn!("No instrument for fast-execution update: {symbol}");
242                    continue;
243                };
244                dispatch_execution_fill_fast(exec, instrument, emitter, state, account_id, ts_init);
245            }
246        }
247        BybitWsMessage::AccountWallet(msg) => {
248            let ts_init = clock.get_time_ns();
249            let ts_event = parse_millis_i64(msg.creation_time, "wallet.creation_time")
250                .unwrap_or_else(|e| {
251                    log::warn!("Failed to parse wallet creation_time, using ts_init: {e}");
252                    ts_init
253                });
254
255            for wallet in &msg.data {
256                match parse_ws_account_state(wallet, account_id, ts_event, ts_init) {
257                    Ok(state) => emitter.send_account_state(state),
258                    Err(e) => log::error!("Failed to parse account state: {e}"),
259                }
260            }
261        }
262        BybitWsMessage::AccountPosition(msg) => {
263            let ts_init = clock.get_time_ns();
264
265            for position in &msg.data {
266                let symbol = make_bybit_symbol(position.symbol, position.category);
267                let Some(instrument) = instruments.get(&symbol) else {
268                    log::warn!("No instrument for position update: {symbol}");
269                    continue;
270                };
271
272                match parse_ws_position_status_report(position, account_id, instrument, ts_init) {
273                    Ok(report) => emitter.send_position_report(report),
274                    Err(e) => log::error!("Failed to parse position status report: {e}"),
275                }
276            }
277        }
278        BybitWsMessage::OrderResponse(resp) => {
279            let ts_init = clock.get_time_ns();
280            dispatch_order_response(resp, emitter, state, ts_init);
281        }
282        BybitWsMessage::Error(e) => {
283            log::warn!("WebSocket error: code={} message={}", e.code, e.message);
284        }
285        BybitWsMessage::Reconnected => {
286            log::info!("WebSocket reconnected");
287        }
288        BybitWsMessage::Auth(_)
289        | BybitWsMessage::Orderbook(_)
290        | BybitWsMessage::Trade(_)
291        | BybitWsMessage::Liquidation(_)
292        | BybitWsMessage::Kline(_)
293        | BybitWsMessage::TickerLinear(_)
294        | BybitWsMessage::TickerOption(_) => {}
295    }
296}
297
298/// Dispatches a single order status update.
299///
300/// Tracked orders produce lifecycle events (OrderAccepted, OrderTriggered,
301/// OrderCanceled, OrderRejected). Untracked orders fall back to
302/// `OrderStatusReport` for reconciliation.
303fn dispatch_order_update(
304    order: &BybitWsAccountOrder,
305    instrument: &InstrumentAny,
306    emitter: &ExecutionEventEmitter,
307    state: &WsDispatchState,
308    account_id: AccountId,
309    ts_init: UnixNanos,
310) {
311    let client_order_id = if order.order_link_id.is_empty() {
312        None
313    } else {
314        Some(ClientOrderId::new(order.order_link_id))
315    };
316
317    let identity = client_order_id
318        .as_ref()
319        .and_then(|cid| state.order_identities.get(cid).map(|r| r.clone()));
320
321    if let (Some(client_order_id), Some(identity)) = (client_order_id, identity) {
322        let venue_order_id = VenueOrderId::new(order.order_id);
323
324        match order.order_status {
325            BybitOrderStatus::Created | BybitOrderStatus::New | BybitOrderStatus::Untriggered => {
326                let snapshot = parse_order_snapshot(order, instrument);
327
328                if state.emitted_accepted.contains(&client_order_id)
329                    || state.filled_orders.contains(&client_order_id)
330                    || state.triggered_orders.contains(&client_order_id)
331                {
332                    if let Some(snapshot) = snapshot
333                        && is_snapshot_updated(&snapshot, &client_order_id, state)
334                    {
335                        let updated = OrderUpdated::new(
336                            emitter.trader_id(),
337                            identity.strategy_id,
338                            identity.instrument_id,
339                            client_order_id,
340                            snapshot.quantity,
341                            UUID4::new(),
342                            ts_init,
343                            ts_init,
344                            false,
345                            Some(venue_order_id),
346                            Some(account_id),
347                            snapshot.price,
348                            snapshot.trigger_price,
349                            None,
350                            false,
351                        );
352                        state.order_snapshots.insert(client_order_id, snapshot);
353                        emitter.send_order_event(OrderEventAny::Updated(updated));
354                        return;
355                    }
356                    log::debug!("Skipping duplicate Accepted for {client_order_id}");
357                    return;
358                }
359
360                state.insert_accepted(client_order_id);
361
362                // BBO orders resolve their limit price venue-side: emit
363                // OrderUpdated after OrderAccepted when the seed diverges.
364                let venue_differs_from_submitted = snapshot
365                    .as_ref()
366                    .is_some_and(|s| is_snapshot_updated(s, &client_order_id, state));
367
368                if let Some(snapshot) = snapshot.as_ref() {
369                    state
370                        .order_snapshots
371                        .insert(client_order_id, snapshot.clone());
372                }
373
374                let accepted = OrderAccepted::new(
375                    emitter.trader_id(),
376                    identity.strategy_id,
377                    identity.instrument_id,
378                    client_order_id,
379                    venue_order_id,
380                    account_id,
381                    UUID4::new(),
382                    ts_init,
383                    ts_init,
384                    false,
385                );
386                emitter.send_order_event(OrderEventAny::Accepted(accepted));
387
388                if venue_differs_from_submitted && let Some(snapshot) = snapshot {
389                    let updated = OrderUpdated::new(
390                        emitter.trader_id(),
391                        identity.strategy_id,
392                        identity.instrument_id,
393                        client_order_id,
394                        snapshot.quantity,
395                        UUID4::new(),
396                        ts_init,
397                        ts_init,
398                        false,
399                        Some(venue_order_id),
400                        Some(account_id),
401                        snapshot.price,
402                        snapshot.trigger_price,
403                        None,
404                        false,
405                    );
406                    emitter.send_order_event(OrderEventAny::Updated(updated));
407                }
408            }
409            BybitOrderStatus::Triggered => {
410                if state.filled_orders.contains(&client_order_id) {
411                    log::debug!("Skipping stale Triggered for {client_order_id} (already filled)");
412                    return;
413                }
414
415                if !TRIGGERABLE_ORDER_TYPES.contains(&identity.order_type) {
416                    log::debug!(
417                        "Skipping OrderTriggered for {} order {client_order_id}: market-style stops have no TRIGGERED state",
418                        identity.order_type,
419                    );
420                    return;
421                }
422
423                ensure_accepted_emitted(
424                    client_order_id,
425                    account_id,
426                    venue_order_id,
427                    &identity,
428                    emitter,
429                    state,
430                    ts_init,
431                );
432                state.insert_triggered(client_order_id);
433                let triggered = OrderTriggered::new(
434                    emitter.trader_id(),
435                    identity.strategy_id,
436                    identity.instrument_id,
437                    client_order_id,
438                    UUID4::new(),
439                    ts_init,
440                    ts_init,
441                    false,
442                    Some(venue_order_id),
443                    Some(account_id),
444                );
445                emitter.send_order_event(OrderEventAny::Triggered(triggered));
446            }
447            BybitOrderStatus::Rejected => {
448                let filled_qty = parse_quantity_with_precision(
449                    &order.cum_exec_qty,
450                    instrument.size_precision(),
451                    "order.cumExecQty",
452                )
453                .unwrap_or_default();
454
455                if filled_qty.is_positive() {
456                    // Partially filled then rejected - treat as canceled
457                    ensure_accepted_emitted(
458                        client_order_id,
459                        account_id,
460                        venue_order_id,
461                        &identity,
462                        emitter,
463                        state,
464                        ts_init,
465                    );
466                    let canceled = OrderCanceled::new(
467                        emitter.trader_id(),
468                        identity.strategy_id,
469                        identity.instrument_id,
470                        client_order_id,
471                        UUID4::new(),
472                        ts_init,
473                        ts_init,
474                        false,
475                        Some(venue_order_id),
476                        Some(account_id),
477                        None,
478                    );
479                    cleanup_terminal(client_order_id, state);
480                    emitter.send_order_event(OrderEventAny::Canceled(canceled));
481                } else {
482                    let reason = if order.reject_reason.is_empty() {
483                        Ustr::from("Order rejected by venue")
484                    } else {
485                        order.reject_reason
486                    };
487                    state.order_identities.remove(&client_order_id);
488                    state.order_snapshots.remove(&client_order_id);
489                    emitter.emit_order_rejected_event(
490                        identity.strategy_id,
491                        identity.instrument_id,
492                        client_order_id,
493                        reason.as_str(),
494                        ts_init,
495                        bybit_rejection_due_post_only(reason.as_str()),
496                    );
497                }
498            }
499            BybitOrderStatus::PartiallyFilled => {
500                // Fills arrive on the execution channel; no event needed here.
501                // Ensure accepted was emitted so the fill has a valid prior state.
502                ensure_accepted_emitted(
503                    client_order_id,
504                    account_id,
505                    venue_order_id,
506                    &identity,
507                    emitter,
508                    state,
509                    ts_init,
510                );
511
512                // A successful amend on a partially filled order keeps the
513                // PartiallyFilled status. Detect price/qty/trigger changes and
514                // emit OrderUpdated so PendingUpdate resolves.
515                if let Some(snapshot) = parse_order_snapshot(order, instrument)
516                    && is_snapshot_updated(&snapshot, &client_order_id, state)
517                {
518                    let updated = OrderUpdated::new(
519                        emitter.trader_id(),
520                        identity.strategy_id,
521                        identity.instrument_id,
522                        client_order_id,
523                        snapshot.quantity,
524                        UUID4::new(),
525                        ts_init,
526                        ts_init,
527                        false,
528                        Some(venue_order_id),
529                        Some(account_id),
530                        snapshot.price,
531                        snapshot.trigger_price,
532                        None,
533                        false,
534                    );
535                    state.order_snapshots.insert(client_order_id, snapshot);
536                    emitter.send_order_event(OrderEventAny::Updated(updated));
537                }
538            }
539            BybitOrderStatus::Filled => {
540                // Fills arrive on the execution channel; no event needed here.
541                // Ensure accepted was emitted so the fill has a valid prior state.
542                ensure_accepted_emitted(
543                    client_order_id,
544                    account_id,
545                    venue_order_id,
546                    &identity,
547                    emitter,
548                    state,
549                    ts_init,
550                );
551
552                // Reconcile seed against venue values before the fill (BBO
553                // orders may land directly on Filled).
554                if let Some(snapshot) = parse_order_snapshot(order, instrument)
555                    && is_snapshot_updated(&snapshot, &client_order_id, state)
556                {
557                    let updated = OrderUpdated::new(
558                        emitter.trader_id(),
559                        identity.strategy_id,
560                        identity.instrument_id,
561                        client_order_id,
562                        snapshot.quantity,
563                        UUID4::new(),
564                        ts_init,
565                        ts_init,
566                        false,
567                        Some(venue_order_id),
568                        Some(account_id),
569                        snapshot.price,
570                        snapshot.trigger_price,
571                        None,
572                        false,
573                    );
574                    state.order_snapshots.insert(client_order_id, snapshot);
575                    emitter.send_order_event(OrderEventAny::Updated(updated));
576                }
577                // Identity cleaned up in dispatch_execution_fill when leaves_qty
578                // reaches zero, since there is no guaranteed ordering between
579                // the order and execution topics.
580            }
581            BybitOrderStatus::Canceled
582            | BybitOrderStatus::PartiallyFilledCanceled
583            | BybitOrderStatus::Deactivated => {
584                let filled_qty = parse_quantity_with_precision(
585                    &order.cum_exec_qty,
586                    instrument.size_precision(),
587                    "order.cumExecQty",
588                )
589                .unwrap_or_default();
590
591                // Bybit reports a post-only order that would take liquidity as
592                // Cancelled with rejectReason=EC_PostOnlyWillTakeLiquidity,
593                // not Rejected. Surface it as OrderRejected carrying due_post_only.
594                if filled_qty.is_zero()
595                    && bybit_rejection_due_post_only(order.reject_reason.as_str())
596                {
597                    cleanup_terminal(client_order_id, state);
598                    emitter.emit_order_rejected_event(
599                        identity.strategy_id,
600                        identity.instrument_id,
601                        client_order_id,
602                        order.reject_reason.as_str(),
603                        ts_init,
604                        true,
605                    );
606                } else {
607                    ensure_accepted_emitted(
608                        client_order_id,
609                        account_id,
610                        venue_order_id,
611                        &identity,
612                        emitter,
613                        state,
614                        ts_init,
615                    );
616                    let canceled = OrderCanceled::new(
617                        emitter.trader_id(),
618                        identity.strategy_id,
619                        identity.instrument_id,
620                        client_order_id,
621                        UUID4::new(),
622                        ts_init,
623                        ts_init,
624                        false,
625                        Some(venue_order_id),
626                        Some(account_id),
627                        None,
628                    );
629                    cleanup_terminal(client_order_id, state);
630                    emitter.send_order_event(OrderEventAny::Canceled(canceled));
631                }
632            }
633        }
634    } else {
635        // Untracked order: fall back to report for reconciliation
636        match super::parse::parse_ws_order_status_report(order, instrument, account_id, ts_init) {
637            Ok(report) => emitter.send_order_status_report(report),
638            Err(e) => log::error!("Failed to parse order status report: {e}"),
639        }
640    }
641}
642
643/// Dispatches a single execution (fill) message.
644///
645/// Tracked orders are parsed directly to [`OrderFilled`]. Untracked orders
646/// fall back to [`FillReport`] for reconciliation.
647fn dispatch_execution_fill(
648    exec: &BybitWsAccountExecution,
649    instrument: &InstrumentAny,
650    emitter: &ExecutionEventEmitter,
651    state: &WsDispatchState,
652    account_id: AccountId,
653    ts_init: UnixNanos,
654) {
655    if exec.exec_type == BybitExecType::Funding {
656        log::debug!(
657            "Skipping funding execution: symbol={}, order_id={}, exec_id={}",
658            exec.symbol,
659            exec.order_id,
660            exec.exec_id,
661        );
662        return;
663    }
664
665    if exec.exec_type.is_exchange_generated() {
666        log::warn!(
667            "Exchange-generated execution: exec_type={:?}, symbol={}, order_id={}, order_link_id={}, side={:?}, qty={}, price={}",
668            exec.exec_type,
669            exec.symbol,
670            exec.order_id,
671            exec.order_link_id,
672            exec.side,
673            exec.exec_qty,
674            exec.exec_price,
675        );
676    }
677
678    let client_order_id = if exec.order_link_id.is_empty() {
679        None
680    } else {
681        Some(ClientOrderId::new(exec.order_link_id))
682    };
683
684    let identity = client_order_id
685        .as_ref()
686        .and_then(|cid| state.order_identities.get(cid).map(|r| r.clone()));
687
688    if let (Some(client_order_id), Some(identity)) = (client_order_id, identity) {
689        match parse_order_filled(exec, instrument, &identity, emitter, account_id, ts_init) {
690            Ok(filled) => {
691                let venue_order_id = filled.venue_order_id;
692                ensure_accepted_emitted(
693                    client_order_id,
694                    account_id,
695                    venue_order_id,
696                    &identity,
697                    emitter,
698                    state,
699                    ts_init,
700                );
701
702                let is_spot_buy =
703                    exec.category == BybitProductType::Spot && exec.side == BybitOrderSide::Buy;
704
705                if is_spot_buy {
706                    record_spot_repay_fill(client_order_id, &filled, instrument, state);
707                }
708
709                state.insert_filled(client_order_id);
710                state.triggered_orders.remove(&client_order_id);
711                emitter.send_order_event(OrderEventAny::Filled(filled));
712
713                if exec.leaves_qty == "0" {
714                    if is_spot_buy {
715                        enqueue_spot_repay(client_order_id, instrument, state);
716                    }
717                    cleanup_terminal(client_order_id, state);
718                }
719            }
720            Err(e) => log::error!("Failed to parse OrderFilled for {client_order_id}: {e}"),
721        }
722    } else {
723        // Untracked: fall back to FillReport for reconciliation
724        match super::parse::parse_ws_fill_report(exec, account_id, instrument, ts_init) {
725            Ok(report) => emitter.send_fill_report(report),
726            Err(e) => log::error!("Failed to parse fill report: {e}"),
727        }
728    }
729}
730
731/// Dispatches a single fast-execution (fill) message.
732///
733/// The fast channel lacks fee and exec-type fields, so all fills route to
734/// [`FillReport`] with zero commission; liquidity side is derived from the
735/// payload's `isMaker` flag. Subscribe to the standard `execution` channel
736/// for full fill metadata (e.g. fees).
737fn dispatch_execution_fill_fast(
738    exec: &BybitWsAccountExecutionFast,
739    instrument: &InstrumentAny,
740    emitter: &ExecutionEventEmitter,
741    state: &WsDispatchState,
742    account_id: AccountId,
743    ts_init: UnixNanos,
744) {
745    let client_order_id = if exec.order_link_id.is_empty() {
746        None
747    } else {
748        Some(ClientOrderId::new(exec.order_link_id))
749    };
750
751    let mut venue_position_id = None;
752
753    if let Some(cid) = client_order_id.as_ref()
754        && let Some(identity) = state.order_identities.get(cid).map(|r| r.clone())
755    {
756        venue_position_id = identity.venue_position_id;
757        let venue_order_id = VenueOrderId::new(exec.order_id);
758        ensure_accepted_emitted(
759            *cid,
760            account_id,
761            venue_order_id,
762            &identity,
763            emitter,
764            state,
765            ts_init,
766        );
767    }
768
769    match parse_ws_fill_report_fast(exec, account_id, instrument, venue_position_id, ts_init) {
770        Ok(report) => emitter.send_fill_report(report),
771        Err(e) => log::error!("Failed to parse fast fill report: {e}"),
772    }
773}
774
775/// Parses a Bybit execution message directly into an [`OrderFilled`] event.
776fn parse_order_filled(
777    exec: &BybitWsAccountExecution,
778    instrument: &InstrumentAny,
779    identity: &OrderIdentity,
780    emitter: &ExecutionEventEmitter,
781    account_id: AccountId,
782    ts_init: UnixNanos,
783) -> anyhow::Result<OrderFilled> {
784    let client_order_id = ClientOrderId::new(exec.order_link_id);
785    let venue_order_id = VenueOrderId::new(exec.order_id);
786    let trade_id =
787        TradeId::new_checked(exec.exec_id.as_str()).context("invalid execId in Bybit execution")?;
788
789    let last_qty = parse_quantity_with_precision(
790        &exec.exec_qty,
791        instrument.size_precision(),
792        "execution.execQty",
793    )?;
794    let last_px = parse_price_with_precision(
795        &exec.exec_price,
796        instrument.price_precision(),
797        "execution.execPrice",
798    )?;
799
800    let liquidity_side = if exec.is_maker {
801        LiquiditySide::Maker
802    } else {
803        LiquiditySide::Taker
804    };
805
806    let fee_decimal: Decimal = exec
807        .exec_fee
808        .parse()
809        .with_context(|| format!("failed to parse execFee='{}'", exec.exec_fee))?;
810    let price_currency = instrument.quote_currency();
811    let commission_currency = get_currency(&exec.fee_currency);
812    let commission = Money::from_decimal(fee_decimal, commission_currency).with_context(|| {
813        format!(
814            "failed to create commission from execFee='{}'",
815            exec.exec_fee
816        )
817    })?;
818
819    let ts_event = parse_millis_timestamp(&exec.exec_time, "execution.execTime")?;
820
821    Ok(OrderFilled::new(
822        emitter.trader_id(),
823        identity.strategy_id,
824        identity.instrument_id,
825        client_order_id,
826        venue_order_id,
827        account_id,
828        trade_id,
829        identity.order_side,
830        identity.order_type,
831        last_qty,
832        last_px,
833        price_currency,
834        liquidity_side,
835        UUID4::new(),
836        ts_event,
837        ts_init,
838        false,
839        identity.venue_position_id,
840        Some(commission),
841        None,
842    ))
843}
844
845/// Handles a Bybit WS order command response.
846fn dispatch_order_response(
847    resp: &super::messages::BybitWsOrderResponse,
848    emitter: &ExecutionEventEmitter,
849    state: &WsDispatchState,
850    ts_init: UnixNanos,
851) {
852    if resp.ret_code == 0 {
853        // Check for per-order failures in batch retExtInfo even on success
854        let pending = resp
855            .req_id
856            .as_ref()
857            .and_then(|rid| state.pending_requests.remove(rid))
858            .map(|(_, v)| v);
859
860        if let Some((cids, voids, pending_op)) = pending {
861            let batch_errors = resp.extract_batch_errors();
862            let data_array = resp.data.as_array();
863
864            for (idx, error) in batch_errors.iter().enumerate() {
865                if error.code == 0 {
866                    continue;
867                }
868
869                if is_bybit_ambiguous_order_error_code(error.code) {
870                    log::warn!(
871                        "Ambiguous batch order item failure at index {idx}: code={}, msg={}; awaiting reconciliation",
872                        error.code,
873                        error.msg,
874                    );
875                    continue;
876                }
877
878                // Extract orderLinkId from the corresponding data entry
879                let cid = data_array
880                    .and_then(|arr| arr.get(idx))
881                    .and_then(extract_order_link_id_from_data)
882                    .or_else(|| cids.get(idx).copied());
883
884                let Some(cid) = cid else {
885                    log::warn!(
886                        "Batch error at index {idx} without correlation: code={}, msg={}",
887                        error.code,
888                        error.msg,
889                    );
890                    continue;
891                };
892
893                let Some(identity) = state.order_identities.get(&cid).map(|r| r.clone()) else {
894                    log::warn!(
895                        "Batch error for untracked order: client_order_id={cid}, msg={}",
896                        error.msg,
897                    );
898                    continue;
899                };
900
901                let stored_void = voids.get(idx).and_then(|v| *v);
902
903                emit_rejection_for_op(
904                    &pending_op,
905                    cid,
906                    &identity,
907                    stored_void,
908                    &error.msg,
909                    emitter,
910                    state,
911                    ts_init,
912                );
913            }
914        }
915        return;
916    }
917
918    // Remove the pending request entry (if any) to get client_order_ids and op
919    let pending = resp
920        .req_id
921        .as_ref()
922        .and_then(|rid| state.pending_requests.remove(rid))
923        .map(|(_, v)| v);
924
925    let is_batch_response = is_batch_order_op(resp.op.as_str())
926        || pending.as_ref().is_some_and(|(cids, _, _)| cids.len() > 1);
927
928    if is_batch_response {
929        if is_bybit_ambiguous_order_error_code(resp.ret_code) {
930            let order_count = pending.as_ref().map_or(0, |(cids, _, _)| cids.len());
931            log::warn!(
932                "Ambiguous batch order response failure for {order_count} orders: op={}, ret_code={}, ret_msg={}; awaiting reconciliation",
933                resp.op,
934                resp.ret_code,
935                resp.ret_msg,
936            );
937            return;
938        }
939
940        let Some((client_order_ids, venue_order_ids, pending_op)) = pending else {
941            log::warn!(
942                "Batch order response error without correlation: op={}, ret_code={}, ret_msg={}, req_id={:?}",
943                resp.op,
944                resp.ret_code,
945                resp.ret_msg,
946                resp.req_id,
947            );
948            return;
949        };
950
951        for (index, client_order_id) in client_order_ids.into_iter().enumerate() {
952            let Some(identity) = state
953                .order_identities
954                .get(&client_order_id)
955                .map(|identity| identity.clone())
956            else {
957                log::warn!(
958                    "Batch order response error for untracked order: op={}, client_order_id={client_order_id}, ret_msg={}",
959                    resp.op,
960                    resp.ret_msg,
961                );
962                continue;
963            };
964            emit_rejection_for_op(
965                &pending_op,
966                client_order_id,
967                &identity,
968                venue_order_ids.get(index).copied().flatten(),
969                &resp.ret_msg,
970                emitter,
971                state,
972                ts_init,
973            );
974        }
975        return;
976    }
977
978    if is_bybit_ambiguous_order_error_code(resp.ret_code) {
979        log::warn!(
980            "Ambiguous order response failure: op={}, ret_code={}, ret_msg={}; awaiting reconciliation",
981            resp.op,
982            resp.ret_code,
983            resp.ret_msg,
984        );
985        return;
986    }
987
988    let effective_op = pending
989        .as_ref()
990        .map(|(_, _, op)| *op)
991        .or_else(|| pending_op_from_str(resp.op.as_str()))
992        .unwrap_or_else(|| {
993            log::warn!("Unknown order operation '{}', defaulting to Place", resp.op);
994            PendingOperation::Place
995        });
996
997    // Single-order rejection path
998    let client_order_id = extract_order_link_id_from_data(&resp.data).or_else(|| {
999        pending
1000            .as_ref()
1001            .and_then(|(cids, _, _)| cids.first().copied())
1002    });
1003
1004    let stored_venue_order_id = pending
1005        .as_ref()
1006        .and_then(|(_, voids, _)| voids.first().and_then(|v| *v));
1007
1008    let Some(client_order_id) = client_order_id else {
1009        log::warn!(
1010            "Order response error without correlation: op={}, ret_code={}, ret_msg={}, req_id={:?}",
1011            resp.op,
1012            resp.ret_code,
1013            resp.ret_msg,
1014            resp.req_id,
1015        );
1016        return;
1017    };
1018    let Some(identity) = state
1019        .order_identities
1020        .get(&client_order_id)
1021        .map(|r| r.clone())
1022    else {
1023        log::warn!(
1024            "Order response error for untracked order: op={}, client_order_id={client_order_id}, ret_msg={}",
1025            resp.op,
1026            resp.ret_msg,
1027        );
1028        return;
1029    };
1030
1031    let venue_order_id = extract_venue_order_id_from_data(&resp.data).or(stored_venue_order_id);
1032
1033    emit_rejection_for_op(
1034        &effective_op,
1035        client_order_id,
1036        &identity,
1037        venue_order_id,
1038        &resp.ret_msg,
1039        emitter,
1040        state,
1041        ts_init,
1042    );
1043}
1044
1045/// Emits the appropriate rejection event based on the pending operation type.
1046#[expect(clippy::too_many_arguments)]
1047fn emit_rejection_for_op(
1048    pending_op: &PendingOperation,
1049    client_order_id: ClientOrderId,
1050    identity: &OrderIdentity,
1051    venue_order_id: Option<VenueOrderId>,
1052    reason: &str,
1053    emitter: &ExecutionEventEmitter,
1054    state: &WsDispatchState,
1055    ts_init: UnixNanos,
1056) {
1057    match pending_op {
1058        PendingOperation::Place => {
1059            state.order_identities.remove(&client_order_id);
1060            state.order_snapshots.remove(&client_order_id);
1061            emitter.emit_order_rejected_event(
1062                identity.strategy_id,
1063                identity.instrument_id,
1064                client_order_id,
1065                reason,
1066                ts_init,
1067                false,
1068            );
1069        }
1070        PendingOperation::Cancel => {
1071            emitter.emit_order_cancel_rejected_event(
1072                identity.strategy_id,
1073                identity.instrument_id,
1074                client_order_id,
1075                venue_order_id,
1076                reason,
1077                ts_init,
1078            );
1079        }
1080        PendingOperation::Amend => {
1081            emitter.emit_order_modify_rejected_event(
1082                identity.strategy_id,
1083                identity.instrument_id,
1084                client_order_id,
1085                venue_order_id,
1086                reason,
1087                ts_init,
1088            );
1089        }
1090    }
1091}
1092
1093/// Maps an operation string to a `PendingOperation`.
1094fn pending_op_from_str(op: &str) -> Option<PendingOperation> {
1095    match op {
1096        BYBIT_OP_ORDER_CREATE => Some(PendingOperation::Place),
1097        BYBIT_OP_ORDER_CANCEL => Some(PendingOperation::Cancel),
1098        BYBIT_OP_ORDER_AMEND => Some(PendingOperation::Amend),
1099        _ => None,
1100    }
1101}
1102
1103fn is_batch_order_op(op: &str) -> bool {
1104    matches!(
1105        op,
1106        BYBIT_OP_ORDER_CREATE_BATCH | BYBIT_OP_ORDER_AMEND_BATCH | BYBIT_OP_ORDER_CANCEL_BATCH
1107    )
1108}
1109
1110/// Parses an order snapshot from a WS order message for modification detection.
1111fn parse_order_snapshot(
1112    order: &BybitWsAccountOrder,
1113    instrument: &InstrumentAny,
1114) -> Option<OrderStateSnapshot> {
1115    let quantity =
1116        parse_quantity_with_precision(&order.qty, instrument.size_precision(), "order.qty").ok()?;
1117
1118    let price = if !order.price.is_empty() && order.price != "0" {
1119        parse_price_with_precision(&order.price, instrument.price_precision(), "order.price").ok()
1120    } else {
1121        None
1122    };
1123
1124    let trigger_price = if !order.trigger_price.is_empty() && order.trigger_price != "0" {
1125        parse_price_with_precision(
1126            &order.trigger_price,
1127            instrument.price_precision(),
1128            "order.triggerPrice",
1129        )
1130        .ok()
1131    } else {
1132        None
1133    };
1134
1135    Some(OrderStateSnapshot {
1136        quantity,
1137        price,
1138        trigger_price,
1139    })
1140}
1141
1142/// Returns whether the incoming snapshot differs from the stored snapshot.
1143fn is_snapshot_updated(
1144    snapshot: &OrderStateSnapshot,
1145    client_order_id: &ClientOrderId,
1146    state: &WsDispatchState,
1147) -> bool {
1148    let Some(previous) = state.order_snapshots.get(client_order_id) else {
1149        return false;
1150    };
1151
1152    if let (Some(prev_price), Some(new_price)) = (previous.price, snapshot.price)
1153        && prev_price != new_price
1154    {
1155        return true;
1156    }
1157
1158    if let (Some(prev_trigger), Some(new_trigger)) =
1159        (previous.trigger_price, snapshot.trigger_price)
1160        && prev_trigger != new_trigger
1161    {
1162        return true;
1163    }
1164
1165    previous.quantity != snapshot.quantity
1166}
1167
1168/// Synthesizes and emits `OrderAccepted` if one has not yet been emitted for
1169/// this order. Handles fast-filling orders that skip the `New` state on Bybit.
1170fn ensure_accepted_emitted(
1171    client_order_id: ClientOrderId,
1172    account_id: AccountId,
1173    venue_order_id: VenueOrderId,
1174    identity: &OrderIdentity,
1175    emitter: &ExecutionEventEmitter,
1176    state: &WsDispatchState,
1177    ts_init: UnixNanos,
1178) {
1179    if state.emitted_accepted.contains(&client_order_id) {
1180        return;
1181    }
1182    state.insert_accepted(client_order_id);
1183    let accepted = OrderAccepted::new(
1184        emitter.trader_id(),
1185        identity.strategy_id,
1186        identity.instrument_id,
1187        client_order_id,
1188        venue_order_id,
1189        account_id,
1190        UUID4::new(),
1191        ts_init,
1192        ts_init,
1193        false,
1194    );
1195    emitter.send_order_event(OrderEventAny::Accepted(accepted));
1196}
1197
1198/// Removes a terminal order from all tracking sets.
1199fn cleanup_terminal(client_order_id: ClientOrderId, state: &WsDispatchState) {
1200    state.order_identities.remove(&client_order_id);
1201    state.order_snapshots.remove(&client_order_id);
1202    state.emitted_accepted.remove(&client_order_id);
1203    state.triggered_orders.remove(&client_order_id);
1204    state.filled_orders.remove(&client_order_id);
1205    state.spot_repay_fills.remove(&client_order_id);
1206}
1207
1208/// Tries to extract `orderLinkId` from the response data Value.
1209fn extract_order_link_id_from_data(data: &serde_json::Value) -> Option<ClientOrderId> {
1210    data.get("orderLinkId")
1211        .and_then(|v| v.as_str())
1212        .filter(|s| !s.is_empty())
1213        .map(ClientOrderId::new)
1214}
1215
1216/// Tries to extract `orderId` from the response data Value.
1217fn extract_venue_order_id_from_data(data: &serde_json::Value) -> Option<VenueOrderId> {
1218    data.get("orderId")
1219        .and_then(|v| v.as_str())
1220        .filter(|s| !s.is_empty())
1221        .map(VenueOrderId::new)
1222}
1223
1224fn record_spot_repay_fill(
1225    client_order_id: ClientOrderId,
1226    filled: &OrderFilled,
1227    instrument: &InstrumentAny,
1228    state: &WsDispatchState,
1229) {
1230    let Some(base_currency) = instrument.base_currency() else {
1231        return;
1232    };
1233
1234    let base_fee = filled
1235        .commission
1236        .filter(|fee| fee.currency.code == base_currency.code)
1237        .map_or(Decimal::ZERO, |fee| fee.as_decimal().max(Decimal::ZERO));
1238    let fill = SpotRepayFill {
1239        quantity: filled.last_qty,
1240        base_fee,
1241    };
1242    state
1243        .spot_repay_fills
1244        .entry(client_order_id)
1245        .and_modify(|total| {
1246            total.quantity = total.quantity + fill.quantity;
1247            total.base_fee += fill.base_fee;
1248        })
1249        .or_insert(fill);
1250}
1251
1252/// Enqueues an auto-repay for a fully-filled SPOT BUY.
1253fn enqueue_spot_repay(
1254    client_order_id: ClientOrderId,
1255    instrument: &InstrumentAny,
1256    state: &WsDispatchState,
1257) {
1258    let Some(base_currency) = instrument.base_currency() else {
1259        return;
1260    };
1261    let Some((_, fill)) = state.spot_repay_fills.remove(&client_order_id) else {
1262        return;
1263    };
1264
1265    state.enqueue_repay(RepayRequest {
1266        coin: base_currency.code,
1267        quantity: fill.quantity,
1268        base_fee: fill.base_fee,
1269        repayment_precision: fill.quantity.precision.max(base_currency.precision),
1270    });
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use ahash::AHashMap;
1276    use nautilus_common::messages::{ExecutionEvent, execution::ExecutionReport};
1277    use nautilus_core::{
1278        UnixNanos,
1279        time::{AtomicTime, get_atomic_clock_realtime},
1280    };
1281    use nautilus_live::{emitter::ExecutionEventEmitter, testing::ExecutionHarness};
1282    use nautilus_model::{
1283        accounts::MarginAccount,
1284        enums::{AccountType, OrderSide, OrderStatus, OrderType},
1285        events::OrderEventAny,
1286        identifiers::{
1287            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TraderId, VenueOrderId,
1288        },
1289        instruments::{Instrument, InstrumentAny},
1290        orders::{Order, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
1291        reports::FillReport,
1292        types::Currency,
1293    };
1294    use rstest::rstest;
1295    use ustr::Ustr;
1296
1297    use super::*;
1298    use crate::{
1299        common::{
1300            enums::{BybitExecType, BybitOrderSide, BybitProductType},
1301            parse::{parse_inverse_instrument, parse_linear_instrument, parse_spot_instrument},
1302            testing::load_test_json,
1303        },
1304        http::models::{
1305            BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
1306            BybitInstrumentSpotResponse,
1307        },
1308        websocket::messages::{
1309            BybitWsAccountExecutionFastMsg, BybitWsMessage, BybitWsOrderResponse,
1310        },
1311    };
1312
1313    fn linear_instrument() -> InstrumentAny {
1314        let json = load_test_json("http_get_instruments_linear.json");
1315        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1316        let instrument = &response.result.list[0];
1317        let ts = UnixNanos::new(1_700_000_000_000_000_000);
1318        parse_linear_instrument(instrument, ts, ts).unwrap()
1319    }
1320
1321    fn spot_instrument() -> InstrumentAny {
1322        let json = load_test_json("http_get_instruments_spot.json");
1323        let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1324        let instrument = &response.result.list[0];
1325        let ts = UnixNanos::new(1_700_000_000_000_000_000);
1326        parse_spot_instrument(instrument, ts, ts).unwrap()
1327    }
1328
1329    fn inverse_instrument() -> InstrumentAny {
1330        let json = load_test_json("http_get_instruments_inverse.json");
1331        let response: BybitInstrumentInverseResponse = serde_json::from_str(&json).unwrap();
1332        let instrument = &response.result.list[0];
1333        let ts = UnixNanos::new(1_700_000_000_000_000_000);
1334        parse_inverse_instrument(instrument, ts, ts).unwrap()
1335    }
1336
1337    fn build_instruments(instruments: &[InstrumentAny]) -> AHashMap<Ustr, InstrumentAny> {
1338        let mut map = AHashMap::new();
1339        for inst in instruments {
1340            map.insert(inst.id().symbol.inner(), inst.clone());
1341        }
1342        map
1343    }
1344
1345    fn test_account_id() -> AccountId {
1346        AccountId::from("BYBIT-001")
1347    }
1348
1349    fn create_emitter() -> (
1350        ExecutionEventEmitter,
1351        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
1352    ) {
1353        let clock = get_atomic_clock_realtime();
1354        let trader_id = TraderId::from("TESTER-001");
1355        let account_id = test_account_id();
1356        let mut emitter =
1357            ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
1358        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1359        emitter.set_sender(tx);
1360        (emitter, rx)
1361    }
1362
1363    fn default_identity() -> OrderIdentity {
1364        OrderIdentity {
1365            instrument_id: InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
1366            strategy_id: StrategyId::from("S-001"),
1367            order_side: OrderSide::Buy,
1368            order_type: OrderType::Limit,
1369            venue_position_id: None,
1370        }
1371    }
1372
1373    #[rstest]
1374    #[case::base_fee("BTC", "0.0000015", "0.0000025", "0.000004")]
1375    #[case::base_rebate("BTC", "-0.0000015", "-0.0000025", "0")]
1376    #[case::quote_fee("USDT", "0.075", "0.125", "0")]
1377    fn test_spot_repay_uses_accumulated_execution_quantity(
1378        #[case] fee_currency: &str,
1379        #[case] first_fee: &str,
1380        #[case] second_fee: &str,
1381        #[case] expected_base_fee: &str,
1382    ) {
1383        let instrument = spot_instrument();
1384        let instruments = build_instruments(std::slice::from_ref(&instrument));
1385        let (emitter, _rx) = create_emitter();
1386        let clock = get_atomic_clock_realtime();
1387        let state = WsDispatchState::default();
1388        let (repay_tx, mut repay_rx) = tokio::sync::mpsc::unbounded_channel();
1389        state.set_repay_sender(repay_tx);
1390
1391        let json = load_test_json("ws_account_execution.json");
1392        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1393        value["data"][0]["category"] = serde_json::Value::String("spot".to_string());
1394        value["data"][0]["symbol"] = serde_json::Value::String("BTCUSDT".to_string());
1395        value["data"][0]["side"] = serde_json::Value::String("Buy".to_string());
1396        value["data"][0]["orderType"] = serde_json::Value::String("Market".to_string());
1397        value["data"][0]["orderQty"] = serde_json::Value::String("100".to_string());
1398        value["data"][0]["execQty"] = serde_json::Value::String("0.0015".to_string());
1399        value["data"][0]["leavesQty"] = serde_json::Value::String("0.0025".to_string());
1400        value["data"][0]["execFee"] = serde_json::Value::String(first_fee.to_string());
1401        value["data"][0]["feeCurrency"] = serde_json::Value::String(fee_currency.to_string());
1402
1403        let first: crate::websocket::messages::BybitWsAccountExecutionMsg =
1404            serde_json::from_value(value.clone()).unwrap();
1405        let client_order_id = ClientOrderId::new(first.data[0].order_link_id);
1406        state.order_identities.insert(
1407            client_order_id,
1408            OrderIdentity {
1409                instrument_id: InstrumentId::from("BTCUSDT-SPOT.BYBIT"),
1410                order_type: OrderType::Market,
1411                ..default_identity()
1412            },
1413        );
1414
1415        dispatch_ws_message(
1416            &BybitWsMessage::AccountExecution(first),
1417            &emitter,
1418            &state,
1419            test_account_id(),
1420            &instruments,
1421            clock,
1422        );
1423        assert!(repay_rx.try_recv().is_err());
1424
1425        value["data"][0]["execId"] = serde_json::Value::String("second-execution".to_string());
1426        value["data"][0]["execQty"] = serde_json::Value::String("0.0025".to_string());
1427        value["data"][0]["leavesQty"] = serde_json::Value::String("0".to_string());
1428        value["data"][0]["execFee"] = serde_json::Value::String(second_fee.to_string());
1429        let second: crate::websocket::messages::BybitWsAccountExecutionMsg =
1430            serde_json::from_value(value).unwrap();
1431
1432        dispatch_ws_message(
1433            &BybitWsMessage::AccountExecution(second),
1434            &emitter,
1435            &state,
1436            test_account_id(),
1437            &instruments,
1438            clock,
1439        );
1440
1441        let repay = repay_rx.try_recv().expect("expected a repay request");
1442        assert_eq!(repay.coin, "BTC");
1443        assert_eq!(repay.quantity, Quantity::from("0.0040"));
1444        assert_eq!(
1445            repay.base_fee,
1446            expected_base_fee.parse::<Decimal>().unwrap()
1447        );
1448        assert_eq!(repay.repayment_precision, 8);
1449        assert!(repay_rx.try_recv().is_err());
1450    }
1451
1452    #[rstest]
1453    fn test_dispatch_tracked_canceled_order_emits_accepted_then_canceled() {
1454        let instrument = linear_instrument();
1455        let instruments = build_instruments(std::slice::from_ref(&instrument));
1456        let (emitter, mut rx) = create_emitter();
1457        let clock = get_atomic_clock_realtime();
1458        let state = WsDispatchState::default();
1459
1460        // Fixture has orderStatus=Cancelled
1461        let json = load_test_json("ws_account_order.json");
1462        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1463            serde_json::from_str(&json).unwrap();
1464
1465        if let Some(order) = msg.data.first()
1466            && !order.order_link_id.is_empty()
1467        {
1468            let cid = ClientOrderId::new(order.order_link_id);
1469            state.order_identities.insert(cid, default_identity());
1470        }
1471
1472        let ws_msg = BybitWsMessage::AccountOrder(msg);
1473        dispatch_ws_message(
1474            &ws_msg,
1475            &emitter,
1476            &state,
1477            test_account_id(),
1478            &instruments,
1479            clock,
1480        );
1481
1482        // First: synthesized Accepted
1483        let event1 = rx.try_recv().unwrap();
1484        assert!(
1485            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(ref a)) if a.strategy_id == StrategyId::from("S-001")),
1486            "Expected Accepted, found {event1:?}"
1487        );
1488
1489        // Second: Canceled (from Cancelled status)
1490        let event2 = rx.try_recv().unwrap();
1491        assert!(
1492            matches!(event2, ExecutionEvent::Order(OrderEventAny::Canceled(_))),
1493            "Expected Canceled, found {event2:?}"
1494        );
1495    }
1496
1497    #[rstest]
1498    fn test_dispatch_tracked_post_only_cancel_emits_rejected() {
1499        const BYBIT_POST_ONLY_REJECT_REASON: &str = "EC_PostOnlyWillTakeLiquidity";
1500
1501        let instrument = linear_instrument();
1502        let instruments = build_instruments(std::slice::from_ref(&instrument));
1503        let (emitter, mut rx) = create_emitter();
1504        let clock = get_atomic_clock_realtime();
1505        let state = WsDispatchState::default();
1506
1507        // Bybit reports a post-only order that would take liquidity as
1508        // orderStatus=Cancelled with rejectReason=EC_PostOnlyWillTakeLiquidity.
1509        let json = load_test_json("ws_account_order.json");
1510        let mut msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1511            serde_json::from_str(&json).unwrap();
1512
1513        let order = msg.data.first_mut().expect("fixture has an order");
1514        order.reject_reason = Ustr::from(BYBIT_POST_ONLY_REJECT_REASON);
1515        order.cum_exec_qty = "0".to_string();
1516        let cid = ClientOrderId::new(order.order_link_id);
1517        state.order_identities.insert(cid, default_identity());
1518
1519        let ws_msg = BybitWsMessage::AccountOrder(msg);
1520        dispatch_ws_message(
1521            &ws_msg,
1522            &emitter,
1523            &state,
1524            test_account_id(),
1525            &instruments,
1526            clock,
1527        );
1528
1529        let event = rx.try_recv().unwrap();
1530        let ExecutionEvent::Order(OrderEventAny::Rejected(rejected)) = event else {
1531            panic!("Expected Rejected, found {event:?}");
1532        };
1533        assert!(rejected.due_post_only);
1534        assert_eq!(rejected.reason, BYBIT_POST_ONLY_REJECT_REASON);
1535        assert_eq!(rejected.client_order_id, cid);
1536        assert!(rx.try_recv().is_err(), "expected only a single event");
1537    }
1538
1539    #[rstest]
1540    fn test_dispatch_untracked_order_emits_report() {
1541        let instrument = linear_instrument();
1542        let instruments = build_instruments(std::slice::from_ref(&instrument));
1543        let (emitter, mut rx) = create_emitter();
1544        let clock = get_atomic_clock_realtime();
1545        let state = WsDispatchState::default();
1546
1547        let json = load_test_json("ws_account_order.json");
1548        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1549            serde_json::from_str(&json).unwrap();
1550
1551        // No identity registered → untracked
1552        let ws_msg = BybitWsMessage::AccountOrder(msg);
1553        dispatch_ws_message(
1554            &ws_msg,
1555            &emitter,
1556            &state,
1557            test_account_id(),
1558            &instruments,
1559            clock,
1560        );
1561
1562        let event = rx.try_recv().unwrap();
1563        assert!(matches!(
1564            event,
1565            ExecutionEvent::Report(ExecutionReport::Order(_))
1566        ));
1567    }
1568
1569    #[rstest]
1570    fn test_dispatch_tracked_execution_emits_order_filled() {
1571        let instrument = linear_instrument();
1572        let instruments = build_instruments(std::slice::from_ref(&instrument));
1573        let (emitter, mut rx) = create_emitter();
1574        let clock = get_atomic_clock_realtime();
1575        let state = WsDispatchState::default();
1576
1577        let json = load_test_json("ws_account_execution.json");
1578        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1579            serde_json::from_str(&json).unwrap();
1580
1581        // Register identity for the execution's orderLinkId
1582        if let Some(exec) = msg.data.first()
1583            && !exec.order_link_id.is_empty()
1584        {
1585            let cid = ClientOrderId::new(exec.order_link_id);
1586            state.order_identities.insert(cid, default_identity());
1587        }
1588
1589        let ws_msg = BybitWsMessage::AccountExecution(msg);
1590        dispatch_ws_message(
1591            &ws_msg,
1592            &emitter,
1593            &state,
1594            test_account_id(),
1595            &instruments,
1596            clock,
1597        );
1598
1599        // First event should be synthesized Accepted
1600        let event1 = rx.try_recv().unwrap();
1601        assert!(
1602            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1603            "Expected Accepted, found {event1:?}"
1604        );
1605
1606        // Second event should be OrderFilled
1607        let event2 = rx.try_recv().unwrap();
1608        match event2 {
1609            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1610                assert_eq!(filled.strategy_id, StrategyId::from("S-001"));
1611                assert_eq!(filled.order_side, OrderSide::Buy);
1612                assert_eq!(filled.order_type, OrderType::Limit);
1613            }
1614            other => panic!("Expected Filled event, found {other:?}"),
1615        }
1616    }
1617
1618    #[rstest]
1619    #[case::commission_overflow("execFee", "9999999999999999999999999999")]
1620    #[case::commission_parse("execFee", "invalid")]
1621    #[case::qty("execQty", "invalid")]
1622    #[case::price("execPrice", "invalid")]
1623    #[case::timestamp("execTime", "invalid")]
1624    #[case::trade_id("execId", "")]
1625    fn test_dispatch_invalid_execution_preserves_state(
1626        #[case] field: &str,
1627        #[case] value: &str,
1628        #[values(false, true)] spot: bool,
1629    ) {
1630        let instrument = if spot {
1631            spot_instrument()
1632        } else {
1633            linear_instrument()
1634        };
1635
1636        let instruments = build_instruments(std::slice::from_ref(&instrument));
1637        let (emitter, mut rx) = create_emitter();
1638        let state = WsDispatchState::default();
1639        let (repay_tx, mut repay_rx) = tokio::sync::mpsc::unbounded_channel();
1640        state.set_repay_sender(repay_tx);
1641        let mut payload: serde_json::Value =
1642            serde_json::from_str(&load_test_json("ws_account_execution.json")).unwrap();
1643        payload["data"][0][field] = serde_json::Value::String(value.to_string());
1644        payload["data"][0]["side"] = serde_json::Value::String("Buy".to_string());
1645        if spot {
1646            payload["data"][0]["category"] = serde_json::Value::String("spot".to_string());
1647        }
1648
1649        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1650            serde_json::from_value(payload).unwrap();
1651        let cid = ClientOrderId::new(msg.data[0].order_link_id);
1652
1653        let identity = OrderIdentity {
1654            instrument_id: instrument.id(),
1655            venue_position_id: Some(PositionId::from("P-001")),
1656            ..default_identity()
1657        };
1658
1659        state.order_identities.insert(cid, identity.clone());
1660        state.order_snapshots.insert(
1661            cid,
1662            OrderStateSnapshot {
1663                quantity: Quantity::from("0.500"),
1664                price: Some(Price::from("95900.1")),
1665                trigger_price: Some(Price::from("95800.2")),
1666            },
1667        );
1668
1669        state.insert_triggered(cid);
1670
1671        dispatch_ws_message(
1672            &BybitWsMessage::AccountExecution(msg),
1673            &emitter,
1674            &state,
1675            test_account_id(),
1676            &instruments,
1677            get_atomic_clock_realtime(),
1678        );
1679
1680        assert!(rx.try_recv().is_err());
1681        assert!(repay_rx.try_recv().is_err());
1682        assert!(state.emitted_accepted.is_empty());
1683        assert!(state.filled_orders.is_empty());
1684        assert!(state.spot_repay_fills.is_empty());
1685        assert!(state.pending_requests.is_empty());
1686        assert_eq!(state.triggered_orders.len(), 1);
1687        assert!(state.triggered_orders.contains(&cid));
1688        assert_eq!(state.order_identities.len(), 1);
1689        let retained = state.order_identities.get(&cid).unwrap();
1690        assert_eq!(retained.instrument_id, identity.instrument_id);
1691        assert_eq!(retained.strategy_id, identity.strategy_id);
1692        assert_eq!(retained.order_side, identity.order_side);
1693        assert_eq!(retained.order_type, identity.order_type);
1694        assert_eq!(retained.venue_position_id, identity.venue_position_id);
1695        assert_eq!(state.order_snapshots.len(), 1);
1696        let snapshot = state.order_snapshots.get(&cid).unwrap();
1697        assert_eq!(snapshot.quantity, Quantity::from("0.500"));
1698        assert_eq!(snapshot.price, Some(Price::from("95900.1")));
1699        assert_eq!(snapshot.trigger_price, Some(Price::from("95800.2")));
1700    }
1701
1702    #[rstest]
1703    #[case::partial("0.5", "1.000", OrderStatus::PartiallyFilled)]
1704    #[case::terminal("0", "0.500", OrderStatus::Filled)]
1705    fn test_dispatch_execution_commission_failure_recovery_and_replay(
1706        #[case] leaves_qty: &str,
1707        #[case] order_qty: &str,
1708        #[case] expected_status: OrderStatus,
1709    ) {
1710        let instrument = linear_instrument();
1711        let instruments = build_instruments(std::slice::from_ref(&instrument));
1712        let (emitter, mut rx) = create_emitter();
1713        let state = WsDispatchState::default();
1714        let clock = AtomicTime::new(false, UnixNanos::new(1_746_270_400_400_000_000));
1715        let mut msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1716            serde_json::from_str(&load_test_json("ws_account_execution.json")).unwrap();
1717        msg.data[0].leaves_qty = leaves_qty.to_string();
1718        msg.data[0].side = BybitOrderSide::Buy;
1719        msg.data[0].exec_fee = "9999999999999999999999999999".to_string();
1720        let cid = ClientOrderId::new(msg.data[0].order_link_id);
1721        let identity = default_identity();
1722        state.order_identities.insert(cid, identity.clone());
1723
1724        let harness = ExecutionHarness::new(
1725            emitter.trader_id(),
1726            *crate::common::consts::BYBIT_CLIENT_ID,
1727            test_account_id(),
1728            instrument.clone(),
1729        );
1730        let wallet: crate::websocket::messages::BybitWsAccountWalletMsg =
1731            serde_json::from_str(&load_test_json("ws_account_wallet.json")).unwrap();
1732        let account_state = parse_ws_account_state(
1733            &wallet.data[0],
1734            test_account_id(),
1735            UnixNanos::default(),
1736            UnixNanos::default(),
1737        )
1738        .unwrap();
1739        let account = MarginAccount::new(account_state, false);
1740        harness
1741            .cache()
1742            .borrow_mut()
1743            .add_account(account.into())
1744            .unwrap();
1745        let mut order = OrderTestBuilder::new(identity.order_type)
1746            .trader_id(emitter.trader_id())
1747            .strategy_id(identity.strategy_id)
1748            .instrument_id(instrument.id())
1749            .client_order_id(cid)
1750            .side(identity.order_side)
1751            .quantity(Quantity::from(order_qty))
1752            .price(Price::from("94942.5"))
1753            .build();
1754        let submitted = TestOrderEventStubs::submitted(&order, test_account_id());
1755        order.apply(submitted).unwrap();
1756        harness
1757            .cache()
1758            .borrow_mut()
1759            .add_order(order, None, None, false)
1760            .unwrap();
1761
1762        dispatch_ws_message(
1763            &BybitWsMessage::AccountExecution(msg.clone()),
1764            &emitter,
1765            &state,
1766            test_account_id(),
1767            &instruments,
1768            &clock,
1769        );
1770
1771        assert!(rx.try_recv().is_err());
1772        assert!(state.emitted_accepted.is_empty());
1773        assert!(state.filled_orders.is_empty());
1774        assert_eq!(state.order_identities.len(), 1);
1775
1776        msg.data[0].exec_fee = "0.01234567".to_string();
1777        let message = BybitWsMessage::AccountExecution(msg);
1778        dispatch_ws_message(
1779            &message,
1780            &emitter,
1781            &state,
1782            test_account_id(),
1783            &instruments,
1784            &clock,
1785        );
1786
1787        let ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) = rx.try_recv().unwrap()
1788        else {
1789            panic!("expected Accepted before Filled");
1790        };
1791
1792        let expected_accepted = OrderAccepted::new(
1793            emitter.trader_id(),
1794            identity.strategy_id,
1795            instrument.id(),
1796            cid,
1797            VenueOrderId::from("9aac161b-8ed6-450d-9cab-c5cc67c21784"),
1798            test_account_id(),
1799            accepted.event_id,
1800            clock.get_time_ns(),
1801            clock.get_time_ns(),
1802            false,
1803        );
1804        assert_eq!(accepted, expected_accepted);
1805        harness
1806            .exec_engine()
1807            .borrow_mut()
1808            .process(&OrderEventAny::Accepted(accepted));
1809
1810        let ExecutionEvent::Order(OrderEventAny::Filled(filled)) = rx.try_recv().unwrap() else {
1811            panic!("expected Filled after Accepted");
1812        };
1813
1814        let commission = Money::from("0.01234567 USDT");
1815
1816        let expected_filled = OrderFilled::new(
1817            emitter.trader_id(),
1818            identity.strategy_id,
1819            instrument.id(),
1820            cid,
1821            VenueOrderId::from("9aac161b-8ed6-450d-9cab-c5cc67c21784"),
1822            test_account_id(),
1823            TradeId::from("0ab1bdf7-4219-438b-b30a-32ec863018f7"),
1824            OrderSide::Buy,
1825            OrderType::Limit,
1826            Quantity::from("0.500"),
1827            Price::from("95900.1"),
1828            Currency::USDT(),
1829            LiquiditySide::Taker,
1830            filled.event_id,
1831            UnixNanos::new(1_746_270_400_353_000_000),
1832            clock.get_time_ns(),
1833            false,
1834            None,
1835            Some(commission),
1836            None,
1837        );
1838        assert_eq!(filled, expected_filled);
1839        assert!(rx.try_recv().is_err());
1840        harness
1841            .exec_engine()
1842            .borrow_mut()
1843            .process(&OrderEventAny::Filled(filled.clone()));
1844        let applied = harness.cache().borrow().order(&cid).unwrap().clone();
1845        assert_eq!(applied.status(), expected_status);
1846        assert_eq!(applied.filled_qty(), Quantity::from("0.500"));
1847        assert_eq!(
1848            applied.commissions().get(&Currency::USDT()),
1849            Some(&commission)
1850        );
1851        assert_eq!(applied.trade_ids(), vec![&filled.trade_id]);
1852        let position_id = *harness.cache().borrow().position_id(&cid).unwrap();
1853        let position = harness
1854            .cache()
1855            .borrow()
1856            .position(&position_id)
1857            .unwrap()
1858            .clone();
1859        assert_eq!(position.quantity, Quantity::from("0.500"));
1860        assert_eq!(position.commissions(), vec![commission]);
1861        let mut expected_position_fill = filled.clone();
1862        expected_position_fill.position_id = Some(position_id);
1863        assert_eq!(position.events, vec![expected_position_fill]);
1864
1865        dispatch_ws_message(
1866            &message,
1867            &emitter,
1868            &state,
1869            test_account_id(),
1870            &instruments,
1871            &clock,
1872        );
1873        let replay = rx.try_recv().unwrap();
1874        match replay {
1875            ExecutionEvent::Order(OrderEventAny::Filled(replayed)) => {
1876                let expected_replay = OrderFilled {
1877                    event_id: replayed.event_id,
1878                    ..filled.clone()
1879                };
1880
1881                assert_eq!(replayed, expected_replay);
1882                assert_eq!(leaves_qty, "0.5");
1883                harness
1884                    .exec_engine()
1885                    .borrow_mut()
1886                    .process(&OrderEventAny::Filled(replayed));
1887            }
1888            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
1889                let expected_report = FillReport::new(
1890                    filled.account_id,
1891                    filled.instrument_id,
1892                    filled.venue_order_id,
1893                    filled.trade_id,
1894                    filled.order_side,
1895                    filled.last_qty,
1896                    filled.last_px,
1897                    commission,
1898                    filled.liquidity_side,
1899                    Some(cid),
1900                    None,
1901                    filled.ts_event,
1902                    filled.ts_init,
1903                    Some(report.report_id),
1904                );
1905                assert_eq!(*report, expected_report);
1906                assert_eq!(leaves_qty, "0");
1907                harness
1908                    .exec_engine()
1909                    .borrow_mut()
1910                    .reconcile_fill_report(&report);
1911            }
1912            other => panic!("unexpected replay: {other:?}"),
1913        }
1914
1915        harness
1916            .exec_engine()
1917            .borrow_mut()
1918            .process(&OrderEventAny::Filled(filled));
1919
1920        assert!(rx.try_recv().is_err());
1921        assert_eq!(
1922            serde_json::to_value(harness.cache().borrow().order(&cid).unwrap().clone()).unwrap(),
1923            serde_json::to_value(&applied).unwrap()
1924        );
1925        assert_eq!(
1926            serde_json::to_value(
1927                harness
1928                    .cache()
1929                    .borrow()
1930                    .position(&position_id)
1931                    .unwrap()
1932                    .clone()
1933            )
1934            .unwrap(),
1935            serde_json::to_value(&position).unwrap()
1936        );
1937    }
1938
1939    #[rstest]
1940    fn parse_order_filled_uses_payload_fee_currency() {
1941        let instrument = linear_instrument();
1942        let (emitter, _rx) = create_emitter();
1943
1944        let json = load_test_json("ws_account_execution.json");
1945        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1946            serde_json::from_str(&json).unwrap();
1947
1948        let mut exec = msg.data[0].clone();
1949        exec.fee_currency = Ustr::from("BTC");
1950
1951        let filled = parse_order_filled(
1952            &exec,
1953            &instrument,
1954            &default_identity(),
1955            &emitter,
1956            test_account_id(),
1957            UnixNanos::default(),
1958        )
1959        .unwrap();
1960
1961        let commission = filled.commission.expect("commission present");
1962        assert_eq!(commission.currency.code, "BTC");
1963    }
1964
1965    #[rstest]
1966    #[case::spot_base_fee(BybitProductType::Spot, "BTC", "0.000001", "USDT")]
1967    #[case::spot_quote_fee(BybitProductType::Spot, "USDT", "0.1", "USDT")]
1968    #[case::linear_fee(BybitProductType::Linear, "USDT", "0.1", "USDT")]
1969    #[case::inverse_fee(BybitProductType::Inverse, "BTC", "0.000001", "USD")]
1970    #[case::spot_zero_fee(BybitProductType::Spot, "BTC", "0", "USDT")]
1971    #[case::inverse_rebate(BybitProductType::Inverse, "BTC", "-0.000001", "USD")]
1972    fn parse_order_filled_separates_price_and_commission_currencies(
1973        #[case] category: BybitProductType,
1974        #[case] fee_currency: &str,
1975        #[case] fee: &str,
1976        #[case] expected_price_currency: &str,
1977    ) {
1978        let instrument = match category {
1979            BybitProductType::Spot => spot_instrument(),
1980            BybitProductType::Linear => linear_instrument(),
1981            BybitProductType::Inverse => inverse_instrument(),
1982            _ => unreachable!("unsupported test product"),
1983        };
1984        let (emitter, _rx) = create_emitter();
1985        let json = load_test_json("ws_account_execution.json");
1986        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1987            serde_json::from_str(&json).unwrap();
1988        let mut exec = msg.data[0].clone();
1989        exec.category = category;
1990        exec.symbol = instrument.raw_symbol().inner();
1991        exec.exec_qty = "1".to_string();
1992        exec.exec_fee = fee.to_string();
1993        exec.fee_currency = Ustr::from(fee_currency);
1994        let identity = OrderIdentity {
1995            instrument_id: instrument.id(),
1996            order_side: OrderSide::Sell,
1997            order_type: OrderType::Market,
1998            ..default_identity()
1999        };
2000
2001        let filled = parse_order_filled(
2002            &exec,
2003            &instrument,
2004            &identity,
2005            &emitter,
2006            test_account_id(),
2007            UnixNanos::default(),
2008        )
2009        .unwrap();
2010
2011        let commission = filled.commission.expect("commission present");
2012        assert_eq!(commission.currency.code, fee_currency);
2013        assert_eq!(commission.as_decimal(), fee.parse::<Decimal>().unwrap());
2014        assert_eq!(filled.currency.code, expected_price_currency);
2015    }
2016
2017    #[rstest]
2018    fn test_dispatch_tracked_execution_preserves_venue_position_id() {
2019        let instrument = linear_instrument();
2020        let instruments = build_instruments(std::slice::from_ref(&instrument));
2021        let (emitter, mut rx) = create_emitter();
2022        let clock = get_atomic_clock_realtime();
2023        let state = WsDispatchState::default();
2024
2025        let json = load_test_json("ws_account_execution.json");
2026        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
2027            serde_json::from_str(&json).unwrap();
2028        let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
2029
2030        if let Some(exec) = msg.data.first()
2031            && !exec.order_link_id.is_empty()
2032        {
2033            let cid = ClientOrderId::new(exec.order_link_id);
2034            state.order_identities.insert(
2035                cid,
2036                OrderIdentity {
2037                    venue_position_id: Some(venue_position_id),
2038                    ..default_identity()
2039                },
2040            );
2041        }
2042
2043        let ws_msg = BybitWsMessage::AccountExecution(msg);
2044        dispatch_ws_message(
2045            &ws_msg,
2046            &emitter,
2047            &state,
2048            test_account_id(),
2049            &instruments,
2050            clock,
2051        );
2052
2053        let _accepted = rx.try_recv().unwrap();
2054        let event = rx.try_recv().unwrap();
2055        match event {
2056            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
2057                assert_eq!(filled.position_id, Some(venue_position_id));
2058            }
2059            other => panic!("Expected Filled event, found {other:?}"),
2060        }
2061    }
2062
2063    fn fast_execution_msg(is_maker: bool, order_link_id: &str) -> BybitWsAccountExecutionFastMsg {
2064        BybitWsAccountExecutionFastMsg {
2065            topic: Ustr::from("execution.fast"),
2066            id: String::new(),
2067            creation_time: 1_716_800_399_338,
2068            data: vec![BybitWsAccountExecutionFast {
2069                category: BybitProductType::Linear,
2070                symbol: Ustr::from("BTCUSDT"),
2071                exec_id: "fast-1".to_string(),
2072                exec_price: "50000.0".to_string(),
2073                exec_qty: "0.5".to_string(),
2074                order_id: Ustr::from("ord-1"),
2075                order_link_id: Ustr::from(order_link_id),
2076                side: BybitOrderSide::Buy,
2077                exec_time: "1716800399334".to_string(),
2078                is_maker,
2079                seq: 42,
2080            }],
2081        }
2082    }
2083
2084    #[rstest]
2085    fn test_dispatch_tracked_fast_execution_preserves_venue_position_id() {
2086        let instrument = linear_instrument();
2087        let instruments = build_instruments(std::slice::from_ref(&instrument));
2088        let (emitter, mut rx) = create_emitter();
2089        let clock = get_atomic_clock_realtime();
2090        let state = WsDispatchState::default();
2091        let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
2092
2093        // Taker fast fill (orderLinkId populated) so the identity lookup hits.
2094        let msg = fast_execution_msg(false, "link-1");
2095        let cid = ClientOrderId::new(msg.data[0].order_link_id);
2096        state.order_identities.insert(
2097            cid,
2098            OrderIdentity {
2099                venue_position_id: Some(venue_position_id),
2100                ..default_identity()
2101            },
2102        );
2103
2104        let ws_msg = BybitWsMessage::AccountExecutionFast(msg);
2105        dispatch_ws_message(
2106            &ws_msg,
2107            &emitter,
2108            &state,
2109            test_account_id(),
2110            &instruments,
2111            clock,
2112        );
2113
2114        // First event: synthesized OrderAccepted from the identity hit.
2115        let event1 = rx.try_recv().unwrap();
2116        assert!(
2117            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
2118            "Expected Accepted, found {event1:?}",
2119        );
2120
2121        // Second event: FillReport carrying the hedge venue_position_id.
2122        let event2 = rx.try_recv().unwrap();
2123        match event2 {
2124            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
2125                assert_eq!(report.venue_position_id, Some(venue_position_id));
2126                assert_eq!(report.client_order_id, Some(cid));
2127            }
2128            other => panic!("Expected FillReport, found {other:?}"),
2129        }
2130    }
2131
2132    #[rstest]
2133    fn test_dispatch_untracked_fast_execution_emits_fill_report_without_position() {
2134        let instrument = linear_instrument();
2135        let instruments = build_instruments(std::slice::from_ref(&instrument));
2136        let (emitter, mut rx) = create_emitter();
2137        let clock = get_atomic_clock_realtime();
2138        let state = WsDispatchState::default();
2139
2140        // Maker fast fill: orderLinkId is empty per venue docs, so no identity lookup.
2141        let msg = fast_execution_msg(true, "");
2142        let ws_msg = BybitWsMessage::AccountExecutionFast(msg);
2143        dispatch_ws_message(
2144            &ws_msg,
2145            &emitter,
2146            &state,
2147            test_account_id(),
2148            &instruments,
2149            clock,
2150        );
2151
2152        // No OrderAccepted is synthesized for untracked fills.
2153        let event = rx.try_recv().unwrap();
2154        match event {
2155            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
2156                assert_eq!(report.client_order_id, None);
2157                assert_eq!(report.venue_position_id, None);
2158                assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2159                assert_eq!(report.commission.as_f64(), 0.0);
2160            }
2161            other => panic!("Expected FillReport, found {other:?}"),
2162        }
2163    }
2164
2165    #[rstest]
2166    fn test_dispatch_untracked_execution_emits_fill_report() {
2167        let instrument = linear_instrument();
2168        let instruments = build_instruments(std::slice::from_ref(&instrument));
2169        let (emitter, mut rx) = create_emitter();
2170        let clock = get_atomic_clock_realtime();
2171        let state = WsDispatchState::default();
2172
2173        let json = load_test_json("ws_account_execution.json");
2174        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
2175            serde_json::from_str(&json).unwrap();
2176
2177        // No identity registered → untracked
2178        let ws_msg = BybitWsMessage::AccountExecution(msg);
2179        dispatch_ws_message(
2180            &ws_msg,
2181            &emitter,
2182            &state,
2183            test_account_id(),
2184            &instruments,
2185            clock,
2186        );
2187
2188        let event = rx.try_recv().unwrap();
2189        assert!(matches!(
2190            event,
2191            ExecutionEvent::Report(ExecutionReport::Fill(_))
2192        ));
2193    }
2194
2195    #[rstest]
2196    #[case::untracked("")]
2197    #[case::tracked("test-order-link-001")]
2198    fn test_dispatch_funding_execution_emits_no_event(#[case] order_link_id: &str) {
2199        let instrument = linear_instrument();
2200        let instruments = build_instruments(std::slice::from_ref(&instrument));
2201        let (emitter, mut rx) = create_emitter();
2202        let clock = get_atomic_clock_realtime();
2203        let state = WsDispatchState::default();
2204
2205        let json = load_test_json("ws_account_execution_funding.json");
2206        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2207        value["data"][0]["orderLinkId"] = serde_json::Value::String(order_link_id.to_string());
2208        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
2209            serde_json::from_value(value).unwrap();
2210        let execution = &msg.data[0];
2211
2212        assert_eq!(execution.exec_type, BybitExecType::Funding);
2213
2214        if !execution.order_link_id.is_empty() {
2215            state.order_identities.insert(
2216                ClientOrderId::new(execution.order_link_id),
2217                default_identity(),
2218            );
2219        }
2220
2221        dispatch_ws_message(
2222            &BybitWsMessage::AccountExecution(msg),
2223            &emitter,
2224            &state,
2225            test_account_id(),
2226            &instruments,
2227            clock,
2228        );
2229
2230        assert!(matches!(
2231            rx.try_recv(),
2232            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2233        ));
2234    }
2235
2236    #[rstest]
2237    #[case::corporate_action("CorporateAction")]
2238    #[case::forward_split_settle("ForwardSplitSettle")]
2239    #[case::reverse_split_settle("ReverseSplitSettle")]
2240    #[case::dividend("Dividend")]
2241    #[case::unrecognized("StockMerger")]
2242    fn test_dispatch_venue_initiated_execution_emits_only_fill_report(#[case] exec_type: &str) {
2243        let instrument = linear_instrument();
2244        let instruments = build_instruments(std::slice::from_ref(&instrument));
2245        let (emitter, mut rx) = create_emitter();
2246        let clock = get_atomic_clock_realtime();
2247        let state = WsDispatchState::default();
2248
2249        let json = load_test_json("ws_account_execution_adl.json");
2250        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2251        value["data"][0]["execType"] = serde_json::Value::String(exec_type.to_string());
2252        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
2253            serde_json::from_value(value).unwrap();
2254        let execution = &msg.data[0];
2255
2256        assert!(execution.order_link_id.is_empty());
2257
2258        dispatch_ws_message(
2259            &BybitWsMessage::AccountExecution(msg),
2260            &emitter,
2261            &state,
2262            test_account_id(),
2263            &instruments,
2264            clock,
2265        );
2266
2267        let event = rx.try_recv().unwrap();
2268        match event {
2269            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
2270                assert_eq!(report.client_order_id, None);
2271                assert_eq!(
2272                    report.venue_order_id,
2273                    VenueOrderId::from("9aac161b-8ed6-450d-9cab-c5cc67c21785")
2274                );
2275            }
2276            other => panic!("Expected FillReport, found {other:?}"),
2277        }
2278        assert!(rx.try_recv().is_err());
2279    }
2280
2281    #[rstest]
2282    fn test_dispatch_wallet_emits_account_state() {
2283        let instruments = AHashMap::new();
2284        let (emitter, mut rx) = create_emitter();
2285        let clock = get_atomic_clock_realtime();
2286        let state = WsDispatchState::default();
2287
2288        let json = load_test_json("ws_account_wallet.json");
2289        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
2290            serde_json::from_str(&json).unwrap();
2291        let ws_msg = BybitWsMessage::AccountWallet(msg);
2292
2293        dispatch_ws_message(
2294            &ws_msg,
2295            &emitter,
2296            &state,
2297            test_account_id(),
2298            &instruments,
2299            clock,
2300        );
2301
2302        let event = rx.try_recv().unwrap();
2303        assert!(matches!(event, ExecutionEvent::Account(_)));
2304    }
2305
2306    #[rstest]
2307    fn test_dispatch_data_message_ignored() {
2308        let instruments = AHashMap::new();
2309        let (emitter, mut rx) = create_emitter();
2310        let clock = get_atomic_clock_realtime();
2311        let state = WsDispatchState::default();
2312
2313        let json = load_test_json("ws_public_trade.json");
2314        let msg: crate::websocket::messages::BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
2315        let ws_msg = BybitWsMessage::Trade(msg);
2316
2317        dispatch_ws_message(
2318            &ws_msg,
2319            &emitter,
2320            &state,
2321            test_account_id(),
2322            &instruments,
2323            clock,
2324        );
2325
2326        rx.try_recv().unwrap_err();
2327    }
2328
2329    #[rstest]
2330    fn test_accepted_dedup_prevents_duplicate() {
2331        let instrument = linear_instrument();
2332        let instruments = build_instruments(std::slice::from_ref(&instrument));
2333        let (emitter, mut rx) = create_emitter();
2334        let clock = get_atomic_clock_realtime();
2335        let state = WsDispatchState::default();
2336
2337        // Fixture has orderStatus=Cancelled. Patch to New for this dedup test.
2338        let json = load_test_json("ws_account_order.json");
2339        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2340        value["data"][0]["orderStatus"] = serde_json::Value::String("New".to_string());
2341        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2342            serde_json::from_value(value).unwrap();
2343
2344        if let Some(order) = msg.data.first()
2345            && !order.order_link_id.is_empty()
2346        {
2347            let cid = ClientOrderId::new(order.order_link_id);
2348            state.order_identities.insert(cid, default_identity());
2349        }
2350
2351        let ws_msg = BybitWsMessage::AccountOrder(msg.clone());
2352        dispatch_ws_message(
2353            &ws_msg,
2354            &emitter,
2355            &state,
2356            test_account_id(),
2357            &instruments,
2358            clock,
2359        );
2360
2361        let event = rx.try_recv().unwrap();
2362        assert!(matches!(
2363            event,
2364            ExecutionEvent::Order(OrderEventAny::Accepted(_))
2365        ));
2366
2367        // Dispatch the same message again: dedup should suppress the duplicate
2368        let ws_msg2 = BybitWsMessage::AccountOrder(msg);
2369        dispatch_ws_message(
2370            &ws_msg2,
2371            &emitter,
2372            &state,
2373            test_account_id(),
2374            &instruments,
2375            clock,
2376        );
2377
2378        rx.try_recv().unwrap_err();
2379    }
2380
2381    fn new_order_value() -> serde_json::Value {
2382        let json = load_test_json("ws_account_order.json");
2383        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2384        value["data"][0]["orderStatus"] = serde_json::Value::String("New".to_string());
2385        value
2386    }
2387
2388    struct DispatchTestContext {
2389        instruments: AHashMap<Ustr, InstrumentAny>,
2390        emitter: ExecutionEventEmitter,
2391        rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2392        clock: &'static AtomicTime,
2393        state: WsDispatchState,
2394    }
2395
2396    impl DispatchTestContext {
2397        fn new() -> Self {
2398            let instrument = linear_instrument();
2399            let instruments = build_instruments(std::slice::from_ref(&instrument));
2400            let (emitter, rx) = create_emitter();
2401            let clock = get_atomic_clock_realtime();
2402            let state = WsDispatchState::default();
2403            Self {
2404                instruments,
2405                emitter,
2406                rx,
2407                clock,
2408                state,
2409            }
2410        }
2411
2412        fn accept_order(&mut self, value: &serde_json::Value) {
2413            let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2414                serde_json::from_value(value.clone()).unwrap();
2415
2416            if let Some(order) = msg.data.first()
2417                && !order.order_link_id.is_empty()
2418                && !self
2419                    .state
2420                    .order_identities
2421                    .contains_key(&ClientOrderId::new(order.order_link_id))
2422            {
2423                let cid = ClientOrderId::new(order.order_link_id);
2424                self.state.order_identities.insert(cid, default_identity());
2425            }
2426
2427            self.dispatch_value(value);
2428
2429            let event = self.rx.try_recv().unwrap();
2430            assert!(
2431                matches!(event, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
2432                "Expected Accepted, found {event:?}"
2433            );
2434        }
2435
2436        fn dispatch_value(&self, value: &serde_json::Value) {
2437            let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2438                serde_json::from_value(value.clone()).unwrap();
2439            let ws_msg = BybitWsMessage::AccountOrder(msg);
2440            dispatch_ws_message(
2441                &ws_msg,
2442                &self.emitter,
2443                &self.state,
2444                test_account_id(),
2445                &self.instruments,
2446                self.clock,
2447            );
2448        }
2449
2450        fn recv_updated(&mut self) -> OrderUpdated {
2451            let event = self.rx.try_recv().unwrap();
2452            match event {
2453                ExecutionEvent::Order(OrderEventAny::Updated(updated)) => updated,
2454                other => panic!("Expected Updated event, found {other:?}"),
2455            }
2456        }
2457    }
2458
2459    #[rstest]
2460    fn test_dispatch_order_updated_on_price_change() {
2461        let mut ctx = DispatchTestContext::new();
2462        let value = new_order_value();
2463        ctx.accept_order(&value);
2464
2465        let mut amended = value;
2466        amended["data"][0]["price"] = serde_json::Value::String("31000".to_string());
2467        ctx.dispatch_value(&amended);
2468
2469        let updated = ctx.recv_updated();
2470        assert_eq!(updated.client_order_id, ClientOrderId::from("client-1"));
2471        assert_eq!(updated.price, Some(Price::from("31000.00")));
2472        assert_eq!(updated.quantity, Quantity::from("0.010"));
2473        assert_eq!(updated.trigger_price, None);
2474        assert!(updated.venue_order_id.is_some());
2475    }
2476
2477    #[rstest]
2478    fn test_dispatch_order_updated_on_quantity_change() {
2479        let mut ctx = DispatchTestContext::new();
2480        let value = new_order_value();
2481        ctx.accept_order(&value);
2482
2483        let mut amended = value;
2484        amended["data"][0]["qty"] = serde_json::Value::String("0.020".to_string());
2485        ctx.dispatch_value(&amended);
2486
2487        let updated = ctx.recv_updated();
2488        assert_eq!(updated.quantity, Quantity::from("0.020"));
2489        assert_eq!(updated.price, Some(Price::from("30000.00")));
2490    }
2491
2492    #[rstest]
2493    fn test_dispatch_order_updated_on_trigger_price_change() {
2494        let mut ctx = DispatchTestContext::new();
2495        let mut value = new_order_value();
2496        value["data"][0]["triggerPrice"] = serde_json::Value::String("29000".to_string());
2497        ctx.accept_order(&value);
2498
2499        let mut amended = value;
2500        amended["data"][0]["triggerPrice"] = serde_json::Value::String("28000".to_string());
2501        ctx.dispatch_value(&amended);
2502
2503        let updated = ctx.recv_updated();
2504        assert_eq!(updated.trigger_price, Some(Price::from("28000.00")));
2505        assert_eq!(updated.price, Some(Price::from("30000.00")));
2506    }
2507
2508    #[rstest]
2509    fn test_dispatch_dedup_suppresses_identical_after_snapshot() {
2510        let mut ctx = DispatchTestContext::new();
2511        let value = new_order_value();
2512        ctx.accept_order(&value);
2513
2514        ctx.dispatch_value(&value);
2515
2516        assert!(
2517            ctx.rx.try_recv().is_err(),
2518            "Expected no event for identical redelivery"
2519        );
2520    }
2521
2522    #[rstest]
2523    fn test_dispatch_order_updated_stores_snapshot_for_subsequent_change() {
2524        let mut ctx = DispatchTestContext::new();
2525        let value = new_order_value();
2526        ctx.accept_order(&value);
2527
2528        let mut amended1 = value.clone();
2529        amended1["data"][0]["price"] = serde_json::Value::String("31000".to_string());
2530        ctx.dispatch_value(&amended1);
2531        let _result = ctx.recv_updated();
2532
2533        let mut amended2 = value;
2534        amended2["data"][0]["price"] = serde_json::Value::String("32000".to_string());
2535        ctx.dispatch_value(&amended2);
2536
2537        let updated = ctx.recv_updated();
2538        assert_eq!(updated.price, Some(Price::from("32000.00")));
2539    }
2540
2541    #[rstest]
2542    fn test_dispatch_accepted_with_seeded_snapshot_emits_updated_for_bbo() {
2543        let mut ctx = DispatchTestContext::new();
2544        let value = new_order_value();
2545        let cid = ClientOrderId::from("client-1");
2546        ctx.state.order_identities.insert(cid, default_identity());
2547        ctx.state.order_snapshots.insert(
2548            cid,
2549            OrderStateSnapshot {
2550                quantity: Quantity::from("0.010"),
2551                price: Some(Price::from("29000.00")),
2552                trigger_price: None,
2553            },
2554        );
2555
2556        ctx.dispatch_value(&value);
2557
2558        let accepted = ctx.rx.try_recv().unwrap();
2559        assert!(
2560            matches!(accepted, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
2561            "Expected Accepted first, found {accepted:?}"
2562        );
2563
2564        let updated = ctx.recv_updated();
2565        assert_eq!(updated.client_order_id, cid);
2566        assert_eq!(updated.price, Some(Price::from("30000.00")));
2567        assert_eq!(updated.quantity, Quantity::from("0.010"));
2568        assert!(updated.venue_order_id.is_some());
2569    }
2570
2571    #[rstest]
2572    fn test_emit_rejection_for_place_clears_snapshot() {
2573        let ctx = DispatchTestContext::new();
2574        let cid = ClientOrderId::from("client-1");
2575        let identity = default_identity();
2576        ctx.state.order_identities.insert(cid, identity.clone());
2577        ctx.state.order_snapshots.insert(
2578            cid,
2579            OrderStateSnapshot {
2580                quantity: Quantity::from("0.010"),
2581                price: Some(Price::from("30000.00")),
2582                trigger_price: None,
2583            },
2584        );
2585
2586        emit_rejection_for_op(
2587            &PendingOperation::Place,
2588            cid,
2589            &identity,
2590            None,
2591            "rejected",
2592            &ctx.emitter,
2593            &ctx.state,
2594            UnixNanos::from(1u64),
2595        );
2596
2597        assert!(!ctx.state.order_identities.contains_key(&cid));
2598        assert!(!ctx.state.order_snapshots.contains_key(&cid));
2599    }
2600
2601    fn order_response(
2602        op: &str,
2603        ret_code: i64,
2604        ret_msg: &str,
2605        req_id: &str,
2606        data: serde_json::Value,
2607        ret_ext_info: Option<serde_json::Value>,
2608    ) -> BybitWsOrderResponse {
2609        BybitWsOrderResponse {
2610            op: Ustr::from(op),
2611            conn_id: Some("test-conn-id".to_string()),
2612            ret_code,
2613            ret_msg: ret_msg.to_string(),
2614            data,
2615            req_id: Some(req_id.to_string()),
2616            header: None,
2617            ret_ext_info,
2618        }
2619    }
2620
2621    #[rstest]
2622    fn test_dispatch_local_not_sent_emits_order_rejected() {
2623        let mut ctx = DispatchTestContext::new();
2624        let cid = ClientOrderId::from("not-sent-1");
2625        ctx.state.order_identities.insert(cid, default_identity());
2626        ctx.state.pending_requests.insert(
2627            "req-not-sent".to_string(),
2628            (vec![cid], vec![None], PendingOperation::Place),
2629        );
2630        let response = order_response(
2631            BYBIT_OP_ORDER_CREATE,
2632            -1,
2633            "Order command was not written",
2634            "req-not-sent",
2635            serde_json::json!({}),
2636            None,
2637        );
2638
2639        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2640
2641        let event = ctx.rx.try_recv().expect("expected OrderRejected event");
2642        assert!(
2643            matches!(event, ExecutionEvent::Order(OrderEventAny::Rejected(ref rejected))
2644                if rejected.client_order_id == cid
2645                    && rejected.reason == "Order command was not written"),
2646            "Expected OrderRejected for {cid}, found {event:?}"
2647        );
2648        assert!(!ctx.state.order_identities.contains_key(&cid));
2649        assert!(!ctx.state.pending_requests.contains_key("req-not-sent"));
2650    }
2651
2652    #[rstest]
2653    fn test_dispatch_single_cancel_rejection_emits_cancel_rejected() {
2654        let mut ctx = DispatchTestContext::new();
2655        let cid = ClientOrderId::from("cancel-reject-1");
2656        let venue_order_id = VenueOrderId::from("venue-cancel-1");
2657        ctx.state.order_identities.insert(cid, default_identity());
2658        ctx.state.pending_requests.insert(
2659            "req-cancel".to_string(),
2660            (
2661                vec![cid],
2662                vec![Some(venue_order_id)],
2663                PendingOperation::Cancel,
2664            ),
2665        );
2666
2667        let response = order_response(
2668            BYBIT_OP_ORDER_CANCEL,
2669            110001,
2670            "Order does not exist.",
2671            "req-cancel",
2672            serde_json::json!({
2673                "orderId": venue_order_id.to_string(),
2674                "orderLinkId": cid.to_string(),
2675            }),
2676            None,
2677        );
2678
2679        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2680
2681        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2682        assert!(
2683            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected)) if rejected.client_order_id == cid),
2684            "Expected CancelRejected for {cid}, found {event:?}"
2685        );
2686        assert!(
2687            ctx.rx.try_recv().is_err(),
2688            "Expected no extra events for single cancel rejection"
2689        );
2690    }
2691
2692    #[rstest]
2693    fn test_dispatch_single_modify_rejection_emits_modify_rejected() {
2694        let mut ctx = DispatchTestContext::new();
2695        let cid = ClientOrderId::from("modify-reject-1");
2696        let venue_order_id = VenueOrderId::from("venue-modify-1");
2697        ctx.state.order_identities.insert(cid, default_identity());
2698        ctx.state.pending_requests.insert(
2699            "req-modify".to_string(),
2700            (
2701                vec![cid],
2702                vec![Some(venue_order_id)],
2703                PendingOperation::Amend,
2704            ),
2705        );
2706
2707        let response = order_response(
2708            BYBIT_OP_ORDER_AMEND,
2709            110003,
2710            "Order price exceeds allowable range.",
2711            "req-modify",
2712            serde_json::json!({
2713                "orderId": venue_order_id.to_string(),
2714                "orderLinkId": cid.to_string(),
2715            }),
2716            None,
2717        );
2718
2719        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2720
2721        let event = ctx.rx.try_recv().expect("expected ModifyRejected event");
2722        assert!(
2723            matches!(event, ExecutionEvent::Order(OrderEventAny::ModifyRejected(ref rejected)) if rejected.client_order_id == cid),
2724            "Expected ModifyRejected for {cid}, found {event:?}"
2725        );
2726        assert!(
2727            ctx.rx.try_recv().is_err(),
2728            "Expected no extra events for single modify rejection"
2729        );
2730    }
2731
2732    #[rstest]
2733    fn test_dispatch_single_cancel_rate_limit_emits_cancel_rejected() {
2734        let mut ctx = DispatchTestContext::new();
2735        let cid = ClientOrderId::from("cancel-rate-limit-1");
2736        let venue_order_id = VenueOrderId::from("venue-cancel-rate-limit-1");
2737        ctx.state.order_identities.insert(cid, default_identity());
2738        ctx.state.pending_requests.insert(
2739            "req-cancel-rate-limit".to_string(),
2740            (
2741                vec![cid],
2742                vec![Some(venue_order_id)],
2743                PendingOperation::Cancel,
2744            ),
2745        );
2746
2747        let response = order_response(
2748            BYBIT_OP_ORDER_CANCEL,
2749            10429,
2750            "System level frequency protection.",
2751            "req-cancel-rate-limit",
2752            serde_json::json!({
2753                "orderId": venue_order_id.to_string(),
2754                "orderLinkId": cid.to_string(),
2755            }),
2756            None,
2757        );
2758
2759        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2760
2761        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2762        assert!(
2763            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2764                if rejected.client_order_id == cid && rejected.venue_order_id == Some(venue_order_id)),
2765            "Expected CancelRejected for {cid}, found {event:?}"
2766        );
2767        assert!(ctx.state.order_identities.contains_key(&cid));
2768    }
2769
2770    #[rstest]
2771    fn test_dispatch_single_modify_server_error_keeps_outcome_unresolved() {
2772        let mut ctx = DispatchTestContext::new();
2773        let cid = ClientOrderId::from("modify-server-error-1");
2774        let venue_order_id = VenueOrderId::from("venue-modify-server-error-1");
2775        ctx.state.order_identities.insert(cid, default_identity());
2776        ctx.state.pending_requests.insert(
2777            "req-modify-server-error".to_string(),
2778            (
2779                vec![cid],
2780                vec![Some(venue_order_id)],
2781                PendingOperation::Amend,
2782            ),
2783        );
2784
2785        let response = order_response(
2786            BYBIT_OP_ORDER_AMEND,
2787            10016,
2788            "Internal server error.",
2789            "req-modify-server-error",
2790            serde_json::json!({
2791                "orderId": venue_order_id.to_string(),
2792                "orderLinkId": cid.to_string(),
2793            }),
2794            None,
2795        );
2796
2797        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2798
2799        assert!(
2800            ctx.rx.try_recv().is_err(),
2801            "Expected no ModifyRejected event for server error response"
2802        );
2803        assert!(ctx.state.order_identities.contains_key(&cid));
2804    }
2805
2806    #[rstest]
2807    fn test_dispatch_batch_cancel_top_level_failure_does_not_emit_cancel_rejected() {
2808        let mut ctx = DispatchTestContext::new();
2809        let cid_1 = ClientOrderId::from("batch-cancel-1");
2810        let cid_2 = ClientOrderId::from("batch-cancel-2");
2811        ctx.state.order_identities.insert(cid_1, default_identity());
2812        ctx.state.order_identities.insert(cid_2, default_identity());
2813        ctx.state.pending_requests.insert(
2814            "req-batch-cancel".to_string(),
2815            (
2816                vec![cid_1, cid_2],
2817                vec![
2818                    Some(VenueOrderId::from("venue-batch-1")),
2819                    Some(VenueOrderId::from("venue-batch-2")),
2820                ],
2821                PendingOperation::Cancel,
2822            ),
2823        );
2824
2825        let response = order_response(
2826            BYBIT_OP_ORDER_CANCEL_BATCH,
2827            10016,
2828            "Internal server error.",
2829            "req-batch-cancel",
2830            serde_json::json!({}),
2831            None,
2832        );
2833
2834        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2835
2836        assert!(
2837            ctx.rx.try_recv().is_err(),
2838            "Expected no CancelRejected events for ambiguous batch failure"
2839        );
2840        assert!(ctx.state.order_identities.contains_key(&cid_1));
2841        assert!(ctx.state.order_identities.contains_key(&cid_2));
2842    }
2843
2844    #[rstest]
2845    fn test_dispatch_single_item_batch_cancel_top_level_failure_does_not_emit_cancel_rejected() {
2846        let mut ctx = DispatchTestContext::new();
2847        let cid = ClientOrderId::from("batch-cancel-single");
2848        ctx.state.order_identities.insert(cid, default_identity());
2849        ctx.state.pending_requests.insert(
2850            "req-batch-cancel-single".to_string(),
2851            (
2852                vec![cid],
2853                vec![Some(VenueOrderId::from("venue-batch-single"))],
2854                PendingOperation::Cancel,
2855            ),
2856        );
2857
2858        let response = order_response(
2859            BYBIT_OP_ORDER_CANCEL_BATCH,
2860            10016,
2861            "Internal server error.",
2862            "req-batch-cancel-single",
2863            serde_json::json!({}),
2864            None,
2865        );
2866
2867        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2868
2869        assert!(
2870            ctx.rx.try_recv().is_err(),
2871            "Expected no CancelRejected event for ambiguous one-item batch failure"
2872        );
2873        assert!(ctx.state.order_identities.contains_key(&cid));
2874    }
2875
2876    #[rstest]
2877    fn test_dispatch_batch_cancel_top_level_rate_limit_emits_cancel_rejected() {
2878        let mut ctx = DispatchTestContext::new();
2879        let cid_1 = ClientOrderId::from("batch-rate-1");
2880        let cid_2 = ClientOrderId::from("batch-rate-2");
2881        let venue_1 = VenueOrderId::from("venue-rate-1");
2882        let venue_2 = VenueOrderId::from("venue-rate-2");
2883        ctx.state.order_identities.insert(cid_1, default_identity());
2884        ctx.state.order_identities.insert(cid_2, default_identity());
2885        ctx.state.pending_requests.insert(
2886            "req-batch-rate".to_string(),
2887            (
2888                vec![cid_1, cid_2],
2889                vec![Some(venue_1), Some(venue_2)],
2890                PendingOperation::Cancel,
2891            ),
2892        );
2893
2894        let response = order_response(
2895            BYBIT_OP_ORDER_CANCEL_BATCH,
2896            10006,
2897            "Too many visits.",
2898            "req-batch-rate",
2899            serde_json::json!({}),
2900            None,
2901        );
2902
2903        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2904
2905        let first = ctx.rx.try_recv().expect("expected first CancelRejected");
2906        let second = ctx.rx.try_recv().expect("expected second CancelRejected");
2907        assert!(
2908            matches!(first, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2909                if rejected.client_order_id == cid_1 && rejected.venue_order_id == Some(venue_1))
2910        );
2911        assert!(
2912            matches!(second, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2913                if rejected.client_order_id == cid_2 && rejected.venue_order_id == Some(venue_2))
2914        );
2915    }
2916
2917    #[rstest]
2918    fn test_dispatch_batch_cancel_per_item_error_emits_only_failed_item() {
2919        let mut ctx = DispatchTestContext::new();
2920        let cid_1 = ClientOrderId::from("batch-cancel-ok");
2921        let cid_2 = ClientOrderId::from("batch-cancel-reject");
2922        ctx.state.order_identities.insert(cid_1, default_identity());
2923        ctx.state.order_identities.insert(cid_2, default_identity());
2924        ctx.state.pending_requests.insert(
2925            "req-batch-mixed".to_string(),
2926            (
2927                vec![cid_1, cid_2],
2928                vec![
2929                    Some(VenueOrderId::from("venue-batch-ok")),
2930                    Some(VenueOrderId::from("venue-batch-reject")),
2931                ],
2932                PendingOperation::Cancel,
2933            ),
2934        );
2935
2936        let response = order_response(
2937            BYBIT_OP_ORDER_CANCEL_BATCH,
2938            0,
2939            "OK",
2940            "req-batch-mixed",
2941            serde_json::json!({
2942                "list": [
2943                    {"orderId": "venue-batch-ok", "orderLinkId": cid_1.to_string()},
2944                    {"orderId": "venue-batch-reject", "orderLinkId": cid_2.to_string()}
2945                ]
2946            }),
2947            Some(serde_json::json!({
2948                "list": [
2949                    {"code": 0, "msg": "OK"},
2950                    {"code": 170213, "msg": "Order does not exist."}
2951                ]
2952            })),
2953        );
2954
2955        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2956
2957        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2958        assert!(
2959            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected)) if rejected.client_order_id == cid_2),
2960            "Expected CancelRejected for failed batch item {cid_2}, found {event:?}"
2961        );
2962        assert!(
2963            ctx.rx.try_recv().is_err(),
2964            "Expected no CancelRejected event for successful batch item"
2965        );
2966    }
2967
2968    #[rstest]
2969    fn test_dispatch_batch_cancel_per_item_rate_limit_emits_cancel_rejected() {
2970        let mut ctx = DispatchTestContext::new();
2971        let cid = ClientOrderId::from("batch-cancel-rate-limit");
2972        let venue_order_id = VenueOrderId::from("venue-batch-rate-limit");
2973        ctx.state.order_identities.insert(cid, default_identity());
2974        ctx.state.pending_requests.insert(
2975            "req-batch-rate-limit".to_string(),
2976            (
2977                vec![cid],
2978                vec![Some(venue_order_id)],
2979                PendingOperation::Cancel,
2980            ),
2981        );
2982
2983        let response = order_response(
2984            BYBIT_OP_ORDER_CANCEL_BATCH,
2985            0,
2986            "OK",
2987            "req-batch-rate-limit",
2988            serde_json::json!({
2989                "list": [
2990                    {"orderId": venue_order_id.to_string(), "orderLinkId": cid.to_string()}
2991                ]
2992            }),
2993            Some(serde_json::json!({
2994                "list": [
2995                    {"code": 10006, "msg": "Too many visits."}
2996                ]
2997            })),
2998        );
2999
3000        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
3001
3002        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
3003        assert!(
3004            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
3005                if rejected.client_order_id == cid && rejected.venue_order_id == Some(venue_order_id)),
3006            "Expected CancelRejected for {cid}, found {event:?}"
3007        );
3008        assert!(ctx.state.order_identities.contains_key(&cid));
3009    }
3010
3011    #[rstest]
3012    fn test_dispatch_filled_with_seeded_snapshot_emits_updated_for_bbo() {
3013        let mut ctx = DispatchTestContext::new();
3014        let mut value = new_order_value();
3015        value["data"][0]["orderStatus"] = serde_json::Value::String("Filled".to_string());
3016        value["data"][0]["cumExecQty"] = serde_json::Value::String("0.010".to_string());
3017        let cid = ClientOrderId::from("client-1");
3018        ctx.state.order_identities.insert(cid, default_identity());
3019        ctx.state.order_snapshots.insert(
3020            cid,
3021            OrderStateSnapshot {
3022                quantity: Quantity::from("0.010"),
3023                price: Some(Price::from("29000.00")),
3024                trigger_price: None,
3025            },
3026        );
3027
3028        ctx.dispatch_value(&value);
3029
3030        let accepted_event = ctx.rx.try_recv().unwrap();
3031        let accepted = match accepted_event {
3032            ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) => accepted,
3033            other => panic!("Expected Accepted first, found {other:?}"),
3034        };
3035        assert_eq!(accepted.client_order_id, cid);
3036        assert!(!accepted.venue_order_id.to_string().is_empty());
3037
3038        let updated = ctx.recv_updated();
3039        assert_eq!(updated.client_order_id, cid);
3040        assert_eq!(updated.price, Some(Price::from("30000.00")));
3041        assert_eq!(updated.quantity, Quantity::from("0.010"));
3042        assert_eq!(updated.trigger_price, None);
3043        assert_eq!(updated.venue_order_id, Some(accepted.venue_order_id));
3044
3045        let stored = ctx.state.order_snapshots.get(&cid).unwrap();
3046        assert_eq!(stored.price, Some(Price::from("30000.00")));
3047    }
3048
3049    #[rstest]
3050    fn test_dispatch_accepted_with_matching_seeded_snapshot_no_updated() {
3051        let mut ctx = DispatchTestContext::new();
3052        let value = new_order_value();
3053        let cid = ClientOrderId::from("client-1");
3054        ctx.state.order_identities.insert(cid, default_identity());
3055        ctx.state.order_snapshots.insert(
3056            cid,
3057            OrderStateSnapshot {
3058                quantity: Quantity::from("0.010"),
3059                price: Some(Price::from("30000.00")),
3060                trigger_price: None,
3061            },
3062        );
3063
3064        ctx.dispatch_value(&value);
3065
3066        let accepted = ctx.rx.try_recv().unwrap();
3067        assert!(matches!(
3068            accepted,
3069            ExecutionEvent::Order(OrderEventAny::Accepted(_))
3070        ));
3071
3072        assert!(
3073            ctx.rx.try_recv().is_err(),
3074            "Expected no OrderUpdated when seed matches venue snapshot"
3075        );
3076    }
3077
3078    #[rstest]
3079    #[case::price_changed(
3080        Some(Price::from("100.00")),
3081        None,
3082        Quantity::from("1.000"),
3083        Some(Price::from("200.00")),
3084        None,
3085        Quantity::from("1.000"),
3086        true
3087    )]
3088    #[case::trigger_changed(
3089        None,
3090        Some(Price::from("100.00")),
3091        Quantity::from("1.000"),
3092        None,
3093        Some(Price::from("90.00")),
3094        Quantity::from("1.000"),
3095        true
3096    )]
3097    #[case::qty_changed(
3098        Some(Price::from("100.00")),
3099        None,
3100        Quantity::from("1.000"),
3101        Some(Price::from("100.00")),
3102        None,
3103        Quantity::from("2.000"),
3104        true
3105    )]
3106    #[case::no_change(
3107        Some(Price::from("100.00")),
3108        None,
3109        Quantity::from("1.000"),
3110        Some(Price::from("100.00")),
3111        None,
3112        Quantity::from("1.000"),
3113        false
3114    )]
3115    fn test_is_snapshot_updated(
3116        #[case] prev_price: Option<Price>,
3117        #[case] prev_trigger: Option<Price>,
3118        #[case] prev_qty: Quantity,
3119        #[case] new_price: Option<Price>,
3120        #[case] new_trigger: Option<Price>,
3121        #[case] new_qty: Quantity,
3122        #[case] expected: bool,
3123    ) {
3124        let state = WsDispatchState::default();
3125        let cid = ClientOrderId::from("test-1");
3126        state.order_snapshots.insert(
3127            cid,
3128            OrderStateSnapshot {
3129                quantity: prev_qty,
3130                price: prev_price,
3131                trigger_price: prev_trigger,
3132            },
3133        );
3134
3135        let new_snapshot = OrderStateSnapshot {
3136            quantity: new_qty,
3137            price: new_price,
3138            trigger_price: new_trigger,
3139        };
3140        assert_eq!(is_snapshot_updated(&new_snapshot, &cid, &state), expected);
3141    }
3142
3143    #[rstest]
3144    fn test_is_snapshot_updated_no_previous() {
3145        let state = WsDispatchState::default();
3146        let cid = ClientOrderId::from("test-1");
3147
3148        let new_snapshot = OrderStateSnapshot {
3149            quantity: Quantity::from("1.000"),
3150            price: Some(Price::from("100.00")),
3151            trigger_price: None,
3152        };
3153        assert!(!is_snapshot_updated(&new_snapshot, &cid, &state));
3154    }
3155
3156    #[rstest]
3157    #[case::limit_order("30000", "0", Some(Price::from("30000.00")), None)]
3158    #[case::conditional("0", "29000", None, Some(Price::from("29000.00")))]
3159    #[case::both(
3160        "30000",
3161        "29000",
3162        Some(Price::from("30000.00")),
3163        Some(Price::from("29000.00"))
3164    )]
3165    fn test_parse_order_snapshot(
3166        #[case] price: &str,
3167        #[case] trigger: &str,
3168        #[case] expected_price: Option<Price>,
3169        #[case] expected_trigger: Option<Price>,
3170    ) {
3171        let instrument = linear_instrument();
3172        let json = load_test_json("ws_account_order.json");
3173        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
3174        value["data"][0]["price"] = serde_json::Value::String(price.to_string());
3175        value["data"][0]["triggerPrice"] = serde_json::Value::String(trigger.to_string());
3176        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
3177            serde_json::from_value(value).unwrap();
3178
3179        let snapshot = parse_order_snapshot(&msg.data[0], &instrument).unwrap();
3180        assert_eq!(snapshot.price, expected_price);
3181        assert_eq!(snapshot.trigger_price, expected_trigger);
3182        assert_eq!(snapshot.quantity, Quantity::from("0.010"));
3183    }
3184
3185    #[rstest]
3186    fn test_parse_order_snapshot_invalid_qty_returns_none() {
3187        let instrument = linear_instrument();
3188        let json = load_test_json("ws_account_order.json");
3189        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
3190        value["data"][0]["qty"] = serde_json::Value::String(String::new());
3191        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
3192            serde_json::from_value(value).unwrap();
3193
3194        assert!(parse_order_snapshot(&msg.data[0], &instrument).is_none());
3195    }
3196
3197    #[rstest]
3198    fn test_dispatch_order_updated_on_partially_filled_price_change() {
3199        let mut ctx = DispatchTestContext::new();
3200        let value = new_order_value();
3201        ctx.accept_order(&value);
3202
3203        let mut amended = value;
3204        amended["data"][0]["orderStatus"] =
3205            serde_json::Value::String("PartiallyFilled".to_string());
3206        amended["data"][0]["cumExecQty"] = serde_json::Value::String("0.005".to_string());
3207        amended["data"][0]["price"] = serde_json::Value::String("31000".to_string());
3208        ctx.dispatch_value(&amended);
3209
3210        let updated = ctx.recv_updated();
3211        assert_eq!(updated.client_order_id, ClientOrderId::from("client-1"));
3212        assert_eq!(updated.price, Some(Price::from("31000.00")));
3213    }
3214}