1use 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#[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#[derive(Debug, Clone, Copy)]
92pub enum PendingOperation {
93 Place,
94 Cancel,
95 Amend,
96}
97
98pub type PendingRequestData = (
101 Vec<ClientOrderId>,
102 Vec<Option<VenueOrderId>>,
103 PendingOperation,
104);
105
106#[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
196pub 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
298fn 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 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 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 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 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 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 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 }
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 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 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
643fn 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 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
732fn 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
776fn 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
845fn 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 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 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 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 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#[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
1093fn 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
1110fn 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
1142fn 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
1168fn 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
1198fn 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
1208fn 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
1216fn 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
1252fn 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 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 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 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 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 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 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 let event1 = rx.try_recv().unwrap();
1586 assert!(
1587 matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1588 "Expected Accepted, found {event1:?}"
1589 );
1590
1591 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 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 let event1 = rx.try_recv().unwrap();
1728 assert!(
1729 matches!(event1, ExecutionEvent::Order(OrderEventAny::Accepted(_))),
1730 "Expected Accepted, found {event1:?}",
1731 );
1732
1733 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 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 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 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 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 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}