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        let venue_order_id = VenueOrderId::new(exec.order_id);
690
691        ensure_accepted_emitted(
692            client_order_id,
693            account_id,
694            venue_order_id,
695            &identity,
696            emitter,
697            state,
698            ts_init,
699        );
700
701        match parse_order_filled(exec, instrument, &identity, emitter, account_id, ts_init) {
702            Ok(filled) => {
703                let is_spot_buy =
704                    exec.category == BybitProductType::Spot && exec.side == BybitOrderSide::Buy;
705
706                if is_spot_buy {
707                    record_spot_repay_fill(client_order_id, &filled, instrument, state);
708                }
709
710                state.insert_filled(client_order_id);
711                state.triggered_orders.remove(&client_order_id);
712                emitter.send_order_event(OrderEventAny::Filled(filled));
713
714                if exec.leaves_qty == "0" {
715                    if is_spot_buy {
716                        enqueue_spot_repay(client_order_id, instrument, state);
717                    }
718                    cleanup_terminal(client_order_id, state);
719                }
720            }
721            Err(e) => log::error!("Failed to parse OrderFilled for {client_order_id}: {e}"),
722        }
723    } else {
724        // Untracked: fall back to FillReport for reconciliation
725        match super::parse::parse_ws_fill_report(exec, account_id, instrument, ts_init) {
726            Ok(report) => emitter.send_fill_report(report),
727            Err(e) => log::error!("Failed to parse fill report: {e}"),
728        }
729    }
730}
731
732/// Dispatches a single fast-execution (fill) message.
733///
734/// The fast channel lacks fee and exec-type fields, so all fills route to
735/// [`FillReport`] with zero commission; liquidity side is derived from the
736/// payload's `isMaker` flag. Subscribe to the standard `execution` channel
737/// for full fill metadata (e.g. fees).
738fn dispatch_execution_fill_fast(
739    exec: &BybitWsAccountExecutionFast,
740    instrument: &InstrumentAny,
741    emitter: &ExecutionEventEmitter,
742    state: &WsDispatchState,
743    account_id: AccountId,
744    ts_init: UnixNanos,
745) {
746    let client_order_id = if exec.order_link_id.is_empty() {
747        None
748    } else {
749        Some(ClientOrderId::new(exec.order_link_id))
750    };
751
752    let mut venue_position_id = None;
753
754    if let Some(cid) = client_order_id.as_ref()
755        && let Some(identity) = state.order_identities.get(cid).map(|r| r.clone())
756    {
757        venue_position_id = identity.venue_position_id;
758        let venue_order_id = VenueOrderId::new(exec.order_id);
759        ensure_accepted_emitted(
760            *cid,
761            account_id,
762            venue_order_id,
763            &identity,
764            emitter,
765            state,
766            ts_init,
767        );
768    }
769
770    match parse_ws_fill_report_fast(exec, account_id, instrument, venue_position_id, ts_init) {
771        Ok(report) => emitter.send_fill_report(report),
772        Err(e) => log::error!("Failed to parse fast fill report: {e}"),
773    }
774}
775
776/// Parses a Bybit execution message directly into an [`OrderFilled`] event.
777fn parse_order_filled(
778    exec: &BybitWsAccountExecution,
779    instrument: &InstrumentAny,
780    identity: &OrderIdentity,
781    emitter: &ExecutionEventEmitter,
782    account_id: AccountId,
783    ts_init: UnixNanos,
784) -> anyhow::Result<OrderFilled> {
785    let client_order_id = ClientOrderId::new(exec.order_link_id);
786    let venue_order_id = VenueOrderId::new(exec.order_id);
787    let trade_id =
788        TradeId::new_checked(exec.exec_id.as_str()).context("invalid execId in Bybit execution")?;
789
790    let last_qty = parse_quantity_with_precision(
791        &exec.exec_qty,
792        instrument.size_precision(),
793        "execution.execQty",
794    )?;
795    let last_px = parse_price_with_precision(
796        &exec.exec_price,
797        instrument.price_precision(),
798        "execution.execPrice",
799    )?;
800
801    let liquidity_side = if exec.is_maker {
802        LiquiditySide::Maker
803    } else {
804        LiquiditySide::Taker
805    };
806
807    let fee_decimal: Decimal = exec
808        .exec_fee
809        .parse()
810        .with_context(|| format!("failed to parse execFee='{}'", exec.exec_fee))?;
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        commission_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;
1282    use nautilus_model::{
1283        enums::{AccountType, OrderSide, OrderType},
1284        events::OrderEventAny,
1285        identifiers::{
1286            AccountId, ClientOrderId, InstrumentId, PositionId, StrategyId, TraderId, VenueOrderId,
1287        },
1288        instruments::{Instrument, InstrumentAny},
1289    };
1290    use rstest::rstest;
1291    use ustr::Ustr;
1292
1293    use super::*;
1294    use crate::{
1295        common::{
1296            enums::{BybitExecType, BybitOrderSide, BybitProductType},
1297            parse::{parse_linear_instrument, parse_spot_instrument},
1298            testing::load_test_json,
1299        },
1300        http::models::{BybitInstrumentLinearResponse, BybitInstrumentSpotResponse},
1301        websocket::messages::{
1302            BybitWsAccountExecutionFastMsg, BybitWsMessage, BybitWsOrderResponse,
1303        },
1304    };
1305
1306    fn linear_instrument() -> InstrumentAny {
1307        let json = load_test_json("http_get_instruments_linear.json");
1308        let response: BybitInstrumentLinearResponse = serde_json::from_str(&json).unwrap();
1309        let instrument = &response.result.list[0];
1310        let ts = UnixNanos::new(1_700_000_000_000_000_000);
1311        parse_linear_instrument(instrument, ts, ts).unwrap()
1312    }
1313
1314    fn spot_instrument() -> InstrumentAny {
1315        let json = load_test_json("http_get_instruments_spot.json");
1316        let response: BybitInstrumentSpotResponse = serde_json::from_str(&json).unwrap();
1317        let instrument = &response.result.list[0];
1318        let ts = UnixNanos::new(1_700_000_000_000_000_000);
1319        parse_spot_instrument(instrument, ts, ts).unwrap()
1320    }
1321
1322    fn build_instruments(instruments: &[InstrumentAny]) -> AHashMap<Ustr, InstrumentAny> {
1323        let mut map = AHashMap::new();
1324        for inst in instruments {
1325            map.insert(inst.id().symbol.inner(), inst.clone());
1326        }
1327        map
1328    }
1329
1330    fn test_account_id() -> AccountId {
1331        AccountId::from("BYBIT-001")
1332    }
1333
1334    fn create_emitter() -> (
1335        ExecutionEventEmitter,
1336        tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
1337    ) {
1338        let clock = get_atomic_clock_realtime();
1339        let trader_id = TraderId::from("TESTER-001");
1340        let account_id = test_account_id();
1341        let mut emitter =
1342            ExecutionEventEmitter::new(clock, trader_id, account_id, AccountType::Margin, None);
1343        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1344        emitter.set_sender(tx);
1345        (emitter, rx)
1346    }
1347
1348    fn default_identity() -> OrderIdentity {
1349        OrderIdentity {
1350            instrument_id: InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
1351            strategy_id: StrategyId::from("S-001"),
1352            order_side: OrderSide::Buy,
1353            order_type: OrderType::Limit,
1354            venue_position_id: None,
1355        }
1356    }
1357
1358    #[rstest]
1359    #[case::base_fee("BTC", "0.0000015", "0.0000025", "0.000004")]
1360    #[case::base_rebate("BTC", "-0.0000015", "-0.0000025", "0")]
1361    #[case::quote_fee("USDT", "0.075", "0.125", "0")]
1362    fn test_spot_repay_uses_accumulated_execution_quantity(
1363        #[case] fee_currency: &str,
1364        #[case] first_fee: &str,
1365        #[case] second_fee: &str,
1366        #[case] expected_base_fee: &str,
1367    ) {
1368        let instrument = spot_instrument();
1369        let instruments = build_instruments(std::slice::from_ref(&instrument));
1370        let (emitter, _rx) = create_emitter();
1371        let clock = get_atomic_clock_realtime();
1372        let state = WsDispatchState::default();
1373        let (repay_tx, mut repay_rx) = tokio::sync::mpsc::unbounded_channel();
1374        state.set_repay_sender(repay_tx);
1375
1376        let json = load_test_json("ws_account_execution.json");
1377        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1378        value["data"][0]["category"] = serde_json::Value::String("spot".to_string());
1379        value["data"][0]["symbol"] = serde_json::Value::String("BTCUSDT".to_string());
1380        value["data"][0]["side"] = serde_json::Value::String("Buy".to_string());
1381        value["data"][0]["orderType"] = serde_json::Value::String("Market".to_string());
1382        value["data"][0]["orderQty"] = serde_json::Value::String("100".to_string());
1383        value["data"][0]["execQty"] = serde_json::Value::String("0.0015".to_string());
1384        value["data"][0]["leavesQty"] = serde_json::Value::String("0.0025".to_string());
1385        value["data"][0]["execFee"] = serde_json::Value::String(first_fee.to_string());
1386        value["data"][0]["feeCurrency"] = serde_json::Value::String(fee_currency.to_string());
1387
1388        let first: crate::websocket::messages::BybitWsAccountExecutionMsg =
1389            serde_json::from_value(value.clone()).unwrap();
1390        let client_order_id = ClientOrderId::new(first.data[0].order_link_id);
1391        state.order_identities.insert(
1392            client_order_id,
1393            OrderIdentity {
1394                instrument_id: InstrumentId::from("BTCUSDT-SPOT.BYBIT"),
1395                order_type: OrderType::Market,
1396                ..default_identity()
1397            },
1398        );
1399
1400        dispatch_ws_message(
1401            &BybitWsMessage::AccountExecution(first),
1402            &emitter,
1403            &state,
1404            test_account_id(),
1405            &instruments,
1406            clock,
1407        );
1408        assert!(repay_rx.try_recv().is_err());
1409
1410        value["data"][0]["execId"] = serde_json::Value::String("second-execution".to_string());
1411        value["data"][0]["execQty"] = serde_json::Value::String("0.0025".to_string());
1412        value["data"][0]["leavesQty"] = serde_json::Value::String("0".to_string());
1413        value["data"][0]["execFee"] = serde_json::Value::String(second_fee.to_string());
1414        let second: crate::websocket::messages::BybitWsAccountExecutionMsg =
1415            serde_json::from_value(value).unwrap();
1416
1417        dispatch_ws_message(
1418            &BybitWsMessage::AccountExecution(second),
1419            &emitter,
1420            &state,
1421            test_account_id(),
1422            &instruments,
1423            clock,
1424        );
1425
1426        let repay = repay_rx.try_recv().expect("expected a repay request");
1427        assert_eq!(repay.coin, "BTC");
1428        assert_eq!(repay.quantity, Quantity::from("0.0040"));
1429        assert_eq!(
1430            repay.base_fee,
1431            expected_base_fee.parse::<Decimal>().unwrap()
1432        );
1433        assert_eq!(repay.repayment_precision, 8);
1434        assert!(repay_rx.try_recv().is_err());
1435    }
1436
1437    #[rstest]
1438    fn test_dispatch_tracked_canceled_order_emits_accepted_then_canceled() {
1439        let instrument = linear_instrument();
1440        let instruments = build_instruments(std::slice::from_ref(&instrument));
1441        let (emitter, mut rx) = create_emitter();
1442        let clock = get_atomic_clock_realtime();
1443        let state = WsDispatchState::default();
1444
1445        // Fixture has orderStatus=Cancelled
1446        let json = load_test_json("ws_account_order.json");
1447        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1448            serde_json::from_str(&json).unwrap();
1449
1450        if let Some(order) = msg.data.first()
1451            && !order.order_link_id.is_empty()
1452        {
1453            let cid = ClientOrderId::new(order.order_link_id);
1454            state.order_identities.insert(cid, default_identity());
1455        }
1456
1457        let ws_msg = BybitWsMessage::AccountOrder(msg);
1458        dispatch_ws_message(
1459            &ws_msg,
1460            &emitter,
1461            &state,
1462            test_account_id(),
1463            &instruments,
1464            clock,
1465        );
1466
1467        // First: synthesized Accepted
1468        let event1 = rx.try_recv().unwrap();
1469        assert!(
1470            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(ref a)) if a.strategy_id == StrategyId::from("S-001")),
1471            "Expected Accepted, found {event1:?}"
1472        );
1473
1474        // Second: Canceled (from Cancelled status)
1475        let event2 = rx.try_recv().unwrap();
1476        assert!(
1477            matches!(event2, ExecutionEvent::Order(OrderEventAny::Canceled(_))),
1478            "Expected Canceled, found {event2:?}"
1479        );
1480    }
1481
1482    #[rstest]
1483    fn test_dispatch_tracked_post_only_cancel_emits_rejected() {
1484        const BYBIT_POST_ONLY_REJECT_REASON: &str = "EC_PostOnlyWillTakeLiquidity";
1485
1486        let instrument = linear_instrument();
1487        let instruments = build_instruments(std::slice::from_ref(&instrument));
1488        let (emitter, mut rx) = create_emitter();
1489        let clock = get_atomic_clock_realtime();
1490        let state = WsDispatchState::default();
1491
1492        // Bybit reports a post-only order that would take liquidity as
1493        // orderStatus=Cancelled with rejectReason=EC_PostOnlyWillTakeLiquidity.
1494        let json = load_test_json("ws_account_order.json");
1495        let mut msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1496            serde_json::from_str(&json).unwrap();
1497
1498        let order = msg.data.first_mut().expect("fixture has an order");
1499        order.reject_reason = Ustr::from(BYBIT_POST_ONLY_REJECT_REASON);
1500        order.cum_exec_qty = "0".to_string();
1501        let cid = ClientOrderId::new(order.order_link_id);
1502        state.order_identities.insert(cid, default_identity());
1503
1504        let ws_msg = BybitWsMessage::AccountOrder(msg);
1505        dispatch_ws_message(
1506            &ws_msg,
1507            &emitter,
1508            &state,
1509            test_account_id(),
1510            &instruments,
1511            clock,
1512        );
1513
1514        let event = rx.try_recv().unwrap();
1515        let ExecutionEvent::Order(OrderEventAny::Rejected(rejected)) = event else {
1516            panic!("Expected Rejected, found {event:?}");
1517        };
1518        assert!(rejected.due_post_only);
1519        assert_eq!(rejected.reason, BYBIT_POST_ONLY_REJECT_REASON);
1520        assert_eq!(rejected.client_order_id, cid);
1521        assert!(rx.try_recv().is_err(), "expected only a single event");
1522    }
1523
1524    #[rstest]
1525    fn test_dispatch_untracked_order_emits_report() {
1526        let instrument = linear_instrument();
1527        let instruments = build_instruments(std::slice::from_ref(&instrument));
1528        let (emitter, mut rx) = create_emitter();
1529        let clock = get_atomic_clock_realtime();
1530        let state = WsDispatchState::default();
1531
1532        let json = load_test_json("ws_account_order.json");
1533        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1534            serde_json::from_str(&json).unwrap();
1535
1536        // No identity registered → untracked
1537        let ws_msg = BybitWsMessage::AccountOrder(msg);
1538        dispatch_ws_message(
1539            &ws_msg,
1540            &emitter,
1541            &state,
1542            test_account_id(),
1543            &instruments,
1544            clock,
1545        );
1546
1547        let event = rx.try_recv().unwrap();
1548        assert!(matches!(
1549            event,
1550            ExecutionEvent::Report(ExecutionReport::Order(_))
1551        ));
1552    }
1553
1554    #[rstest]
1555    fn test_dispatch_tracked_execution_emits_order_filled() {
1556        let instrument = linear_instrument();
1557        let instruments = build_instruments(std::slice::from_ref(&instrument));
1558        let (emitter, mut rx) = create_emitter();
1559        let clock = get_atomic_clock_realtime();
1560        let state = WsDispatchState::default();
1561
1562        let json = load_test_json("ws_account_execution.json");
1563        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1564            serde_json::from_str(&json).unwrap();
1565
1566        // Register identity for the execution's orderLinkId
1567        if let Some(exec) = msg.data.first()
1568            && !exec.order_link_id.is_empty()
1569        {
1570            let cid = ClientOrderId::new(exec.order_link_id);
1571            state.order_identities.insert(cid, default_identity());
1572        }
1573
1574        let ws_msg = BybitWsMessage::AccountExecution(msg);
1575        dispatch_ws_message(
1576            &ws_msg,
1577            &emitter,
1578            &state,
1579            test_account_id(),
1580            &instruments,
1581            clock,
1582        );
1583
1584        // First event should be synthesized Accepted
1585        let event1 = rx.try_recv().unwrap();
1586        assert!(
1587            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1588            "Expected Accepted, found {event1:?}"
1589        );
1590
1591        // Second event should be OrderFilled
1592        let event2 = rx.try_recv().unwrap();
1593        match event2 {
1594            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1595                assert_eq!(filled.strategy_id, StrategyId::from("S-001"));
1596                assert_eq!(filled.order_side, OrderSide::Buy);
1597                assert_eq!(filled.order_type, OrderType::Limit);
1598            }
1599            other => panic!("Expected Filled event, found {other:?}"),
1600        }
1601    }
1602
1603    #[rstest]
1604    fn parse_order_filled_uses_payload_fee_currency() {
1605        let instrument = linear_instrument();
1606        let (emitter, _rx) = create_emitter();
1607
1608        let json = load_test_json("ws_account_execution.json");
1609        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1610            serde_json::from_str(&json).unwrap();
1611
1612        let mut exec = msg.data[0].clone();
1613        exec.fee_currency = Ustr::from("BTC");
1614
1615        let filled = parse_order_filled(
1616            &exec,
1617            &instrument,
1618            &default_identity(),
1619            &emitter,
1620            test_account_id(),
1621            UnixNanos::default(),
1622        )
1623        .unwrap();
1624
1625        let commission = filled.commission.expect("commission present");
1626        assert_eq!(commission.currency.code, "BTC");
1627    }
1628
1629    #[rstest]
1630    fn test_dispatch_tracked_execution_preserves_venue_position_id() {
1631        let instrument = linear_instrument();
1632        let instruments = build_instruments(std::slice::from_ref(&instrument));
1633        let (emitter, mut rx) = create_emitter();
1634        let clock = get_atomic_clock_realtime();
1635        let state = WsDispatchState::default();
1636
1637        let json = load_test_json("ws_account_execution.json");
1638        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1639            serde_json::from_str(&json).unwrap();
1640        let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
1641
1642        if let Some(exec) = msg.data.first()
1643            && !exec.order_link_id.is_empty()
1644        {
1645            let cid = ClientOrderId::new(exec.order_link_id);
1646            state.order_identities.insert(
1647                cid,
1648                OrderIdentity {
1649                    venue_position_id: Some(venue_position_id),
1650                    ..default_identity()
1651                },
1652            );
1653        }
1654
1655        let ws_msg = BybitWsMessage::AccountExecution(msg);
1656        dispatch_ws_message(
1657            &ws_msg,
1658            &emitter,
1659            &state,
1660            test_account_id(),
1661            &instruments,
1662            clock,
1663        );
1664
1665        let _accepted = rx.try_recv().unwrap();
1666        let event = rx.try_recv().unwrap();
1667        match event {
1668            ExecutionEvent::Order(OrderEventAny::Filled(filled)) => {
1669                assert_eq!(filled.position_id, Some(venue_position_id));
1670            }
1671            other => panic!("Expected Filled event, found {other:?}"),
1672        }
1673    }
1674
1675    fn fast_execution_msg(is_maker: bool, order_link_id: &str) -> BybitWsAccountExecutionFastMsg {
1676        BybitWsAccountExecutionFastMsg {
1677            topic: Ustr::from("execution.fast"),
1678            id: String::new(),
1679            creation_time: 1_716_800_399_338,
1680            data: vec![BybitWsAccountExecutionFast {
1681                category: BybitProductType::Linear,
1682                symbol: Ustr::from("BTCUSDT"),
1683                exec_id: "fast-1".to_string(),
1684                exec_price: "50000.0".to_string(),
1685                exec_qty: "0.5".to_string(),
1686                order_id: Ustr::from("ord-1"),
1687                order_link_id: Ustr::from(order_link_id),
1688                side: BybitOrderSide::Buy,
1689                exec_time: "1716800399334".to_string(),
1690                is_maker,
1691                seq: 42,
1692            }],
1693        }
1694    }
1695
1696    #[rstest]
1697    fn test_dispatch_tracked_fast_execution_preserves_venue_position_id() {
1698        let instrument = linear_instrument();
1699        let instruments = build_instruments(std::slice::from_ref(&instrument));
1700        let (emitter, mut rx) = create_emitter();
1701        let clock = get_atomic_clock_realtime();
1702        let state = WsDispatchState::default();
1703        let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
1704
1705        // Taker fast fill (orderLinkId populated) so the identity lookup hits.
1706        let msg = fast_execution_msg(false, "link-1");
1707        let cid = ClientOrderId::new(msg.data[0].order_link_id);
1708        state.order_identities.insert(
1709            cid,
1710            OrderIdentity {
1711                venue_position_id: Some(venue_position_id),
1712                ..default_identity()
1713            },
1714        );
1715
1716        let ws_msg = BybitWsMessage::AccountExecutionFast(msg);
1717        dispatch_ws_message(
1718            &ws_msg,
1719            &emitter,
1720            &state,
1721            test_account_id(),
1722            &instruments,
1723            clock,
1724        );
1725
1726        // First event: synthesized OrderAccepted from the identity hit.
1727        let event1 = rx.try_recv().unwrap();
1728        assert!(
1729            matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1730            "Expected Accepted, found {event1:?}",
1731        );
1732
1733        // Second event: FillReport carrying the hedge venue_position_id.
1734        let event2 = rx.try_recv().unwrap();
1735        match event2 {
1736            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
1737                assert_eq!(report.venue_position_id, Some(venue_position_id));
1738                assert_eq!(report.client_order_id, Some(cid));
1739            }
1740            other => panic!("Expected FillReport, found {other:?}"),
1741        }
1742    }
1743
1744    #[rstest]
1745    fn test_dispatch_untracked_fast_execution_emits_fill_report_without_position() {
1746        let instrument = linear_instrument();
1747        let instruments = build_instruments(std::slice::from_ref(&instrument));
1748        let (emitter, mut rx) = create_emitter();
1749        let clock = get_atomic_clock_realtime();
1750        let state = WsDispatchState::default();
1751
1752        // Maker fast fill: orderLinkId is empty per venue docs, so no identity lookup.
1753        let msg = fast_execution_msg(true, "");
1754        let ws_msg = BybitWsMessage::AccountExecutionFast(msg);
1755        dispatch_ws_message(
1756            &ws_msg,
1757            &emitter,
1758            &state,
1759            test_account_id(),
1760            &instruments,
1761            clock,
1762        );
1763
1764        // No OrderAccepted is synthesized for untracked fills.
1765        let event = rx.try_recv().unwrap();
1766        match event {
1767            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
1768                assert_eq!(report.client_order_id, None);
1769                assert_eq!(report.venue_position_id, None);
1770                assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1771                assert_eq!(report.commission.as_f64(), 0.0);
1772            }
1773            other => panic!("Expected FillReport, found {other:?}"),
1774        }
1775    }
1776
1777    #[rstest]
1778    fn test_dispatch_untracked_execution_emits_fill_report() {
1779        let instrument = linear_instrument();
1780        let instruments = build_instruments(std::slice::from_ref(&instrument));
1781        let (emitter, mut rx) = create_emitter();
1782        let clock = get_atomic_clock_realtime();
1783        let state = WsDispatchState::default();
1784
1785        let json = load_test_json("ws_account_execution.json");
1786        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1787            serde_json::from_str(&json).unwrap();
1788
1789        // No identity registered → untracked
1790        let ws_msg = BybitWsMessage::AccountExecution(msg);
1791        dispatch_ws_message(
1792            &ws_msg,
1793            &emitter,
1794            &state,
1795            test_account_id(),
1796            &instruments,
1797            clock,
1798        );
1799
1800        let event = rx.try_recv().unwrap();
1801        assert!(matches!(
1802            event,
1803            ExecutionEvent::Report(ExecutionReport::Fill(_))
1804        ));
1805    }
1806
1807    #[rstest]
1808    #[case::untracked("")]
1809    #[case::tracked("test-order-link-001")]
1810    fn test_dispatch_funding_execution_emits_no_event(#[case] order_link_id: &str) {
1811        let instrument = linear_instrument();
1812        let instruments = build_instruments(std::slice::from_ref(&instrument));
1813        let (emitter, mut rx) = create_emitter();
1814        let clock = get_atomic_clock_realtime();
1815        let state = WsDispatchState::default();
1816
1817        let json = load_test_json("ws_account_execution_funding.json");
1818        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1819        value["data"][0]["orderLinkId"] = serde_json::Value::String(order_link_id.to_string());
1820        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1821            serde_json::from_value(value).unwrap();
1822        let execution = &msg.data[0];
1823
1824        assert_eq!(execution.exec_type, BybitExecType::Funding);
1825
1826        if !execution.order_link_id.is_empty() {
1827            state.order_identities.insert(
1828                ClientOrderId::new(execution.order_link_id),
1829                default_identity(),
1830            );
1831        }
1832
1833        dispatch_ws_message(
1834            &BybitWsMessage::AccountExecution(msg),
1835            &emitter,
1836            &state,
1837            test_account_id(),
1838            &instruments,
1839            clock,
1840        );
1841
1842        assert!(matches!(
1843            rx.try_recv(),
1844            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
1845        ));
1846    }
1847
1848    #[rstest]
1849    #[case::corporate_action("CorporateAction")]
1850    #[case::forward_split_settle("ForwardSplitSettle")]
1851    #[case::reverse_split_settle("ReverseSplitSettle")]
1852    #[case::dividend("Dividend")]
1853    #[case::unrecognized("StockMerger")]
1854    fn test_dispatch_venue_initiated_execution_emits_only_fill_report(#[case] exec_type: &str) {
1855        let instrument = linear_instrument();
1856        let instruments = build_instruments(std::slice::from_ref(&instrument));
1857        let (emitter, mut rx) = create_emitter();
1858        let clock = get_atomic_clock_realtime();
1859        let state = WsDispatchState::default();
1860
1861        let json = load_test_json("ws_account_execution_adl.json");
1862        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1863        value["data"][0]["execType"] = serde_json::Value::String(exec_type.to_string());
1864        let msg: crate::websocket::messages::BybitWsAccountExecutionMsg =
1865            serde_json::from_value(value).unwrap();
1866        let execution = &msg.data[0];
1867
1868        assert!(execution.order_link_id.is_empty());
1869
1870        dispatch_ws_message(
1871            &BybitWsMessage::AccountExecution(msg),
1872            &emitter,
1873            &state,
1874            test_account_id(),
1875            &instruments,
1876            clock,
1877        );
1878
1879        let event = rx.try_recv().unwrap();
1880        match event {
1881            ExecutionEvent::Report(ExecutionReport::Fill(report)) => {
1882                assert_eq!(report.client_order_id, None);
1883                assert_eq!(
1884                    report.venue_order_id,
1885                    VenueOrderId::from("9aac161b-8ed6-450d-9cab-c5cc67c21785")
1886                );
1887            }
1888            other => panic!("Expected FillReport, found {other:?}"),
1889        }
1890        assert!(rx.try_recv().is_err());
1891    }
1892
1893    #[rstest]
1894    fn test_dispatch_wallet_emits_account_state() {
1895        let instruments = AHashMap::new();
1896        let (emitter, mut rx) = create_emitter();
1897        let clock = get_atomic_clock_realtime();
1898        let state = WsDispatchState::default();
1899
1900        let json = load_test_json("ws_account_wallet.json");
1901        let msg: crate::websocket::messages::BybitWsAccountWalletMsg =
1902            serde_json::from_str(&json).unwrap();
1903        let ws_msg = BybitWsMessage::AccountWallet(msg);
1904
1905        dispatch_ws_message(
1906            &ws_msg,
1907            &emitter,
1908            &state,
1909            test_account_id(),
1910            &instruments,
1911            clock,
1912        );
1913
1914        let event = rx.try_recv().unwrap();
1915        assert!(matches!(event, ExecutionEvent::Account(_)));
1916    }
1917
1918    #[rstest]
1919    fn test_dispatch_data_message_ignored() {
1920        let instruments = AHashMap::new();
1921        let (emitter, mut rx) = create_emitter();
1922        let clock = get_atomic_clock_realtime();
1923        let state = WsDispatchState::default();
1924
1925        let json = load_test_json("ws_public_trade.json");
1926        let msg: crate::websocket::messages::BybitWsTradeMsg = serde_json::from_str(&json).unwrap();
1927        let ws_msg = BybitWsMessage::Trade(msg);
1928
1929        dispatch_ws_message(
1930            &ws_msg,
1931            &emitter,
1932            &state,
1933            test_account_id(),
1934            &instruments,
1935            clock,
1936        );
1937
1938        rx.try_recv().unwrap_err();
1939    }
1940
1941    #[rstest]
1942    fn test_accepted_dedup_prevents_duplicate() {
1943        let instrument = linear_instrument();
1944        let instruments = build_instruments(std::slice::from_ref(&instrument));
1945        let (emitter, mut rx) = create_emitter();
1946        let clock = get_atomic_clock_realtime();
1947        let state = WsDispatchState::default();
1948
1949        // Fixture has orderStatus=Cancelled. Patch to New for this dedup test.
1950        let json = load_test_json("ws_account_order.json");
1951        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1952        value["data"][0]["orderStatus"] = serde_json::Value::String("New".to_string());
1953        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
1954            serde_json::from_value(value).unwrap();
1955
1956        if let Some(order) = msg.data.first()
1957            && !order.order_link_id.is_empty()
1958        {
1959            let cid = ClientOrderId::new(order.order_link_id);
1960            state.order_identities.insert(cid, default_identity());
1961        }
1962
1963        let ws_msg = BybitWsMessage::AccountOrder(msg.clone());
1964        dispatch_ws_message(
1965            &ws_msg,
1966            &emitter,
1967            &state,
1968            test_account_id(),
1969            &instruments,
1970            clock,
1971        );
1972
1973        let event = rx.try_recv().unwrap();
1974        assert!(matches!(
1975            event,
1976            ExecutionEvent::Order(OrderEventAny::Accepted(_))
1977        ));
1978
1979        // Dispatch the same message again: dedup should suppress the duplicate
1980        let ws_msg2 = BybitWsMessage::AccountOrder(msg);
1981        dispatch_ws_message(
1982            &ws_msg2,
1983            &emitter,
1984            &state,
1985            test_account_id(),
1986            &instruments,
1987            clock,
1988        );
1989
1990        rx.try_recv().unwrap_err();
1991    }
1992
1993    fn new_order_value() -> serde_json::Value {
1994        let json = load_test_json("ws_account_order.json");
1995        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1996        value["data"][0]["orderStatus"] = serde_json::Value::String("New".to_string());
1997        value
1998    }
1999
2000    struct DispatchTestContext {
2001        instruments: AHashMap<Ustr, InstrumentAny>,
2002        emitter: ExecutionEventEmitter,
2003        rx: tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2004        clock: &'static AtomicTime,
2005        state: WsDispatchState,
2006    }
2007
2008    impl DispatchTestContext {
2009        fn new() -> Self {
2010            let instrument = linear_instrument();
2011            let instruments = build_instruments(std::slice::from_ref(&instrument));
2012            let (emitter, rx) = create_emitter();
2013            let clock = get_atomic_clock_realtime();
2014            let state = WsDispatchState::default();
2015            Self {
2016                instruments,
2017                emitter,
2018                rx,
2019                clock,
2020                state,
2021            }
2022        }
2023
2024        fn accept_order(&mut self, value: &serde_json::Value) {
2025            let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2026                serde_json::from_value(value.clone()).unwrap();
2027
2028            if let Some(order) = msg.data.first()
2029                && !order.order_link_id.is_empty()
2030                && !self
2031                    .state
2032                    .order_identities
2033                    .contains_key(&ClientOrderId::new(order.order_link_id))
2034            {
2035                let cid = ClientOrderId::new(order.order_link_id);
2036                self.state.order_identities.insert(cid, default_identity());
2037            }
2038
2039            self.dispatch_value(value);
2040
2041            let event = self.rx.try_recv().unwrap();
2042            assert!(
2043                matches!(event, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
2044                "Expected Accepted, found {event:?}"
2045            );
2046        }
2047
2048        fn dispatch_value(&self, value: &serde_json::Value) {
2049            let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2050                serde_json::from_value(value.clone()).unwrap();
2051            let ws_msg = BybitWsMessage::AccountOrder(msg);
2052            dispatch_ws_message(
2053                &ws_msg,
2054                &self.emitter,
2055                &self.state,
2056                test_account_id(),
2057                &self.instruments,
2058                self.clock,
2059            );
2060        }
2061
2062        fn recv_updated(&mut self) -> OrderUpdated {
2063            let event = self.rx.try_recv().unwrap();
2064            match event {
2065                ExecutionEvent::Order(OrderEventAny::Updated(updated)) => updated,
2066                other => panic!("Expected Updated event, found {other:?}"),
2067            }
2068        }
2069    }
2070
2071    #[rstest]
2072    fn test_dispatch_order_updated_on_price_change() {
2073        let mut ctx = DispatchTestContext::new();
2074        let value = new_order_value();
2075        ctx.accept_order(&value);
2076
2077        let mut amended = value;
2078        amended["data"][0]["price"] = serde_json::Value::String("31000".to_string());
2079        ctx.dispatch_value(&amended);
2080
2081        let updated = ctx.recv_updated();
2082        assert_eq!(updated.client_order_id, ClientOrderId::from("client-1"));
2083        assert_eq!(updated.price, Some(Price::from("31000.00")));
2084        assert_eq!(updated.quantity, Quantity::from("0.010"));
2085        assert_eq!(updated.trigger_price, None);
2086        assert!(updated.venue_order_id.is_some());
2087    }
2088
2089    #[rstest]
2090    fn test_dispatch_order_updated_on_quantity_change() {
2091        let mut ctx = DispatchTestContext::new();
2092        let value = new_order_value();
2093        ctx.accept_order(&value);
2094
2095        let mut amended = value;
2096        amended["data"][0]["qty"] = serde_json::Value::String("0.020".to_string());
2097        ctx.dispatch_value(&amended);
2098
2099        let updated = ctx.recv_updated();
2100        assert_eq!(updated.quantity, Quantity::from("0.020"));
2101        assert_eq!(updated.price, Some(Price::from("30000.00")));
2102    }
2103
2104    #[rstest]
2105    fn test_dispatch_order_updated_on_trigger_price_change() {
2106        let mut ctx = DispatchTestContext::new();
2107        let mut value = new_order_value();
2108        value["data"][0]["triggerPrice"] = serde_json::Value::String("29000".to_string());
2109        ctx.accept_order(&value);
2110
2111        let mut amended = value;
2112        amended["data"][0]["triggerPrice"] = serde_json::Value::String("28000".to_string());
2113        ctx.dispatch_value(&amended);
2114
2115        let updated = ctx.recv_updated();
2116        assert_eq!(updated.trigger_price, Some(Price::from("28000.00")));
2117        assert_eq!(updated.price, Some(Price::from("30000.00")));
2118    }
2119
2120    #[rstest]
2121    fn test_dispatch_dedup_suppresses_identical_after_snapshot() {
2122        let mut ctx = DispatchTestContext::new();
2123        let value = new_order_value();
2124        ctx.accept_order(&value);
2125
2126        ctx.dispatch_value(&value);
2127
2128        assert!(
2129            ctx.rx.try_recv().is_err(),
2130            "Expected no event for identical redelivery"
2131        );
2132    }
2133
2134    #[rstest]
2135    fn test_dispatch_order_updated_stores_snapshot_for_subsequent_change() {
2136        let mut ctx = DispatchTestContext::new();
2137        let value = new_order_value();
2138        ctx.accept_order(&value);
2139
2140        let mut amended1 = value.clone();
2141        amended1["data"][0]["price"] = serde_json::Value::String("31000".to_string());
2142        ctx.dispatch_value(&amended1);
2143        let _result = ctx.recv_updated();
2144
2145        let mut amended2 = value;
2146        amended2["data"][0]["price"] = serde_json::Value::String("32000".to_string());
2147        ctx.dispatch_value(&amended2);
2148
2149        let updated = ctx.recv_updated();
2150        assert_eq!(updated.price, Some(Price::from("32000.00")));
2151    }
2152
2153    #[rstest]
2154    fn test_dispatch_accepted_with_seeded_snapshot_emits_updated_for_bbo() {
2155        let mut ctx = DispatchTestContext::new();
2156        let value = new_order_value();
2157        let cid = ClientOrderId::from("client-1");
2158        ctx.state.order_identities.insert(cid, default_identity());
2159        ctx.state.order_snapshots.insert(
2160            cid,
2161            OrderStateSnapshot {
2162                quantity: Quantity::from("0.010"),
2163                price: Some(Price::from("29000.00")),
2164                trigger_price: None,
2165            },
2166        );
2167
2168        ctx.dispatch_value(&value);
2169
2170        let accepted = ctx.rx.try_recv().unwrap();
2171        assert!(
2172            matches!(accepted, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
2173            "Expected Accepted first, found {accepted:?}"
2174        );
2175
2176        let updated = ctx.recv_updated();
2177        assert_eq!(updated.client_order_id, cid);
2178        assert_eq!(updated.price, Some(Price::from("30000.00")));
2179        assert_eq!(updated.quantity, Quantity::from("0.010"));
2180        assert!(updated.venue_order_id.is_some());
2181    }
2182
2183    #[rstest]
2184    fn test_emit_rejection_for_place_clears_snapshot() {
2185        let ctx = DispatchTestContext::new();
2186        let cid = ClientOrderId::from("client-1");
2187        let identity = default_identity();
2188        ctx.state.order_identities.insert(cid, identity.clone());
2189        ctx.state.order_snapshots.insert(
2190            cid,
2191            OrderStateSnapshot {
2192                quantity: Quantity::from("0.010"),
2193                price: Some(Price::from("30000.00")),
2194                trigger_price: None,
2195            },
2196        );
2197
2198        emit_rejection_for_op(
2199            &PendingOperation::Place,
2200            cid,
2201            &identity,
2202            None,
2203            "rejected",
2204            &ctx.emitter,
2205            &ctx.state,
2206            UnixNanos::from(1u64),
2207        );
2208
2209        assert!(!ctx.state.order_identities.contains_key(&cid));
2210        assert!(!ctx.state.order_snapshots.contains_key(&cid));
2211    }
2212
2213    fn order_response(
2214        op: &str,
2215        ret_code: i64,
2216        ret_msg: &str,
2217        req_id: &str,
2218        data: serde_json::Value,
2219        ret_ext_info: Option<serde_json::Value>,
2220    ) -> BybitWsOrderResponse {
2221        BybitWsOrderResponse {
2222            op: Ustr::from(op),
2223            conn_id: Some("test-conn-id".to_string()),
2224            ret_code,
2225            ret_msg: ret_msg.to_string(),
2226            data,
2227            req_id: Some(req_id.to_string()),
2228            header: None,
2229            ret_ext_info,
2230        }
2231    }
2232
2233    #[rstest]
2234    fn test_dispatch_local_not_sent_emits_order_rejected() {
2235        let mut ctx = DispatchTestContext::new();
2236        let cid = ClientOrderId::from("not-sent-1");
2237        ctx.state.order_identities.insert(cid, default_identity());
2238        ctx.state.pending_requests.insert(
2239            "req-not-sent".to_string(),
2240            (vec![cid], vec![None], PendingOperation::Place),
2241        );
2242        let response = order_response(
2243            BYBIT_OP_ORDER_CREATE,
2244            -1,
2245            "Order command was not written",
2246            "req-not-sent",
2247            serde_json::json!({}),
2248            None,
2249        );
2250
2251        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2252
2253        let event = ctx.rx.try_recv().expect("expected OrderRejected event");
2254        assert!(
2255            matches!(event, ExecutionEvent::Order(OrderEventAny::Rejected(ref rejected))
2256                if rejected.client_order_id == cid
2257                    && rejected.reason == "Order command was not written"),
2258            "Expected OrderRejected for {cid}, found {event:?}"
2259        );
2260        assert!(!ctx.state.order_identities.contains_key(&cid));
2261        assert!(!ctx.state.pending_requests.contains_key("req-not-sent"));
2262    }
2263
2264    #[rstest]
2265    fn test_dispatch_single_cancel_rejection_emits_cancel_rejected() {
2266        let mut ctx = DispatchTestContext::new();
2267        let cid = ClientOrderId::from("cancel-reject-1");
2268        let venue_order_id = VenueOrderId::from("venue-cancel-1");
2269        ctx.state.order_identities.insert(cid, default_identity());
2270        ctx.state.pending_requests.insert(
2271            "req-cancel".to_string(),
2272            (
2273                vec![cid],
2274                vec![Some(venue_order_id)],
2275                PendingOperation::Cancel,
2276            ),
2277        );
2278
2279        let response = order_response(
2280            BYBIT_OP_ORDER_CANCEL,
2281            110001,
2282            "Order does not exist.",
2283            "req-cancel",
2284            serde_json::json!({
2285                "orderId": venue_order_id.to_string(),
2286                "orderLinkId": cid.to_string(),
2287            }),
2288            None,
2289        );
2290
2291        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2292
2293        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2294        assert!(
2295            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected)) if rejected.client_order_id == cid),
2296            "Expected CancelRejected for {cid}, found {event:?}"
2297        );
2298        assert!(
2299            ctx.rx.try_recv().is_err(),
2300            "Expected no extra events for single cancel rejection"
2301        );
2302    }
2303
2304    #[rstest]
2305    fn test_dispatch_single_modify_rejection_emits_modify_rejected() {
2306        let mut ctx = DispatchTestContext::new();
2307        let cid = ClientOrderId::from("modify-reject-1");
2308        let venue_order_id = VenueOrderId::from("venue-modify-1");
2309        ctx.state.order_identities.insert(cid, default_identity());
2310        ctx.state.pending_requests.insert(
2311            "req-modify".to_string(),
2312            (
2313                vec![cid],
2314                vec![Some(venue_order_id)],
2315                PendingOperation::Amend,
2316            ),
2317        );
2318
2319        let response = order_response(
2320            BYBIT_OP_ORDER_AMEND,
2321            110003,
2322            "Order price exceeds allowable range.",
2323            "req-modify",
2324            serde_json::json!({
2325                "orderId": venue_order_id.to_string(),
2326                "orderLinkId": cid.to_string(),
2327            }),
2328            None,
2329        );
2330
2331        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2332
2333        let event = ctx.rx.try_recv().expect("expected ModifyRejected event");
2334        assert!(
2335            matches!(event, ExecutionEvent::Order(OrderEventAny::ModifyRejected(ref rejected)) if rejected.client_order_id == cid),
2336            "Expected ModifyRejected for {cid}, found {event:?}"
2337        );
2338        assert!(
2339            ctx.rx.try_recv().is_err(),
2340            "Expected no extra events for single modify rejection"
2341        );
2342    }
2343
2344    #[rstest]
2345    fn test_dispatch_single_cancel_rate_limit_emits_cancel_rejected() {
2346        let mut ctx = DispatchTestContext::new();
2347        let cid = ClientOrderId::from("cancel-rate-limit-1");
2348        let venue_order_id = VenueOrderId::from("venue-cancel-rate-limit-1");
2349        ctx.state.order_identities.insert(cid, default_identity());
2350        ctx.state.pending_requests.insert(
2351            "req-cancel-rate-limit".to_string(),
2352            (
2353                vec![cid],
2354                vec![Some(venue_order_id)],
2355                PendingOperation::Cancel,
2356            ),
2357        );
2358
2359        let response = order_response(
2360            BYBIT_OP_ORDER_CANCEL,
2361            10429,
2362            "System level frequency protection.",
2363            "req-cancel-rate-limit",
2364            serde_json::json!({
2365                "orderId": venue_order_id.to_string(),
2366                "orderLinkId": cid.to_string(),
2367            }),
2368            None,
2369        );
2370
2371        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2372
2373        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2374        assert!(
2375            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2376                if rejected.client_order_id == cid && rejected.venue_order_id == Some(venue_order_id)),
2377            "Expected CancelRejected for {cid}, found {event:?}"
2378        );
2379        assert!(ctx.state.order_identities.contains_key(&cid));
2380    }
2381
2382    #[rstest]
2383    fn test_dispatch_single_modify_server_error_keeps_outcome_unresolved() {
2384        let mut ctx = DispatchTestContext::new();
2385        let cid = ClientOrderId::from("modify-server-error-1");
2386        let venue_order_id = VenueOrderId::from("venue-modify-server-error-1");
2387        ctx.state.order_identities.insert(cid, default_identity());
2388        ctx.state.pending_requests.insert(
2389            "req-modify-server-error".to_string(),
2390            (
2391                vec![cid],
2392                vec![Some(venue_order_id)],
2393                PendingOperation::Amend,
2394            ),
2395        );
2396
2397        let response = order_response(
2398            BYBIT_OP_ORDER_AMEND,
2399            10016,
2400            "Internal server error.",
2401            "req-modify-server-error",
2402            serde_json::json!({
2403                "orderId": venue_order_id.to_string(),
2404                "orderLinkId": cid.to_string(),
2405            }),
2406            None,
2407        );
2408
2409        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2410
2411        assert!(
2412            ctx.rx.try_recv().is_err(),
2413            "Expected no ModifyRejected event for server error response"
2414        );
2415        assert!(ctx.state.order_identities.contains_key(&cid));
2416    }
2417
2418    #[rstest]
2419    fn test_dispatch_batch_cancel_top_level_failure_does_not_emit_cancel_rejected() {
2420        let mut ctx = DispatchTestContext::new();
2421        let cid_1 = ClientOrderId::from("batch-cancel-1");
2422        let cid_2 = ClientOrderId::from("batch-cancel-2");
2423        ctx.state.order_identities.insert(cid_1, default_identity());
2424        ctx.state.order_identities.insert(cid_2, default_identity());
2425        ctx.state.pending_requests.insert(
2426            "req-batch-cancel".to_string(),
2427            (
2428                vec![cid_1, cid_2],
2429                vec![
2430                    Some(VenueOrderId::from("venue-batch-1")),
2431                    Some(VenueOrderId::from("venue-batch-2")),
2432                ],
2433                PendingOperation::Cancel,
2434            ),
2435        );
2436
2437        let response = order_response(
2438            BYBIT_OP_ORDER_CANCEL_BATCH,
2439            10016,
2440            "Internal server error.",
2441            "req-batch-cancel",
2442            serde_json::json!({}),
2443            None,
2444        );
2445
2446        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2447
2448        assert!(
2449            ctx.rx.try_recv().is_err(),
2450            "Expected no CancelRejected events for ambiguous batch failure"
2451        );
2452        assert!(ctx.state.order_identities.contains_key(&cid_1));
2453        assert!(ctx.state.order_identities.contains_key(&cid_2));
2454    }
2455
2456    #[rstest]
2457    fn test_dispatch_single_item_batch_cancel_top_level_failure_does_not_emit_cancel_rejected() {
2458        let mut ctx = DispatchTestContext::new();
2459        let cid = ClientOrderId::from("batch-cancel-single");
2460        ctx.state.order_identities.insert(cid, default_identity());
2461        ctx.state.pending_requests.insert(
2462            "req-batch-cancel-single".to_string(),
2463            (
2464                vec![cid],
2465                vec![Some(VenueOrderId::from("venue-batch-single"))],
2466                PendingOperation::Cancel,
2467            ),
2468        );
2469
2470        let response = order_response(
2471            BYBIT_OP_ORDER_CANCEL_BATCH,
2472            10016,
2473            "Internal server error.",
2474            "req-batch-cancel-single",
2475            serde_json::json!({}),
2476            None,
2477        );
2478
2479        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2480
2481        assert!(
2482            ctx.rx.try_recv().is_err(),
2483            "Expected no CancelRejected event for ambiguous one-item batch failure"
2484        );
2485        assert!(ctx.state.order_identities.contains_key(&cid));
2486    }
2487
2488    #[rstest]
2489    fn test_dispatch_batch_cancel_top_level_rate_limit_emits_cancel_rejected() {
2490        let mut ctx = DispatchTestContext::new();
2491        let cid_1 = ClientOrderId::from("batch-rate-1");
2492        let cid_2 = ClientOrderId::from("batch-rate-2");
2493        let venue_1 = VenueOrderId::from("venue-rate-1");
2494        let venue_2 = VenueOrderId::from("venue-rate-2");
2495        ctx.state.order_identities.insert(cid_1, default_identity());
2496        ctx.state.order_identities.insert(cid_2, default_identity());
2497        ctx.state.pending_requests.insert(
2498            "req-batch-rate".to_string(),
2499            (
2500                vec![cid_1, cid_2],
2501                vec![Some(venue_1), Some(venue_2)],
2502                PendingOperation::Cancel,
2503            ),
2504        );
2505
2506        let response = order_response(
2507            BYBIT_OP_ORDER_CANCEL_BATCH,
2508            10006,
2509            "Too many visits.",
2510            "req-batch-rate",
2511            serde_json::json!({}),
2512            None,
2513        );
2514
2515        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2516
2517        let first = ctx.rx.try_recv().expect("expected first CancelRejected");
2518        let second = ctx.rx.try_recv().expect("expected second CancelRejected");
2519        assert!(
2520            matches!(first, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2521                if rejected.client_order_id == cid_1 && rejected.venue_order_id == Some(venue_1))
2522        );
2523        assert!(
2524            matches!(second, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2525                if rejected.client_order_id == cid_2 && rejected.venue_order_id == Some(venue_2))
2526        );
2527    }
2528
2529    #[rstest]
2530    fn test_dispatch_batch_cancel_per_item_error_emits_only_failed_item() {
2531        let mut ctx = DispatchTestContext::new();
2532        let cid_1 = ClientOrderId::from("batch-cancel-ok");
2533        let cid_2 = ClientOrderId::from("batch-cancel-reject");
2534        ctx.state.order_identities.insert(cid_1, default_identity());
2535        ctx.state.order_identities.insert(cid_2, default_identity());
2536        ctx.state.pending_requests.insert(
2537            "req-batch-mixed".to_string(),
2538            (
2539                vec![cid_1, cid_2],
2540                vec![
2541                    Some(VenueOrderId::from("venue-batch-ok")),
2542                    Some(VenueOrderId::from("venue-batch-reject")),
2543                ],
2544                PendingOperation::Cancel,
2545            ),
2546        );
2547
2548        let response = order_response(
2549            BYBIT_OP_ORDER_CANCEL_BATCH,
2550            0,
2551            "OK",
2552            "req-batch-mixed",
2553            serde_json::json!({
2554                "list": [
2555                    {"orderId": "venue-batch-ok", "orderLinkId": cid_1.to_string()},
2556                    {"orderId": "venue-batch-reject", "orderLinkId": cid_2.to_string()}
2557                ]
2558            }),
2559            Some(serde_json::json!({
2560                "list": [
2561                    {"code": 0, "msg": "OK"},
2562                    {"code": 170213, "msg": "Order does not exist."}
2563                ]
2564            })),
2565        );
2566
2567        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2568
2569        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2570        assert!(
2571            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected)) if rejected.client_order_id == cid_2),
2572            "Expected CancelRejected for failed batch item {cid_2}, found {event:?}"
2573        );
2574        assert!(
2575            ctx.rx.try_recv().is_err(),
2576            "Expected no CancelRejected event for successful batch item"
2577        );
2578    }
2579
2580    #[rstest]
2581    fn test_dispatch_batch_cancel_per_item_rate_limit_emits_cancel_rejected() {
2582        let mut ctx = DispatchTestContext::new();
2583        let cid = ClientOrderId::from("batch-cancel-rate-limit");
2584        let venue_order_id = VenueOrderId::from("venue-batch-rate-limit");
2585        ctx.state.order_identities.insert(cid, default_identity());
2586        ctx.state.pending_requests.insert(
2587            "req-batch-rate-limit".to_string(),
2588            (
2589                vec![cid],
2590                vec![Some(venue_order_id)],
2591                PendingOperation::Cancel,
2592            ),
2593        );
2594
2595        let response = order_response(
2596            BYBIT_OP_ORDER_CANCEL_BATCH,
2597            0,
2598            "OK",
2599            "req-batch-rate-limit",
2600            serde_json::json!({
2601                "list": [
2602                    {"orderId": venue_order_id.to_string(), "orderLinkId": cid.to_string()}
2603                ]
2604            }),
2605            Some(serde_json::json!({
2606                "list": [
2607                    {"code": 10006, "msg": "Too many visits."}
2608                ]
2609            })),
2610        );
2611
2612        dispatch_order_response(&response, &ctx.emitter, &ctx.state, UnixNanos::from(1u64));
2613
2614        let event = ctx.rx.try_recv().expect("expected CancelRejected event");
2615        assert!(
2616            matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2617                if rejected.client_order_id == cid && rejected.venue_order_id == Some(venue_order_id)),
2618            "Expected CancelRejected for {cid}, found {event:?}"
2619        );
2620        assert!(ctx.state.order_identities.contains_key(&cid));
2621    }
2622
2623    #[rstest]
2624    fn test_dispatch_filled_with_seeded_snapshot_emits_updated_for_bbo() {
2625        let mut ctx = DispatchTestContext::new();
2626        let mut value = new_order_value();
2627        value["data"][0]["orderStatus"] = serde_json::Value::String("Filled".to_string());
2628        value["data"][0]["cumExecQty"] = serde_json::Value::String("0.010".to_string());
2629        let cid = ClientOrderId::from("client-1");
2630        ctx.state.order_identities.insert(cid, default_identity());
2631        ctx.state.order_snapshots.insert(
2632            cid,
2633            OrderStateSnapshot {
2634                quantity: Quantity::from("0.010"),
2635                price: Some(Price::from("29000.00")),
2636                trigger_price: None,
2637            },
2638        );
2639
2640        ctx.dispatch_value(&value);
2641
2642        let accepted_event = ctx.rx.try_recv().unwrap();
2643        let accepted = match accepted_event {
2644            ExecutionEvent::Order(OrderEventAny::Accepted(accepted)) => accepted,
2645            other => panic!("Expected Accepted first, found {other:?}"),
2646        };
2647        assert_eq!(accepted.client_order_id, cid);
2648        assert!(!accepted.venue_order_id.to_string().is_empty());
2649
2650        let updated = ctx.recv_updated();
2651        assert_eq!(updated.client_order_id, cid);
2652        assert_eq!(updated.price, Some(Price::from("30000.00")));
2653        assert_eq!(updated.quantity, Quantity::from("0.010"));
2654        assert_eq!(updated.trigger_price, None);
2655        assert_eq!(updated.venue_order_id, Some(accepted.venue_order_id));
2656
2657        let stored = ctx.state.order_snapshots.get(&cid).unwrap();
2658        assert_eq!(stored.price, Some(Price::from("30000.00")));
2659    }
2660
2661    #[rstest]
2662    fn test_dispatch_accepted_with_matching_seeded_snapshot_no_updated() {
2663        let mut ctx = DispatchTestContext::new();
2664        let value = new_order_value();
2665        let cid = ClientOrderId::from("client-1");
2666        ctx.state.order_identities.insert(cid, default_identity());
2667        ctx.state.order_snapshots.insert(
2668            cid,
2669            OrderStateSnapshot {
2670                quantity: Quantity::from("0.010"),
2671                price: Some(Price::from("30000.00")),
2672                trigger_price: None,
2673            },
2674        );
2675
2676        ctx.dispatch_value(&value);
2677
2678        let accepted = ctx.rx.try_recv().unwrap();
2679        assert!(matches!(
2680            accepted,
2681            ExecutionEvent::Order(OrderEventAny::Accepted(_))
2682        ));
2683
2684        assert!(
2685            ctx.rx.try_recv().is_err(),
2686            "Expected no OrderUpdated when seed matches venue snapshot"
2687        );
2688    }
2689
2690    #[rstest]
2691    #[case::price_changed(
2692        Some(Price::from("100.00")),
2693        None,
2694        Quantity::from("1.000"),
2695        Some(Price::from("200.00")),
2696        None,
2697        Quantity::from("1.000"),
2698        true
2699    )]
2700    #[case::trigger_changed(
2701        None,
2702        Some(Price::from("100.00")),
2703        Quantity::from("1.000"),
2704        None,
2705        Some(Price::from("90.00")),
2706        Quantity::from("1.000"),
2707        true
2708    )]
2709    #[case::qty_changed(
2710        Some(Price::from("100.00")),
2711        None,
2712        Quantity::from("1.000"),
2713        Some(Price::from("100.00")),
2714        None,
2715        Quantity::from("2.000"),
2716        true
2717    )]
2718    #[case::no_change(
2719        Some(Price::from("100.00")),
2720        None,
2721        Quantity::from("1.000"),
2722        Some(Price::from("100.00")),
2723        None,
2724        Quantity::from("1.000"),
2725        false
2726    )]
2727    fn test_is_snapshot_updated(
2728        #[case] prev_price: Option<Price>,
2729        #[case] prev_trigger: Option<Price>,
2730        #[case] prev_qty: Quantity,
2731        #[case] new_price: Option<Price>,
2732        #[case] new_trigger: Option<Price>,
2733        #[case] new_qty: Quantity,
2734        #[case] expected: bool,
2735    ) {
2736        let state = WsDispatchState::default();
2737        let cid = ClientOrderId::from("test-1");
2738        state.order_snapshots.insert(
2739            cid,
2740            OrderStateSnapshot {
2741                quantity: prev_qty,
2742                price: prev_price,
2743                trigger_price: prev_trigger,
2744            },
2745        );
2746
2747        let new_snapshot = OrderStateSnapshot {
2748            quantity: new_qty,
2749            price: new_price,
2750            trigger_price: new_trigger,
2751        };
2752        assert_eq!(is_snapshot_updated(&new_snapshot, &cid, &state), expected);
2753    }
2754
2755    #[rstest]
2756    fn test_is_snapshot_updated_no_previous() {
2757        let state = WsDispatchState::default();
2758        let cid = ClientOrderId::from("test-1");
2759
2760        let new_snapshot = OrderStateSnapshot {
2761            quantity: Quantity::from("1.000"),
2762            price: Some(Price::from("100.00")),
2763            trigger_price: None,
2764        };
2765        assert!(!is_snapshot_updated(&new_snapshot, &cid, &state));
2766    }
2767
2768    #[rstest]
2769    #[case::limit_order("30000", "0", Some(Price::from("30000.00")), None)]
2770    #[case::conditional("0", "29000", None, Some(Price::from("29000.00")))]
2771    #[case::both(
2772        "30000",
2773        "29000",
2774        Some(Price::from("30000.00")),
2775        Some(Price::from("29000.00"))
2776    )]
2777    fn test_parse_order_snapshot(
2778        #[case] price: &str,
2779        #[case] trigger: &str,
2780        #[case] expected_price: Option<Price>,
2781        #[case] expected_trigger: Option<Price>,
2782    ) {
2783        let instrument = linear_instrument();
2784        let json = load_test_json("ws_account_order.json");
2785        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2786        value["data"][0]["price"] = serde_json::Value::String(price.to_string());
2787        value["data"][0]["triggerPrice"] = serde_json::Value::String(trigger.to_string());
2788        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2789            serde_json::from_value(value).unwrap();
2790
2791        let snapshot = parse_order_snapshot(&msg.data[0], &instrument).unwrap();
2792        assert_eq!(snapshot.price, expected_price);
2793        assert_eq!(snapshot.trigger_price, expected_trigger);
2794        assert_eq!(snapshot.quantity, Quantity::from("0.010"));
2795    }
2796
2797    #[rstest]
2798    fn test_parse_order_snapshot_invalid_qty_returns_none() {
2799        let instrument = linear_instrument();
2800        let json = load_test_json("ws_account_order.json");
2801        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
2802        value["data"][0]["qty"] = serde_json::Value::String(String::new());
2803        let msg: crate::websocket::messages::BybitWsAccountOrderMsg =
2804            serde_json::from_value(value).unwrap();
2805
2806        assert!(parse_order_snapshot(&msg.data[0], &instrument).is_none());
2807    }
2808
2809    #[rstest]
2810    fn test_dispatch_order_updated_on_partially_filled_price_change() {
2811        let mut ctx = DispatchTestContext::new();
2812        let value = new_order_value();
2813        ctx.accept_order(&value);
2814
2815        let mut amended = value;
2816        amended["data"][0]["orderStatus"] =
2817            serde_json::Value::String("PartiallyFilled".to_string());
2818        amended["data"][0]["cumExecQty"] = serde_json::Value::String("0.005".to_string());
2819        amended["data"][0]["price"] = serde_json::Value::String("31000".to_string());
2820        ctx.dispatch_value(&amended);
2821
2822        let updated = ctx.recv_updated();
2823        assert_eq!(updated.client_order_id, ClientOrderId::from("client-1"));
2824        assert_eq!(updated.price, Some(Price::from("31000.00")));
2825    }
2826}