1use std::{
19 future::Future,
20 sync::Arc,
21 time::{Duration, Instant},
22};
23
24use ahash::AHashMap;
25use anyhow::Context;
26use async_trait::async_trait;
27use futures_util::{StreamExt, pin_mut};
28use nautilus_common::{
29 clients::{ExecutionClient, ExecutionReportTask},
30 live::runner::get_exec_event_sender,
31 messages::execution::{
32 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
33 GenerateFillReportsBuilder, GenerateOrderStatusReport, GenerateOrderStatusReports,
34 GenerateOrderStatusReportsBuilder, GeneratePositionStatusReports, ModifyOrder,
35 QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
36 },
37};
38use nautilus_core::{
39 DurationNanos, Params, UUID4, UnixNanos,
40 env::get_or_env_var,
41 string::secret::SecretString,
42 time::{AtomicTime, get_atomic_clock_realtime},
43};
44use nautilus_live::{
45 ExecutionClientCore, ExecutionEventEmitter, SocketControl,
46 execution::failure::CommandFailure,
47 task::{TaskGroup, TaskGroupGuard},
48};
49use nautilus_model::{
50 accounts::AccountAny,
51 enums::{OmsType, OrderStatus, OrderType},
52 events::{OrderCanceled, OrderDeniedReason, OrderEventAny},
53 identifiers::{AccountId, ClientId, ClientOrderId, InstrumentId, Venue},
54 instruments::{Instrument, InstrumentAny},
55 orders::{Order, OrderAny},
56 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
57 types::{AccountBalance, MarginBalance, Price},
58};
59use ustr::Ustr;
60
61use crate::{
62 common::{
63 consts::BYBIT_VENUE,
64 credential::credential_env_vars,
65 enums::{
66 BybitAccountType, BybitEnvironment, BybitOrderSide, BybitOrderSmpType, BybitOrderType,
67 BybitPositionIdx, BybitPositionMode, BybitProductType, BybitTpSlMode,
68 resolve_trigger_type,
69 },
70 parse::{
71 BybitTpSlParams, bybit_rejection_due_post_only, extract_raw_symbol, get_price_str,
72 make_hedge_venue_position_id, map_time_in_force, nanos_to_millis,
73 parse_bybit_tp_sl_params, resolve_position_idx as resolve_bybit_position_idx,
74 spot_leverage, spot_market_unit, trigger_direction,
75 },
76 rate_limit::batch_send_limit,
77 symbol::BybitSymbol,
78 },
79 config::BybitExecutionClientConfig,
80 http::{
81 client::BybitHttpClient,
82 error::{
83 BybitCancelOrderError, BybitHttpError, BybitModifyOrderError, BybitSubmitOrderError,
84 is_bybit_ambiguous_order_error_code,
85 },
86 },
87 websocket::{
88 client::BybitWebSocketClient,
89 dispatch::{
90 OrderIdentity, OrderStateSnapshot, PendingOperation, WsDispatchState,
91 dispatch_ws_message,
92 },
93 messages::{BybitWsAmendOrderParams, BybitWsCancelOrderParams, BybitWsPlaceOrderParams},
94 },
95};
96
97#[derive(Debug)]
99pub struct BybitExecutionClient {
100 core: ExecutionClientCore,
101 clock: &'static AtomicTime,
102 config: BybitExecutionClientConfig,
103 emitter: ExecutionEventEmitter,
104 http_client: BybitHttpClient,
105 ws_private: BybitWebSocketClient,
106 ws_trade: BybitWebSocketClient,
107 session_tasks: TaskGroup,
108 pending_tasks: TaskGroup,
109 shutdown_errors: Vec<String>,
110 instruments_cache: Arc<AHashMap<Ustr, InstrumentAny>>,
111 dispatch_state: Arc<WsDispatchState>,
112}
113
114impl BybitExecutionClient {
115 pub fn new(
121 core: ExecutionClientCore,
122 config: BybitExecutionClientConfig,
123 ) -> anyhow::Result<Self> {
124 config.validate()?;
125
126 let (key_var, secret_var) = credential_env_vars(config.environment);
127 let api_key = get_or_env_var(
128 config.api_key.clone().map(SecretString::into_inner),
129 key_var,
130 )?;
131 let api_secret = get_or_env_var(
132 config.api_secret.clone().map(SecretString::into_inner),
133 secret_var,
134 )?;
135 let proxy_url = config
136 .proxy_url
137 .as_ref()
138 .map(|value| value.expose_secret().to_owned());
139
140 let http_client = BybitHttpClient::with_credentials(
141 api_key.clone(),
142 api_secret.clone(),
143 Some(config.http_base_url()),
144 config.http_timeout_secs,
145 config.max_retries,
146 config.retry_delay_initial_ms,
147 config.retry_delay_max_ms,
148 config.recv_window_ms,
149 proxy_url.clone(),
150 )?;
151 http_client.set_use_spot_position_reports(config.use_spot_position_reports);
152
153 let mut ws_private = BybitWebSocketClient::new_private(
154 config.environment,
155 Some(api_key.clone()),
156 Some(api_secret.clone()),
157 Some(config.ws_private_url()),
158 config.heartbeat_interval_secs,
159 config.transport_backend,
160 proxy_url.clone(),
161 )
162 .with_socket_control(SocketControl::new(
163 core.client_id,
164 Some(*BYBIT_VENUE),
165 "bybit-user-streams",
166 ));
167
168 if let Some(secs) = config.auth_timeout_secs {
169 ws_private.set_auth_wait_timeout(Duration::from_secs(secs));
170 }
171
172 let mut ws_trade = BybitWebSocketClient::new_trade(
173 config.environment,
174 Some(api_key),
175 Some(api_secret),
176 Some(config.ws_trade_url()),
177 config.heartbeat_interval_secs,
178 config.transport_backend,
179 proxy_url,
180 )
181 .with_socket_control(SocketControl::new(
182 core.client_id,
183 Some(*BYBIT_VENUE),
184 "bybit-trading",
185 ));
186
187 if let Some(secs) = config.auth_timeout_secs {
188 ws_trade.set_auth_wait_timeout(Duration::from_secs(secs));
189 }
190 ws_trade.set_recv_window_ms(config.recv_window_ms);
191
192 let clock = get_atomic_clock_realtime();
193 let emitter = ExecutionEventEmitter::new(
194 clock,
195 core.trader_id,
196 core.account_id,
197 core.account_type,
198 None,
199 );
200
201 let session_tasks = TaskGroup::new();
202 let pending_tasks = TaskGroup::new();
203
204 Ok(Self {
205 core,
206 clock,
207 config,
208 emitter,
209 http_client,
210 ws_private,
211 ws_trade,
212 session_tasks,
213 pending_tasks,
214 shutdown_errors: Vec::new(),
215 instruments_cache: Arc::new(AHashMap::new()),
216 dispatch_state: Arc::new(WsDispatchState::default()),
217 })
218 }
219
220 fn product_types(&self) -> Vec<BybitProductType> {
221 if self.config.product_types.is_empty() {
222 vec![BybitProductType::Linear]
223 } else {
224 self.config.product_types.clone()
225 }
226 }
227
228 fn update_account_state(&self) {
229 let http_client = self.http_client.clone();
230 let account_id = self.core.account_id;
231 let emitter = self.emitter.clone();
232
233 self.spawn_task("query_account", async move {
234 let account_state = http_client
235 .request_account_state(BybitAccountType::Unified, account_id)
236 .await
237 .context("failed to request Bybit account state")?;
238 emitter.send_account_state(account_state);
239 Ok(())
240 });
241 }
242
243 fn spawn_task<F>(&self, description: &'static str, fut: F)
244 where
245 F: Future<Output = anyhow::Result<()>> + Send + 'static,
246 {
247 let future = async move {
248 if let Err(e) = fut.await {
249 log::warn!("{description} failed: {e:?}");
250 }
251 };
252
253 if let Err(e) = self.pending_tasks.spawn(future) {
254 log::warn!("Skipping Bybit {description} after shutdown began: {e}");
255 }
256 }
257
258 fn abort_pending_tasks(&self) {
259 self.pending_tasks.begin_shutdown();
260 }
261
262 fn abort_session_tasks(&self) {
263 self.session_tasks.begin_shutdown();
264 }
265
266 async fn await_pending_tasks(&self) -> anyhow::Result<()> {
267 self.pending_tasks.begin_shutdown();
268 self.pending_tasks
269 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
270 .await
271 .map_err(|e| anyhow::anyhow!("Failed to terminate Bybit execution tasks: {e}"))?;
272 Ok(())
273 }
274
275 async fn await_session_tasks(&self) -> anyhow::Result<()> {
276 self.session_tasks.begin_shutdown();
277 self.session_tasks
278 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
279 .await
280 .map_err(|e| {
281 anyhow::anyhow!("Failed to terminate Bybit execution session tasks: {e}")
282 })?;
283 Ok(())
284 }
285
286 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
287 self.abort_session_tasks();
288 self.abort_pending_tasks();
289 self.http_client.cancel_all_requests();
290 self.ws_private.begin_shutdown();
291 self.ws_trade.begin_shutdown();
292
293 if let Err(e) = self.ws_private.close().await {
294 self.shutdown_errors
295 .push(format!("private WebSocket shutdown failed: {e}"));
296 }
297
298 if let Err(e) = self.ws_trade.close().await {
299 self.shutdown_errors
300 .push(format!("trade WebSocket shutdown failed: {e}"));
301 }
302 self.dispatch_state.clear_repay_sender();
303
304 if let Err(e) = self.await_session_tasks().await {
305 self.shutdown_errors.push(e.to_string());
306 }
307
308 if let Err(e) = self.await_pending_tasks().await {
309 self.shutdown_errors.push(e.to_string());
310 }
311 self.core.set_disconnected();
312
313 if self.shutdown_errors.is_empty() {
314 Ok(())
315 } else {
316 let errors = std::mem::take(&mut self.shutdown_errors);
317 anyhow::bail!("Bybit execution shutdown failed: {}", errors.join("; "))
318 }
319 }
320
321 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
323 let account_id = self.core.account_id;
324
325 if self.core.cache().account(&account_id).is_some() {
326 log::info!("Account {account_id} registered");
327 return Ok(());
328 }
329
330 let start = Instant::now();
331 let timeout = Duration::from_secs_f64(timeout_secs);
332 let interval = Duration::from_millis(10);
333
334 loop {
335 tokio::time::sleep(interval).await;
336
337 if self.core.cache().account(&account_id).is_some() {
338 log::info!("Account {account_id} registered");
339 return Ok(());
340 }
341
342 if start.elapsed() >= timeout {
343 anyhow::bail!(
344 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
345 );
346 }
347 }
348 }
349
350 fn get_product_type_for_instrument(&self, instrument_id: InstrumentId) -> BybitProductType {
351 BybitProductType::from_suffix(instrument_id.symbol.as_str()).unwrap_or_else(|| {
352 log::warn!("No product-type suffix on {instrument_id}, defaulting to Linear");
353 BybitProductType::Linear
354 })
355 }
356
357 const fn provides_bulk_position_coverage_for_product_type(
358 product_type: BybitProductType,
359 ) -> bool {
360 !matches!(product_type, BybitProductType::Spot)
361 }
362
363 async fn generate_bulk_position_status_reports(
364 &self,
365 product_types: Vec<BybitProductType>,
366 ) -> anyhow::Result<Vec<PositionStatusReport>> {
367 let mut reports = Vec::new();
368
369 for product_type in product_types {
370 if !Self::provides_bulk_position_coverage_for_product_type(product_type) {
371 continue;
372 }
373
374 let mut fetched = self
375 .http_client
376 .request_position_status_reports(self.core.account_id, product_type, None)
377 .await?;
378 reports.append(&mut fetched);
379 }
380
381 Ok(reports)
382 }
383
384 fn resolve_position_idx(
385 &self,
386 instrument_id: InstrumentId,
387 order_side: BybitOrderSide,
388 is_reduce_only: bool,
389 manual_override: Option<BybitPositionIdx>,
390 ) -> Option<BybitPositionIdx> {
391 let product_type = self.get_product_type_for_instrument(instrument_id);
392 if !matches!(
393 product_type,
394 BybitProductType::Linear | BybitProductType::Inverse
395 ) {
396 return None;
397 }
398 let mode = self
399 .config
400 .position_mode
401 .as_ref()
402 .and_then(|map| map.get(instrument_id.symbol.as_str()).copied());
403 resolve_bybit_position_idx(mode, order_side, is_reduce_only, manual_override)
404 }
405
406 fn resolve_smp_type(
407 &self,
408 manual_override: Option<BybitOrderSmpType>,
409 ) -> Option<BybitOrderSmpType> {
410 manual_override.or(self.config.smp_type)
411 }
412
413 async fn apply_account_configuration(&self) -> anyhow::Result<()> {
414 self.apply_leverages_setting().await;
415 self.apply_position_modes_setting().await;
416 self.apply_margin_mode_setting().await
417 }
418
419 async fn apply_leverages_setting(&self) {
420 let Some(leverages) = &self.config.futures_leverages else {
421 return;
422 };
423
424 for (symbol_str, leverage) in leverages {
425 self.apply_leverage_entry(symbol_str, *leverage).await;
426 }
427 }
428
429 async fn apply_leverage_entry(&self, symbol_str: &str, leverage: u32) {
430 let Some(symbol) = Self::parse_derivative_symbol(symbol_str) else {
431 return;
432 };
433 let lev = leverage.to_string();
434 let result = self
435 .http_client
436 .set_leverage(symbol.product_type(), symbol.raw_symbol(), &lev, &lev)
437 .await;
438
439 match result {
440 Ok(_) => log::info!("Set leverage for {symbol_str} to {leverage}"),
441 Err(e) if Self::is_unchanged_error(&e, "110043") => {
442 log::debug!("Leverage already set for {symbol_str} to {leverage}");
443 }
444 Err(e) => log::error!("Failed to set leverage for {symbol_str}: {e}"),
445 }
446 }
447
448 async fn apply_position_modes_setting(&self) {
449 let Some(modes) = &self.config.position_mode else {
450 return;
451 };
452
453 for (symbol_str, mode) in modes {
454 self.apply_position_mode_entry(symbol_str, *mode).await;
455 }
456 }
457
458 async fn apply_position_mode_entry(&self, symbol_str: &str, mode: BybitPositionMode) {
459 let Some(symbol) = Self::parse_derivative_symbol(symbol_str) else {
460 return;
461 };
462 let result = self
463 .http_client
464 .switch_mode(
465 symbol.product_type(),
466 mode,
467 Some(symbol.raw_symbol().to_string()),
468 None,
469 )
470 .await;
471
472 match result {
473 Ok(_) => log::info!("Set symbol `{symbol_str}` position mode to `{mode:?}`"),
474 Err(e) if Self::is_unchanged_error(&e, "110025") => {
475 log::debug!("Symbol `{symbol_str}` position mode already set to `{mode:?}`");
476 }
477 Err(e) => log::error!("Failed to set position mode for {symbol_str}: {e}"),
478 }
479 }
480
481 async fn apply_margin_mode_setting(&self) -> anyhow::Result<()> {
482 let Some(margin_mode) = self.config.margin_mode else {
483 return Ok(());
484 };
485
486 let result = self.http_client.set_margin_mode(margin_mode).await;
487
488 match result {
489 Ok(_) => {
490 log::info!("Set account margin mode to {margin_mode:?}");
491 Ok(())
492 }
493 Err(e) if Self::is_unchanged_error(&e, "") => {
494 log::debug!("Margin mode already set to {margin_mode:?}");
495 Ok(())
496 }
497 Err(e) if Self::is_low_margin_error(&e) => {
498 log::warn!("Cannot set margin mode: {e}");
499 Ok(())
500 }
501 Err(e) => Err(anyhow::Error::from(e).context("failed to set margin mode")),
502 }
503 }
504
505 fn parse_derivative_symbol(symbol_str: &str) -> Option<BybitSymbol> {
506 let symbol = match BybitSymbol::new(symbol_str) {
507 Ok(s) => s,
508 Err(e) => {
509 log::warn!("Failed to parse symbol {symbol_str}: {e}");
510 return None;
511 }
512 };
513 matches!(
514 symbol.product_type(),
515 BybitProductType::Linear | BybitProductType::Inverse
516 )
517 .then_some(symbol)
518 }
519
520 fn is_unchanged_error<E: std::fmt::Display>(err: &E, code: &str) -> bool {
521 let msg = err.to_string().to_lowercase();
522 if msg.contains("not been modified") {
523 return true;
524 }
525 !code.is_empty() && msg.contains(code)
526 }
527
528 fn is_low_margin_error<E: std::fmt::Display>(err: &E) -> bool {
529 err.to_string()
530 .contains("needs to be equal to or greater than")
531 }
532
533 fn map_order_type(order_type: OrderType) -> anyhow::Result<(BybitOrderType, bool)> {
534 match order_type {
535 OrderType::Market => Ok((BybitOrderType::Market, false)),
536 OrderType::Limit => Ok((BybitOrderType::Limit, false)),
537 OrderType::StopMarket | OrderType::MarketIfTouched => {
538 Ok((BybitOrderType::Market, true))
539 }
540 OrderType::StopLimit | OrderType::LimitIfTouched => Ok((BybitOrderType::Limit, true)),
541 _ => anyhow::bail!("unsupported order type for Bybit: {order_type}"),
542 }
543 }
544
545 fn validate_bbo_params(
546 order: &OrderAny,
547 product_type: BybitProductType,
548 tp_sl: &BybitTpSlParams,
549 ) -> anyhow::Result<()> {
550 if !tp_sl.has_bbo() {
551 return Ok(());
552 }
553
554 anyhow::ensure!(
555 matches!(
556 product_type,
557 BybitProductType::Linear | BybitProductType::Inverse
558 ),
559 "`bbo_side_type` and `bbo_level` are only supported for Bybit linear and inverse products"
560 );
561
562 let order_type = order.order_type();
563 anyhow::ensure!(
564 matches!(
565 order_type,
566 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
567 ),
568 "`bbo_side_type` and `bbo_level` are not supported for order type {order_type:?}"
569 );
570
571 Ok(())
572 }
573
574 fn build_ws_place_params(
575 order: &OrderAny,
576 product_type: BybitProductType,
577 raw_symbol: &str,
578 tp_sl: &BybitTpSlParams,
579 position_idx: Option<BybitPositionIdx>,
580 smp_type: Option<BybitOrderSmpType>,
581 ) -> anyhow::Result<BybitWsPlaceOrderParams> {
582 let bybit_side = BybitOrderSide::from(order.order_side());
583 let (bybit_order_type, is_conditional) = Self::map_order_type(order.order_type())?;
584 let time_in_force = map_time_in_force(
585 bybit_order_type,
586 Some(order.time_in_force()),
587 Some(order.is_post_only()),
588 )
589 .map_err(OrderDeniedReason::UnsupportedTimeInForce)?;
590 let has_tp_sl = tp_sl.has_tp_sl();
591 let trigger_dir = trigger_direction(order.order_type(), order.order_side(), is_conditional);
592
593 Ok(BybitWsPlaceOrderParams {
594 category: product_type,
595 symbol: Ustr::from(raw_symbol),
596 side: bybit_side,
597 order_type: bybit_order_type,
598 qty: order.quantity().to_string(),
599 is_leverage: spot_leverage(product_type, tp_sl.is_leverage),
600 market_unit: spot_market_unit(
601 product_type,
602 bybit_order_type,
603 order.is_quote_quantity(),
604 ),
605 price: if tp_sl.has_bbo() {
606 None
607 } else {
608 order.price().map(|p: Price| p.to_string())
609 },
610 time_in_force,
611 order_link_id: Some(order.client_order_id().to_string()),
612 reduce_only: if order.is_reduce_only() {
613 Some(true)
614 } else {
615 None
616 },
617 close_on_trigger: tp_sl.close_on_trigger,
618 trigger_price: order.trigger_price().map(|p: Price| p.to_string()),
619 trigger_by: if is_conditional {
620 Some(resolve_trigger_type(order.trigger_type()))
621 } else {
622 None
623 },
624 trigger_direction: trigger_dir.map(|d| d as i32),
625 tpsl_mode: tp_sl.tpsl_mode.or(has_tp_sl.then_some(BybitTpSlMode::Full)),
626 take_profit: tp_sl.take_profit.map(|p| p.to_string()),
627 stop_loss: tp_sl.stop_loss.map(|p| p.to_string()),
628 tp_trigger_by: tp_sl.tp_trigger_by.or(tp_sl
629 .take_profit
630 .map(|_| resolve_trigger_type(order.trigger_type()))),
631 sl_trigger_by: tp_sl.sl_trigger_by.or(tp_sl
632 .stop_loss
633 .map(|_| resolve_trigger_type(order.trigger_type()))),
634 sl_trigger_price: tp_sl.sl_trigger_price.clone(),
635 tp_trigger_price: tp_sl.tp_trigger_price.clone(),
636 sl_order_type: tp_sl.sl_order_type,
637 tp_order_type: tp_sl.tp_order_type,
638 sl_limit_price: tp_sl.sl_limit_price.clone(),
639 tp_limit_price: tp_sl.tp_limit_price.clone(),
640 order_iv: tp_sl.order_iv.clone(),
641 smp_type,
642 mmp: tp_sl.mmp,
643 position_idx,
644 bbo_side_type: tp_sl.bbo_side_type,
645 bbo_level: tp_sl.bbo_level.clone(),
646 })
647 }
648}
649
650fn submit_rejection_reason(error: &anyhow::Error) -> Option<&str> {
651 for cause in error.chain() {
652 if let Some(submit_error) = cause.downcast_ref::<BybitSubmitOrderError>() {
653 return match submit_error {
654 BybitSubmitOrderError::Rejected { reason } => Some(reason.as_str()),
655 BybitSubmitOrderError::MissingOrderId
656 | BybitSubmitOrderError::PostSubmitLookup { .. } => None,
657 };
658 }
659
660 if let Some(BybitHttpError::BybitError {
661 error_code,
662 message,
663 }) = cause.downcast_ref()
664 && !is_bybit_ambiguous_order_error_code(i64::from(*error_code))
665 {
666 return Some(message.as_str());
667 }
668 }
669 None
670}
671
672#[async_trait(?Send)]
673impl ExecutionClient for BybitExecutionClient {
674 fn is_connected(&self) -> bool {
675 self.core.is_connected()
676 }
677
678 fn client_id(&self) -> ClientId {
679 self.core.client_id
680 }
681
682 fn account_id(&self) -> AccountId {
683 self.core.account_id
684 }
685
686 fn venue(&self) -> Venue {
687 *BYBIT_VENUE
688 }
689
690 fn oms_type(&self) -> OmsType {
691 self.core.oms_type
692 }
693
694 fn get_account(&self) -> Option<AccountAny> {
695 self.core.cache().account_owned(&self.core.account_id)
696 }
697
698 fn provides_bulk_position_coverage(&self, instrument_id: InstrumentId) -> bool {
699 let Some(product_type) = BybitProductType::from_suffix(instrument_id.symbol.as_str())
704 else {
705 return false;
706 };
707
708 self.product_types().contains(&product_type)
709 && Self::provides_bulk_position_coverage_for_product_type(product_type)
710 }
711
712 async fn connect(&mut self) -> anyhow::Result<()> {
713 if self.core.is_connected() && self.pending_tasks.is_open() && self.session_tasks.is_open()
714 {
715 return Ok(());
716 }
717
718 if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
719 self.teardown_partial_connect().await?;
720 }
721
722 if !self.pending_tasks.is_open() {
723 self.await_pending_tasks().await?;
724 self.pending_tasks
725 .start_generation()
726 .map_err(|e| anyhow::anyhow!("Failed to start Bybit task generation: {e}"))?;
727 }
728
729 if !self.session_tasks.is_open() {
730 self.await_session_tasks().await?;
731 self.session_tasks
732 .start_generation()
733 .map_err(|e| anyhow::anyhow!("Failed to start Bybit session generation: {e}"))?;
734 }
735 let http_client = self.http_client.clone();
736 let ws_private = self.ws_private.clone();
737 let ws_trade = self.ws_trade.clone();
738 let setup_guard =
739 TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
740 http_client.cancel_all_requests();
741 ws_private.begin_shutdown();
742 ws_trade.begin_shutdown();
743 });
744
745 let connect_result: anyhow::Result<()> = async {
746 self.http_client.reset_cancellation_token();
748
749 let product_types = self.product_types();
750
751 if !self.core.instruments_initialized() {
752 let mut all_instruments = Vec::new();
753
754 for product_type in &product_types {
755 let instruments = self
756 .http_client
757 .request_instruments(*product_type, None, None)
758 .await
759 .with_context(|| {
760 format!("failed to request Bybit instruments for {product_type:?}")
761 })?;
762
763 if instruments.is_empty() {
764 log::warn!("No instruments returned for {product_type:?}");
765 continue;
766 }
767
768 log::debug!("Loaded {} {product_type:?} instruments", instruments.len());
769
770 self.http_client.cache_instruments(&instruments);
771 all_instruments.extend(instruments);
772 }
773
774 if !all_instruments.is_empty() {
775 let mut instruments_map = AHashMap::new();
776 for instrument in &all_instruments {
777 instruments_map.insert(instrument.id().symbol.inner(), instrument.clone());
778 }
779 self.instruments_cache = Arc::new(instruments_map);
780 }
781 self.core.set_instruments_initialized();
782 }
783
784 self.ws_private.set_account_id(self.core.account_id);
785 self.ws_trade.set_account_id(self.core.account_id);
786
787 self.ws_private.connect().await?;
788 self.ws_private.wait_until_active(10.0).await?;
789 log::debug!("Connected to private WebSocket");
790
791 let stream = self.ws_private.stream();
792 let emitter = self.emitter.clone();
793 let account_id = self.core.account_id;
794 let instruments = Arc::clone(&self.instruments_cache);
795 let state = Arc::clone(&self.dispatch_state);
796 let clock = self.clock;
797
798 self.session_tasks.spawn(async move {
799 pin_mut!(stream);
800 while let Some(message) = stream.next().await {
801 dispatch_ws_message(
802 &message,
803 &emitter,
804 &state,
805 account_id,
806 &instruments,
807 clock,
808 );
809 }
810 })?;
811
812 if self.config.environment == BybitEnvironment::Demo {
814 log::info!("Demo mode: Trade WebSocket not available, orders use HTTP REST API");
815 } else {
816 self.ws_trade.connect().await?;
817 self.ws_trade.wait_until_active(10.0).await?;
818 log::debug!("Connected to trade WebSocket");
819
820 let stream = self.ws_trade.stream();
821 let emitter = self.emitter.clone();
822 let account_id = self.core.account_id;
823 let instruments = Arc::clone(&self.instruments_cache);
824 let state = Arc::clone(&self.dispatch_state);
825 let clock = self.clock;
826
827 self.session_tasks.spawn(async move {
828 pin_mut!(stream);
829 while let Some(message) = stream.next().await {
830 dispatch_ws_message(
831 &message,
832 &emitter,
833 &state,
834 account_id,
835 &instruments,
836 clock,
837 );
838 }
839 })?;
840 }
841
842 if self.config.auto_repay_spot_borrows {
843 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
844 self.dispatch_state.set_repay_sender(tx);
845
846 let http_client = self.http_client.clone();
847 let clock = self.clock;
848 self.session_tasks.spawn(async move {
849 crate::repay::run_spot_repay_consumer(rx, http_client, clock).await;
850 })?;
851 }
852
853 self.ws_private.subscribe_orders().await?;
854 self.ws_private.subscribe_executions().await?;
855 self.ws_private.subscribe_positions().await?;
856 self.ws_private.subscribe_wallet().await?;
857
858 self.apply_account_configuration().await?;
859
860 let account_state = self
861 .http_client
862 .request_account_state(BybitAccountType::Unified, self.core.account_id)
863 .await
864 .context("failed to request Bybit account state")?;
865
866 if !account_state.balances.is_empty() {
867 log::debug!(
868 "Received account state with {} balance(s)",
869 account_state.balances.len()
870 );
871 }
872 self.emitter.send_account_state(account_state);
873
874 self.await_account_registered(30.0).await?;
875
876 Ok(())
877 }
878 .await;
879
880 if let Err(e) = connect_result {
881 if let Err(teardown_error) = self.teardown_partial_connect().await {
882 return Err(e.context(format!("Bybit startup teardown failed: {teardown_error}")));
883 }
884 return Err(e);
885 }
886
887 setup_guard.disarm();
888 self.core.set_connected();
889 log::info!("Connected: client_id={}", self.core.client_id);
890 Ok(())
891 }
892
893 async fn disconnect(&mut self) -> anyhow::Result<()> {
894 self.teardown_partial_connect().await?;
895 log::info!("Disconnected: client_id={}", self.core.client_id);
896 Ok(())
897 }
898
899 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
900 self.update_account_state();
901 Ok(())
902 }
903
904 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
905 let instrument_id = cmd.instrument_id;
906 let product_type = self.get_product_type_for_instrument(instrument_id);
907 let client_order_id = cmd.client_order_id;
908 let venue_order_id = cmd.venue_order_id;
909 let account_id = self.core.account_id;
910 let http_client = self.http_client.clone();
911 let emitter = self.emitter.clone();
912
913 self.spawn_task("query_order", async move {
914 match http_client
915 .query_order(
916 account_id,
917 product_type,
918 instrument_id,
919 Some(client_order_id),
920 venue_order_id,
921 )
922 .await
923 {
924 Ok(Some(report)) => {
925 emitter.send_order_status_report(report);
926 }
927 Ok(None) => {
928 log::warn!("Order not found: client_order_id={client_order_id}, venue_order_id={venue_order_id:?}");
929 }
930 Err(e) => {
931 log::warn!("Failed to query order: {e}");
932 }
933 }
934 Ok(())
935 });
936
937 Ok(())
938 }
939
940 fn generate_account_state(
941 &self,
942 balances: Vec<AccountBalance>,
943 margins: Vec<MarginBalance>,
944 reported: bool,
945 ts_event: UnixNanos,
946 info: Option<Params>,
947 ) -> anyhow::Result<()> {
948 self.emitter
949 .emit_account_state(balances, margins, reported, ts_event, info);
950 Ok(())
951 }
952
953 fn start(&mut self) -> anyhow::Result<()> {
954 if self.core.is_started() {
955 return Ok(());
956 }
957
958 let sender = get_exec_event_sender();
959 self.emitter.set_sender(sender);
960 self.core.set_started();
961
962 let http_client = self.http_client.clone();
963 let product_types = self.config.product_types.clone();
964
965 self.session_tasks.spawn(async move {
966 let mut all_instruments = Vec::new();
967
968 for product_type in product_types {
969 match http_client
970 .request_instruments(product_type, None, None)
971 .await
972 {
973 Ok(instruments) => {
974 if instruments.is_empty() {
975 log::warn!("No instruments returned for {product_type:?}");
976 continue;
977 }
978 http_client.cache_instruments(&instruments);
979 all_instruments.extend(instruments);
980 }
981 Err(e) => {
982 log::error!("Failed to request instruments for {product_type:?}: {e}");
983 }
984 }
985 }
986
987 if all_instruments.is_empty() {
988 log::warn!(
989 "Instrument bootstrap yielded no instruments; WebSocket submissions may fail"
990 );
991 } else {
992 log::debug!("Instruments initialized: count={}", all_instruments.len());
993 }
994 })?;
995
996 log::info!(
997 "Started: client_id={}, account_id={}, account_type={:?}, product_types={:?}, environment={:?}, proxy_url={:?}",
998 self.core.client_id,
999 self.core.account_id,
1000 self.core.account_type,
1001 self.config.product_types,
1002 self.config.environment,
1003 self.config.proxy_url,
1004 );
1005 Ok(())
1006 }
1007
1008 fn stop(&mut self) -> anyhow::Result<()> {
1009 if self.core.is_stopped() {
1010 return Ok(());
1011 }
1012
1013 self.core.set_stopped();
1014 self.core.set_disconnected();
1015
1016 self.abort_session_tasks();
1017 self.abort_pending_tasks();
1018 self.http_client.cancel_all_requests();
1019 self.ws_private.begin_shutdown();
1020 self.ws_trade.begin_shutdown();
1021 log::info!("Stopped: client_id={}", self.core.client_id);
1022 Ok(())
1023 }
1024
1025 fn generate_order_status_report_task(
1026 &self,
1027 cmd: &GenerateOrderStatusReport,
1028 ) -> Option<ExecutionReportTask<Option<OrderStatusReport>>> {
1029 let http_client = self.http_client.clone();
1030 let core = self.core.clone();
1031 let account_id = core.account_id;
1032 let state = Arc::clone(&self.dispatch_state);
1033
1034 let instrument = cmd.instrument_id.map(|instrument_id| {
1035 (
1036 instrument_id,
1037 self.get_product_type_for_instrument(instrument_id),
1038 )
1039 });
1040
1041 let command = cmd.clone();
1042 Some(ExecutionReportTask::new(
1043 async move {
1044 let Some((instrument_id, product_type)) = instrument else {
1045 log::warn!("generate_order_status_report requires instrument_id: {command}");
1046 return Ok(None);
1047 };
1048
1049 let reports = http_client
1050 .request_order_status_reports(
1051 account_id,
1052 product_type,
1053 Some(instrument_id),
1054 false,
1055 None,
1056 None,
1057 None,
1058 )
1059 .await?;
1060
1061 Ok(reports.into_iter().find(|report| {
1062 command
1063 .client_order_id
1064 .is_none_or(|id| report.client_order_id == Some(id))
1065 && command
1066 .venue_order_id
1067 .is_none_or(|id| report.venue_order_id.as_str() == id.as_str())
1068 }))
1069 },
1070 move |report| {
1071 if let Some(report) = &report {
1072 cache_reconciliation_order_identity(&core, &state, report);
1073 }
1074
1075 Ok(report)
1076 },
1077 ))
1078 }
1079
1080 fn generate_order_status_reports_task(
1081 &self,
1082 cmd: &GenerateOrderStatusReports,
1083 ) -> Option<ExecutionReportTask<Vec<OrderStatusReport>>> {
1084 let http_client = self.http_client.clone();
1085 let core = self.core.clone();
1086 let account_id = core.account_id;
1087 let state = Arc::clone(&self.dispatch_state);
1088 let product_types = cmd.instrument_id.map_or_else(
1089 || self.product_types(),
1090 |instrument_id| vec![self.get_product_type_for_instrument(instrument_id)],
1091 );
1092 let command = cmd.clone();
1093 Some(ExecutionReportTask::new(
1094 async move {
1095 let mut reports = Vec::new();
1096
1097 for product_type in product_types {
1098 let mut fetched = http_client
1099 .request_order_status_reports(
1100 account_id,
1101 product_type,
1102 command.instrument_id,
1103 command.open_only,
1104 None,
1105 None,
1106 None,
1107 )
1108 .await?;
1109 reports.append(&mut fetched);
1110 }
1111
1112 if let Some(start) = command.start {
1113 reports.retain(|report| report.ts_last >= start);
1114 }
1115
1116 if let Some(end) = command.end {
1117 reports.retain(|report| report.ts_last <= end);
1118 }
1119
1120 Ok(reports)
1121 },
1122 move |reports| {
1123 for report in &reports {
1124 cache_reconciliation_order_identity(&core, &state, report);
1125 }
1126
1127 Ok(reports)
1128 },
1129 ))
1130 }
1131
1132 async fn generate_order_status_report(
1133 &self,
1134 cmd: &GenerateOrderStatusReport,
1135 ) -> anyhow::Result<Option<OrderStatusReport>> {
1136 let task = self
1137 .generate_order_status_report_task(cmd)
1138 .expect("Bybit single-order report task");
1139 task.collection.await;
1140 (task.finish)()
1141 }
1142
1143 async fn generate_order_status_reports(
1144 &self,
1145 cmd: &GenerateOrderStatusReports,
1146 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1147 let task = self
1148 .generate_order_status_reports_task(cmd)
1149 .expect("Bybit bulk order report task");
1150 task.collection.await;
1151 (task.finish)()
1152 }
1153
1154 async fn generate_fill_reports(
1155 &self,
1156 cmd: GenerateFillReports,
1157 ) -> anyhow::Result<Vec<FillReport>> {
1158 let start_ms = nanos_to_millis(cmd.start);
1159 let end_ms = nanos_to_millis(cmd.end);
1160 let mut reports = Vec::new();
1161
1162 if let Some(instrument_id) = cmd.instrument_id {
1163 let product_type = self.get_product_type_for_instrument(instrument_id);
1164 let mut fetched = self
1165 .http_client
1166 .request_fill_reports(
1167 self.core.account_id,
1168 product_type,
1169 Some(instrument_id),
1170 start_ms,
1171 end_ms,
1172 None,
1173 )
1174 .await?;
1175 reports.append(&mut fetched);
1176 } else {
1177 for product_type in self.product_types() {
1178 let mut fetched = self
1179 .http_client
1180 .request_fill_reports(
1181 self.core.account_id,
1182 product_type,
1183 None,
1184 start_ms,
1185 end_ms,
1186 None,
1187 )
1188 .await?;
1189 reports.append(&mut fetched);
1190 }
1191 }
1192
1193 if let Some(venue_order_id) = cmd.venue_order_id {
1194 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
1195 }
1196
1197 Ok(reports)
1198 }
1199
1200 async fn generate_position_status_reports(
1201 &self,
1202 cmd: &GeneratePositionStatusReports,
1203 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1204 if let Some(instrument_id) = cmd.instrument_id {
1205 let product_type = self.get_product_type_for_instrument(instrument_id);
1206 self.http_client
1207 .request_position_status_reports(
1208 self.core.account_id,
1209 product_type,
1210 Some(instrument_id),
1211 )
1212 .await
1213 } else {
1214 self.generate_bulk_position_status_reports(self.product_types())
1215 .await
1216 }
1217 }
1218
1219 async fn generate_mass_status(
1220 &self,
1221 lookback_mins: Option<u64>,
1222 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1223 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
1224
1225 let ts_now = self.clock.get_time_ns();
1226
1227 let start = lookback_mins
1228 .map(DurationNanos::try_from_mins)
1229 .transpose()?
1230 .map(|lookback| ts_now.saturating_sub(lookback));
1231
1232 let order_cmd = GenerateOrderStatusReportsBuilder::default()
1233 .ts_init(ts_now)
1234 .open_only(false)
1235 .start(start)
1236 .build()
1237 .map_err(|e| anyhow::anyhow!("{e}"))?;
1238
1239 let fill_cmd = GenerateFillReportsBuilder::default()
1240 .ts_init(ts_now)
1241 .start(start)
1242 .build()
1243 .map_err(|e| anyhow::anyhow!("{e}"))?;
1244
1245 let position_reports_fut = async {
1246 let product_types = self.product_types();
1247 let skip_spot = product_types.iter().any(|product_type| {
1248 !Self::provides_bulk_position_coverage_for_product_type(*product_type)
1249 });
1250
1251 if skip_spot {
1252 log::info!(
1253 "SPOT mass-status position coverage is unavailable because wallet balances cannot be attributed to pairs"
1254 );
1255 }
1256
1257 self.generate_bulk_position_status_reports(product_types)
1258 .await
1259 };
1260
1261 let (order_reports, fill_reports, position_reports) = tokio::try_join!(
1262 self.generate_order_status_reports(&order_cmd),
1263 self.generate_fill_reports(fill_cmd),
1264 position_reports_fut,
1265 )?;
1266
1267 log::info!("Received {} OrderStatusReports", order_reports.len());
1268 log::info!("Received {} FillReports", fill_reports.len());
1269 log::info!("Received {} PositionReports", position_reports.len());
1270
1271 let mut mass_status = ExecutionMassStatus::new(
1272 self.core.client_id,
1273 self.core.account_id,
1274 *BYBIT_VENUE,
1275 ts_now,
1276 None,
1277 );
1278
1279 mass_status.add_order_reports(order_reports);
1280 mass_status.add_fill_reports(fill_reports);
1281 mass_status.add_position_reports(position_reports);
1282
1283 Ok(Some(mass_status))
1284 }
1285
1286 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1287 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1288 if order.is_closed() {
1289 log::warn!("Cannot submit closed order {}", order.client_order_id());
1290 return Ok(());
1291 }
1292
1293 let instrument_id = order.instrument_id();
1294 let product_type = self.get_product_type_for_instrument(instrument_id);
1295
1296 if let Err(denied) = validate_order(&order, product_type) {
1298 self.emitter.emit_order_denied(&order, &denied.to_string());
1299 return Ok(());
1300 }
1301
1302 let tp_sl = match parse_bybit_tp_sl_params(cmd.params.as_ref()) {
1303 Ok(p) => p,
1304 Err(e) => {
1305 let denied = OrderDeniedReason::ValidationFailed {
1306 detail: e.to_string(),
1307 };
1308 self.emitter.emit_order_denied(&order, &denied.to_string());
1309 return Ok(());
1310 }
1311 };
1312
1313 if let Err(e) = Self::validate_bbo_params(&order, product_type, &tp_sl) {
1314 let denied = OrderDeniedReason::ValidationFailed {
1315 detail: e.to_string(),
1316 };
1317 self.emitter.emit_order_denied(&order, &denied.to_string());
1318 return Ok(());
1319 }
1320
1321 if self.config.environment == BybitEnvironment::Demo
1324 && (tp_sl.tp_trigger_price.is_some() || tp_sl.sl_trigger_price.is_some())
1325 {
1326 let denied = OrderDeniedReason::UnsupportedTpSl {
1327 detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
1328 };
1329 self.emitter.emit_order_denied(&order, &denied.to_string());
1330 return Ok(());
1331 }
1332
1333 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
1334 self.emitter.emit_order_submitted(&order);
1335
1336 let client_order_id = order.client_order_id();
1337 let strategy_id = order.strategy_id();
1338 let emitter = self.emitter.clone();
1339 let clock = self.clock;
1340
1341 let bybit_side = BybitOrderSide::from(order.order_side());
1342 let position_idx = self.resolve_position_idx(
1343 instrument_id,
1344 bybit_side,
1345 order.is_reduce_only(),
1346 tp_sl.position_idx,
1347 );
1348 let venue_position_id =
1349 position_idx.and_then(|idx| make_hedge_venue_position_id(instrument_id, idx as i32));
1350
1351 self.dispatch_state.order_identities.insert(
1352 client_order_id,
1353 OrderIdentity {
1354 instrument_id,
1355 strategy_id,
1356 order_side: order.order_side(),
1357 order_type: order.order_type(),
1358 venue_position_id,
1359 },
1360 );
1361
1362 self.dispatch_state.order_snapshots.insert(
1364 client_order_id,
1365 OrderStateSnapshot {
1366 quantity: order.quantity(),
1367 price: order.price(),
1368 trigger_price: order.trigger_price(),
1369 },
1370 );
1371
1372 let smp_type = self.resolve_smp_type(tp_sl.smp_type);
1373
1374 if self.config.environment == BybitEnvironment::Demo {
1375 let http_client = self.http_client.clone();
1376 let account_id = self.core.account_id;
1377 let order_side = order.order_side();
1378 let order_type = order.order_type();
1379 let quantity = order.quantity();
1380 let time_in_force = order.time_in_force();
1381 let price = order.price();
1382 let trigger_price = order.trigger_price();
1383 let post_only = order.is_post_only();
1384 let reduce_only = order.is_reduce_only();
1385 let is_quote_quantity = order.is_quote_quantity();
1386 let is_leverage = tp_sl.is_leverage;
1387 let bbo_side_type = tp_sl.bbo_side_type;
1388 let bbo_level = tp_sl.bbo_level.clone();
1389 let native_tp_sl = tp_sl.to_native_tp_sl();
1390 let dispatch_state = Arc::clone(&self.dispatch_state);
1391
1392 self.spawn_task("submit_order_http", async move {
1393 let native_tp_sl_ref = (!native_tp_sl.is_empty()).then_some(&native_tp_sl);
1394 let result = http_client
1395 .submit_order(
1396 account_id,
1397 product_type,
1398 instrument_id,
1399 client_order_id,
1400 order_side,
1401 order_type,
1402 quantity,
1403 Some(time_in_force),
1404 price,
1405 trigger_price,
1406 Some(post_only),
1407 reduce_only,
1408 is_quote_quantity,
1409 is_leverage,
1410 position_idx,
1411 bbo_side_type,
1412 bbo_level,
1413 smp_type,
1414 native_tp_sl_ref,
1415 )
1416 .await;
1417
1418 if let Err(e) = result {
1419 if let Some(reason) = submit_rejection_reason(&e) {
1420 dispatch_state.order_identities.remove(&client_order_id);
1421 dispatch_state.order_snapshots.remove(&client_order_id);
1422 let ts_event = clock.get_time_ns();
1423 emitter.emit_order_rejected_event(
1424 strategy_id,
1425 instrument_id,
1426 client_order_id,
1427 reason,
1428 ts_event,
1429 bybit_rejection_due_post_only(reason),
1430 );
1431 anyhow::bail!("submit order rejected: {reason}");
1432 }
1433
1434 log::warn!(
1435 "Submit failure without confirmed venue rejection for {client_order_id}: \
1436 {e}; awaiting reconciliation",
1437 );
1438 return Ok(());
1439 }
1440
1441 Ok(())
1442 });
1443
1444 return Ok(());
1445 }
1446
1447 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1448 let params = Self::build_ws_place_params(
1449 &order,
1450 product_type,
1451 raw_symbol,
1452 &tp_sl,
1453 position_idx,
1454 smp_type,
1455 )?;
1456
1457 let ws_trade = self.ws_trade.clone();
1458 let dispatch_state = Arc::clone(&self.dispatch_state);
1459
1460 self.spawn_task("submit_order", async move {
1461 let req_id = UUID4::new().to_string();
1462 dispatch_state.pending_requests.insert(
1463 req_id.clone(),
1464 (vec![client_order_id], vec![None], PendingOperation::Place),
1465 );
1466
1467 if let Err(e) = ws_trade.place_order_with_id(params, req_id.clone()).await {
1468 dispatch_state.pending_requests.remove(&req_id);
1469 dispatch_state.order_identities.remove(&client_order_id);
1470 dispatch_state.order_snapshots.remove(&client_order_id);
1471 let reason = e.to_string();
1472 emitter.emit_order_rejected_event(
1473 strategy_id,
1474 instrument_id,
1475 client_order_id,
1476 &reason,
1477 clock.get_time_ns(),
1478 false,
1479 );
1480 }
1481
1482 Ok(())
1483 });
1484
1485 Ok(())
1486 }
1487
1488 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1489 if cmd.order_list.client_order_ids.is_empty() {
1490 return Ok(());
1491 }
1492
1493 let tp_sl = match parse_bybit_tp_sl_params(cmd.params.as_ref()) {
1494 Ok(p) => p,
1495 Err(e) => {
1496 let cache = self.core.cache();
1497 let denied = OrderDeniedReason::ValidationFailed {
1498 detail: e.to_string(),
1499 }
1500 .to_string();
1501
1502 for cid in &cmd.order_list.client_order_ids {
1503 if let Some(order) = cache.order(cid) {
1504 self.emitter.emit_order_denied(&order, &denied);
1505 }
1506 }
1507 return Ok(());
1508 }
1509 };
1510
1511 let instrument_id = cmd.instrument_id;
1512 let product_type = self.get_product_type_for_instrument(instrument_id);
1513
1514 if self.config.environment == BybitEnvironment::Demo
1517 && (tp_sl.tp_trigger_price.is_some() || tp_sl.sl_trigger_price.is_some())
1518 {
1519 let cache = self.core.cache();
1520 let denied = OrderDeniedReason::UnsupportedTpSl {
1521 detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
1522 }
1523 .to_string();
1524
1525 for cid in &cmd.order_list.client_order_ids {
1526 if let Some(order) = cache.order(cid) {
1527 self.emitter.emit_order_denied(&order, &denied);
1528 }
1529 }
1530 return Ok(());
1531 }
1532
1533 let strategy_id = cmd.strategy_id;
1534
1535 let mut valid_orders = Vec::with_capacity(cmd.order_list.client_order_ids.len());
1536 {
1537 let cache = self.core.cache();
1538 let order_list_id = cmd.order_list.id;
1539 let list_denied = OrderDeniedReason::OrderListDenied { order_list_id };
1540 let mut denial: Option<(ClientOrderId, OrderDeniedReason, OrderDeniedReason)> = None;
1544
1545 for cid in &cmd.order_list.client_order_ids {
1546 let Some(order) = cache.order(cid) else {
1547 let reason = OrderDeniedReason::OrderListIncomplete { order_list_id };
1548 denial = Some((*cid, reason.clone(), reason));
1549 break;
1550 };
1551
1552 if order.is_closed() {
1553 denial = Some((
1554 *cid,
1555 OrderDeniedReason::ValidationFailed {
1556 detail: format!("cannot submit closed order {cid}"),
1557 },
1558 list_denied,
1559 ));
1560 break;
1561 }
1562
1563 if let Err(reason) = validate_order(&order, product_type) {
1564 denial = Some((*cid, reason, list_denied));
1565 break;
1566 }
1567
1568 if let Err(e) = Self::validate_bbo_params(&order, product_type, &tp_sl) {
1569 denial = Some((
1570 *cid,
1571 OrderDeniedReason::ValidationFailed {
1572 detail: e.to_string(),
1573 },
1574 list_denied,
1575 ));
1576 break;
1577 }
1578
1579 valid_orders.push(order.clone());
1580 }
1581
1582 if let Some((offender, offender_reason, rest_reason)) = denial {
1584 let offender_reason = offender_reason.to_string();
1585 let rest_reason = rest_reason.to_string();
1586
1587 for cid in &cmd.order_list.client_order_ids {
1588 if let Some(order) = cache.order(cid) {
1589 let reason = if *cid == offender {
1590 offender_reason.as_str()
1591 } else {
1592 rest_reason.as_str()
1593 };
1594 self.emitter.emit_order_denied(&order, reason);
1595 }
1596 }
1597 return Ok(());
1598 }
1599 }
1600
1601 if valid_orders.is_empty() {
1602 return Ok(());
1603 }
1604
1605 for order in &valid_orders {
1606 self.emitter.emit_order_submitted(order);
1607 let bybit_side = BybitOrderSide::from(order.order_side());
1608 let position_idx = self.resolve_position_idx(
1609 instrument_id,
1610 bybit_side,
1611 order.is_reduce_only(),
1612 tp_sl.position_idx,
1613 );
1614 let venue_position_id = position_idx
1615 .and_then(|idx| make_hedge_venue_position_id(instrument_id, idx as i32));
1616 self.dispatch_state.order_identities.insert(
1617 order.client_order_id(),
1618 OrderIdentity {
1619 instrument_id,
1620 strategy_id,
1621 order_side: order.order_side(),
1622 order_type: order.order_type(),
1623 venue_position_id,
1624 },
1625 );
1626 self.dispatch_state.order_snapshots.insert(
1627 order.client_order_id(),
1628 OrderStateSnapshot {
1629 quantity: order.quantity(),
1630 price: order.price(),
1631 trigger_price: order.trigger_price(),
1632 },
1633 );
1634 }
1635
1636 let emitter = self.emitter.clone();
1637 let clock = self.clock;
1638
1639 let smp_type = self.resolve_smp_type(tp_sl.smp_type);
1640
1641 if self.config.environment == BybitEnvironment::Demo {
1643 let http_client = self.http_client.clone();
1644 let account_id = self.core.account_id;
1645 let is_leverage = tp_sl.is_leverage;
1646 let bbo_side_type = tp_sl.bbo_side_type;
1647 let bbo_level = tp_sl.bbo_level.clone();
1648 let native_tp_sl = tp_sl.to_native_tp_sl();
1649 let dispatch_state = Arc::clone(&self.dispatch_state);
1650
1651 let order_data: Vec<_> = valid_orders
1652 .iter()
1653 .map(|o| {
1654 let bybit_side = BybitOrderSide::from(o.order_side());
1655 let position_idx = self.resolve_position_idx(
1656 instrument_id,
1657 bybit_side,
1658 o.is_reduce_only(),
1659 tp_sl.position_idx,
1660 );
1661 (
1662 o.client_order_id(),
1663 o.order_side(),
1664 o.order_type(),
1665 o.quantity(),
1666 o.time_in_force(),
1667 o.price(),
1668 o.trigger_price(),
1669 o.is_post_only(),
1670 o.is_reduce_only(),
1671 o.is_quote_quantity(),
1672 position_idx,
1673 )
1674 })
1675 .collect();
1676
1677 self.spawn_task("submit_order_list_http", async move {
1678 let native_tp_sl_ref = (!native_tp_sl.is_empty()).then_some(&native_tp_sl);
1679
1680 for (
1681 cid,
1682 side,
1683 otype,
1684 qty,
1685 tif,
1686 price,
1687 trigger,
1688 post_only,
1689 reduce,
1690 quote_qty,
1691 position_idx,
1692 ) in order_data
1693 {
1694 if let Err(e) = http_client
1695 .submit_order(
1696 account_id,
1697 product_type,
1698 instrument_id,
1699 cid,
1700 side,
1701 otype,
1702 qty,
1703 Some(tif),
1704 price,
1705 trigger,
1706 Some(post_only),
1707 reduce,
1708 quote_qty,
1709 is_leverage,
1710 position_idx,
1711 bbo_side_type,
1712 bbo_level.clone(),
1713 smp_type,
1714 native_tp_sl_ref,
1715 )
1716 .await
1717 {
1718 if let Some(reason) = submit_rejection_reason(&e) {
1719 dispatch_state.order_identities.remove(&cid);
1720 dispatch_state.order_snapshots.remove(&cid);
1721 let ts_event = clock.get_time_ns();
1722 emitter.emit_order_rejected_event(
1723 strategy_id,
1724 instrument_id,
1725 cid,
1726 reason,
1727 ts_event,
1728 bybit_rejection_due_post_only(reason),
1729 );
1730 continue;
1731 }
1732
1733 log::warn!(
1734 "Submit failure without confirmed venue rejection for {cid}: {e}; \
1735 awaiting reconciliation",
1736 );
1737 }
1738 }
1739 Ok(())
1740 });
1741
1742 return Ok(());
1743 }
1744
1745 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1747
1748 let mut order_params = Vec::with_capacity(valid_orders.len());
1749 let mut client_order_ids = Vec::with_capacity(valid_orders.len());
1750
1751 for order in &valid_orders {
1752 let bybit_side = BybitOrderSide::from(order.order_side());
1753 let position_idx = self.resolve_position_idx(
1754 instrument_id,
1755 bybit_side,
1756 order.is_reduce_only(),
1757 tp_sl.position_idx,
1758 );
1759 let params = Self::build_ws_place_params(
1760 order,
1761 product_type,
1762 raw_symbol,
1763 &tp_sl,
1764 position_idx,
1765 smp_type,
1766 )
1767 .expect("validated above");
1768 order_params.push(params);
1769 client_order_ids.push(order.client_order_id());
1770 }
1771
1772 let ws_trade = self.ws_trade.clone();
1773 let dispatch_state = Arc::clone(&self.dispatch_state);
1774
1775 self.spawn_task("submit_order_list", async move {
1776 let req_ids = BybitWebSocketClient::batch_request_ids(product_type, order_params.len());
1777 for (req_id, chunk_cids) in req_ids.iter().zip(
1778 client_order_ids
1779 .chunks(batch_send_limit(product_type))
1780 .map(|chunk| chunk.to_vec()),
1781 ) {
1782 let chunk_voids = vec![None; chunk_cids.len()];
1783 dispatch_state.pending_requests.insert(
1784 req_id.clone(),
1785 (chunk_cids, chunk_voids, PendingOperation::Place),
1786 );
1787 }
1788
1789 if let Err(e) = ws_trade
1790 .batch_place_orders_with_ids(order_params, req_ids.clone())
1791 .await
1792 {
1793 for req_id in req_ids {
1794 dispatch_state.pending_requests.remove(&req_id);
1795 }
1796 let reason = e.to_string();
1797 let ts_event = clock.get_time_ns();
1798
1799 for client_order_id in client_order_ids {
1800 dispatch_state.order_identities.remove(&client_order_id);
1801 dispatch_state.order_snapshots.remove(&client_order_id);
1802 emitter.emit_order_rejected_event(
1803 strategy_id,
1804 instrument_id,
1805 client_order_id,
1806 &reason,
1807 ts_event,
1808 false,
1809 );
1810 }
1811 }
1812 Ok(())
1813 });
1814
1815 Ok(())
1816 }
1817
1818 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1819 let instrument_id = cmd.instrument_id;
1820 let product_type = self.get_product_type_for_instrument(instrument_id);
1821 let client_order_id = cmd.client_order_id;
1822 let strategy_id = cmd.strategy_id;
1823 let venue_order_id = cmd.venue_order_id;
1824 let emitter = self.emitter.clone();
1825 let clock = self.clock;
1826
1827 let has_order_iv = cmd
1828 .params
1829 .as_ref()
1830 .and_then(|p| p.get("order_iv"))
1831 .is_some();
1832
1833 if self.config.environment == BybitEnvironment::Demo && has_order_iv {
1834 log::warn!(
1835 "Modify command failed local validation for {client_order_id}: {}",
1836 "Option params (order_iv) are not supported in demo mode",
1837 );
1838 return Ok(());
1839 }
1840
1841 if self.config.environment == BybitEnvironment::Demo {
1842 let http_client = self.http_client.clone();
1843 let account_id = self.core.account_id;
1844 let quantity = cmd.quantity;
1845 let price = cmd.price;
1846
1847 self.spawn_task("modify_order_http", async move {
1848 let result = http_client
1849 .modify_order(
1850 account_id,
1851 product_type,
1852 instrument_id,
1853 Some(client_order_id),
1854 venue_order_id,
1855 quantity,
1856 price,
1857 )
1858 .await;
1859
1860 if let Err(e) = result {
1861 match classify_modify_http_failure(&e) {
1862 CommandFailure::VenueRejected(reason) => {
1863 let ts_event = clock.get_time_ns();
1864 emitter.emit_order_modify_rejected_event(
1865 strategy_id,
1866 instrument_id,
1867 client_order_id,
1868 venue_order_id,
1869 &format!("modify-order-error: {reason}"),
1870 ts_event,
1871 );
1872 anyhow::bail!("modify order rejected: {reason}");
1873 }
1874 CommandFailure::NotSent(reason) => {
1875 log::warn!(
1876 "HTTP modify command failed local validation for {client_order_id}: {reason}"
1877 );
1878 }
1879 CommandFailure::Ambiguous(reason) => {
1880 log::warn!(
1881 "Ambiguous HTTP modify failure for {client_order_id}, awaiting reconciliation: {reason}"
1882 );
1883 }
1884 }
1885 }
1886
1887 Ok(())
1888 });
1889
1890 return Ok(());
1891 }
1892
1893 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1894
1895 let order_iv = if let Some(value) = cmd.params.as_ref().and_then(|p| p.get("order_iv")) {
1896 match get_price_str(cmd.params.as_ref().unwrap(), "order_iv") {
1897 Some(s) => Some(s),
1898 None => {
1899 log::warn!(
1900 "Modify command failed local validation for {client_order_id}: invalid type for 'order_iv': {value}, expected string or number",
1901 );
1902 return Ok(());
1903 }
1904 }
1905 } else {
1906 None
1907 };
1908
1909 let params = BybitWsAmendOrderParams {
1910 category: product_type,
1911 symbol: Ustr::from(raw_symbol),
1912 order_id: cmd.venue_order_id.map(|v| v.to_string()),
1913 order_link_id: Some(cmd.client_order_id.to_string()),
1914 qty: cmd.quantity.map(|q| q.to_string()),
1915 price: cmd.price.map(|p| p.to_string()),
1916 trigger_price: None,
1917 take_profit: None,
1918 stop_loss: None,
1919 tp_trigger_by: None,
1920 sl_trigger_by: None,
1921 order_iv,
1922 };
1923
1924 let ws_trade = self.ws_trade.clone();
1925 let dispatch_state = Arc::clone(&self.dispatch_state);
1926
1927 self.spawn_task("modify_order", async move {
1928 let req_id = UUID4::new().to_string();
1929 dispatch_state.pending_requests.insert(
1930 req_id.clone(),
1931 (
1932 vec![client_order_id],
1933 vec![venue_order_id],
1934 PendingOperation::Amend,
1935 ),
1936 );
1937
1938 if let Err(e) = ws_trade.amend_order_with_id(params, req_id.clone()).await {
1939 dispatch_state.pending_requests.remove(&req_id);
1940 emitter.emit_order_modify_rejected_event(
1941 strategy_id,
1942 instrument_id,
1943 client_order_id,
1944 venue_order_id,
1945 &e.to_string(),
1946 clock.get_time_ns(),
1947 );
1948 }
1949
1950 Ok(())
1951 });
1952
1953 Ok(())
1954 }
1955
1956 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1957 let instrument_id = cmd.instrument_id;
1958 let product_type = self.get_product_type_for_instrument(instrument_id);
1959 let client_order_id = cmd.client_order_id;
1960 let strategy_id = cmd.strategy_id;
1961 let venue_order_id = cmd.venue_order_id;
1962 let emitter = self.emitter.clone();
1963 let clock = self.clock;
1964
1965 if self.config.environment == BybitEnvironment::Demo {
1966 let http_client = self.http_client.clone();
1967 let account_id = self.core.account_id;
1968
1969 self.spawn_task("cancel_order_http", async move {
1970 let result = http_client
1971 .cancel_order(
1972 account_id,
1973 product_type,
1974 instrument_id,
1975 Some(client_order_id),
1976 venue_order_id,
1977 )
1978 .await;
1979
1980 if let Err(e) = result {
1981 match classify_cancel_http_failure(&e) {
1982 CommandFailure::VenueRejected(reason) => {
1983 let ts_event = clock.get_time_ns();
1984 emitter.emit_order_cancel_rejected_event(
1985 strategy_id,
1986 instrument_id,
1987 client_order_id,
1988 venue_order_id,
1989 &format!("cancel-order-error: {reason}"),
1990 ts_event,
1991 );
1992 anyhow::bail!("cancel order rejected: {reason}");
1993 }
1994 CommandFailure::NotSent(reason) => {
1995 log::warn!(
1996 "HTTP cancel command failed local validation for {client_order_id}: {reason}"
1997 );
1998 }
1999 CommandFailure::Ambiguous(reason) => {
2000 log::warn!(
2001 "Ambiguous HTTP cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
2002 );
2003 }
2004 }
2005 }
2006
2007 Ok(())
2008 });
2009
2010 return Ok(());
2011 }
2012
2013 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
2014
2015 let params = BybitWsCancelOrderParams {
2016 category: product_type,
2017 symbol: Ustr::from(raw_symbol),
2018 order_id: cmd.venue_order_id.map(|v| v.to_string()),
2019 order_link_id: Some(cmd.client_order_id.to_string()),
2020 };
2021
2022 let ws_trade = self.ws_trade.clone();
2023 let dispatch_state = Arc::clone(&self.dispatch_state);
2024
2025 self.spawn_task("cancel_order", async move {
2026 let req_id = UUID4::new().to_string();
2027 dispatch_state.pending_requests.insert(
2028 req_id.clone(),
2029 (
2030 vec![client_order_id],
2031 vec![venue_order_id],
2032 PendingOperation::Cancel,
2033 ),
2034 );
2035
2036 if let Err(e) = ws_trade.cancel_order_with_id(params, req_id.clone()).await {
2037 dispatch_state.pending_requests.remove(&req_id);
2038 emitter.emit_order_cancel_rejected_event(
2039 strategy_id,
2040 instrument_id,
2041 client_order_id,
2042 venue_order_id,
2043 &e.to_string(),
2044 clock.get_time_ns(),
2045 );
2046 }
2047
2048 Ok(())
2049 });
2050
2051 Ok(())
2052 }
2053
2054 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
2055 if cmd.order_side.is_some() {
2056 let cancels: Vec<CancelOrder> = {
2059 let cache = self.core.cache();
2060 cache
2061 .orders_open(None, Some(&cmd.instrument_id), None, None, cmd.order_side)
2062 .iter()
2063 .map(|order| CancelOrder {
2064 trader_id: order.trader_id(),
2065 client_id: cmd.client_id,
2066 strategy_id: order.strategy_id(),
2067 instrument_id: order.instrument_id(),
2068 client_order_id: order.client_order_id(),
2069 venue_order_id: order.venue_order_id(),
2070 command_id: cmd.command_id,
2071 ts_init: cmd.ts_init,
2072 params: cmd.params.clone(),
2073 correlation_id: cmd.correlation_id,
2074 causation_id: cmd.causation_id,
2075 })
2076 .collect()
2077 };
2078
2079 if cancels.is_empty() {
2080 log::debug!("No open orders to cancel for {}", cmd.instrument_id);
2081 return Ok(());
2082 }
2083
2084 return self.batch_cancel_orders(BatchCancelOrders {
2085 trader_id: cmd.trader_id,
2086 client_id: cmd.client_id,
2087 strategy_id: cmd.strategy_id,
2088 instrument_id: cmd.instrument_id,
2089 cancels,
2090 command_id: cmd.command_id,
2091 ts_init: cmd.ts_init,
2092 params: cmd.params,
2093 correlation_id: cmd.correlation_id,
2094 causation_id: cmd.causation_id,
2095 });
2096 }
2097
2098 let instrument_id = cmd.instrument_id;
2099 let product_type = self.get_product_type_for_instrument(instrument_id);
2100 let account_id = self.core.account_id;
2101 let http_client = self.http_client.clone();
2102 let emitter = self.emitter.clone();
2103
2104 let mut orders: Vec<_> = self
2105 .core
2106 .cache()
2107 .orders_open(None, Some(&instrument_id), None, Some(&account_id), None)
2108 .into_iter()
2109 .map(|order| {
2110 (
2111 order.client_order_id(),
2112 order.venue_order_id(),
2113 order.strategy_id(),
2114 )
2115 })
2116 .collect();
2117
2118 let timeout = Duration::from_secs(self.config.http_timeout_secs);
2119 let interval = Duration::from_millis(self.config.retry_delay_initial_ms.max(1));
2120
2121 self.spawn_task("cancel_all_orders", async move {
2122 let emit_canceled = |client_order_id, strategy_id, report: &OrderStatusReport| {
2124 if report.order_status == OrderStatus::Canceled {
2125 let canceled = OrderCanceled::new(
2126 emitter.trader_id(),
2127 strategy_id,
2128 instrument_id,
2129 client_order_id,
2130 UUID4::new(),
2131 report.ts_last,
2132 report.ts_init,
2133 false,
2134 Some(report.venue_order_id),
2135 Some(account_id),
2136 None,
2137 );
2138 emitter.send_order_event(OrderEventAny::Canceled(canceled));
2139 }
2140 };
2141
2142 let confirmation = async {
2143 let reports = http_client
2144 .cancel_all_orders(account_id, product_type, instrument_id)
2145 .await
2146 .with_context(|| {
2147 format!("failed to cancel Bybit orders for {instrument_id}")
2148 })?;
2149
2150 for report in reports {
2151 if !report.order_status.is_closed() {
2152 continue;
2153 }
2154
2155 if let Some(index) =
2156 orders
2157 .iter()
2158 .position(|(client_order_id, venue_order_id, _)| {
2159 venue_order_id.map_or(
2160 report.client_order_id == Some(*client_order_id),
2161 |id| id == report.venue_order_id,
2162 )
2163 })
2164 {
2165 let (client_order_id, _, strategy_id) = orders.swap_remove(index);
2166 emit_canceled(client_order_id, strategy_id, &report);
2167 }
2168 }
2169
2170 while !orders.is_empty() {
2171 for index in (0..orders.len()).rev() {
2172 let (client_order_id, venue_order_id, strategy_id) = orders[index];
2173 if let Some(report) = http_client
2174 .query_order(
2175 account_id,
2176 product_type,
2177 instrument_id,
2178 Some(client_order_id),
2179 venue_order_id,
2180 )
2181 .await?
2182 && report.order_status.is_closed()
2183 {
2184 orders.swap_remove(index);
2185 emit_canceled(client_order_id, strategy_id, &report);
2186 }
2187 }
2188
2189 if !orders.is_empty() {
2190 tokio::time::sleep(interval).await;
2191 }
2192 }
2193
2194 Ok(())
2195 };
2196
2197 tokio::time::timeout(timeout, confirmation)
2198 .await
2199 .context("timed out confirming Bybit cancel-all orders")?
2200 });
2201
2202 Ok(())
2203 }
2204
2205 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
2206 if cmd.cancels.is_empty() {
2207 return Ok(());
2208 }
2209
2210 let instrument_id = cmd.instrument_id;
2211 let product_type = self.get_product_type_for_instrument(instrument_id);
2212 let emitter = self.emitter.clone();
2213 let clock = self.clock;
2214
2215 if self.config.environment == BybitEnvironment::Demo {
2217 let http_client = self.http_client.clone();
2218 let account_id = self.core.account_id;
2219 let cancels: Vec<_> = cmd
2220 .cancels
2221 .iter()
2222 .map(|c| (c.strategy_id, c.client_order_id, c.venue_order_id))
2223 .collect();
2224
2225 self.spawn_task("batch_cancel_orders_http", async move {
2226 for (strategy_id, client_order_id, venue_order_id) in cancels {
2227 if let Err(e) = http_client
2228 .cancel_order(
2229 account_id,
2230 product_type,
2231 instrument_id,
2232 Some(client_order_id),
2233 venue_order_id,
2234 )
2235 .await
2236 {
2237 match classify_cancel_http_failure(&e) {
2238 CommandFailure::VenueRejected(reason) => {
2239 let ts_event = clock.get_time_ns();
2240 emitter.emit_order_cancel_rejected_event(
2241 strategy_id,
2242 instrument_id,
2243 client_order_id,
2244 venue_order_id,
2245 &format!("cancel-order-error: {reason}"),
2246 ts_event,
2247 );
2248 }
2249 CommandFailure::NotSent(reason) => {
2250 log::warn!(
2251 "HTTP batch cancel command failed local validation for {client_order_id}: {reason}"
2252 );
2253 }
2254 CommandFailure::Ambiguous(reason) => {
2255 log::warn!(
2256 "Ambiguous HTTP batch cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
2257 );
2258 }
2259 }
2260 }
2261 }
2262 Ok(())
2263 });
2264
2265 return Ok(());
2266 }
2267
2268 let raw_symbol = Ustr::from(extract_raw_symbol(instrument_id.symbol.as_str()));
2269
2270 let mut cancel_params = Vec::with_capacity(cmd.cancels.len());
2271 let strategy_ids: Vec<_> = cmd.cancels.iter().map(|c| c.strategy_id).collect();
2272 let client_order_ids: Vec<_> = cmd.cancels.iter().map(|c| c.client_order_id).collect();
2273 let venue_order_ids: Vec<_> = cmd.cancels.iter().map(|c| c.venue_order_id).collect();
2274
2275 for cancel in &cmd.cancels {
2276 cancel_params.push(BybitWsCancelOrderParams {
2277 category: product_type,
2278 symbol: raw_symbol,
2279 order_id: cancel.venue_order_id.map(|v| v.to_string()),
2280 order_link_id: Some(cancel.client_order_id.to_string()),
2281 });
2282 }
2283
2284 let ws_trade = self.ws_trade.clone();
2285 let dispatch_state = Arc::clone(&self.dispatch_state);
2286
2287 self.spawn_task("batch_cancel_orders", async move {
2288 let req_ids =
2289 BybitWebSocketClient::batch_request_ids(product_type, cancel_params.len());
2290 let chunk_limit = batch_send_limit(product_type);
2291
2292 for (req_id, (chunk_cids, chunk_voids)) in req_ids.iter().zip(
2293 client_order_ids
2294 .chunks(chunk_limit)
2295 .map(|chunk| chunk.to_vec())
2296 .zip(
2297 venue_order_ids
2298 .chunks(chunk_limit)
2299 .map(|chunk| chunk.to_vec()),
2300 ),
2301 ) {
2302 dispatch_state.pending_requests.insert(
2303 req_id.clone(),
2304 (chunk_cids, chunk_voids, PendingOperation::Cancel),
2305 );
2306 }
2307
2308 if let Err(e) = ws_trade
2309 .batch_cancel_orders_with_ids(cancel_params, req_ids.clone())
2310 .await
2311 {
2312 for req_id in req_ids {
2313 dispatch_state.pending_requests.remove(&req_id);
2314 }
2315 let reason = e.to_string();
2316 let ts_event = clock.get_time_ns();
2317
2318 for ((strategy_id, client_order_id), venue_order_id) in strategy_ids
2319 .into_iter()
2320 .zip(client_order_ids)
2321 .zip(venue_order_ids)
2322 {
2323 emitter.emit_order_cancel_rejected_event(
2324 strategy_id,
2325 instrument_id,
2326 client_order_id,
2327 venue_order_id,
2328 &reason,
2329 ts_event,
2330 );
2331 }
2332 }
2333 Ok(())
2334 });
2335
2336 Ok(())
2337 }
2338}
2339
2340fn validate_order(
2341 order: &OrderAny,
2342 product_type: BybitProductType,
2343) -> Result<(), OrderDeniedReason> {
2344 let (bybit_order_type, is_conditional) =
2345 BybitExecutionClient::map_order_type(order.order_type()).map_err(|_| {
2346 OrderDeniedReason::UnsupportedOrderType {
2347 order_type: order.order_type(),
2348 }
2349 })?;
2350
2351 map_time_in_force(
2352 bybit_order_type,
2353 Some(order.time_in_force()),
2354 Some(order.is_post_only()),
2355 )
2356 .map_err(OrderDeniedReason::UnsupportedTimeInForce)?;
2357
2358 if product_type == BybitProductType::Option && is_conditional {
2359 return Err(OrderDeniedReason::UnsupportedOrderType {
2360 order_type: order.order_type(),
2361 });
2362 }
2363
2364 Ok(())
2365}
2366
2367fn classify_cancel_http_failure(error: &anyhow::Error) -> CommandFailure {
2368 if error
2369 .chain()
2370 .any(|cause| cause.downcast_ref::<BybitCancelOrderError>().is_some())
2371 {
2372 return CommandFailure::ambiguous(error.to_string());
2373 }
2374
2375 classify_http_failure(error)
2376}
2377
2378fn classify_modify_http_failure(error: &anyhow::Error) -> CommandFailure {
2379 if error
2380 .chain()
2381 .any(|cause| cause.downcast_ref::<BybitModifyOrderError>().is_some())
2382 {
2383 return CommandFailure::ambiguous(error.to_string());
2384 }
2385
2386 classify_http_failure(error)
2387}
2388
2389fn classify_http_failure(error: &anyhow::Error) -> CommandFailure {
2390 let reason = error.to_string();
2391
2392 for cause in error.chain() {
2393 let Some(http_error) = cause.downcast_ref::<BybitHttpError>() else {
2394 continue;
2395 };
2396
2397 return match http_error {
2398 BybitHttpError::BybitError { error_code, .. }
2399 if is_bybit_ambiguous_order_error_code(i64::from(*error_code)) =>
2400 {
2401 CommandFailure::Ambiguous(reason)
2402 }
2403 BybitHttpError::BybitError { .. } => CommandFailure::VenueRejected(reason),
2404 BybitHttpError::MissingCredentials
2405 | BybitHttpError::ValidationError(_)
2406 | BybitHttpError::BuildError(_) => CommandFailure::NotSent(reason),
2407 BybitHttpError::JsonError(_)
2408 | BybitHttpError::Canceled(_)
2409 | BybitHttpError::NetworkError(_)
2410 | BybitHttpError::UnexpectedStatus { .. } => CommandFailure::Ambiguous(reason),
2411 };
2412 }
2413
2414 CommandFailure::NotSent(reason)
2415}
2416
2417fn cache_reconciliation_order_identity(
2418 core: &ExecutionClientCore,
2419 state: &WsDispatchState,
2420 report: &OrderStatusReport,
2421) {
2422 let Some(client_order_id) = report.client_order_id else {
2423 return;
2424 };
2425
2426 if report.order_status.is_closed() {
2427 state.order_identities.remove(&client_order_id);
2428 return;
2429 }
2430
2431 let cache = core.cache();
2432
2433 let Some(order) = cache.order(&client_order_id) else {
2434 return;
2435 };
2436
2437 let identity = OrderIdentity {
2438 instrument_id: report.instrument_id,
2439 strategy_id: order.strategy_id(),
2440 order_side: order.order_side(),
2441 order_type: order.order_type(),
2442 venue_position_id: report.venue_position_id,
2443 };
2444
2445 state.order_identities.insert(client_order_id, identity);
2446}
2447
2448#[cfg(test)]
2449mod tests {
2450 use std::{
2451 cell::RefCell,
2452 rc::Rc,
2453 sync::atomic::{AtomicBool, Ordering},
2454 time::Duration,
2455 };
2456
2457 use axum::{
2458 Json, Router,
2459 routing::{get, post},
2460 };
2461 use nautilus_common::{
2462 cache::Cache,
2463 clients::ExecutionClient,
2464 messages::{
2465 ExecutionEvent,
2466 execution::{
2467 BatchCancelOrders, CancelAllOrders, CancelOrder, ModifyOrder, SubmitOrder,
2468 SubmitOrderList,
2469 },
2470 },
2471 testing::wait_until_async,
2472 };
2473 use nautilus_core::{Params, UUID4};
2474 use nautilus_live::ExecutionClientCore;
2475 use nautilus_model::{
2476 enums::{AccountType, OrderSide, OrderStatus, TimeInForce},
2477 events::OrderEventAny,
2478 identifiers::{ClientOrderId, OrderListId, PositionId, StrategyId, TraderId, VenueOrderId},
2479 instruments::stubs::crypto_perpetual_ethusdt,
2480 orders::{OrderList, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
2481 types::Quantity,
2482 };
2483 use rstest::rstest;
2484
2485 use super::*;
2486 use crate::common::{
2487 consts::{BYBIT_CLIENT_ID, BYBIT_VENUE},
2488 enums::{BybitMarketUnit, BybitTimeInForce},
2489 };
2490
2491 fn test_execution_client() -> (BybitExecutionClient, Rc<RefCell<Cache>>) {
2492 let cache = Rc::new(RefCell::new(Cache::default()));
2493 let core = ExecutionClientCore::new(
2494 TraderId::from("TESTER-001"),
2495 *BYBIT_CLIENT_ID,
2496 *BYBIT_VENUE,
2497 OmsType::Netting,
2498 AccountId::from("BYBIT-001"),
2499 AccountType::Margin,
2500 None,
2501 cache.clone(),
2502 );
2503 let config = BybitExecutionClientConfig {
2504 api_key: Some("test_key".into()),
2505 api_secret: Some("test_secret".into()),
2506 ..Default::default()
2507 };
2508
2509 (BybitExecutionClient::new(core, config).unwrap(), cache)
2510 }
2511
2512 async fn wait_for_spawned_tasks(client: &BybitExecutionClient) {
2513 for _ in 0..20 {
2514 if client.pending_tasks.all_finished() {
2515 return;
2516 }
2517
2518 tokio::time::sleep(Duration::from_millis(25)).await;
2519 }
2520
2521 panic!("timed out waiting for spawned Bybit execution tasks");
2522 }
2523
2524 fn assert_next_submitted(
2525 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2526 client_order_id: ClientOrderId,
2527 ) {
2528 let event = rx.try_recv().expect("expected OrderSubmitted event");
2529 assert!(
2530 matches!(event, ExecutionEvent::Order(OrderEventAny::Submitted(ref submitted)) if submitted.client_order_id == client_order_id),
2531 "expected OrderSubmitted for {client_order_id}, was {event:?}",
2532 );
2533 }
2534
2535 fn assert_next_rejected(
2536 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2537 client_order_id: ClientOrderId,
2538 ) {
2539 let event = rx.try_recv().expect("expected OrderRejected event");
2540 assert!(
2541 matches!(event, ExecutionEvent::Order(OrderEventAny::Rejected(ref rejected))
2542 if rejected.client_order_id == client_order_id),
2543 "expected OrderRejected for {client_order_id}, was {event:?}",
2544 );
2545 }
2546
2547 fn assert_next_cancel_rejected(
2548 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2549 client_order_id: ClientOrderId,
2550 ) {
2551 let event = rx.try_recv().expect("expected OrderCancelRejected event");
2552 assert!(
2553 matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2554 if rejected.client_order_id == client_order_id),
2555 "expected OrderCancelRejected for {client_order_id}, was {event:?}",
2556 );
2557 }
2558
2559 fn assert_next_modify_rejected(
2560 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2561 client_order_id: ClientOrderId,
2562 ) {
2563 let event = rx.try_recv().expect("expected OrderModifyRejected event");
2564 assert!(
2565 matches!(event, ExecutionEvent::Order(OrderEventAny::ModifyRejected(ref rejected))
2566 if rejected.client_order_id == client_order_id),
2567 "expected OrderModifyRejected for {client_order_id}, was {event:?}",
2568 );
2569 }
2570
2571 fn assert_no_order_cancel_rejected(
2572 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2573 ) {
2574 while let Ok(event) = rx.try_recv() {
2575 assert!(
2576 !matches!(
2577 event,
2578 ExecutionEvent::Order(OrderEventAny::CancelRejected(_))
2579 ),
2580 "unexpected OrderCancelRejected event: {event:?}",
2581 );
2582 }
2583 }
2584
2585 fn assert_no_order_modify_rejected(
2586 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2587 ) {
2588 while let Ok(event) = rx.try_recv() {
2589 assert!(
2590 !matches!(
2591 event,
2592 ExecutionEvent::Order(OrderEventAny::ModifyRejected(_))
2593 ),
2594 "unexpected OrderModifyRejected event: {event:?}",
2595 );
2596 }
2597 }
2598
2599 fn cancel_command(client_order_id: ClientOrderId) -> CancelOrder {
2600 CancelOrder::new(
2601 TraderId::from("TESTER-001"),
2602 Some(*BYBIT_CLIENT_ID),
2603 StrategyId::from("S-001"),
2604 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
2605 client_order_id,
2606 Some(VenueOrderId::from("venue-cancel-1")),
2607 UUID4::new(),
2608 UnixNanos::default(),
2609 None,
2610 None,
2611 )
2612 }
2613
2614 fn modify_command(client_order_id: ClientOrderId, params: Option<Params>) -> ModifyOrder {
2615 ModifyOrder::new(
2616 TraderId::from("TESTER-001"),
2617 Some(*BYBIT_CLIENT_ID),
2618 StrategyId::from("S-001"),
2619 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
2620 client_order_id,
2621 Some(VenueOrderId::from("venue-modify-1")),
2622 Some(Quantity::from("1")),
2623 Some(Price::from("10001.00")),
2624 None,
2625 UUID4::new(),
2626 UnixNanos::default(),
2627 params,
2628 None,
2629 )
2630 }
2631
2632 #[rstest]
2633 fn test_cancel_http_failure_classification_matches_policy() {
2634 let venue_reject = anyhow::Error::from(BybitHttpError::BybitError {
2635 error_code: 110001,
2636 message: "Order does not exist".to_string(),
2637 });
2638 assert_eq!(
2639 classify_cancel_http_failure(&venue_reject),
2640 CommandFailure::VenueRejected(venue_reject.to_string()),
2641 );
2642
2643 let rate_limit = anyhow::Error::from(BybitHttpError::BybitError {
2644 error_code: 10006,
2645 message: "Too many visits".to_string(),
2646 });
2647 assert_eq!(
2648 classify_cancel_http_failure(&rate_limit),
2649 CommandFailure::VenueRejected(rate_limit.to_string()),
2650 );
2651
2652 let post_lookup = anyhow::Error::from(BybitCancelOrderError::PostCancelLookup {
2653 source: anyhow::anyhow!("history lookup failed"),
2654 });
2655 assert_eq!(
2656 classify_cancel_http_failure(&post_lookup),
2657 CommandFailure::Ambiguous(post_lookup.to_string()),
2658 );
2659
2660 let transport = anyhow::Error::from(BybitHttpError::NetworkError(
2661 "connection closed".to_string(),
2662 ));
2663 assert_eq!(
2664 classify_cancel_http_failure(&transport),
2665 CommandFailure::Ambiguous(transport.to_string()),
2666 );
2667 }
2668
2669 #[rstest]
2670 fn test_modify_http_failure_classification_matches_policy() {
2671 let venue_reject = anyhow::Error::from(BybitHttpError::BybitError {
2672 error_code: 110003,
2673 message: "Order price exceeds allowable range".to_string(),
2674 });
2675 assert_eq!(
2676 classify_modify_http_failure(&venue_reject),
2677 CommandFailure::VenueRejected(venue_reject.to_string()),
2678 );
2679
2680 let server_error = anyhow::Error::from(BybitHttpError::BybitError {
2681 error_code: 10016,
2682 message: "Server error".to_string(),
2683 });
2684 assert_eq!(
2685 classify_modify_http_failure(&server_error),
2686 CommandFailure::Ambiguous(server_error.to_string()),
2687 );
2688
2689 let post_lookup = anyhow::Error::from(BybitModifyOrderError::PostModifyLookup {
2690 source: anyhow::anyhow!("realtime lookup failed"),
2691 });
2692 assert_eq!(
2693 classify_modify_http_failure(&post_lookup),
2694 CommandFailure::Ambiguous(post_lookup.to_string()),
2695 );
2696
2697 let status = anyhow::Error::from(BybitHttpError::UnexpectedStatus {
2698 status: 503,
2699 body: "service unavailable".to_string(),
2700 });
2701 assert_eq!(
2702 classify_modify_http_failure(&status),
2703 CommandFailure::Ambiguous(status.to_string()),
2704 );
2705 }
2706
2707 #[rstest]
2708 #[tokio::test]
2709 async fn test_ws_cancel_queue_failure_emits_cancel_rejected() {
2710 let (mut client, _cache) = test_execution_client();
2711 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2712 client.emitter.set_sender(tx);
2713 client.ws_trade.close().await.unwrap();
2714 let client_order_id = ClientOrderId::from("O-CANCEL-WS-FAIL");
2715
2716 client
2717 .cancel_order(cancel_command(client_order_id))
2718 .unwrap();
2719 wait_for_spawned_tasks(&client).await;
2720
2721 assert_next_cancel_rejected(&mut rx, client_order_id);
2722 }
2723
2724 #[rstest]
2725 #[tokio::test]
2726 async fn test_ws_modify_queue_failure_emits_modify_rejected() {
2727 let (mut client, _cache) = test_execution_client();
2728 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2729 client.emitter.set_sender(tx);
2730 client.ws_trade.close().await.unwrap();
2731 let client_order_id = ClientOrderId::from("O-MODIFY-WS-FAIL");
2732
2733 client
2734 .modify_order(modify_command(client_order_id, None))
2735 .unwrap();
2736 wait_for_spawned_tasks(&client).await;
2737
2738 assert_next_modify_rejected(&mut rx, client_order_id);
2739 }
2740
2741 #[rstest]
2742 #[tokio::test]
2743 async fn test_http_cancel_local_validation_failure_does_not_emit_cancel_rejected() {
2744 let (mut client, _cache) = test_execution_client();
2745 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2746 client.emitter.set_sender(tx);
2747 client.config.environment = BybitEnvironment::Demo;
2748
2749 client
2750 .cancel_order(cancel_command(ClientOrderId::from(
2751 "O-CANCEL-LOCAL-VALIDATION",
2752 )))
2753 .unwrap();
2754 wait_for_spawned_tasks(&client).await;
2755
2756 assert_no_order_cancel_rejected(&mut rx);
2757 }
2758
2759 #[rstest]
2760 #[tokio::test]
2761 async fn test_modify_local_validation_failure_does_not_emit_modify_rejected() {
2762 let (mut client, _cache) = test_execution_client();
2763 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2764 client.emitter.set_sender(tx);
2765
2766 let params =
2767 Params::new([("order_iv".to_string(), serde_json::json!({ "bad": true }))].into());
2768
2769 client
2770 .modify_order(modify_command(
2771 ClientOrderId::from("O-MODIFY-LOCAL-VALIDATION"),
2772 Some(params),
2773 ))
2774 .unwrap();
2775
2776 assert_no_order_modify_rejected(&mut rx);
2777 }
2778
2779 #[rstest]
2780 #[case(OrderType::Limit, false)]
2781 #[case(OrderType::Limit, true)]
2782 #[case(OrderType::Market, false)]
2783 #[case(OrderType::StopLimit, false)]
2784 #[case(OrderType::StopMarket, false)]
2785 #[case(OrderType::LimitIfTouched, false)]
2786 #[case(OrderType::MarketIfTouched, false)]
2787 #[tokio::test]
2788 async fn test_submit_order_unsupported_time_in_force_denied(
2789 #[case] order_type: OrderType,
2790 #[case] post_only: bool,
2791 #[values(
2792 BybitEnvironment::Demo,
2793 BybitEnvironment::Mainnet,
2794 BybitEnvironment::Testnet
2795 )]
2796 environment: BybitEnvironment,
2797 #[values(TimeInForce::Day, TimeInForce::AtTheOpen, TimeInForce::AtTheClose)]
2798 time_in_force: TimeInForce,
2799 ) {
2800 let (mut client, cache) = test_execution_client();
2801 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2802 client.emitter.set_sender(tx);
2803 client.config.environment = environment;
2804 let client_order_id = ClientOrderId::from("O-TIF-DENIED");
2805 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2806 let order = OrderTestBuilder::new(order_type)
2807 .instrument_id(instrument_id)
2808 .client_order_id(client_order_id)
2809 .side(OrderSide::Buy)
2810 .quantity(Quantity::from("0.02"))
2811 .price(Price::from("10000.00"))
2812 .trigger_price(Price::from("10000.00"))
2813 .time_in_force(time_in_force)
2814 .post_only(post_only)
2815 .build();
2816
2817 let command = SubmitOrder::new(
2818 order.trader_id(),
2819 Some(*BYBIT_CLIENT_ID),
2820 order.strategy_id(),
2821 instrument_id,
2822 client_order_id,
2823 order.init_event().clone(),
2824 None,
2825 None,
2826 None,
2827 UUID4::new(),
2828 UnixNanos::default(),
2829 None,
2830 );
2831 cache
2832 .borrow_mut()
2833 .add_order(order, None, None, false)
2834 .unwrap();
2835 let ts_before = client.clock.get_time_ns();
2836
2837 client.submit_order(command).unwrap();
2838
2839 let ts_after = client.clock.get_time_ns();
2840
2841 let ExecutionEvent::Order(OrderEventAny::Denied(event)) = rx.try_recv().unwrap() else {
2842 panic!("Expected OrderDenied before submission");
2843 };
2844
2845 assert_eq!(event.trader_id, TraderId::from("TESTER-001"));
2846 assert_eq!(event.strategy_id, StrategyId::from("S-001"));
2847 assert_eq!(event.instrument_id, instrument_id);
2848 assert_eq!(event.client_order_id, client_order_id);
2849 assert_eq!(
2850 event.reason.as_str(),
2851 format!("UNSUPPORTED_TIME_IN_FORCE: {time_in_force}")
2852 );
2853 assert!(event.ts_event >= ts_before && event.ts_event <= ts_after);
2854 assert_eq!(event.ts_init, event.ts_event);
2855 assert_eq!(event.causation_id, None);
2856 assert_eq!(
2857 rx.try_recv().unwrap_err(),
2858 tokio::sync::mpsc::error::TryRecvError::Empty
2859 );
2860 assert!(client.pending_tasks.is_empty());
2861 assert!(client.dispatch_state.order_identities.is_empty());
2862 assert!(client.dispatch_state.order_snapshots.is_empty());
2863 assert!(client.dispatch_state.pending_requests.is_empty());
2864 }
2865
2866 #[rstest]
2867 #[case(OrderType::Limit, false)]
2868 #[case(OrderType::Limit, true)]
2869 #[case(OrderType::Market, false)]
2870 #[tokio::test]
2871 async fn test_submit_order_list_unsupported_time_in_force_denied(
2872 #[case] order_type: OrderType,
2873 #[case] post_only: bool,
2874 #[values(
2875 BybitEnvironment::Demo,
2876 BybitEnvironment::Mainnet,
2877 BybitEnvironment::Testnet
2878 )]
2879 environment: BybitEnvironment,
2880 #[values(TimeInForce::Day, TimeInForce::AtTheOpen, TimeInForce::AtTheClose)]
2881 time_in_force: TimeInForce,
2882 ) {
2883 let (mut client, cache) = test_execution_client();
2884 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2885 client.emitter.set_sender(tx);
2886 client.config.environment = environment;
2887 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2888 let client_order_ids = [
2889 ClientOrderId::from("O-TIF-VALID"),
2890 ClientOrderId::from("O-TIF-DENIED"),
2891 ];
2892 let mut inits = Vec::new();
2893
2894 for (cid, tif) in client_order_ids
2895 .into_iter()
2896 .zip([TimeInForce::Gtc, time_in_force])
2897 {
2898 let order = OrderTestBuilder::new(order_type)
2899 .instrument_id(instrument_id)
2900 .client_order_id(cid)
2901 .side(OrderSide::Sell)
2902 .quantity(Quantity::from("0.03"))
2903 .price(Price::from("12000.00"))
2904 .time_in_force(tif)
2905 .post_only(post_only)
2906 .build();
2907 inits.push(order.init_event().clone());
2908 cache
2909 .borrow_mut()
2910 .add_order(order, None, None, false)
2911 .unwrap();
2912 }
2913
2914 let list_id = OrderListId::from("OL-TIF-DENIED");
2915
2916 let order_list = OrderList::new(
2917 list_id,
2918 instrument_id,
2919 StrategyId::from("S-001"),
2920 client_order_ids.to_vec(),
2921 UnixNanos::default(),
2922 );
2923
2924 let command = SubmitOrderList::new(
2925 TraderId::from("TESTER-001"),
2926 Some(*BYBIT_CLIENT_ID),
2927 StrategyId::from("S-001"),
2928 order_list,
2929 inits,
2930 None,
2931 None,
2932 None,
2933 UUID4::new(),
2934 UnixNanos::default(),
2935 None,
2936 );
2937 let ts_before = client.clock.get_time_ns();
2938
2939 client.submit_order_list(command).unwrap();
2940
2941 let ts_after = client.clock.get_time_ns();
2942
2943 for (cid, reason) in client_order_ids.into_iter().zip([
2944 format!("ORDER_LIST_DENIED: {list_id}"),
2945 format!("UNSUPPORTED_TIME_IN_FORCE: {time_in_force}"),
2946 ]) {
2947 let ExecutionEvent::Order(OrderEventAny::Denied(event)) = rx.try_recv().unwrap() else {
2948 panic!("Expected OrderDenied before any list leg submission");
2949 };
2950
2951 assert_eq!(event.trader_id, TraderId::from("TESTER-001"));
2952 assert_eq!(event.strategy_id, StrategyId::from("S-001"));
2953 assert_eq!(event.instrument_id, instrument_id);
2954 assert_eq!(event.client_order_id, cid);
2955 assert_eq!(event.reason.as_str(), reason);
2956 assert!(event.ts_event >= ts_before && event.ts_event <= ts_after);
2957 assert_eq!(event.ts_init, event.ts_event);
2958 assert_eq!(event.causation_id, None);
2959 }
2960
2961 assert_eq!(
2962 rx.try_recv().unwrap_err(),
2963 tokio::sync::mpsc::error::TryRecvError::Empty
2964 );
2965 assert!(client.pending_tasks.is_empty());
2966 assert!(client.dispatch_state.order_identities.is_empty());
2967 assert!(client.dispatch_state.order_snapshots.is_empty());
2968 assert!(client.dispatch_state.pending_requests.is_empty());
2969 }
2970
2971 #[rstest]
2972 #[tokio::test]
2973 async fn test_ws_submit_queue_failure_emits_order_rejected() {
2974 let (mut client, cache) = test_execution_client();
2975 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2976 client.emitter.set_sender(tx);
2977
2978 let client_order_id = ClientOrderId::from("O-WS-SEND-FAIL");
2979 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2980 let mut builder = OrderTestBuilder::new(OrderType::Limit);
2981 let order = builder
2982 .instrument_id(instrument_id)
2983 .client_order_id(client_order_id)
2984 .side(OrderSide::Buy)
2985 .quantity(Quantity::from("1"))
2986 .price(Price::from("10000.00"))
2987 .build();
2988 let init = order.init_event().clone();
2989 let trader_id = order.trader_id();
2990 let strategy_id = order.strategy_id();
2991
2992 cache
2993 .borrow_mut()
2994 .add_order(order, None, Some(*BYBIT_CLIENT_ID), false)
2995 .unwrap();
2996
2997 let command = SubmitOrder::new(
2998 trader_id,
2999 Some(*BYBIT_CLIENT_ID),
3000 strategy_id,
3001 instrument_id,
3002 client_order_id,
3003 init,
3004 None,
3005 None,
3006 None,
3007 UUID4::new(),
3008 UnixNanos::default(),
3009 None, );
3011
3012 client.submit_order(command).unwrap();
3013
3014 assert_next_submitted(&mut rx, client_order_id);
3015 wait_for_spawned_tasks(&client).await;
3016
3017 assert!(
3018 !client
3019 .dispatch_state
3020 .order_identities
3021 .contains_key(&client_order_id)
3022 );
3023 assert!(
3024 !client
3025 .dispatch_state
3026 .order_snapshots
3027 .contains_key(&client_order_id)
3028 );
3029 assert_next_rejected(&mut rx, client_order_id);
3030 }
3031
3032 #[rstest]
3033 #[tokio::test]
3034 async fn test_ws_submit_order_list_queue_failure_emits_order_rejected() {
3035 let (mut client, cache) = test_execution_client();
3036 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3037 client.emitter.set_sender(tx);
3038
3039 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3040 let strategy_id = StrategyId::from("S-001");
3041 let client_order_id_1 = ClientOrderId::from("O-WS-LIST-SEND-FAIL-1");
3042 let client_order_id_2 = ClientOrderId::from("O-WS-LIST-SEND-FAIL-2");
3043
3044 let mut builder_1 = OrderTestBuilder::new(OrderType::Limit);
3045 let order_1 = builder_1
3046 .strategy_id(strategy_id)
3047 .instrument_id(instrument_id)
3048 .client_order_id(client_order_id_1)
3049 .side(OrderSide::Buy)
3050 .quantity(Quantity::from("1"))
3051 .price(Price::from("10000.00"))
3052 .build();
3053 let init_1 = order_1.init_event().clone();
3054 let trader_id = order_1.trader_id();
3055
3056 let mut builder_2 = OrderTestBuilder::new(OrderType::Limit);
3057 let order_2 = builder_2
3058 .strategy_id(strategy_id)
3059 .instrument_id(instrument_id)
3060 .client_order_id(client_order_id_2)
3061 .side(OrderSide::Sell)
3062 .quantity(Quantity::from("1"))
3063 .price(Price::from("10001.00"))
3064 .build();
3065 let init_2 = order_2.init_event().clone();
3066
3067 cache
3068 .borrow_mut()
3069 .add_order(order_1, None, Some(*BYBIT_CLIENT_ID), false)
3070 .unwrap();
3071 cache
3072 .borrow_mut()
3073 .add_order(order_2, None, Some(*BYBIT_CLIENT_ID), false)
3074 .unwrap();
3075
3076 let order_list = OrderList::new(
3077 OrderListId::from("OL-WS-SEND-FAIL"),
3078 instrument_id,
3079 strategy_id,
3080 vec![client_order_id_1, client_order_id_2],
3081 UnixNanos::default(),
3082 );
3083 let command = SubmitOrderList::new(
3084 trader_id,
3085 Some(*BYBIT_CLIENT_ID),
3086 strategy_id,
3087 order_list,
3088 vec![init_1, init_2],
3089 None,
3090 None,
3091 None,
3092 UUID4::new(),
3093 UnixNanos::default(),
3094 None, );
3096
3097 client.submit_order_list(command).unwrap();
3098
3099 assert_next_submitted(&mut rx, client_order_id_1);
3100 assert_next_submitted(&mut rx, client_order_id_2);
3101 wait_for_spawned_tasks(&client).await;
3102
3103 for client_order_id in [client_order_id_1, client_order_id_2] {
3104 assert!(
3105 !client
3106 .dispatch_state
3107 .order_identities
3108 .contains_key(&client_order_id)
3109 );
3110 assert!(
3111 !client
3112 .dispatch_state
3113 .order_snapshots
3114 .contains_key(&client_order_id)
3115 );
3116 assert_next_rejected(&mut rx, client_order_id);
3117 }
3118 }
3119
3120 fn sample_order_status_report(
3121 client_order_id: ClientOrderId,
3122 instrument_id: InstrumentId,
3123 order_status: OrderStatus,
3124 venue_position_id: Option<PositionId>,
3125 ) -> OrderStatusReport {
3126 let mut report = OrderStatusReport::new(
3127 AccountId::from("BYBIT-001"),
3128 instrument_id,
3129 Some(client_order_id),
3130 VenueOrderId::from("BYBIT-ORDER-001"),
3131 OrderSide::Buy.into(),
3132 OrderType::Limit,
3133 TimeInForce::Gtc,
3134 order_status,
3135 Quantity::from("1"),
3136 Quantity::from("0"),
3137 UnixNanos::default(),
3138 UnixNanos::default(),
3139 UnixNanos::default(),
3140 None,
3141 );
3142 report.venue_position_id = venue_position_id;
3143 report
3144 }
3145
3146 #[rstest]
3147 fn test_cache_reconciliation_order_identity_caches_and_clears_hedge_report() {
3148 let (client, cache) = test_execution_client();
3149 let client_order_id = ClientOrderId::from("O-HEDGE-RECON");
3150 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3151 let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
3152 let mut builder = OrderTestBuilder::new(OrderType::Limit);
3153 let order = builder
3154 .instrument_id(instrument_id)
3155 .client_order_id(client_order_id)
3156 .side(OrderSide::Buy)
3157 .quantity(Quantity::from("1"))
3158 .price(Price::from("10000.00"))
3159 .build();
3160 cache
3161 .borrow_mut()
3162 .add_order(order.clone(), None, None, false)
3163 .unwrap();
3164
3165 let report = sample_order_status_report(
3166 client_order_id,
3167 instrument_id,
3168 OrderStatus::Accepted,
3169 Some(venue_position_id),
3170 );
3171 cache_reconciliation_order_identity(&client.core, &client.dispatch_state, &report);
3172
3173 {
3174 let identity = client
3175 .dispatch_state
3176 .order_identities
3177 .get(&client_order_id)
3178 .unwrap();
3179 assert_eq!(identity.instrument_id, instrument_id);
3180 assert_eq!(identity.strategy_id, order.strategy_id());
3181 assert_eq!(identity.order_side, order.order_side());
3182 assert_eq!(identity.order_type, order.order_type());
3183 assert_eq!(identity.venue_position_id, Some(venue_position_id));
3184 }
3185
3186 let terminal_report = sample_order_status_report(
3187 client_order_id,
3188 instrument_id,
3189 OrderStatus::Filled,
3190 Some(venue_position_id),
3191 );
3192 cache_reconciliation_order_identity(&client.core, &client.dispatch_state, &terminal_report);
3193
3194 assert!(
3195 client
3196 .dispatch_state
3197 .order_identities
3198 .get(&client_order_id)
3199 .is_none()
3200 );
3201 }
3202
3203 #[rstest]
3204 fn test_cache_reconciliation_order_identity_keeps_one_way_local_report() {
3205 let (client, cache) = test_execution_client();
3206 let client_order_id = ClientOrderId::from("O-ONEWAY-RECON");
3207 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3208 let mut builder = OrderTestBuilder::new(OrderType::Limit);
3209 let order = builder
3210 .instrument_id(instrument_id)
3211 .client_order_id(client_order_id)
3212 .side(OrderSide::Buy)
3213 .quantity(Quantity::from("1"))
3214 .price(Price::from("10000.00"))
3215 .build();
3216 cache
3217 .borrow_mut()
3218 .add_order(order, None, None, false)
3219 .unwrap();
3220
3221 let report =
3222 sample_order_status_report(client_order_id, instrument_id, OrderStatus::Accepted, None);
3223 cache_reconciliation_order_identity(&client.core, &client.dispatch_state, &report);
3224
3225 let identity = client
3226 .dispatch_state
3227 .order_identities
3228 .get(&client_order_id)
3229 .unwrap();
3230 assert_eq!(identity.instrument_id, instrument_id);
3231 assert_eq!(identity.venue_position_id, None);
3232 }
3233
3234 #[rstest]
3235 #[case::single_discard(true, false)]
3236 #[case::bulk_discard(false, false)]
3237 #[case::single_finish(true, true)]
3238 #[case::bulk_finish(false, true)]
3239 #[tokio::test]
3240 async fn test_closed_report_collection_preserves_pending_command_identity(
3241 #[case] single: bool,
3242 #[case] accepted: bool,
3243 #[values(PendingOperation::Cancel, PendingOperation::Amend)] operation: PendingOperation,
3244 ) {
3245 let client_order_id = ClientOrderId::from("client-open-1");
3246 let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
3247 let report =
3248 sample_order_status_report(client_order_id, instrument_id, OrderStatus::Accepted, None);
3249 let json = crate::common::testing::load_test_json("http_get_orders_realtime.json");
3250 let mut orders: serde_json::Value = serde_json::from_str(&json).unwrap();
3251 orders["result"]["list"].as_array_mut().unwrap().truncate(1);
3252 orders["result"]["list"][0]["orderStatus"] = serde_json::json!("Cancelled");
3253 orders["result"]["list"][0]["orderId"] = serde_json::json!(report.venue_order_id.as_str());
3254 let (mut client, cache, server) = order_report_client(orders).await;
3255 let mut order = OrderTestBuilder::new(OrderType::Limit)
3256 .client_order_id(client_order_id)
3257 .instrument_id(instrument_id)
3258 .side(OrderSide::Buy)
3259 .quantity(Quantity::from("0.010"))
3260 .price(Price::from("3930.41"))
3261 .build();
3262 let event =
3263 TestOrderEventStubs::accepted(&order, client.core.account_id, report.venue_order_id);
3264 order.apply(event).unwrap();
3265 cache
3266 .borrow_mut()
3267 .add_order(order.clone(), None, None, false)
3268 .unwrap();
3269 cache_reconciliation_order_identity(&client.core, &client.dispatch_state, &report);
3270 client.dispatch_state.pending_requests.insert(
3271 "late-command".to_string(),
3272 (
3273 vec![client_order_id],
3274 vec![Some(report.venue_order_id)],
3275 operation,
3276 ),
3277 );
3278 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3279 client.emitter.set_sender(tx);
3280
3281 let task = if single {
3282 let command = GenerateOrderStatusReport::new(
3283 UUID4::new(),
3284 UnixNanos::default(),
3285 Some(instrument_id),
3286 Some(client_order_id),
3287 Some(report.venue_order_id),
3288 None,
3289 None,
3290 );
3291 let task = client.generate_order_status_report_task(&command).unwrap();
3292 ExecutionReportTask {
3293 collection: task.collection,
3294 finish: Box::new(move || Ok((task.finish)()?.into_iter().collect::<Vec<_>>())),
3295 }
3296 } else {
3297 let command = GenerateOrderStatusReports::new(
3298 UUID4::new(),
3299 UnixNanos::default(),
3300 false,
3301 Some(instrument_id),
3302 None,
3303 None,
3304 None,
3305 None,
3306 );
3307 client.generate_order_status_reports_task(&command).unwrap()
3308 };
3309
3310 tokio::spawn(task.collection).await.unwrap();
3311 assert!(
3312 client
3313 .dispatch_state
3314 .order_identities
3315 .contains_key(&client_order_id)
3316 );
3317
3318 if accepted {
3319 let reports = (task.finish)().unwrap();
3320 assert_eq!(reports.len(), 1);
3321 assert_eq!(reports[0].order_status, OrderStatus::Canceled);
3322 } else {
3323 drop(task.finish);
3324 }
3325
3326 let json = crate::common::testing::load_test_json("ws_order_response.json");
3327 let mut response: serde_json::Value = serde_json::from_str(&json).unwrap();
3328 response["op"] = serde_json::json!(match operation {
3329 PendingOperation::Cancel => "order.cancel",
3330 PendingOperation::Amend => "order.amend",
3331 PendingOperation::Place => unreachable!(),
3332 });
3333 response["retCode"] = serde_json::json!(110001);
3334 response["retMsg"] = serde_json::json!("Order does not exist.");
3335 response["reqId"] = serde_json::json!("late-command");
3336 response["data"]["orderLinkId"] = serde_json::json!(client_order_id.as_str());
3337 response["data"]["orderId"] = serde_json::json!(report.venue_order_id.as_str());
3338 let response = serde_json::from_value(response).unwrap();
3339 let message = crate::websocket::messages::BybitWsMessage::OrderResponse(response);
3340 dispatch_ws_message(
3341 &message,
3342 &client.emitter,
3343 &client.dispatch_state,
3344 client.core.account_id,
3345 &client.instruments_cache,
3346 client.clock,
3347 );
3348 assert_eq!(
3349 client
3350 .dispatch_state
3351 .order_identities
3352 .contains_key(&client_order_id),
3353 !accepted
3354 );
3355 assert!(client.dispatch_state.pending_requests.is_empty());
3356
3357 if accepted {
3358 assert!(rx.try_recv().is_err());
3359 } else {
3360 let ExecutionEvent::Order(event) = rx.try_recv().unwrap() else {
3361 panic!("expected delayed command rejection");
3362 };
3363
3364 let (reason, reconciliation) = match (operation, &event) {
3365 (PendingOperation::Cancel, OrderEventAny::CancelRejected(event)) => {
3366 (event.reason, event.reconciliation)
3367 }
3368 (PendingOperation::Amend, OrderEventAny::ModifyRejected(event)) => {
3369 (event.reason, event.reconciliation)
3370 }
3371 _ => panic!("unexpected rejection event: {event:?}"),
3372 };
3373
3374 assert_eq!(event.trader_id(), client.core.trader_id);
3375 assert_eq!(event.account_id(), Some(client.core.account_id));
3376 assert_eq!(event.client_order_id(), client_order_id);
3377 assert_eq!(event.venue_order_id(), Some(report.venue_order_id));
3378 assert_eq!(event.instrument_id(), instrument_id);
3379 assert_eq!(event.strategy_id(), order.strategy_id());
3380 assert_eq!(reason.as_str(), "Order does not exist.");
3381 assert!(!reconciliation);
3382 assert!(rx.try_recv().is_err());
3383 }
3384
3385 server.abort();
3386 }
3387
3388 #[rstest]
3389 #[case::single(true)]
3390 #[case::bulk(false)]
3391 #[tokio::test]
3392 async fn test_open_report_finalization_uses_current_core(#[case] single: bool) {
3393 let orders = serde_json::from_str(&crate::common::testing::load_test_json(
3394 "http_get_orders_realtime.json",
3395 ))
3396 .unwrap();
3397 let (client, cache, server) = order_report_client(orders).await;
3398 let client_order_id = ClientOrderId::from("client-open-1");
3399 let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
3400
3401 let task = if single {
3402 let command = GenerateOrderStatusReport::new(
3403 UUID4::new(),
3404 UnixNanos::default(),
3405 Some(instrument_id),
3406 Some(client_order_id),
3407 Some(VenueOrderId::from("open-order-1")),
3408 None,
3409 None,
3410 );
3411 let task = client.generate_order_status_report_task(&command).unwrap();
3412 ExecutionReportTask {
3413 collection: task.collection,
3414 finish: Box::new(move || Ok((task.finish)()?.into_iter().collect::<Vec<_>>())),
3415 }
3416 } else {
3417 let command = GenerateOrderStatusReports::new(
3418 UUID4::new(),
3419 UnixNanos::default(),
3420 false,
3421 Some(instrument_id),
3422 None,
3423 None,
3424 None,
3425 None,
3426 );
3427 client.generate_order_status_reports_task(&command).unwrap()
3428 };
3429
3430 tokio::spawn(task.collection).await.unwrap();
3431 assert!(client.dispatch_state.order_identities.is_empty());
3432 let strategy_id = StrategyId::from("LATE_OWNER-007");
3433 let order = OrderTestBuilder::new(OrderType::Market)
3434 .strategy_id(strategy_id)
3435 .client_order_id(client_order_id)
3436 .instrument_id(instrument_id)
3437 .side(OrderSide::Sell)
3438 .quantity(Quantity::from("0.027"))
3439 .build();
3440 cache
3441 .borrow_mut()
3442 .add_order(order, None, None, false)
3443 .unwrap();
3444
3445 let reports = (task.finish)().unwrap();
3446
3447 assert_eq!(reports[0].client_order_id, Some(client_order_id));
3448 let identity = client
3449 .dispatch_state
3450 .order_identities
3451 .get(&client_order_id)
3452 .unwrap();
3453 assert_eq!(
3454 (
3455 identity.instrument_id,
3456 identity.strategy_id,
3457 identity.order_side,
3458 identity.order_type,
3459 identity.venue_position_id
3460 ),
3461 (
3462 instrument_id,
3463 strategy_id,
3464 OrderSide::Sell,
3465 OrderType::Market,
3466 None
3467 ),
3468 );
3469 server.abort();
3470 }
3471
3472 #[rstest]
3473 #[case::single(true)]
3474 #[case::bulk(false)]
3475 #[tokio::test]
3476 async fn test_order_report_collection_error_preserves_identity(#[case] single: bool) {
3477 let response = serde_json::json!({
3478 "retCode": 10001, "retMsg": "report collection failure", "result": {"list": []},
3479 "retExtInfo": {}, "time": 1761355905610u64,
3480 });
3481 let (client, _, server) = order_report_client(response).await;
3482 let client_order_id = ClientOrderId::from("client-open-1");
3483 let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
3484 let strategy_id = StrategyId::from("ERROR_OWNER-009");
3485 client.dispatch_state.order_identities.insert(
3486 client_order_id,
3487 OrderIdentity {
3488 instrument_id,
3489 strategy_id,
3490 order_side: OrderSide::Sell,
3491 order_type: OrderType::Market,
3492 venue_position_id: Some(PositionId::from("ERROR-POSITION")),
3493 },
3494 );
3495
3496 let single_command = GenerateOrderStatusReport::new(
3497 UUID4::new(),
3498 UnixNanos::default(),
3499 Some(instrument_id),
3500 Some(client_order_id),
3501 Some(VenueOrderId::from("open-order-1")),
3502 None,
3503 None,
3504 );
3505
3506 let bulk_command = GenerateOrderStatusReports::new(
3507 UUID4::new(),
3508 UnixNanos::default(),
3509 false,
3510 Some(instrument_id),
3511 None,
3512 None,
3513 None,
3514 None,
3515 );
3516
3517 for fused in [false, true] {
3518 let error = if fused {
3519 if single {
3520 client
3521 .generate_order_status_report(&single_command)
3522 .await
3523 .unwrap_err()
3524 } else {
3525 client
3526 .generate_order_status_reports(&bulk_command)
3527 .await
3528 .unwrap_err()
3529 }
3530 } else if single {
3531 let task = client
3532 .generate_order_status_report_task(&single_command)
3533 .unwrap();
3534
3535 tokio::spawn(task.collection).await.unwrap();
3536 (task.finish)().unwrap_err()
3537 } else {
3538 let task = client
3539 .generate_order_status_reports_task(&bulk_command)
3540 .unwrap();
3541
3542 tokio::spawn(task.collection).await.unwrap();
3543 (task.finish)().unwrap_err()
3544 };
3545
3546 let error = error.downcast_ref::<BybitHttpError>().unwrap();
3547 assert!(
3548 matches!(error, BybitHttpError::BybitError { error_code: 10001, message } if message == "report collection failure")
3549 );
3550 let identity = client
3551 .dispatch_state
3552 .order_identities
3553 .get(&client_order_id)
3554 .unwrap();
3555 assert_eq!(
3556 (
3557 identity.instrument_id,
3558 identity.strategy_id,
3559 identity.order_side,
3560 identity.order_type,
3561 identity.venue_position_id
3562 ),
3563 (
3564 instrument_id,
3565 strategy_id,
3566 OrderSide::Sell,
3567 OrderType::Market,
3568 Some(PositionId::from("ERROR-POSITION"))
3569 ),
3570 );
3571 }
3572
3573 server.abort();
3574 }
3575
3576 #[rstest]
3577 #[case::both_match(Some("ETHUSDT-LINEAR.BYBIT"), Some("client-open-1"), Some("open-order-1"), Some(("client-open-1", "open-order-1")))]
3578 #[case::crossed_identifiers(
3579 Some("ETHUSDT-LINEAR.BYBIT"),
3580 Some("client-open-1"),
3581 Some("open-order-2"),
3582 None
3583 )]
3584 #[case::client_only(Some("ETHUSDT-LINEAR.BYBIT"), Some("client-open-2"), None, Some(("client-open-2", "open-order-2")))]
3585 #[case::venue_only(Some("ETHUSDT-LINEAR.BYBIT"), None, Some("open-order-2"), Some(("client-open-2", "open-order-2")))]
3586 #[case::missing_instrument(None, Some("client-open-1"), Some("open-order-1"), None)]
3587 #[tokio::test]
3588 async fn test_single_report_task_preserves_lookup_filters(
3589 #[case] instrument: Option<&str>,
3590 #[case] client_order_id: Option<&str>,
3591 #[case] venue_order_id: Option<&str>,
3592 #[case] expected: Option<(&str, &str)>,
3593 ) {
3594 let orders = serde_json::from_str(&crate::common::testing::load_test_json(
3595 "http_get_orders_realtime.json",
3596 ))
3597 .unwrap();
3598 let (client, _, server) = order_report_client(orders).await;
3599
3600 let command = GenerateOrderStatusReport::new(
3601 UUID4::new(),
3602 UnixNanos::default(),
3603 instrument.map(InstrumentId::from),
3604 client_order_id.map(ClientOrderId::from),
3605 venue_order_id.map(VenueOrderId::from),
3606 None,
3607 None,
3608 );
3609 let task = client.generate_order_status_report_task(&command).unwrap();
3610 tokio::spawn(task.collection).await.unwrap();
3611
3612 let report = (task.finish)().unwrap();
3613
3614 assert_eq!(
3615 report.map(|report| (report.client_order_id, report.venue_order_id)),
3616 expected.map(|(client, venue)| (
3617 Some(ClientOrderId::from(client)),
3618 VenueOrderId::from(venue)
3619 )),
3620 );
3621 server.abort();
3622 }
3623
3624 #[rstest]
3625 #[case::unbounded(None, None, vec!["open-order-1", "open-order-2"])]
3626 #[case::after_first(Some(1761355905607), None, vec!["open-order-2"])]
3627 #[case::before_second(None, Some(1761355905608), vec!["open-order-1"])]
3628 #[case::inclusive_bounds(Some(1761355905606), Some(1761355905606), vec!["open-order-1"])]
3629 #[tokio::test]
3630 async fn test_bulk_report_task_preserves_window_and_fused_parity(
3631 #[case] start_ms: Option<u64>,
3632 #[case] end_ms: Option<u64>,
3633 #[case] expected: Vec<&str>,
3634 ) {
3635 let orders = serde_json::from_str(&crate::common::testing::load_test_json(
3636 "http_get_orders_realtime.json",
3637 ))
3638 .unwrap();
3639 let (client, _, server) = order_report_client(orders).await;
3640
3641 let command = GenerateOrderStatusReports::new(
3642 UUID4::new(),
3643 UnixNanos::default(),
3644 false,
3645 Some(InstrumentId::from("ETHUSDT-LINEAR.BYBIT")),
3646 start_ms.map(|ms| UnixNanos::from(ms * 1_000_000)),
3647 end_ms.map(|ms| UnixNanos::from(ms * 1_000_000)),
3648 None,
3649 None,
3650 );
3651 let task = client.generate_order_status_reports_task(&command).unwrap();
3652 tokio::spawn(task.collection).await.unwrap();
3653
3654 let collected = (task.finish)().unwrap();
3655 let fused = client
3656 .generate_order_status_reports(&command)
3657 .await
3658 .unwrap();
3659
3660 let expected: Vec<_> = expected.into_iter().map(VenueOrderId::from).collect();
3661 assert_eq!(
3662 collected
3663 .iter()
3664 .map(|report| report.venue_order_id)
3665 .collect::<Vec<_>>(),
3666 expected
3667 );
3668 assert_eq!(
3669 fused
3670 .iter()
3671 .map(|report| report.venue_order_id)
3672 .collect::<Vec<_>>(),
3673 expected
3674 );
3675 server.abort();
3676 }
3677
3678 async fn order_report_client(
3679 response: serde_json::Value,
3680 ) -> (
3681 BybitExecutionClient,
3682 Rc<RefCell<Cache>>,
3683 tokio::task::JoinHandle<()>,
3684 ) {
3685 let history = response.clone();
3686 let router = Router::new()
3687 .route(
3688 "/v5/order/realtime",
3689 get(move || std::future::ready(Json(response.clone()))),
3690 )
3691 .route(
3692 "/v5/order/history",
3693 get(move || std::future::ready(Json(history.clone()))),
3694 );
3695 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3696 let addr = listener.local_addr().unwrap();
3697 let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
3698 let (mut client, cache) = test_execution_client();
3699 client.http_client = BybitHttpClient::with_credentials(
3700 "test_key".to_string(),
3701 "test_secret".to_string(),
3702 Some(format!("http://{addr}")),
3703 1,
3704 0,
3705 10,
3706 10,
3707 5_000,
3708 None,
3709 )
3710 .unwrap();
3711 let mut instrument = crypto_perpetual_ethusdt();
3712 instrument.id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
3713 client.http_client.cache_instrument(instrument.into());
3714 (client, cache, server)
3715 }
3716
3717 #[rstest]
3718 #[case(TimeInForce::Gtc, Ok(Some(BybitTimeInForce::Gtc)))]
3719 #[case(TimeInForce::Gtd, Ok(Some(BybitTimeInForce::Gtc)))]
3720 #[case(TimeInForce::Ioc, Ok(Some(BybitTimeInForce::Ioc)))]
3721 #[case(TimeInForce::Fok, Ok(Some(BybitTimeInForce::Fok)))]
3722 #[case(
3723 TimeInForce::Day,
3724 Err(OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::Day))
3725 )]
3726 #[case(
3727 TimeInForce::AtTheOpen,
3728 Err(OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::AtTheOpen))
3729 )]
3730 #[case(
3731 TimeInForce::AtTheClose,
3732 Err(OrderDeniedReason::UnsupportedTimeInForce(TimeInForce::AtTheClose))
3733 )]
3734 fn test_ws_params_time_in_force(
3735 #[case] tif: TimeInForce,
3736 #[case] expected: Result<Option<BybitTimeInForce>, OrderDeniedReason>,
3737 ) {
3738 let mut builder = OrderTestBuilder::new(OrderType::Limit);
3739 builder
3740 .instrument_id(InstrumentId::from("BTCUSDT-LINEAR.BYBIT"))
3741 .time_in_force(tif)
3742 .quantity(Quantity::from("0.02"))
3743 .price(Price::from("10000.00"));
3744
3745 if tif == TimeInForce::Gtd {
3746 builder.expire_time(UnixNanos::from(1_900_000_000_000_000_000));
3747 }
3748
3749 let order = builder.build();
3750 let params = BybitExecutionClient::build_ws_place_params(
3751 &order,
3752 BybitProductType::Linear,
3753 "BTCUSDT",
3754 &BybitTpSlParams::default(),
3755 None,
3756 None,
3757 );
3758 assert_eq!(
3759 params
3760 .map(|p| p.time_in_force)
3761 .map_err(|e| e.downcast::<OrderDeniedReason>().unwrap()),
3762 expected,
3763 );
3764 }
3765
3766 #[rstest]
3767 #[case::spot_market_base(
3768 BybitProductType::Spot,
3769 BybitOrderType::Market,
3770 false,
3771 Some(BybitMarketUnit::BaseCoin)
3772 )]
3773 #[case::spot_market_quote(
3774 BybitProductType::Spot,
3775 BybitOrderType::Market,
3776 true,
3777 Some(BybitMarketUnit::QuoteCoin)
3778 )]
3779 #[case::spot_limit(BybitProductType::Spot, BybitOrderType::Limit, true, None)]
3780 #[case::linear_market(BybitProductType::Linear, BybitOrderType::Market, true, None)]
3781 fn test_ws_params_market_unit(
3782 #[case] product_type: BybitProductType,
3783 #[case] order_type: BybitOrderType,
3784 #[case] is_quote_quantity: bool,
3785 #[case] expected: Option<BybitMarketUnit>,
3786 ) {
3787 let params = BybitWsPlaceOrderParams {
3788 category: product_type,
3789 symbol: ustr::Ustr::from("BTCUSDT"),
3790 side: BybitOrderSide::Buy,
3791 order_type,
3792 qty: "1.0".to_string(),
3793 is_leverage: None,
3794 market_unit: spot_market_unit(product_type, order_type, is_quote_quantity),
3795 price: None,
3796 time_in_force: None,
3797 order_link_id: None,
3798 reduce_only: None,
3799 close_on_trigger: None,
3800 trigger_price: None,
3801 trigger_by: None,
3802 trigger_direction: None,
3803 tpsl_mode: None,
3804 take_profit: None,
3805 stop_loss: None,
3806 tp_trigger_by: None,
3807 sl_trigger_by: None,
3808 sl_trigger_price: None,
3809 tp_trigger_price: None,
3810 sl_order_type: None,
3811 tp_order_type: None,
3812 sl_limit_price: None,
3813 tp_limit_price: None,
3814 order_iv: None,
3815 smp_type: None,
3816 mmp: None,
3817 position_idx: None,
3818 bbo_side_type: None,
3819 bbo_level: None,
3820 };
3821
3822 assert_eq!(params.market_unit, expected);
3823 }
3824
3825 #[rstest]
3826 #[case::market(OrderType::Market, BybitOrderType::Market, false)]
3827 #[case::limit(OrderType::Limit, BybitOrderType::Limit, false)]
3828 #[case::stop_market(OrderType::StopMarket, BybitOrderType::Market, true)]
3829 #[case::stop_limit(OrderType::StopLimit, BybitOrderType::Limit, true)]
3830 #[case::market_if_touched(OrderType::MarketIfTouched, BybitOrderType::Market, true)]
3831 #[case::limit_if_touched(OrderType::LimitIfTouched, BybitOrderType::Limit, true)]
3832 fn test_map_order_type(
3833 #[case] input: OrderType,
3834 #[case] expected_type: BybitOrderType,
3835 #[case] expected_conditional: bool,
3836 ) {
3837 let (bybit_type, is_conditional) = BybitExecutionClient::map_order_type(input).unwrap();
3838 assert_eq!(bybit_type, expected_type);
3839 assert_eq!(is_conditional, expected_conditional);
3840 }
3841
3842 #[rstest]
3843 fn test_map_order_type_rejects_trailing_stop() {
3844 BybitExecutionClient::map_order_type(OrderType::TrailingStopMarket).unwrap_err();
3845 }
3846
3847 #[rstest]
3848 #[case::linear("BTCUSDT-LINEAR", true)]
3849 #[case::inverse("BTCUSD-INVERSE", true)]
3850 #[case::spot("BTCUSDT-SPOT", false)]
3851 #[case::option("BTC-30JUN25-100000-C-OPTION", false)]
3852 fn test_parse_derivative_symbol_filters_product_type(
3853 #[case] symbol_str: &str,
3854 #[case] keeps: bool,
3855 ) {
3856 let result = BybitExecutionClient::parse_derivative_symbol(symbol_str);
3857 assert_eq!(result.is_some(), keeps);
3858 }
3859
3860 #[rstest]
3861 fn test_parse_derivative_symbol_rejects_malformed() {
3862 assert!(BybitExecutionClient::parse_derivative_symbol("not-a-real-symbol").is_none());
3863 }
3864
3865 #[rstest]
3866 #[case::matches_msg("Position mode has not been modified", "110025", true)]
3867 #[case::matches_code("retCode 110025: noop", "110025", true)]
3868 #[case::matches_msg_only("Already not been modified", "", true)]
3869 #[case::wrong_code("retCode 99999: other", "110025", false)]
3870 #[case::empty_no_modified_msg("retCode 99999", "", false)]
3871 fn test_is_unchanged_error(#[case] msg: &str, #[case] code: &str, #[case] expected: bool) {
3872 let err = anyhow::anyhow!("{msg}");
3873 assert_eq!(
3874 BybitExecutionClient::is_unchanged_error(&err, code),
3875 expected
3876 );
3877 }
3878
3879 #[rstest]
3880 #[case::matches("Margin needs to be equal to or greater than 0.5", true)]
3881 #[case::no_match("Some other error", false)]
3882 fn test_is_low_margin_error(#[case] msg: &str, #[case] expected: bool) {
3883 let err = anyhow::anyhow!("{msg}");
3884 assert_eq!(BybitExecutionClient::is_low_margin_error(&err), expected);
3885 }
3886
3887 #[rstest]
3888 fn test_submit_rejection_reason_matches_confirmed_rejection() {
3889 let err = anyhow::Error::from(BybitSubmitOrderError::Rejected {
3890 reason: "EC_PostOnlyWillTakeLiquidity".to_string(),
3891 });
3892
3893 assert_eq!(
3894 submit_rejection_reason(&err),
3895 Some("EC_PostOnlyWillTakeLiquidity"),
3896 );
3897 }
3898
3899 #[rstest]
3900 fn test_submit_rejection_reason_ignores_post_submit_lookup_failure() {
3901 let err = anyhow::Error::from(BybitSubmitOrderError::PostSubmitLookup {
3902 source: anyhow::Error::from(BybitHttpError::BybitError {
3903 error_code: 110017,
3904 message: "current position is zero, cannot fix reduce-only order qty".to_string(),
3905 }),
3906 })
3907 .context("Submit order failed");
3908
3909 assert_eq!(submit_rejection_reason(&err), None);
3910 }
3911
3912 #[rstest]
3913 fn test_submit_rejection_reason_ignores_missing_order_id() {
3914 let err = anyhow::Error::from(BybitSubmitOrderError::MissingOrderId);
3915
3916 assert_eq!(submit_rejection_reason(&err), None);
3917 }
3918
3919 #[rstest]
3920 fn test_submit_rejection_reason_matches_venue_http_error() {
3921 let err = anyhow::Error::from(BybitHttpError::BybitError {
3922 error_code: 110017,
3923 message: "current position is zero, cannot fix reduce-only order qty".to_string(),
3924 });
3925
3926 assert_eq!(
3927 submit_rejection_reason(&err),
3928 Some("current position is zero, cannot fix reduce-only order qty"),
3929 );
3930 }
3931
3932 #[rstest]
3933 fn test_submit_rejection_reason_ignores_ambiguous_http_error() {
3934 let err = anyhow::Error::from(BybitHttpError::BybitError {
3935 error_code: 10016,
3936 message: "rate limit exceeded".to_string(),
3937 });
3938
3939 assert_eq!(submit_rejection_reason(&err), None);
3940 }
3941
3942 #[rstest]
3943 #[case::buy(Some(OrderSide::Buy), vec![0, 2])]
3944 #[case::sell(Some(OrderSide::Sell), vec![1, 3])]
3945 #[tokio::test]
3946 async fn test_cancel_all_orders_filters_by_side_and_preserves_owners(
3947 #[case] order_side: Option<OrderSide>,
3948 #[case] expected_indices: Vec<usize>,
3949 ) {
3950 let (mut client, cache) = test_execution_client();
3951 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3952 client.emitter.set_sender(tx);
3953 client.ws_trade.close().await.unwrap();
3954
3955 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3956 let other_instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
3957 let account_id = client.core.account_id;
3958 let mut orders = Vec::new();
3959
3960 for (index, (instrument, owner, side, open)) in [
3961 (instrument_id, "S-001", OrderSide::Buy, true),
3962 (instrument_id, "S-001", OrderSide::Sell, true),
3963 (instrument_id, "S-002", OrderSide::Buy, true),
3964 (instrument_id, "S-002", OrderSide::Sell, true),
3965 (other_instrument_id, "S-001", OrderSide::Buy, true),
3966 (other_instrument_id, "S-002", OrderSide::Sell, true),
3967 (instrument_id, "S-001", OrderSide::Buy, false),
3968 (instrument_id, "S-002", OrderSide::Sell, false),
3969 ]
3970 .into_iter()
3971 .enumerate()
3972 {
3973 let client_order_id = ClientOrderId::from(format!("O-CANCEL-ALL-{index}"));
3974 let mut builder = OrderTestBuilder::new(OrderType::Limit);
3975 let order = builder
3976 .instrument_id(instrument)
3977 .client_order_id(client_order_id)
3978 .strategy_id(StrategyId::from(owner))
3979 .side(side)
3980 .quantity(Quantity::from("1"))
3981 .price(Price::from("10000.00"))
3982 .build();
3983 let venue_order_id = VenueOrderId::from(format!("BYBIT-{index}"));
3984 let accepted = TestOrderEventStubs::accepted(&order, account_id, venue_order_id);
3985 cache
3986 .borrow_mut()
3987 .add_order(order.clone(), None, Some(*BYBIT_CLIENT_ID), false)
3988 .unwrap();
3989 let order = cache.borrow_mut().update_order(&accepted).unwrap();
3990
3991 if !open {
3992 let canceled =
3993 TestOrderEventStubs::canceled(&order, account_id, Some(venue_order_id));
3994 cache.borrow_mut().update_order(&canceled).unwrap();
3995 }
3996
3997 orders.push((order, venue_order_id));
3998 }
3999
4000 client
4001 .cancel_all_orders(CancelAllOrders::new(
4002 TraderId::from("TESTER-001"),
4003 Some(*BYBIT_CLIENT_ID),
4004 StrategyId::from("S-001"),
4005 instrument_id,
4006 order_side,
4007 UUID4::new(),
4008 UnixNanos::default(),
4009 None,
4010 None,
4011 ))
4012 .unwrap();
4013 wait_for_spawned_tasks(&client).await;
4014
4015 let mut actual = Vec::new();
4018
4019 for _ in &expected_indices {
4020 let event = rx.try_recv().expect("expected OrderCancelRejected event");
4021 match event {
4022 ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
4023 actual.push((rejected.client_order_id, rejected.strategy_id));
4024 }
4025 event => panic!("expected OrderCancelRejected, was {event:?}"),
4026 }
4027 }
4028
4029 let mut expected: Vec<_> = expected_indices
4030 .iter()
4031 .map(|&index| {
4032 let (order, _) = &orders[index];
4033 (order.client_order_id(), order.strategy_id())
4034 })
4035 .collect();
4036
4037 actual.sort();
4038 expected.sort();
4039
4040 assert_eq!(actual, expected);
4041 assert!(rx.try_recv().is_err());
4042 }
4043
4044 #[rstest]
4045 #[tokio::test]
4046 async fn test_cancel_all_orders_with_side_and_empty_cache_sends_nothing() {
4047 let (mut client, _cache) = test_execution_client();
4048 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4049 client.emitter.set_sender(tx);
4050 client.ws_trade.close().await.unwrap();
4051
4052 client
4053 .cancel_all_orders(CancelAllOrders::new(
4054 TraderId::from("TESTER-001"),
4055 Some(*BYBIT_CLIENT_ID),
4056 StrategyId::from("S-001"),
4057 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
4058 Some(OrderSide::Buy),
4059 UUID4::new(),
4060 UnixNanos::default(),
4061 None,
4062 None,
4063 ))
4064 .unwrap();
4065 wait_for_spawned_tasks(&client).await;
4066
4067 assert!(rx.try_recv().is_err());
4068 assert!(client.dispatch_state.pending_requests.is_empty());
4069 }
4070
4071 #[rstest]
4072 #[case::working("New", "0")]
4073 #[case::filled("Filled", "0.010")]
4074 #[tokio::test]
4075 async fn test_cancel_all_orders_leaves_unconfirmed_cancellation_open(
4076 #[case] status: &str,
4077 #[case] filled_qty: &str,
4078 ) {
4079 let json = crate::common::testing::load_test_json("http_get_orders_realtime.json");
4080 let mut orders: serde_json::Value = serde_json::from_str(&json).unwrap();
4081 orders["result"]["list"][0]["orderStatus"] = serde_json::json!(status);
4082 orders["result"]["list"][0]["cumExecQty"] = serde_json::json!(filled_qty);
4083 orders["result"]["list"][0]["avgPrice"] = serde_json::json!("3930.41");
4084 let mut empty = orders.clone();
4085 empty["result"]["list"] = serde_json::json!([]);
4086 let history = empty.clone();
4087 let queried = Arc::new(AtomicBool::new(false));
4088 let query_observed = Arc::clone(&queried);
4089
4090 let router = Router::new()
4091 .route(
4092 "/v5/order/cancel-all",
4093 post(move || std::future::ready(Json(empty.clone()))),
4094 )
4095 .route(
4096 "/v5/order/history",
4097 get(move || std::future::ready(Json(history.clone()))),
4098 )
4099 .route(
4100 "/v5/order/realtime",
4101 get(move || {
4102 query_observed.store(true, Ordering::Relaxed);
4103 std::future::ready(Json(orders.clone()))
4104 }),
4105 );
4106
4107 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4108 let addr = listener.local_addr().unwrap();
4109 let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
4110
4111 let (mut client, cache) = test_execution_client();
4112 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4113 client.emitter.set_sender(tx);
4114 client.config.http_timeout_secs = 1;
4115 client.config.retry_delay_initial_ms = 10;
4116 client.http_client = BybitHttpClient::with_credentials(
4117 "test_key".to_string(),
4118 "test_secret".to_string(),
4119 Some(format!("http://{addr}")),
4120 1,
4121 0,
4122 10,
4123 10,
4124 5_000,
4125 None,
4126 )
4127 .unwrap();
4128 let instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
4129 let mut instrument = crypto_perpetual_ethusdt();
4130 instrument.id = instrument_id;
4131 client.http_client.cache_instrument(instrument.into());
4132 let mut builder = OrderTestBuilder::new(OrderType::Limit);
4133 let order = builder
4134 .instrument_id(instrument_id)
4135 .client_order_id(ClientOrderId::from("client-open-1"))
4136 .side(OrderSide::Buy)
4137 .quantity(Quantity::from("0.01"))
4138 .price(Price::from("3930.41"))
4139 .build();
4140 let accepted = TestOrderEventStubs::accepted(
4141 &order,
4142 client.core.account_id,
4143 VenueOrderId::from("open-order-1"),
4144 );
4145 cache
4146 .borrow_mut()
4147 .add_order(order.clone(), None, Some(*BYBIT_CLIENT_ID), false)
4148 .unwrap();
4149 cache.borrow_mut().update_order(&accepted).unwrap();
4150 client
4151 .cancel_all_orders(CancelAllOrders::new(
4152 TraderId::from("TESTER-001"),
4153 Some(*BYBIT_CLIENT_ID),
4154 order.strategy_id(),
4155 instrument_id,
4156 None,
4157 UUID4::new(),
4158 UnixNanos::default(),
4159 None,
4160 None,
4161 ))
4162 .unwrap();
4163 wait_until_async(
4164 || async { client.pending_tasks.all_finished() },
4165 Duration::from_secs(5),
4166 )
4167 .await;
4168
4169 assert!(queried.load(Ordering::Relaxed));
4170 assert_eq!(
4171 cache
4172 .borrow()
4173 .order(&order.client_order_id())
4174 .unwrap()
4175 .status(),
4176 OrderStatus::Accepted
4177 );
4178 assert!(rx.try_recv().is_err());
4179 server.abort();
4180 }
4181
4182 #[rstest]
4183 #[tokio::test]
4184 async fn test_batch_cancel_orders_failure_preserves_owning_strategies() {
4185 let (mut client, _cache) = test_execution_client();
4186 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4187 client.emitter.set_sender(tx);
4188 client.ws_trade.close().await.unwrap();
4189
4190 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
4191
4192 let cancels = ["S-001", "S-002"]
4193 .into_iter()
4194 .enumerate()
4195 .map(|(index, owner)| {
4196 CancelOrder::new(
4197 TraderId::from("TESTER-001"),
4198 Some(*BYBIT_CLIENT_ID),
4199 StrategyId::from(owner),
4200 instrument_id,
4201 ClientOrderId::from(format!("O-BATCH-OWNER-{index}")),
4202 Some(VenueOrderId::from(format!("BYBIT-BATCH-{index}"))),
4203 UUID4::new(),
4204 UnixNanos::default(),
4205 None,
4206 None,
4207 )
4208 })
4209 .collect::<Vec<_>>();
4210
4211 client
4212 .batch_cancel_orders(BatchCancelOrders::new(
4213 TraderId::from("TESTER-001"),
4214 Some(*BYBIT_CLIENT_ID),
4215 StrategyId::from("S-001"),
4216 instrument_id,
4217 cancels.clone(),
4218 UUID4::new(),
4219 UnixNanos::default(),
4220 None,
4221 None,
4222 ))
4223 .unwrap();
4224 wait_for_spawned_tasks(&client).await;
4225
4226 let mut actual = Vec::new();
4227
4228 for _ in &cancels {
4229 let event = rx.try_recv().expect("expected OrderCancelRejected event");
4230 match event {
4231 ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
4232 actual.push((rejected.client_order_id, rejected.strategy_id));
4233 }
4234 event => panic!("expected OrderCancelRejected, was {event:?}"),
4235 }
4236 }
4237
4238 let mut expected: Vec<_> = cancels
4239 .iter()
4240 .map(|cancel| (cancel.client_order_id, cancel.strategy_id))
4241 .collect();
4242
4243 actual.sort();
4244 expected.sort();
4245
4246 assert_eq!(actual, expected);
4247 assert!(rx.try_recv().is_err());
4248 }
4249}