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,
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, OrderType, TimeInForce},
52 events::OrderDeniedReason,
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, BybitTimeInForce, 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, nanos_to_millis, parse_bybit_tp_sl_params,
73 resolve_position_idx as resolve_bybit_position_idx, spot_leverage, spot_market_unit,
74 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 let (key_var, secret_var) = credential_env_vars(config.environment);
125 let api_key = get_or_env_var(
126 config.api_key.clone().map(SecretString::into_inner),
127 key_var,
128 )?;
129 let api_secret = get_or_env_var(
130 config.api_secret.clone().map(SecretString::into_inner),
131 secret_var,
132 )?;
133 let proxy_url = config
134 .proxy_url
135 .as_ref()
136 .map(|value| value.expose_secret().to_owned());
137
138 let http_client = BybitHttpClient::with_credentials(
139 api_key.clone(),
140 api_secret.clone(),
141 Some(config.http_base_url()),
142 config.http_timeout_secs,
143 config.max_retries,
144 config.retry_delay_initial_ms,
145 config.retry_delay_max_ms,
146 config.recv_window_ms,
147 proxy_url.clone(),
148 )?;
149 http_client.set_use_spot_position_reports(config.use_spot_position_reports);
150
151 let mut ws_private = BybitWebSocketClient::new_private(
152 config.environment,
153 Some(api_key.clone()),
154 Some(api_secret.clone()),
155 Some(config.ws_private_url()),
156 config.heartbeat_interval_secs,
157 config.transport_backend,
158 proxy_url.clone(),
159 )
160 .with_socket_control(SocketControl::new(
161 core.client_id,
162 Some(*BYBIT_VENUE),
163 "bybit-user-streams",
164 ));
165
166 if let Some(secs) = config.auth_timeout_secs {
167 ws_private.set_auth_wait_timeout(Duration::from_secs(secs));
168 }
169
170 let mut ws_trade = BybitWebSocketClient::new_trade(
171 config.environment,
172 Some(api_key),
173 Some(api_secret),
174 Some(config.ws_trade_url()),
175 config.heartbeat_interval_secs,
176 config.transport_backend,
177 proxy_url,
178 )
179 .with_socket_control(SocketControl::new(
180 core.client_id,
181 Some(*BYBIT_VENUE),
182 "bybit-trading",
183 ));
184
185 if let Some(secs) = config.auth_timeout_secs {
186 ws_trade.set_auth_wait_timeout(Duration::from_secs(secs));
187 }
188 ws_trade.set_recv_window_ms(config.recv_window_ms);
189
190 let clock = get_atomic_clock_realtime();
191 let emitter = ExecutionEventEmitter::new(
192 clock,
193 core.trader_id,
194 core.account_id,
195 core.account_type,
196 None,
197 );
198
199 let session_tasks = TaskGroup::new();
200 let pending_tasks = TaskGroup::new();
201
202 Ok(Self {
203 core,
204 clock,
205 config,
206 emitter,
207 http_client,
208 ws_private,
209 ws_trade,
210 session_tasks,
211 pending_tasks,
212 shutdown_errors: Vec::new(),
213 instruments_cache: Arc::new(AHashMap::new()),
214 dispatch_state: Arc::new(WsDispatchState::default()),
215 })
216 }
217
218 fn product_types(&self) -> Vec<BybitProductType> {
219 if self.config.product_types.is_empty() {
220 vec![BybitProductType::Linear]
221 } else {
222 self.config.product_types.clone()
223 }
224 }
225
226 fn update_account_state(&self) {
227 let http_client = self.http_client.clone();
228 let account_id = self.core.account_id;
229 let emitter = self.emitter.clone();
230
231 self.spawn_task("query_account", async move {
232 let account_state = http_client
233 .request_account_state(BybitAccountType::Unified, account_id)
234 .await
235 .context("failed to request Bybit account state")?;
236 emitter.send_account_state(account_state);
237 Ok(())
238 });
239 }
240
241 fn spawn_task<F>(&self, description: &'static str, fut: F)
242 where
243 F: Future<Output = anyhow::Result<()>> + Send + 'static,
244 {
245 let future = async move {
246 if let Err(e) = fut.await {
247 log::warn!("{description} failed: {e:?}");
248 }
249 };
250
251 if let Err(e) = self.pending_tasks.spawn(future) {
252 log::warn!("Skipping Bybit {description} after shutdown began: {e}");
253 }
254 }
255
256 fn abort_pending_tasks(&self) {
257 self.pending_tasks.begin_shutdown();
258 }
259
260 fn abort_session_tasks(&self) {
261 self.session_tasks.begin_shutdown();
262 }
263
264 async fn await_pending_tasks(&self) -> anyhow::Result<()> {
265 self.pending_tasks.begin_shutdown();
266 self.pending_tasks
267 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
268 .await
269 .map_err(|e| anyhow::anyhow!("Failed to terminate Bybit execution tasks: {e}"))?;
270 Ok(())
271 }
272
273 async fn await_session_tasks(&self) -> anyhow::Result<()> {
274 self.session_tasks.begin_shutdown();
275 self.session_tasks
276 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2))
277 .await
278 .map_err(|e| {
279 anyhow::anyhow!("Failed to terminate Bybit execution session tasks: {e}")
280 })?;
281 Ok(())
282 }
283
284 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
285 self.abort_session_tasks();
286 self.abort_pending_tasks();
287 self.http_client.cancel_all_requests();
288 self.ws_private.begin_shutdown();
289 self.ws_trade.begin_shutdown();
290
291 if let Err(e) = self.ws_private.close().await {
292 self.shutdown_errors
293 .push(format!("private WebSocket shutdown failed: {e}"));
294 }
295
296 if let Err(e) = self.ws_trade.close().await {
297 self.shutdown_errors
298 .push(format!("trade WebSocket shutdown failed: {e}"));
299 }
300 self.dispatch_state.clear_repay_sender();
301
302 if let Err(e) = self.await_session_tasks().await {
303 self.shutdown_errors.push(e.to_string());
304 }
305
306 if let Err(e) = self.await_pending_tasks().await {
307 self.shutdown_errors.push(e.to_string());
308 }
309 self.core.set_disconnected();
310
311 if self.shutdown_errors.is_empty() {
312 Ok(())
313 } else {
314 let errors = std::mem::take(&mut self.shutdown_errors);
315 anyhow::bail!("Bybit execution shutdown failed: {}", errors.join("; "))
316 }
317 }
318
319 async fn await_account_registered(&self, timeout_secs: f64) -> anyhow::Result<()> {
321 let account_id = self.core.account_id;
322
323 if self.core.cache().account(&account_id).is_some() {
324 log::info!("Account {account_id} registered");
325 return Ok(());
326 }
327
328 let start = Instant::now();
329 let timeout = Duration::from_secs_f64(timeout_secs);
330 let interval = Duration::from_millis(10);
331
332 loop {
333 tokio::time::sleep(interval).await;
334
335 if self.core.cache().account(&account_id).is_some() {
336 log::info!("Account {account_id} registered");
337 return Ok(());
338 }
339
340 if start.elapsed() >= timeout {
341 anyhow::bail!(
342 "Timeout waiting for account {account_id} to be registered after {timeout_secs}s"
343 );
344 }
345 }
346 }
347
348 fn get_product_type_for_instrument(&self, instrument_id: InstrumentId) -> BybitProductType {
349 BybitProductType::from_suffix(instrument_id.symbol.as_str()).unwrap_or_else(|| {
350 log::warn!("No product-type suffix on {instrument_id}, defaulting to Linear");
351 BybitProductType::Linear
352 })
353 }
354
355 const fn provides_bulk_position_coverage_for_product_type(
356 product_type: BybitProductType,
357 ) -> bool {
358 !matches!(product_type, BybitProductType::Spot)
359 }
360
361 async fn generate_bulk_position_status_reports(
362 &self,
363 product_types: Vec<BybitProductType>,
364 ) -> anyhow::Result<Vec<PositionStatusReport>> {
365 let mut reports = Vec::new();
366
367 for product_type in product_types {
368 if !Self::provides_bulk_position_coverage_for_product_type(product_type) {
369 continue;
370 }
371
372 let mut fetched = self
373 .http_client
374 .request_position_status_reports(self.core.account_id, product_type, None)
375 .await?;
376 reports.append(&mut fetched);
377 }
378
379 Ok(reports)
380 }
381
382 fn resolve_position_idx(
383 &self,
384 instrument_id: InstrumentId,
385 order_side: BybitOrderSide,
386 is_reduce_only: bool,
387 manual_override: Option<BybitPositionIdx>,
388 ) -> Option<BybitPositionIdx> {
389 let product_type = self.get_product_type_for_instrument(instrument_id);
390 if !matches!(
391 product_type,
392 BybitProductType::Linear | BybitProductType::Inverse
393 ) {
394 return None;
395 }
396 let mode = self
397 .config
398 .position_mode
399 .as_ref()
400 .and_then(|map| map.get(instrument_id.symbol.as_str()).copied());
401 resolve_bybit_position_idx(mode, order_side, is_reduce_only, manual_override)
402 }
403
404 fn resolve_smp_type(
405 &self,
406 manual_override: Option<BybitOrderSmpType>,
407 ) -> Option<BybitOrderSmpType> {
408 manual_override.or(self.config.smp_type)
409 }
410
411 async fn apply_account_configuration(&self) -> anyhow::Result<()> {
412 self.apply_leverages_setting().await;
413 self.apply_position_modes_setting().await;
414 self.apply_margin_mode_setting().await
415 }
416
417 async fn apply_leverages_setting(&self) {
418 let Some(leverages) = &self.config.futures_leverages else {
419 return;
420 };
421
422 for (symbol_str, leverage) in leverages {
423 self.apply_leverage_entry(symbol_str, *leverage).await;
424 }
425 }
426
427 async fn apply_leverage_entry(&self, symbol_str: &str, leverage: u32) {
428 let Some(symbol) = Self::parse_derivative_symbol(symbol_str) else {
429 return;
430 };
431 let lev = leverage.to_string();
432 let result = self
433 .http_client
434 .set_leverage(symbol.product_type(), symbol.raw_symbol(), &lev, &lev)
435 .await;
436
437 match result {
438 Ok(_) => log::info!("Set leverage for {symbol_str} to {leverage}"),
439 Err(e) if Self::is_unchanged_error(&e, "110043") => {
440 log::debug!("Leverage already set for {symbol_str} to {leverage}");
441 }
442 Err(e) => log::error!("Failed to set leverage for {symbol_str}: {e}"),
443 }
444 }
445
446 async fn apply_position_modes_setting(&self) {
447 let Some(modes) = &self.config.position_mode else {
448 return;
449 };
450
451 for (symbol_str, mode) in modes {
452 self.apply_position_mode_entry(symbol_str, *mode).await;
453 }
454 }
455
456 async fn apply_position_mode_entry(&self, symbol_str: &str, mode: BybitPositionMode) {
457 let Some(symbol) = Self::parse_derivative_symbol(symbol_str) else {
458 return;
459 };
460 let result = self
461 .http_client
462 .switch_mode(
463 symbol.product_type(),
464 mode,
465 Some(symbol.raw_symbol().to_string()),
466 None,
467 )
468 .await;
469
470 match result {
471 Ok(_) => log::info!("Set symbol `{symbol_str}` position mode to `{mode:?}`"),
472 Err(e) if Self::is_unchanged_error(&e, "110025") => {
473 log::debug!("Symbol `{symbol_str}` position mode already set to `{mode:?}`");
474 }
475 Err(e) => log::error!("Failed to set position mode for {symbol_str}: {e}"),
476 }
477 }
478
479 async fn apply_margin_mode_setting(&self) -> anyhow::Result<()> {
480 let Some(margin_mode) = self.config.margin_mode else {
481 return Ok(());
482 };
483
484 let result = self.http_client.set_margin_mode(margin_mode).await;
485
486 match result {
487 Ok(_) => {
488 log::info!("Set account margin mode to {margin_mode:?}");
489 Ok(())
490 }
491 Err(e) if Self::is_unchanged_error(&e, "") => {
492 log::debug!("Margin mode already set to {margin_mode:?}");
493 Ok(())
494 }
495 Err(e) if Self::is_low_margin_error(&e) => {
496 log::warn!("Cannot set margin mode: {e}");
497 Ok(())
498 }
499 Err(e) => Err(anyhow::Error::from(e).context("failed to set margin mode")),
500 }
501 }
502
503 fn parse_derivative_symbol(symbol_str: &str) -> Option<BybitSymbol> {
504 let symbol = match BybitSymbol::new(symbol_str) {
505 Ok(s) => s,
506 Err(e) => {
507 log::warn!("Failed to parse symbol {symbol_str}: {e}");
508 return None;
509 }
510 };
511 matches!(
512 symbol.product_type(),
513 BybitProductType::Linear | BybitProductType::Inverse
514 )
515 .then_some(symbol)
516 }
517
518 fn is_unchanged_error<E: std::fmt::Display>(err: &E, code: &str) -> bool {
519 let msg = err.to_string().to_lowercase();
520 if msg.contains("not been modified") {
521 return true;
522 }
523 !code.is_empty() && msg.contains(code)
524 }
525
526 fn is_low_margin_error<E: std::fmt::Display>(err: &E) -> bool {
527 err.to_string()
528 .contains("needs to be equal to or greater than")
529 }
530
531 fn map_order_type(order_type: OrderType) -> anyhow::Result<(BybitOrderType, bool)> {
532 match order_type {
533 OrderType::Market => Ok((BybitOrderType::Market, false)),
534 OrderType::Limit => Ok((BybitOrderType::Limit, false)),
535 OrderType::StopMarket | OrderType::MarketIfTouched => {
536 Ok((BybitOrderType::Market, true))
537 }
538 OrderType::StopLimit | OrderType::LimitIfTouched => Ok((BybitOrderType::Limit, true)),
539 _ => anyhow::bail!("unsupported order type for Bybit: {order_type}"),
540 }
541 }
542
543 fn map_time_in_force(tif: TimeInForce, is_post_only: bool) -> BybitTimeInForce {
544 if is_post_only {
545 return BybitTimeInForce::PostOnly;
546 }
547
548 match tif {
549 TimeInForce::Gtc => BybitTimeInForce::Gtc,
550 TimeInForce::Ioc => BybitTimeInForce::Ioc,
551 TimeInForce::Fok => BybitTimeInForce::Fok,
552 _ => BybitTimeInForce::Gtc,
553 }
554 }
555
556 fn validate_bbo_params(
557 order: &OrderAny,
558 product_type: BybitProductType,
559 tp_sl: &BybitTpSlParams,
560 ) -> anyhow::Result<()> {
561 if !tp_sl.has_bbo() {
562 return Ok(());
563 }
564
565 anyhow::ensure!(
566 matches!(
567 product_type,
568 BybitProductType::Linear | BybitProductType::Inverse
569 ),
570 "`bbo_side_type` and `bbo_level` are only supported for Bybit linear and inverse products"
571 );
572
573 let order_type = order.order_type();
574 anyhow::ensure!(
575 matches!(
576 order_type,
577 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
578 ),
579 "`bbo_side_type` and `bbo_level` are not supported for order type {order_type:?}"
580 );
581
582 Ok(())
583 }
584
585 fn build_ws_place_params(
586 order: &OrderAny,
587 product_type: BybitProductType,
588 raw_symbol: &str,
589 tp_sl: &BybitTpSlParams,
590 position_idx: Option<BybitPositionIdx>,
591 smp_type: Option<BybitOrderSmpType>,
592 ) -> anyhow::Result<BybitWsPlaceOrderParams> {
593 let bybit_side = BybitOrderSide::from(order.order_side());
594 let (bybit_order_type, is_conditional) = Self::map_order_type(order.order_type())?;
595 let has_tp_sl = tp_sl.has_tp_sl();
596 let trigger_dir = trigger_direction(order.order_type(), order.order_side(), is_conditional);
597
598 Ok(BybitWsPlaceOrderParams {
599 category: product_type,
600 symbol: Ustr::from(raw_symbol),
601 side: bybit_side,
602 order_type: bybit_order_type,
603 qty: order.quantity().to_string(),
604 is_leverage: spot_leverage(product_type, tp_sl.is_leverage),
605 market_unit: spot_market_unit(
606 product_type,
607 bybit_order_type,
608 order.is_quote_quantity(),
609 ),
610 price: if tp_sl.has_bbo() {
611 None
612 } else {
613 order.price().map(|p: Price| p.to_string())
614 },
615 time_in_force: if bybit_order_type == BybitOrderType::Market {
616 None
617 } else {
618 Some(Self::map_time_in_force(
619 order.time_in_force(),
620 order.is_post_only(),
621 ))
622 },
623 order_link_id: Some(order.client_order_id().to_string()),
624 reduce_only: if order.is_reduce_only() {
625 Some(true)
626 } else {
627 None
628 },
629 close_on_trigger: tp_sl.close_on_trigger,
630 trigger_price: order.trigger_price().map(|p: Price| p.to_string()),
631 trigger_by: if is_conditional {
632 Some(resolve_trigger_type(order.trigger_type()))
633 } else {
634 None
635 },
636 trigger_direction: trigger_dir.map(|d| d as i32),
637 tpsl_mode: tp_sl.tpsl_mode.or(has_tp_sl.then_some(BybitTpSlMode::Full)),
638 take_profit: tp_sl.take_profit.map(|p| p.to_string()),
639 stop_loss: tp_sl.stop_loss.map(|p| p.to_string()),
640 tp_trigger_by: tp_sl.tp_trigger_by.or(tp_sl
641 .take_profit
642 .map(|_| resolve_trigger_type(order.trigger_type()))),
643 sl_trigger_by: tp_sl.sl_trigger_by.or(tp_sl
644 .stop_loss
645 .map(|_| resolve_trigger_type(order.trigger_type()))),
646 sl_trigger_price: tp_sl.sl_trigger_price.clone(),
647 tp_trigger_price: tp_sl.tp_trigger_price.clone(),
648 sl_order_type: tp_sl.sl_order_type,
649 tp_order_type: tp_sl.tp_order_type,
650 sl_limit_price: tp_sl.sl_limit_price.clone(),
651 tp_limit_price: tp_sl.tp_limit_price.clone(),
652 order_iv: tp_sl.order_iv.clone(),
653 smp_type,
654 mmp: tp_sl.mmp,
655 position_idx,
656 bbo_side_type: tp_sl.bbo_side_type,
657 bbo_level: tp_sl.bbo_level.clone(),
658 })
659 }
660}
661
662fn submit_rejection_reason(error: &anyhow::Error) -> Option<&str> {
663 for cause in error.chain() {
664 if let Some(submit_error) = cause.downcast_ref::<BybitSubmitOrderError>() {
665 return match submit_error {
666 BybitSubmitOrderError::Rejected { reason } => Some(reason.as_str()),
667 BybitSubmitOrderError::MissingOrderId
668 | BybitSubmitOrderError::PostSubmitLookup { .. } => None,
669 };
670 }
671
672 if let Some(BybitHttpError::BybitError {
673 error_code,
674 message,
675 }) = cause.downcast_ref()
676 && !is_bybit_ambiguous_order_error_code(i64::from(*error_code))
677 {
678 return Some(message.as_str());
679 }
680 }
681 None
682}
683
684#[async_trait(?Send)]
685impl ExecutionClient for BybitExecutionClient {
686 fn is_connected(&self) -> bool {
687 self.core.is_connected()
688 }
689
690 fn client_id(&self) -> ClientId {
691 self.core.client_id
692 }
693
694 fn account_id(&self) -> AccountId {
695 self.core.account_id
696 }
697
698 fn venue(&self) -> Venue {
699 *BYBIT_VENUE
700 }
701
702 fn oms_type(&self) -> OmsType {
703 self.core.oms_type
704 }
705
706 fn get_account(&self) -> Option<AccountAny> {
707 self.core.cache().account_owned(&self.core.account_id)
708 }
709
710 fn provides_bulk_position_coverage(&self, instrument_id: InstrumentId) -> bool {
711 let Some(product_type) = BybitProductType::from_suffix(instrument_id.symbol.as_str())
716 else {
717 return false;
718 };
719
720 self.product_types().contains(&product_type)
721 && Self::provides_bulk_position_coverage_for_product_type(product_type)
722 }
723
724 async fn connect(&mut self) -> anyhow::Result<()> {
725 if self.core.is_connected() && self.pending_tasks.is_open() && self.session_tasks.is_open()
726 {
727 return Ok(());
728 }
729
730 if !self.pending_tasks.is_open() || !self.session_tasks.is_open() {
731 self.teardown_partial_connect().await?;
732 }
733
734 if !self.pending_tasks.is_open() {
735 self.await_pending_tasks().await?;
736 self.pending_tasks
737 .start_generation()
738 .map_err(|e| anyhow::anyhow!("Failed to start Bybit task generation: {e}"))?;
739 }
740
741 if !self.session_tasks.is_open() {
742 self.await_session_tasks().await?;
743 self.session_tasks
744 .start_generation()
745 .map_err(|e| anyhow::anyhow!("Failed to start Bybit session generation: {e}"))?;
746 }
747 let http_client = self.http_client.clone();
748 let ws_private = self.ws_private.clone();
749 let ws_trade = self.ws_trade.clone();
750 let setup_guard =
751 TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
752 http_client.cancel_all_requests();
753 ws_private.begin_shutdown();
754 ws_trade.begin_shutdown();
755 });
756
757 let connect_result: anyhow::Result<()> = async {
758 self.http_client.reset_cancellation_token();
760
761 let product_types = self.product_types();
762
763 if !self.core.instruments_initialized() {
764 let mut all_instruments = Vec::new();
765
766 for product_type in &product_types {
767 let instruments = self
768 .http_client
769 .request_instruments(*product_type, None, None)
770 .await
771 .with_context(|| {
772 format!("failed to request Bybit instruments for {product_type:?}")
773 })?;
774
775 if instruments.is_empty() {
776 log::warn!("No instruments returned for {product_type:?}");
777 continue;
778 }
779
780 log::debug!("Loaded {} {product_type:?} instruments", instruments.len());
781
782 self.http_client.cache_instruments(&instruments);
783 all_instruments.extend(instruments);
784 }
785
786 if !all_instruments.is_empty() {
787 let mut instruments_map = AHashMap::new();
788 for instrument in &all_instruments {
789 instruments_map.insert(instrument.id().symbol.inner(), instrument.clone());
790 }
791 self.instruments_cache = Arc::new(instruments_map);
792 }
793 self.core.set_instruments_initialized();
794 }
795
796 self.ws_private.set_account_id(self.core.account_id);
797 self.ws_trade.set_account_id(self.core.account_id);
798
799 self.ws_private.connect().await?;
800 self.ws_private.wait_until_active(10.0).await?;
801 log::debug!("Connected to private WebSocket");
802
803 let stream = self.ws_private.stream();
804 let emitter = self.emitter.clone();
805 let account_id = self.core.account_id;
806 let instruments = Arc::clone(&self.instruments_cache);
807 let state = Arc::clone(&self.dispatch_state);
808 let clock = self.clock;
809
810 self.session_tasks.spawn(async move {
811 pin_mut!(stream);
812 while let Some(message) = stream.next().await {
813 dispatch_ws_message(
814 &message,
815 &emitter,
816 &state,
817 account_id,
818 &instruments,
819 clock,
820 );
821 }
822 })?;
823
824 if self.config.environment == BybitEnvironment::Demo {
826 log::info!("Demo mode: Trade WebSocket not available, orders use HTTP REST API");
827 } else {
828 self.ws_trade.connect().await?;
829 self.ws_trade.wait_until_active(10.0).await?;
830 log::debug!("Connected to trade WebSocket");
831
832 let stream = self.ws_trade.stream();
833 let emitter = self.emitter.clone();
834 let account_id = self.core.account_id;
835 let instruments = Arc::clone(&self.instruments_cache);
836 let state = Arc::clone(&self.dispatch_state);
837 let clock = self.clock;
838
839 self.session_tasks.spawn(async move {
840 pin_mut!(stream);
841 while let Some(message) = stream.next().await {
842 dispatch_ws_message(
843 &message,
844 &emitter,
845 &state,
846 account_id,
847 &instruments,
848 clock,
849 );
850 }
851 })?;
852 }
853
854 if self.config.auto_repay_spot_borrows {
855 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
856 self.dispatch_state.set_repay_sender(tx);
857
858 let http_client = self.http_client.clone();
859 let clock = self.clock;
860 self.session_tasks.spawn(async move {
861 crate::repay::run_spot_repay_consumer(rx, http_client, clock).await;
862 })?;
863 }
864
865 self.ws_private.subscribe_orders().await?;
866 self.ws_private.subscribe_executions().await?;
867 self.ws_private.subscribe_positions().await?;
868 self.ws_private.subscribe_wallet().await?;
869
870 self.apply_account_configuration().await?;
871
872 let account_state = self
873 .http_client
874 .request_account_state(BybitAccountType::Unified, self.core.account_id)
875 .await
876 .context("failed to request Bybit account state")?;
877
878 if !account_state.balances.is_empty() {
879 log::debug!(
880 "Received account state with {} balance(s)",
881 account_state.balances.len()
882 );
883 }
884 self.emitter.send_account_state(account_state);
885
886 self.await_account_registered(30.0).await?;
887
888 Ok(())
889 }
890 .await;
891
892 if let Err(e) = connect_result {
893 if let Err(teardown_error) = self.teardown_partial_connect().await {
894 return Err(e.context(format!("Bybit startup teardown failed: {teardown_error}")));
895 }
896 return Err(e);
897 }
898
899 setup_guard.disarm();
900 self.core.set_connected();
901 log::info!("Connected: client_id={}", self.core.client_id);
902 Ok(())
903 }
904
905 async fn disconnect(&mut self) -> anyhow::Result<()> {
906 self.teardown_partial_connect().await?;
907 log::info!("Disconnected: client_id={}", self.core.client_id);
908 Ok(())
909 }
910
911 fn query_account(&self, _cmd: QueryAccount) -> anyhow::Result<()> {
912 self.update_account_state();
913 Ok(())
914 }
915
916 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
917 let instrument_id = cmd.instrument_id;
918 let product_type = self.get_product_type_for_instrument(instrument_id);
919 let client_order_id = cmd.client_order_id;
920 let venue_order_id = cmd.venue_order_id;
921 let account_id = self.core.account_id;
922 let http_client = self.http_client.clone();
923 let emitter = self.emitter.clone();
924
925 self.spawn_task("query_order", async move {
926 match http_client
927 .query_order(
928 account_id,
929 product_type,
930 instrument_id,
931 Some(client_order_id),
932 venue_order_id,
933 )
934 .await
935 {
936 Ok(Some(report)) => {
937 emitter.send_order_status_report(report);
938 }
939 Ok(None) => {
940 log::warn!("Order not found: client_order_id={client_order_id}, venue_order_id={venue_order_id:?}");
941 }
942 Err(e) => {
943 log::warn!("Failed to query order: {e}");
944 }
945 }
946 Ok(())
947 });
948
949 Ok(())
950 }
951
952 fn generate_account_state(
953 &self,
954 balances: Vec<AccountBalance>,
955 margins: Vec<MarginBalance>,
956 reported: bool,
957 ts_event: UnixNanos,
958 info: Option<Params>,
959 ) -> anyhow::Result<()> {
960 self.emitter
961 .emit_account_state(balances, margins, reported, ts_event, info);
962 Ok(())
963 }
964
965 fn start(&mut self) -> anyhow::Result<()> {
966 if self.core.is_started() {
967 return Ok(());
968 }
969
970 let sender = get_exec_event_sender();
971 self.emitter.set_sender(sender);
972 self.core.set_started();
973
974 let http_client = self.http_client.clone();
975 let product_types = self.config.product_types.clone();
976
977 self.session_tasks.spawn(async move {
978 let mut all_instruments = Vec::new();
979
980 for product_type in product_types {
981 match http_client
982 .request_instruments(product_type, None, None)
983 .await
984 {
985 Ok(instruments) => {
986 if instruments.is_empty() {
987 log::warn!("No instruments returned for {product_type:?}");
988 continue;
989 }
990 http_client.cache_instruments(&instruments);
991 all_instruments.extend(instruments);
992 }
993 Err(e) => {
994 log::error!("Failed to request instruments for {product_type:?}: {e}");
995 }
996 }
997 }
998
999 if all_instruments.is_empty() {
1000 log::warn!(
1001 "Instrument bootstrap yielded no instruments; WebSocket submissions may fail"
1002 );
1003 } else {
1004 log::debug!("Instruments initialized: count={}", all_instruments.len());
1005 }
1006 })?;
1007
1008 log::info!(
1009 "Started: client_id={}, account_id={}, account_type={:?}, product_types={:?}, environment={:?}, proxy_url={:?}",
1010 self.core.client_id,
1011 self.core.account_id,
1012 self.core.account_type,
1013 self.config.product_types,
1014 self.config.environment,
1015 self.config.proxy_url,
1016 );
1017 Ok(())
1018 }
1019
1020 fn stop(&mut self) -> anyhow::Result<()> {
1021 if self.core.is_stopped() {
1022 return Ok(());
1023 }
1024
1025 self.core.set_stopped();
1026 self.core.set_disconnected();
1027
1028 self.abort_session_tasks();
1029 self.abort_pending_tasks();
1030 self.http_client.cancel_all_requests();
1031 self.ws_private.begin_shutdown();
1032 self.ws_trade.begin_shutdown();
1033 log::info!("Stopped: client_id={}", self.core.client_id);
1034 Ok(())
1035 }
1036
1037 async fn generate_order_status_report(
1038 &self,
1039 cmd: &GenerateOrderStatusReport,
1040 ) -> anyhow::Result<Option<OrderStatusReport>> {
1041 let Some(instrument_id) = cmd.instrument_id else {
1042 log::warn!("generate_order_status_report requires instrument_id: {cmd}");
1043 return Ok(None);
1044 };
1045
1046 let product_type = self.get_product_type_for_instrument(instrument_id);
1047
1048 let mut reports = self
1049 .http_client
1050 .request_order_status_reports(
1051 self.core.account_id,
1052 product_type,
1053 Some(instrument_id),
1054 false,
1055 None,
1056 None,
1057 None,
1058 )
1059 .await?;
1060
1061 if let Some(client_order_id) = cmd.client_order_id {
1062 reports.retain(|report| report.client_order_id == Some(client_order_id));
1063 }
1064
1065 if let Some(venue_order_id) = cmd.venue_order_id {
1066 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
1067 }
1068
1069 let report = reports.into_iter().next();
1070 if let Some(report) = &report {
1071 self.cache_reconciliation_order_identity(report);
1072 }
1073
1074 Ok(report)
1075 }
1076
1077 async fn generate_order_status_reports(
1078 &self,
1079 cmd: &GenerateOrderStatusReports,
1080 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1081 let mut reports = Vec::new();
1082
1083 if let Some(instrument_id) = cmd.instrument_id {
1084 let product_type = self.get_product_type_for_instrument(instrument_id);
1085 let mut fetched = self
1086 .http_client
1087 .request_order_status_reports(
1088 self.core.account_id,
1089 product_type,
1090 Some(instrument_id),
1091 cmd.open_only,
1092 None,
1093 None,
1094 None,
1095 )
1096 .await?;
1097 reports.append(&mut fetched);
1098 } else {
1099 for product_type in self.product_types() {
1100 let mut fetched = self
1101 .http_client
1102 .request_order_status_reports(
1103 self.core.account_id,
1104 product_type,
1105 None,
1106 cmd.open_only,
1107 None,
1108 None,
1109 None,
1110 )
1111 .await?;
1112 reports.append(&mut fetched);
1113 }
1114 }
1115
1116 if let Some(start) = cmd.start {
1117 reports.retain(|r| r.ts_last >= start);
1118 }
1119
1120 if let Some(end) = cmd.end {
1121 reports.retain(|r| r.ts_last <= end);
1122 }
1123
1124 for report in &reports {
1125 self.cache_reconciliation_order_identity(report);
1126 }
1127
1128 Ok(reports)
1129 }
1130
1131 async fn generate_fill_reports(
1132 &self,
1133 cmd: GenerateFillReports,
1134 ) -> anyhow::Result<Vec<FillReport>> {
1135 let start_ms = nanos_to_millis(cmd.start);
1136 let end_ms = nanos_to_millis(cmd.end);
1137 let mut reports = Vec::new();
1138
1139 if let Some(instrument_id) = cmd.instrument_id {
1140 let product_type = self.get_product_type_for_instrument(instrument_id);
1141 let mut fetched = self
1142 .http_client
1143 .request_fill_reports(
1144 self.core.account_id,
1145 product_type,
1146 Some(instrument_id),
1147 start_ms,
1148 end_ms,
1149 None,
1150 )
1151 .await?;
1152 reports.append(&mut fetched);
1153 } else {
1154 for product_type in self.product_types() {
1155 let mut fetched = self
1156 .http_client
1157 .request_fill_reports(
1158 self.core.account_id,
1159 product_type,
1160 None,
1161 start_ms,
1162 end_ms,
1163 None,
1164 )
1165 .await?;
1166 reports.append(&mut fetched);
1167 }
1168 }
1169
1170 if let Some(venue_order_id) = cmd.venue_order_id {
1171 reports.retain(|report| report.venue_order_id.as_str() == venue_order_id.as_str());
1172 }
1173
1174 Ok(reports)
1175 }
1176
1177 async fn generate_position_status_reports(
1178 &self,
1179 cmd: &GeneratePositionStatusReports,
1180 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1181 if let Some(instrument_id) = cmd.instrument_id {
1182 let product_type = self.get_product_type_for_instrument(instrument_id);
1183 self.http_client
1184 .request_position_status_reports(
1185 self.core.account_id,
1186 product_type,
1187 Some(instrument_id),
1188 )
1189 .await
1190 } else {
1191 self.generate_bulk_position_status_reports(self.product_types())
1192 .await
1193 }
1194 }
1195
1196 async fn generate_mass_status(
1197 &self,
1198 lookback_mins: Option<u64>,
1199 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
1200 log::info!("Generating ExecutionMassStatus (lookback_mins={lookback_mins:?})");
1201
1202 let ts_now = self.clock.get_time_ns();
1203
1204 let start = lookback_mins
1205 .map(DurationNanos::try_from_mins)
1206 .transpose()?
1207 .map(|lookback| ts_now.saturating_sub(lookback));
1208
1209 let order_cmd = GenerateOrderStatusReportsBuilder::default()
1210 .ts_init(ts_now)
1211 .open_only(false)
1212 .start(start)
1213 .build()
1214 .map_err(|e| anyhow::anyhow!("{e}"))?;
1215
1216 let fill_cmd = GenerateFillReportsBuilder::default()
1217 .ts_init(ts_now)
1218 .start(start)
1219 .build()
1220 .map_err(|e| anyhow::anyhow!("{e}"))?;
1221
1222 let position_reports_fut = async {
1223 let product_types = self.product_types();
1224 let skip_spot = product_types.iter().any(|product_type| {
1225 !Self::provides_bulk_position_coverage_for_product_type(*product_type)
1226 });
1227
1228 if skip_spot {
1229 log::info!(
1230 "SPOT mass-status position coverage is unavailable because wallet balances cannot be attributed to pairs"
1231 );
1232 }
1233
1234 self.generate_bulk_position_status_reports(product_types)
1235 .await
1236 };
1237
1238 let (order_reports, fill_reports, position_reports) = tokio::try_join!(
1239 self.generate_order_status_reports(&order_cmd),
1240 self.generate_fill_reports(fill_cmd),
1241 position_reports_fut,
1242 )?;
1243
1244 log::info!("Received {} OrderStatusReports", order_reports.len());
1245 log::info!("Received {} FillReports", fill_reports.len());
1246 log::info!("Received {} PositionReports", position_reports.len());
1247
1248 let mut mass_status = ExecutionMassStatus::new(
1249 self.core.client_id,
1250 self.core.account_id,
1251 *BYBIT_VENUE,
1252 ts_now,
1253 None,
1254 );
1255
1256 mass_status.add_order_reports(order_reports);
1257 mass_status.add_fill_reports(fill_reports);
1258 mass_status.add_position_reports(position_reports);
1259
1260 Ok(Some(mass_status))
1261 }
1262
1263 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
1264 let order = self.core.cache().try_order_owned(&cmd.client_order_id)?;
1265 if order.is_closed() {
1266 log::warn!("Cannot submit closed order {}", order.client_order_id());
1267 return Ok(());
1268 }
1269
1270 let instrument_id = order.instrument_id();
1271 let product_type = self.get_product_type_for_instrument(instrument_id);
1272
1273 let Ok((_, is_conditional)) = Self::map_order_type(order.order_type()) else {
1275 let denied = OrderDeniedReason::UnsupportedOrderType {
1276 order_type: order.order_type(),
1277 };
1278 self.emitter.emit_order_denied(&order, &denied.to_string());
1279 return Ok(());
1280 };
1281
1282 if product_type == BybitProductType::Option && is_conditional {
1284 let denied = OrderDeniedReason::UnsupportedOrderType {
1285 order_type: order.order_type(),
1286 };
1287 self.emitter.emit_order_denied(&order, &denied.to_string());
1288 return Ok(());
1289 }
1290
1291 let tp_sl = match parse_bybit_tp_sl_params(cmd.params.as_ref()) {
1292 Ok(p) => p,
1293 Err(e) => {
1294 let denied = OrderDeniedReason::ValidationFailed {
1295 detail: e.to_string(),
1296 };
1297 self.emitter.emit_order_denied(&order, &denied.to_string());
1298 return Ok(());
1299 }
1300 };
1301
1302 if let Err(e) = Self::validate_bbo_params(&order, product_type, &tp_sl) {
1303 let denied = OrderDeniedReason::ValidationFailed {
1304 detail: e.to_string(),
1305 };
1306 self.emitter.emit_order_denied(&order, &denied.to_string());
1307 return Ok(());
1308 }
1309
1310 if self.config.environment == BybitEnvironment::Demo
1313 && (tp_sl.tp_trigger_price.is_some() || tp_sl.sl_trigger_price.is_some())
1314 {
1315 let denied = OrderDeniedReason::UnsupportedTpSl {
1316 detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
1317 };
1318 self.emitter.emit_order_denied(&order, &denied.to_string());
1319 return Ok(());
1320 }
1321
1322 log::debug!("OrderSubmitted client_order_id={}", order.client_order_id());
1323 self.emitter.emit_order_submitted(&order);
1324
1325 let client_order_id = order.client_order_id();
1326 let strategy_id = order.strategy_id();
1327 let emitter = self.emitter.clone();
1328 let clock = self.clock;
1329
1330 let bybit_side = BybitOrderSide::from(order.order_side());
1331 let position_idx = self.resolve_position_idx(
1332 instrument_id,
1333 bybit_side,
1334 order.is_reduce_only(),
1335 tp_sl.position_idx,
1336 );
1337 let venue_position_id =
1338 position_idx.and_then(|idx| make_hedge_venue_position_id(instrument_id, idx as i32));
1339
1340 self.dispatch_state.order_identities.insert(
1341 client_order_id,
1342 OrderIdentity {
1343 instrument_id,
1344 strategy_id,
1345 order_side: order.order_side(),
1346 order_type: order.order_type(),
1347 venue_position_id,
1348 },
1349 );
1350
1351 self.dispatch_state.order_snapshots.insert(
1353 client_order_id,
1354 OrderStateSnapshot {
1355 quantity: order.quantity(),
1356 price: order.price(),
1357 trigger_price: order.trigger_price(),
1358 },
1359 );
1360
1361 let smp_type = self.resolve_smp_type(tp_sl.smp_type);
1362
1363 if self.config.environment == BybitEnvironment::Demo {
1364 let http_client = self.http_client.clone();
1365 let account_id = self.core.account_id;
1366 let order_side = order.order_side();
1367 let order_type = order.order_type();
1368 let quantity = order.quantity();
1369 let time_in_force = order.time_in_force();
1370 let price = order.price();
1371 let trigger_price = order.trigger_price();
1372 let post_only = order.is_post_only();
1373 let reduce_only = order.is_reduce_only();
1374 let is_quote_quantity = order.is_quote_quantity();
1375 let is_leverage = tp_sl.is_leverage;
1376 let bbo_side_type = tp_sl.bbo_side_type;
1377 let bbo_level = tp_sl.bbo_level.clone();
1378 let native_tp_sl = tp_sl.to_native_tp_sl();
1379 let dispatch_state = Arc::clone(&self.dispatch_state);
1380
1381 self.spawn_task("submit_order_http", async move {
1382 let native_tp_sl_ref = (!native_tp_sl.is_empty()).then_some(&native_tp_sl);
1383 let result = http_client
1384 .submit_order(
1385 account_id,
1386 product_type,
1387 instrument_id,
1388 client_order_id,
1389 order_side,
1390 order_type,
1391 quantity,
1392 Some(time_in_force),
1393 price,
1394 trigger_price,
1395 Some(post_only),
1396 reduce_only,
1397 is_quote_quantity,
1398 is_leverage,
1399 position_idx,
1400 bbo_side_type,
1401 bbo_level,
1402 smp_type,
1403 native_tp_sl_ref,
1404 )
1405 .await;
1406
1407 if let Err(e) = result {
1408 if let Some(reason) = submit_rejection_reason(&e) {
1409 dispatch_state.order_identities.remove(&client_order_id);
1410 dispatch_state.order_snapshots.remove(&client_order_id);
1411 let ts_event = clock.get_time_ns();
1412 emitter.emit_order_rejected_event(
1413 strategy_id,
1414 instrument_id,
1415 client_order_id,
1416 reason,
1417 ts_event,
1418 bybit_rejection_due_post_only(reason),
1419 );
1420 anyhow::bail!("submit order rejected: {reason}");
1421 }
1422
1423 log::warn!(
1424 "Submit failure without confirmed venue rejection for {client_order_id}: \
1425 {e}; awaiting reconciliation",
1426 );
1427 return Ok(());
1428 }
1429
1430 Ok(())
1431 });
1432
1433 return Ok(());
1434 }
1435
1436 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1437 let params = Self::build_ws_place_params(
1438 &order,
1439 product_type,
1440 raw_symbol,
1441 &tp_sl,
1442 position_idx,
1443 smp_type,
1444 )?;
1445
1446 let ws_trade = self.ws_trade.clone();
1447 let dispatch_state = Arc::clone(&self.dispatch_state);
1448
1449 self.spawn_task("submit_order", async move {
1450 let req_id = UUID4::new().to_string();
1451 dispatch_state.pending_requests.insert(
1452 req_id.clone(),
1453 (vec![client_order_id], vec![None], PendingOperation::Place),
1454 );
1455
1456 if let Err(e) = ws_trade.place_order_with_id(params, req_id.clone()).await {
1457 dispatch_state.pending_requests.remove(&req_id);
1458 dispatch_state.order_identities.remove(&client_order_id);
1459 dispatch_state.order_snapshots.remove(&client_order_id);
1460 let reason = e.to_string();
1461 emitter.emit_order_rejected_event(
1462 strategy_id,
1463 instrument_id,
1464 client_order_id,
1465 &reason,
1466 clock.get_time_ns(),
1467 false,
1468 );
1469 }
1470
1471 Ok(())
1472 });
1473
1474 Ok(())
1475 }
1476
1477 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
1478 if cmd.order_list.client_order_ids.is_empty() {
1479 return Ok(());
1480 }
1481
1482 let tp_sl = match parse_bybit_tp_sl_params(cmd.params.as_ref()) {
1483 Ok(p) => p,
1484 Err(e) => {
1485 let cache = self.core.cache();
1486 let denied = OrderDeniedReason::ValidationFailed {
1487 detail: e.to_string(),
1488 }
1489 .to_string();
1490
1491 for cid in &cmd.order_list.client_order_ids {
1492 if let Some(order) = cache.order(cid) {
1493 self.emitter.emit_order_denied(&order, &denied);
1494 }
1495 }
1496 return Ok(());
1497 }
1498 };
1499
1500 let instrument_id = cmd.instrument_id;
1501 let product_type = self.get_product_type_for_instrument(instrument_id);
1502
1503 if self.config.environment == BybitEnvironment::Demo
1506 && (tp_sl.tp_trigger_price.is_some() || tp_sl.sl_trigger_price.is_some())
1507 {
1508 let cache = self.core.cache();
1509 let denied = OrderDeniedReason::UnsupportedTpSl {
1510 detail: "TP/SL trigger prices are not supported in demo mode".to_string(),
1511 }
1512 .to_string();
1513
1514 for cid in &cmd.order_list.client_order_ids {
1515 if let Some(order) = cache.order(cid) {
1516 self.emitter.emit_order_denied(&order, &denied);
1517 }
1518 }
1519 return Ok(());
1520 }
1521
1522 let strategy_id = cmd.strategy_id;
1523
1524 let mut valid_orders = Vec::with_capacity(cmd.order_list.client_order_ids.len());
1525 {
1526 let cache = self.core.cache();
1527 let order_list_id = cmd.order_list.id;
1528 let list_denied = OrderDeniedReason::OrderListDenied { order_list_id };
1529 let mut denial: Option<(ClientOrderId, OrderDeniedReason, OrderDeniedReason)> = None;
1533
1534 for cid in &cmd.order_list.client_order_ids {
1535 let Some(order) = cache.order(cid) else {
1536 let reason = OrderDeniedReason::OrderListIncomplete { order_list_id };
1537 denial = Some((*cid, reason.clone(), reason));
1538 break;
1539 };
1540
1541 if order.is_closed() {
1542 denial = Some((
1543 *cid,
1544 OrderDeniedReason::ValidationFailed {
1545 detail: format!("cannot submit closed order {cid}"),
1546 },
1547 list_denied,
1548 ));
1549 break;
1550 }
1551
1552 let Ok((_, is_conditional)) = Self::map_order_type(order.order_type()) else {
1553 denial = Some((
1554 *cid,
1555 OrderDeniedReason::UnsupportedOrderType {
1556 order_type: order.order_type(),
1557 },
1558 list_denied,
1559 ));
1560 break;
1561 };
1562
1563 if product_type == BybitProductType::Option && is_conditional {
1565 denial = Some((
1566 *cid,
1567 OrderDeniedReason::UnsupportedOrderType {
1568 order_type: order.order_type(),
1569 },
1570 list_denied,
1571 ));
1572 break;
1573 }
1574
1575 if let Err(e) = Self::validate_bbo_params(&order, product_type, &tp_sl) {
1576 denial = Some((
1577 *cid,
1578 OrderDeniedReason::ValidationFailed {
1579 detail: e.to_string(),
1580 },
1581 list_denied,
1582 ));
1583 break;
1584 }
1585
1586 valid_orders.push(order.clone());
1587 }
1588
1589 if let Some((offender, offender_reason, rest_reason)) = denial {
1591 let offender_reason = offender_reason.to_string();
1592 let rest_reason = rest_reason.to_string();
1593
1594 for cid in &cmd.order_list.client_order_ids {
1595 if let Some(order) = cache.order(cid) {
1596 let reason = if *cid == offender {
1597 offender_reason.as_str()
1598 } else {
1599 rest_reason.as_str()
1600 };
1601 self.emitter.emit_order_denied(&order, reason);
1602 }
1603 }
1604 return Ok(());
1605 }
1606 }
1607
1608 if valid_orders.is_empty() {
1609 return Ok(());
1610 }
1611
1612 for order in &valid_orders {
1613 self.emitter.emit_order_submitted(order);
1614 let bybit_side = BybitOrderSide::from(order.order_side());
1615 let position_idx = self.resolve_position_idx(
1616 instrument_id,
1617 bybit_side,
1618 order.is_reduce_only(),
1619 tp_sl.position_idx,
1620 );
1621 let venue_position_id = position_idx
1622 .and_then(|idx| make_hedge_venue_position_id(instrument_id, idx as i32));
1623 self.dispatch_state.order_identities.insert(
1624 order.client_order_id(),
1625 OrderIdentity {
1626 instrument_id,
1627 strategy_id,
1628 order_side: order.order_side(),
1629 order_type: order.order_type(),
1630 venue_position_id,
1631 },
1632 );
1633 self.dispatch_state.order_snapshots.insert(
1634 order.client_order_id(),
1635 OrderStateSnapshot {
1636 quantity: order.quantity(),
1637 price: order.price(),
1638 trigger_price: order.trigger_price(),
1639 },
1640 );
1641 }
1642
1643 let emitter = self.emitter.clone();
1644 let clock = self.clock;
1645
1646 let smp_type = self.resolve_smp_type(tp_sl.smp_type);
1647
1648 if self.config.environment == BybitEnvironment::Demo {
1650 let http_client = self.http_client.clone();
1651 let account_id = self.core.account_id;
1652 let is_leverage = tp_sl.is_leverage;
1653 let bbo_side_type = tp_sl.bbo_side_type;
1654 let bbo_level = tp_sl.bbo_level.clone();
1655 let native_tp_sl = tp_sl.to_native_tp_sl();
1656 let dispatch_state = Arc::clone(&self.dispatch_state);
1657
1658 let order_data: Vec<_> = valid_orders
1659 .iter()
1660 .map(|o| {
1661 let bybit_side = BybitOrderSide::from(o.order_side());
1662 let position_idx = self.resolve_position_idx(
1663 instrument_id,
1664 bybit_side,
1665 o.is_reduce_only(),
1666 tp_sl.position_idx,
1667 );
1668 (
1669 o.client_order_id(),
1670 o.order_side(),
1671 o.order_type(),
1672 o.quantity(),
1673 o.time_in_force(),
1674 o.price(),
1675 o.trigger_price(),
1676 o.is_post_only(),
1677 o.is_reduce_only(),
1678 o.is_quote_quantity(),
1679 position_idx,
1680 )
1681 })
1682 .collect();
1683
1684 self.spawn_task("submit_order_list_http", async move {
1685 let native_tp_sl_ref = (!native_tp_sl.is_empty()).then_some(&native_tp_sl);
1686
1687 for (
1688 cid,
1689 side,
1690 otype,
1691 qty,
1692 tif,
1693 price,
1694 trigger,
1695 post_only,
1696 reduce,
1697 quote_qty,
1698 position_idx,
1699 ) in order_data
1700 {
1701 if let Err(e) = http_client
1702 .submit_order(
1703 account_id,
1704 product_type,
1705 instrument_id,
1706 cid,
1707 side,
1708 otype,
1709 qty,
1710 Some(tif),
1711 price,
1712 trigger,
1713 Some(post_only),
1714 reduce,
1715 quote_qty,
1716 is_leverage,
1717 position_idx,
1718 bbo_side_type,
1719 bbo_level.clone(),
1720 smp_type,
1721 native_tp_sl_ref,
1722 )
1723 .await
1724 {
1725 if let Some(reason) = submit_rejection_reason(&e) {
1726 dispatch_state.order_identities.remove(&cid);
1727 dispatch_state.order_snapshots.remove(&cid);
1728 let ts_event = clock.get_time_ns();
1729 emitter.emit_order_rejected_event(
1730 strategy_id,
1731 instrument_id,
1732 cid,
1733 reason,
1734 ts_event,
1735 bybit_rejection_due_post_only(reason),
1736 );
1737 continue;
1738 }
1739
1740 log::warn!(
1741 "Submit failure without confirmed venue rejection for {cid}: {e}; \
1742 awaiting reconciliation",
1743 );
1744 }
1745 }
1746 Ok(())
1747 });
1748
1749 return Ok(());
1750 }
1751
1752 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1754
1755 let mut order_params = Vec::with_capacity(valid_orders.len());
1756 let mut client_order_ids = Vec::with_capacity(valid_orders.len());
1757
1758 for order in &valid_orders {
1759 let bybit_side = BybitOrderSide::from(order.order_side());
1760 let position_idx = self.resolve_position_idx(
1761 instrument_id,
1762 bybit_side,
1763 order.is_reduce_only(),
1764 tp_sl.position_idx,
1765 );
1766 let params = Self::build_ws_place_params(
1767 order,
1768 product_type,
1769 raw_symbol,
1770 &tp_sl,
1771 position_idx,
1772 smp_type,
1773 )
1774 .expect("validated above");
1775 order_params.push(params);
1776 client_order_ids.push(order.client_order_id());
1777 }
1778
1779 let ws_trade = self.ws_trade.clone();
1780 let dispatch_state = Arc::clone(&self.dispatch_state);
1781
1782 self.spawn_task("submit_order_list", async move {
1783 let req_ids = BybitWebSocketClient::batch_request_ids(product_type, order_params.len());
1784 for (req_id, chunk_cids) in req_ids.iter().zip(
1785 client_order_ids
1786 .chunks(batch_send_limit(product_type))
1787 .map(|chunk| chunk.to_vec()),
1788 ) {
1789 let chunk_voids = vec![None; chunk_cids.len()];
1790 dispatch_state.pending_requests.insert(
1791 req_id.clone(),
1792 (chunk_cids, chunk_voids, PendingOperation::Place),
1793 );
1794 }
1795
1796 if let Err(e) = ws_trade
1797 .batch_place_orders_with_ids(order_params, req_ids.clone())
1798 .await
1799 {
1800 for req_id in req_ids {
1801 dispatch_state.pending_requests.remove(&req_id);
1802 }
1803 let reason = e.to_string();
1804 let ts_event = clock.get_time_ns();
1805
1806 for client_order_id in client_order_ids {
1807 dispatch_state.order_identities.remove(&client_order_id);
1808 dispatch_state.order_snapshots.remove(&client_order_id);
1809 emitter.emit_order_rejected_event(
1810 strategy_id,
1811 instrument_id,
1812 client_order_id,
1813 &reason,
1814 ts_event,
1815 false,
1816 );
1817 }
1818 }
1819 Ok(())
1820 });
1821
1822 Ok(())
1823 }
1824
1825 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
1826 let instrument_id = cmd.instrument_id;
1827 let product_type = self.get_product_type_for_instrument(instrument_id);
1828 let client_order_id = cmd.client_order_id;
1829 let strategy_id = cmd.strategy_id;
1830 let venue_order_id = cmd.venue_order_id;
1831 let emitter = self.emitter.clone();
1832 let clock = self.clock;
1833
1834 let has_order_iv = cmd
1835 .params
1836 .as_ref()
1837 .and_then(|p| p.get("order_iv"))
1838 .is_some();
1839
1840 if self.config.environment == BybitEnvironment::Demo && has_order_iv {
1841 log::warn!(
1842 "Modify command failed local validation for {client_order_id}: {}",
1843 "Option params (order_iv) are not supported in demo mode",
1844 );
1845 return Ok(());
1846 }
1847
1848 if self.config.environment == BybitEnvironment::Demo {
1849 let http_client = self.http_client.clone();
1850 let account_id = self.core.account_id;
1851 let quantity = cmd.quantity;
1852 let price = cmd.price;
1853
1854 self.spawn_task("modify_order_http", async move {
1855 let result = http_client
1856 .modify_order(
1857 account_id,
1858 product_type,
1859 instrument_id,
1860 Some(client_order_id),
1861 venue_order_id,
1862 quantity,
1863 price,
1864 )
1865 .await;
1866
1867 if let Err(e) = result {
1868 match classify_modify_http_failure(&e) {
1869 CommandFailure::VenueRejected(reason) => {
1870 let ts_event = clock.get_time_ns();
1871 emitter.emit_order_modify_rejected_event(
1872 strategy_id,
1873 instrument_id,
1874 client_order_id,
1875 venue_order_id,
1876 &format!("modify-order-error: {reason}"),
1877 ts_event,
1878 );
1879 anyhow::bail!("modify order rejected: {reason}");
1880 }
1881 CommandFailure::NotSent(reason) => {
1882 log::warn!(
1883 "HTTP modify command failed local validation for {client_order_id}: {reason}"
1884 );
1885 }
1886 CommandFailure::Ambiguous(reason) => {
1887 log::warn!(
1888 "Ambiguous HTTP modify failure for {client_order_id}, awaiting reconciliation: {reason}"
1889 );
1890 }
1891 }
1892 }
1893
1894 Ok(())
1895 });
1896
1897 return Ok(());
1898 }
1899
1900 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
1901
1902 let order_iv = if let Some(value) = cmd.params.as_ref().and_then(|p| p.get("order_iv")) {
1903 match get_price_str(cmd.params.as_ref().unwrap(), "order_iv") {
1904 Some(s) => Some(s),
1905 None => {
1906 log::warn!(
1907 "Modify command failed local validation for {client_order_id}: invalid type for 'order_iv': {value}, expected string or number",
1908 );
1909 return Ok(());
1910 }
1911 }
1912 } else {
1913 None
1914 };
1915
1916 let params = BybitWsAmendOrderParams {
1917 category: product_type,
1918 symbol: Ustr::from(raw_symbol),
1919 order_id: cmd.venue_order_id.map(|v| v.to_string()),
1920 order_link_id: Some(cmd.client_order_id.to_string()),
1921 qty: cmd.quantity.map(|q| q.to_string()),
1922 price: cmd.price.map(|p| p.to_string()),
1923 trigger_price: None,
1924 take_profit: None,
1925 stop_loss: None,
1926 tp_trigger_by: None,
1927 sl_trigger_by: None,
1928 order_iv,
1929 };
1930
1931 let ws_trade = self.ws_trade.clone();
1932 let dispatch_state = Arc::clone(&self.dispatch_state);
1933
1934 self.spawn_task("modify_order", async move {
1935 let req_id = UUID4::new().to_string();
1936 dispatch_state.pending_requests.insert(
1937 req_id.clone(),
1938 (
1939 vec![client_order_id],
1940 vec![venue_order_id],
1941 PendingOperation::Amend,
1942 ),
1943 );
1944
1945 if let Err(e) = ws_trade.amend_order_with_id(params, req_id.clone()).await {
1946 dispatch_state.pending_requests.remove(&req_id);
1947 emitter.emit_order_modify_rejected_event(
1948 strategy_id,
1949 instrument_id,
1950 client_order_id,
1951 venue_order_id,
1952 &e.to_string(),
1953 clock.get_time_ns(),
1954 );
1955 }
1956
1957 Ok(())
1958 });
1959
1960 Ok(())
1961 }
1962
1963 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
1964 let instrument_id = cmd.instrument_id;
1965 let product_type = self.get_product_type_for_instrument(instrument_id);
1966 let client_order_id = cmd.client_order_id;
1967 let strategy_id = cmd.strategy_id;
1968 let venue_order_id = cmd.venue_order_id;
1969 let emitter = self.emitter.clone();
1970 let clock = self.clock;
1971
1972 if self.config.environment == BybitEnvironment::Demo {
1973 let http_client = self.http_client.clone();
1974 let account_id = self.core.account_id;
1975
1976 self.spawn_task("cancel_order_http", async move {
1977 let result = http_client
1978 .cancel_order(
1979 account_id,
1980 product_type,
1981 instrument_id,
1982 Some(client_order_id),
1983 venue_order_id,
1984 )
1985 .await;
1986
1987 if let Err(e) = result {
1988 match classify_cancel_http_failure(&e) {
1989 CommandFailure::VenueRejected(reason) => {
1990 let ts_event = clock.get_time_ns();
1991 emitter.emit_order_cancel_rejected_event(
1992 strategy_id,
1993 instrument_id,
1994 client_order_id,
1995 venue_order_id,
1996 &format!("cancel-order-error: {reason}"),
1997 ts_event,
1998 );
1999 anyhow::bail!("cancel order rejected: {reason}");
2000 }
2001 CommandFailure::NotSent(reason) => {
2002 log::warn!(
2003 "HTTP cancel command failed local validation for {client_order_id}: {reason}"
2004 );
2005 }
2006 CommandFailure::Ambiguous(reason) => {
2007 log::warn!(
2008 "Ambiguous HTTP cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
2009 );
2010 }
2011 }
2012 }
2013
2014 Ok(())
2015 });
2016
2017 return Ok(());
2018 }
2019
2020 let raw_symbol = extract_raw_symbol(instrument_id.symbol.as_str());
2021
2022 let params = BybitWsCancelOrderParams {
2023 category: product_type,
2024 symbol: Ustr::from(raw_symbol),
2025 order_id: cmd.venue_order_id.map(|v| v.to_string()),
2026 order_link_id: Some(cmd.client_order_id.to_string()),
2027 };
2028
2029 let ws_trade = self.ws_trade.clone();
2030 let dispatch_state = Arc::clone(&self.dispatch_state);
2031
2032 self.spawn_task("cancel_order", async move {
2033 let req_id = UUID4::new().to_string();
2034 dispatch_state.pending_requests.insert(
2035 req_id.clone(),
2036 (
2037 vec![client_order_id],
2038 vec![venue_order_id],
2039 PendingOperation::Cancel,
2040 ),
2041 );
2042
2043 if let Err(e) = ws_trade.cancel_order_with_id(params, req_id.clone()).await {
2044 dispatch_state.pending_requests.remove(&req_id);
2045 emitter.emit_order_cancel_rejected_event(
2046 strategy_id,
2047 instrument_id,
2048 client_order_id,
2049 venue_order_id,
2050 &e.to_string(),
2051 clock.get_time_ns(),
2052 );
2053 }
2054
2055 Ok(())
2056 });
2057
2058 Ok(())
2059 }
2060
2061 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
2062 if cmd.order_side.is_some() {
2063 let cancels: Vec<CancelOrder> = {
2066 let cache = self.core.cache();
2067 cache
2068 .orders_open(None, Some(&cmd.instrument_id), None, None, cmd.order_side)
2069 .iter()
2070 .map(|order| CancelOrder {
2071 trader_id: order.trader_id(),
2072 client_id: cmd.client_id,
2073 strategy_id: order.strategy_id(),
2074 instrument_id: order.instrument_id(),
2075 client_order_id: order.client_order_id(),
2076 venue_order_id: order.venue_order_id(),
2077 command_id: cmd.command_id,
2078 ts_init: cmd.ts_init,
2079 params: cmd.params.clone(),
2080 correlation_id: cmd.correlation_id,
2081 causation_id: cmd.causation_id,
2082 })
2083 .collect()
2084 };
2085
2086 if cancels.is_empty() {
2087 log::debug!("No open orders to cancel for {}", cmd.instrument_id);
2088 return Ok(());
2089 }
2090
2091 return self.batch_cancel_orders(BatchCancelOrders {
2092 trader_id: cmd.trader_id,
2093 client_id: cmd.client_id,
2094 strategy_id: cmd.strategy_id,
2095 instrument_id: cmd.instrument_id,
2096 cancels,
2097 command_id: cmd.command_id,
2098 ts_init: cmd.ts_init,
2099 params: cmd.params,
2100 correlation_id: cmd.correlation_id,
2101 causation_id: cmd.causation_id,
2102 });
2103 }
2104
2105 let instrument_id = cmd.instrument_id;
2106 let product_type = self.get_product_type_for_instrument(instrument_id);
2107 let account_id = self.core.account_id;
2108 let http_client = self.http_client.clone();
2109
2110 self.spawn_task("cancel_all_orders", async move {
2111 match http_client
2112 .cancel_all_orders(account_id, product_type, instrument_id)
2113 .await
2114 {
2115 Ok(reports) => {
2116 for report in reports {
2117 log::debug!("Cancelled order: {report}");
2118 }
2119 }
2120 Err(e) => {
2121 log::error!("Failed to cancel all orders for {instrument_id}: {e}");
2122 }
2123 }
2124 Ok(())
2125 });
2126
2127 Ok(())
2128 }
2129
2130 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
2131 if cmd.cancels.is_empty() {
2132 return Ok(());
2133 }
2134
2135 let instrument_id = cmd.instrument_id;
2136 let product_type = self.get_product_type_for_instrument(instrument_id);
2137 let emitter = self.emitter.clone();
2138 let clock = self.clock;
2139
2140 if self.config.environment == BybitEnvironment::Demo {
2142 let http_client = self.http_client.clone();
2143 let account_id = self.core.account_id;
2144 let cancels: Vec<_> = cmd
2145 .cancels
2146 .iter()
2147 .map(|c| (c.strategy_id, c.client_order_id, c.venue_order_id))
2148 .collect();
2149
2150 self.spawn_task("batch_cancel_orders_http", async move {
2151 for (strategy_id, client_order_id, venue_order_id) in cancels {
2152 if let Err(e) = http_client
2153 .cancel_order(
2154 account_id,
2155 product_type,
2156 instrument_id,
2157 Some(client_order_id),
2158 venue_order_id,
2159 )
2160 .await
2161 {
2162 match classify_cancel_http_failure(&e) {
2163 CommandFailure::VenueRejected(reason) => {
2164 let ts_event = clock.get_time_ns();
2165 emitter.emit_order_cancel_rejected_event(
2166 strategy_id,
2167 instrument_id,
2168 client_order_id,
2169 venue_order_id,
2170 &format!("cancel-order-error: {reason}"),
2171 ts_event,
2172 );
2173 }
2174 CommandFailure::NotSent(reason) => {
2175 log::warn!(
2176 "HTTP batch cancel command failed local validation for {client_order_id}: {reason}"
2177 );
2178 }
2179 CommandFailure::Ambiguous(reason) => {
2180 log::warn!(
2181 "Ambiguous HTTP batch cancel failure for {client_order_id}, awaiting reconciliation: {reason}"
2182 );
2183 }
2184 }
2185 }
2186 }
2187 Ok(())
2188 });
2189
2190 return Ok(());
2191 }
2192
2193 let raw_symbol = Ustr::from(extract_raw_symbol(instrument_id.symbol.as_str()));
2194
2195 let mut cancel_params = Vec::with_capacity(cmd.cancels.len());
2196 let strategy_ids: Vec<_> = cmd.cancels.iter().map(|c| c.strategy_id).collect();
2197 let client_order_ids: Vec<_> = cmd.cancels.iter().map(|c| c.client_order_id).collect();
2198 let venue_order_ids: Vec<_> = cmd.cancels.iter().map(|c| c.venue_order_id).collect();
2199
2200 for cancel in &cmd.cancels {
2201 cancel_params.push(BybitWsCancelOrderParams {
2202 category: product_type,
2203 symbol: raw_symbol,
2204 order_id: cancel.venue_order_id.map(|v| v.to_string()),
2205 order_link_id: Some(cancel.client_order_id.to_string()),
2206 });
2207 }
2208
2209 let ws_trade = self.ws_trade.clone();
2210 let dispatch_state = Arc::clone(&self.dispatch_state);
2211
2212 self.spawn_task("batch_cancel_orders", async move {
2213 let req_ids =
2214 BybitWebSocketClient::batch_request_ids(product_type, cancel_params.len());
2215 let chunk_limit = batch_send_limit(product_type);
2216
2217 for (req_id, (chunk_cids, chunk_voids)) in req_ids.iter().zip(
2218 client_order_ids
2219 .chunks(chunk_limit)
2220 .map(|chunk| chunk.to_vec())
2221 .zip(
2222 venue_order_ids
2223 .chunks(chunk_limit)
2224 .map(|chunk| chunk.to_vec()),
2225 ),
2226 ) {
2227 dispatch_state.pending_requests.insert(
2228 req_id.clone(),
2229 (chunk_cids, chunk_voids, PendingOperation::Cancel),
2230 );
2231 }
2232
2233 if let Err(e) = ws_trade
2234 .batch_cancel_orders_with_ids(cancel_params, req_ids.clone())
2235 .await
2236 {
2237 for req_id in req_ids {
2238 dispatch_state.pending_requests.remove(&req_id);
2239 }
2240 let reason = e.to_string();
2241 let ts_event = clock.get_time_ns();
2242
2243 for ((strategy_id, client_order_id), venue_order_id) in strategy_ids
2244 .into_iter()
2245 .zip(client_order_ids)
2246 .zip(venue_order_ids)
2247 {
2248 emitter.emit_order_cancel_rejected_event(
2249 strategy_id,
2250 instrument_id,
2251 client_order_id,
2252 venue_order_id,
2253 &reason,
2254 ts_event,
2255 );
2256 }
2257 }
2258 Ok(())
2259 });
2260
2261 Ok(())
2262 }
2263}
2264
2265fn classify_cancel_http_failure(error: &anyhow::Error) -> CommandFailure {
2266 if error
2267 .chain()
2268 .any(|cause| cause.downcast_ref::<BybitCancelOrderError>().is_some())
2269 {
2270 return CommandFailure::ambiguous(error.to_string());
2271 }
2272
2273 classify_http_failure(error)
2274}
2275
2276fn classify_modify_http_failure(error: &anyhow::Error) -> CommandFailure {
2277 if error
2278 .chain()
2279 .any(|cause| cause.downcast_ref::<BybitModifyOrderError>().is_some())
2280 {
2281 return CommandFailure::ambiguous(error.to_string());
2282 }
2283
2284 classify_http_failure(error)
2285}
2286
2287fn classify_http_failure(error: &anyhow::Error) -> CommandFailure {
2288 let reason = error.to_string();
2289
2290 for cause in error.chain() {
2291 let Some(http_error) = cause.downcast_ref::<BybitHttpError>() else {
2292 continue;
2293 };
2294
2295 return match http_error {
2296 BybitHttpError::BybitError { error_code, .. }
2297 if is_bybit_ambiguous_order_error_code(i64::from(*error_code)) =>
2298 {
2299 CommandFailure::Ambiguous(reason)
2300 }
2301 BybitHttpError::BybitError { .. } => CommandFailure::VenueRejected(reason),
2302 BybitHttpError::MissingCredentials
2303 | BybitHttpError::ValidationError(_)
2304 | BybitHttpError::BuildError(_) => CommandFailure::NotSent(reason),
2305 BybitHttpError::JsonError(_)
2306 | BybitHttpError::Canceled(_)
2307 | BybitHttpError::NetworkError(_)
2308 | BybitHttpError::UnexpectedStatus { .. } => CommandFailure::Ambiguous(reason),
2309 };
2310 }
2311
2312 CommandFailure::NotSent(reason)
2313}
2314
2315impl BybitExecutionClient {
2316 fn cache_reconciliation_order_identity(&self, report: &OrderStatusReport) {
2317 let Some(client_order_id) = report.client_order_id else {
2318 return;
2319 };
2320
2321 if report.order_status.is_closed() {
2322 self.dispatch_state
2323 .order_identities
2324 .remove(&client_order_id);
2325 return;
2326 }
2327
2328 let cache = self.core.cache();
2329 let Some(order) = cache.order(&client_order_id) else {
2330 return;
2331 };
2332
2333 let identity = OrderIdentity {
2334 instrument_id: report.instrument_id,
2335 strategy_id: order.strategy_id(),
2336 order_side: order.order_side(),
2337 order_type: order.order_type(),
2338 venue_position_id: report.venue_position_id,
2339 };
2340 self.dispatch_state
2341 .order_identities
2342 .insert(client_order_id, identity);
2343 }
2344}
2345
2346#[cfg(test)]
2347mod tests {
2348 use std::{cell::RefCell, rc::Rc, time::Duration};
2349
2350 use nautilus_common::{
2351 cache::Cache,
2352 clients::ExecutionClient,
2353 messages::{
2354 ExecutionEvent,
2355 execution::{
2356 BatchCancelOrders, CancelAllOrders, CancelOrder, ModifyOrder, SubmitOrder,
2357 SubmitOrderList,
2358 },
2359 },
2360 };
2361 use nautilus_core::{Params, UUID4};
2362 use nautilus_live::ExecutionClientCore;
2363 use nautilus_model::{
2364 enums::{AccountType, OrderSide, OrderStatus},
2365 events::OrderEventAny,
2366 identifiers::{ClientOrderId, OrderListId, PositionId, StrategyId, TraderId, VenueOrderId},
2367 orders::{OrderList, builder::OrderTestBuilder, stubs::TestOrderEventStubs},
2368 types::Quantity,
2369 };
2370 use rstest::rstest;
2371
2372 use super::*;
2373 use crate::common::{
2374 consts::{BYBIT_CLIENT_ID, BYBIT_VENUE},
2375 enums::BybitMarketUnit,
2376 };
2377
2378 fn test_execution_client() -> (BybitExecutionClient, Rc<RefCell<Cache>>) {
2379 let cache = Rc::new(RefCell::new(Cache::default()));
2380 let core = ExecutionClientCore::new(
2381 TraderId::from("TESTER-001"),
2382 *BYBIT_CLIENT_ID,
2383 *BYBIT_VENUE,
2384 OmsType::Netting,
2385 AccountId::from("BYBIT-001"),
2386 AccountType::Margin,
2387 None,
2388 cache.clone(),
2389 );
2390 let config = BybitExecutionClientConfig {
2391 api_key: Some("test_key".into()),
2392 api_secret: Some("test_secret".into()),
2393 ..Default::default()
2394 };
2395
2396 (BybitExecutionClient::new(core, config).unwrap(), cache)
2397 }
2398
2399 async fn wait_for_spawned_tasks(client: &BybitExecutionClient) {
2400 for _ in 0..20 {
2401 if client.pending_tasks.all_finished() {
2402 return;
2403 }
2404
2405 tokio::time::sleep(Duration::from_millis(25)).await;
2406 }
2407
2408 panic!("timed out waiting for spawned Bybit execution tasks");
2409 }
2410
2411 fn assert_next_submitted(
2412 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2413 client_order_id: ClientOrderId,
2414 ) {
2415 let event = rx.try_recv().expect("expected OrderSubmitted event");
2416 assert!(
2417 matches!(event, ExecutionEvent::Order(OrderEventAny::Submitted(ref submitted)) if submitted.client_order_id == client_order_id),
2418 "expected OrderSubmitted for {client_order_id}, was {event:?}",
2419 );
2420 }
2421
2422 fn assert_next_rejected(
2423 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2424 client_order_id: ClientOrderId,
2425 ) {
2426 let event = rx.try_recv().expect("expected OrderRejected event");
2427 assert!(
2428 matches!(event, ExecutionEvent::Order(OrderEventAny::Rejected(ref rejected))
2429 if rejected.client_order_id == client_order_id),
2430 "expected OrderRejected for {client_order_id}, was {event:?}",
2431 );
2432 }
2433
2434 fn assert_next_cancel_rejected(
2435 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2436 client_order_id: ClientOrderId,
2437 ) {
2438 let event = rx.try_recv().expect("expected OrderCancelRejected event");
2439 assert!(
2440 matches!(event, ExecutionEvent::Order(OrderEventAny::CancelRejected(ref rejected))
2441 if rejected.client_order_id == client_order_id),
2442 "expected OrderCancelRejected for {client_order_id}, was {event:?}",
2443 );
2444 }
2445
2446 fn assert_next_modify_rejected(
2447 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2448 client_order_id: ClientOrderId,
2449 ) {
2450 let event = rx.try_recv().expect("expected OrderModifyRejected event");
2451 assert!(
2452 matches!(event, ExecutionEvent::Order(OrderEventAny::ModifyRejected(ref rejected))
2453 if rejected.client_order_id == client_order_id),
2454 "expected OrderModifyRejected for {client_order_id}, was {event:?}",
2455 );
2456 }
2457
2458 fn assert_no_order_cancel_rejected(
2459 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2460 ) {
2461 while let Ok(event) = rx.try_recv() {
2462 assert!(
2463 !matches!(
2464 event,
2465 ExecutionEvent::Order(OrderEventAny::CancelRejected(_))
2466 ),
2467 "unexpected OrderCancelRejected event: {event:?}",
2468 );
2469 }
2470 }
2471
2472 fn assert_no_order_modify_rejected(
2473 rx: &mut tokio::sync::mpsc::UnboundedReceiver<ExecutionEvent>,
2474 ) {
2475 while let Ok(event) = rx.try_recv() {
2476 assert!(
2477 !matches!(
2478 event,
2479 ExecutionEvent::Order(OrderEventAny::ModifyRejected(_))
2480 ),
2481 "unexpected OrderModifyRejected event: {event:?}",
2482 );
2483 }
2484 }
2485
2486 fn cancel_command(client_order_id: ClientOrderId) -> CancelOrder {
2487 CancelOrder::new(
2488 TraderId::from("TESTER-001"),
2489 Some(*BYBIT_CLIENT_ID),
2490 StrategyId::from("S-001"),
2491 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
2492 client_order_id,
2493 Some(VenueOrderId::from("venue-cancel-1")),
2494 UUID4::new(),
2495 UnixNanos::default(),
2496 None,
2497 None,
2498 )
2499 }
2500
2501 fn modify_command(client_order_id: ClientOrderId, params: Option<Params>) -> ModifyOrder {
2502 ModifyOrder::new(
2503 TraderId::from("TESTER-001"),
2504 Some(*BYBIT_CLIENT_ID),
2505 StrategyId::from("S-001"),
2506 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
2507 client_order_id,
2508 Some(VenueOrderId::from("venue-modify-1")),
2509 Some(Quantity::from("1")),
2510 Some(Price::from("10001.00")),
2511 None,
2512 UUID4::new(),
2513 UnixNanos::default(),
2514 params,
2515 None,
2516 )
2517 }
2518
2519 #[rstest]
2520 fn test_cancel_http_failure_classification_matches_policy() {
2521 let venue_reject = anyhow::Error::from(BybitHttpError::BybitError {
2522 error_code: 110001,
2523 message: "Order does not exist".to_string(),
2524 });
2525 assert_eq!(
2526 classify_cancel_http_failure(&venue_reject),
2527 CommandFailure::VenueRejected(venue_reject.to_string()),
2528 );
2529
2530 let rate_limit = anyhow::Error::from(BybitHttpError::BybitError {
2531 error_code: 10006,
2532 message: "Too many visits".to_string(),
2533 });
2534 assert_eq!(
2535 classify_cancel_http_failure(&rate_limit),
2536 CommandFailure::VenueRejected(rate_limit.to_string()),
2537 );
2538
2539 let post_lookup = anyhow::Error::from(BybitCancelOrderError::PostCancelLookup {
2540 source: anyhow::anyhow!("history lookup failed"),
2541 });
2542 assert_eq!(
2543 classify_cancel_http_failure(&post_lookup),
2544 CommandFailure::Ambiguous(post_lookup.to_string()),
2545 );
2546
2547 let transport = anyhow::Error::from(BybitHttpError::NetworkError(
2548 "connection closed".to_string(),
2549 ));
2550 assert_eq!(
2551 classify_cancel_http_failure(&transport),
2552 CommandFailure::Ambiguous(transport.to_string()),
2553 );
2554 }
2555
2556 #[rstest]
2557 fn test_modify_http_failure_classification_matches_policy() {
2558 let venue_reject = anyhow::Error::from(BybitHttpError::BybitError {
2559 error_code: 110003,
2560 message: "Order price exceeds allowable range".to_string(),
2561 });
2562 assert_eq!(
2563 classify_modify_http_failure(&venue_reject),
2564 CommandFailure::VenueRejected(venue_reject.to_string()),
2565 );
2566
2567 let server_error = anyhow::Error::from(BybitHttpError::BybitError {
2568 error_code: 10016,
2569 message: "Server error".to_string(),
2570 });
2571 assert_eq!(
2572 classify_modify_http_failure(&server_error),
2573 CommandFailure::Ambiguous(server_error.to_string()),
2574 );
2575
2576 let post_lookup = anyhow::Error::from(BybitModifyOrderError::PostModifyLookup {
2577 source: anyhow::anyhow!("realtime lookup failed"),
2578 });
2579 assert_eq!(
2580 classify_modify_http_failure(&post_lookup),
2581 CommandFailure::Ambiguous(post_lookup.to_string()),
2582 );
2583
2584 let status = anyhow::Error::from(BybitHttpError::UnexpectedStatus {
2585 status: 503,
2586 body: "service unavailable".to_string(),
2587 });
2588 assert_eq!(
2589 classify_modify_http_failure(&status),
2590 CommandFailure::Ambiguous(status.to_string()),
2591 );
2592 }
2593
2594 #[rstest]
2595 #[tokio::test]
2596 async fn test_ws_cancel_queue_failure_emits_cancel_rejected() {
2597 let (mut client, _cache) = test_execution_client();
2598 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2599 client.emitter.set_sender(tx);
2600 client.ws_trade.close().await.unwrap();
2601 let client_order_id = ClientOrderId::from("O-CANCEL-WS-FAIL");
2602
2603 client
2604 .cancel_order(cancel_command(client_order_id))
2605 .unwrap();
2606 wait_for_spawned_tasks(&client).await;
2607
2608 assert_next_cancel_rejected(&mut rx, client_order_id);
2609 }
2610
2611 #[rstest]
2612 #[tokio::test]
2613 async fn test_ws_modify_queue_failure_emits_modify_rejected() {
2614 let (mut client, _cache) = test_execution_client();
2615 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2616 client.emitter.set_sender(tx);
2617 client.ws_trade.close().await.unwrap();
2618 let client_order_id = ClientOrderId::from("O-MODIFY-WS-FAIL");
2619
2620 client
2621 .modify_order(modify_command(client_order_id, None))
2622 .unwrap();
2623 wait_for_spawned_tasks(&client).await;
2624
2625 assert_next_modify_rejected(&mut rx, client_order_id);
2626 }
2627
2628 #[rstest]
2629 #[tokio::test]
2630 async fn test_http_cancel_local_validation_failure_does_not_emit_cancel_rejected() {
2631 let (mut client, _cache) = test_execution_client();
2632 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2633 client.emitter.set_sender(tx);
2634 client.config.environment = BybitEnvironment::Demo;
2635
2636 client
2637 .cancel_order(cancel_command(ClientOrderId::from(
2638 "O-CANCEL-LOCAL-VALIDATION",
2639 )))
2640 .unwrap();
2641 wait_for_spawned_tasks(&client).await;
2642
2643 assert_no_order_cancel_rejected(&mut rx);
2644 }
2645
2646 #[rstest]
2647 #[tokio::test]
2648 async fn test_modify_local_validation_failure_does_not_emit_modify_rejected() {
2649 let (mut client, _cache) = test_execution_client();
2650 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2651 client.emitter.set_sender(tx);
2652
2653 let mut params = Params::new();
2654 params.insert("order_iv".to_string(), serde_json::json!({ "bad": true }));
2655
2656 client
2657 .modify_order(modify_command(
2658 ClientOrderId::from("O-MODIFY-LOCAL-VALIDATION"),
2659 Some(params),
2660 ))
2661 .unwrap();
2662
2663 assert_no_order_modify_rejected(&mut rx);
2664 }
2665
2666 #[rstest]
2667 #[tokio::test]
2668 async fn test_ws_submit_queue_failure_emits_order_rejected() {
2669 let (mut client, cache) = test_execution_client();
2670 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2671 client.emitter.set_sender(tx);
2672
2673 let client_order_id = ClientOrderId::from("O-WS-SEND-FAIL");
2674 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2675 let mut builder = OrderTestBuilder::new(OrderType::Limit);
2676 let order = builder
2677 .instrument_id(instrument_id)
2678 .client_order_id(client_order_id)
2679 .side(OrderSide::Buy)
2680 .quantity(Quantity::from("1"))
2681 .price(Price::from("10000.00"))
2682 .build();
2683 let init = order.init_event().clone();
2684 let trader_id = order.trader_id();
2685 let strategy_id = order.strategy_id();
2686
2687 cache
2688 .borrow_mut()
2689 .add_order(order, None, Some(*BYBIT_CLIENT_ID), false)
2690 .unwrap();
2691
2692 let command = SubmitOrder::new(
2693 trader_id,
2694 Some(*BYBIT_CLIENT_ID),
2695 strategy_id,
2696 instrument_id,
2697 client_order_id,
2698 init,
2699 None,
2700 None,
2701 None,
2702 UUID4::new(),
2703 UnixNanos::default(),
2704 None, );
2706
2707 client.submit_order(command).unwrap();
2708
2709 assert_next_submitted(&mut rx, client_order_id);
2710 wait_for_spawned_tasks(&client).await;
2711
2712 assert!(
2713 !client
2714 .dispatch_state
2715 .order_identities
2716 .contains_key(&client_order_id)
2717 );
2718 assert!(
2719 !client
2720 .dispatch_state
2721 .order_snapshots
2722 .contains_key(&client_order_id)
2723 );
2724 assert_next_rejected(&mut rx, client_order_id);
2725 }
2726
2727 #[rstest]
2728 #[tokio::test]
2729 async fn test_ws_submit_order_list_queue_failure_emits_order_rejected() {
2730 let (mut client, cache) = test_execution_client();
2731 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2732 client.emitter.set_sender(tx);
2733
2734 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2735 let strategy_id = StrategyId::from("S-001");
2736 let client_order_id_1 = ClientOrderId::from("O-WS-LIST-SEND-FAIL-1");
2737 let client_order_id_2 = ClientOrderId::from("O-WS-LIST-SEND-FAIL-2");
2738
2739 let mut builder_1 = OrderTestBuilder::new(OrderType::Limit);
2740 let order_1 = builder_1
2741 .strategy_id(strategy_id)
2742 .instrument_id(instrument_id)
2743 .client_order_id(client_order_id_1)
2744 .side(OrderSide::Buy)
2745 .quantity(Quantity::from("1"))
2746 .price(Price::from("10000.00"))
2747 .build();
2748 let init_1 = order_1.init_event().clone();
2749 let trader_id = order_1.trader_id();
2750
2751 let mut builder_2 = OrderTestBuilder::new(OrderType::Limit);
2752 let order_2 = builder_2
2753 .strategy_id(strategy_id)
2754 .instrument_id(instrument_id)
2755 .client_order_id(client_order_id_2)
2756 .side(OrderSide::Sell)
2757 .quantity(Quantity::from("1"))
2758 .price(Price::from("10001.00"))
2759 .build();
2760 let init_2 = order_2.init_event().clone();
2761
2762 cache
2763 .borrow_mut()
2764 .add_order(order_1, None, Some(*BYBIT_CLIENT_ID), false)
2765 .unwrap();
2766 cache
2767 .borrow_mut()
2768 .add_order(order_2, None, Some(*BYBIT_CLIENT_ID), false)
2769 .unwrap();
2770
2771 let order_list = OrderList::new(
2772 OrderListId::from("OL-WS-SEND-FAIL"),
2773 instrument_id,
2774 strategy_id,
2775 vec![client_order_id_1, client_order_id_2],
2776 UnixNanos::default(),
2777 );
2778 let command = SubmitOrderList::new(
2779 trader_id,
2780 Some(*BYBIT_CLIENT_ID),
2781 strategy_id,
2782 order_list,
2783 vec![init_1, init_2],
2784 None,
2785 None,
2786 None,
2787 UUID4::new(),
2788 UnixNanos::default(),
2789 None, );
2791
2792 client.submit_order_list(command).unwrap();
2793
2794 assert_next_submitted(&mut rx, client_order_id_1);
2795 assert_next_submitted(&mut rx, client_order_id_2);
2796 wait_for_spawned_tasks(&client).await;
2797
2798 for client_order_id in [client_order_id_1, client_order_id_2] {
2799 assert!(
2800 !client
2801 .dispatch_state
2802 .order_identities
2803 .contains_key(&client_order_id)
2804 );
2805 assert!(
2806 !client
2807 .dispatch_state
2808 .order_snapshots
2809 .contains_key(&client_order_id)
2810 );
2811 assert_next_rejected(&mut rx, client_order_id);
2812 }
2813 }
2814
2815 fn sample_order_status_report(
2816 client_order_id: ClientOrderId,
2817 instrument_id: InstrumentId,
2818 order_status: OrderStatus,
2819 venue_position_id: Option<PositionId>,
2820 ) -> OrderStatusReport {
2821 let mut report = OrderStatusReport::new(
2822 AccountId::from("BYBIT-001"),
2823 instrument_id,
2824 Some(client_order_id),
2825 VenueOrderId::from("BYBIT-ORDER-001"),
2826 OrderSide::Buy.into(),
2827 OrderType::Limit,
2828 TimeInForce::Gtc,
2829 order_status,
2830 Quantity::from("1"),
2831 Quantity::from("0"),
2832 UnixNanos::default(),
2833 UnixNanos::default(),
2834 UnixNanos::default(),
2835 None,
2836 );
2837 report.venue_position_id = venue_position_id;
2838 report
2839 }
2840
2841 #[rstest]
2842 fn test_cache_reconciliation_order_identity_caches_and_clears_hedge_report() {
2843 let (client, cache) = test_execution_client();
2844 let client_order_id = ClientOrderId::from("O-HEDGE-RECON");
2845 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2846 let venue_position_id = PositionId::from("BTCUSDT-LINEAR.BYBIT-LONG");
2847 let mut builder = OrderTestBuilder::new(OrderType::Limit);
2848 let order = builder
2849 .instrument_id(instrument_id)
2850 .client_order_id(client_order_id)
2851 .side(OrderSide::Buy)
2852 .quantity(Quantity::from("1"))
2853 .price(Price::from("10000.00"))
2854 .build();
2855 cache
2856 .borrow_mut()
2857 .add_order(order.clone(), None, None, false)
2858 .unwrap();
2859
2860 let report = sample_order_status_report(
2861 client_order_id,
2862 instrument_id,
2863 OrderStatus::Accepted,
2864 Some(venue_position_id),
2865 );
2866 client.cache_reconciliation_order_identity(&report);
2867
2868 {
2869 let identity = client
2870 .dispatch_state
2871 .order_identities
2872 .get(&client_order_id)
2873 .unwrap();
2874 assert_eq!(identity.instrument_id, instrument_id);
2875 assert_eq!(identity.strategy_id, order.strategy_id());
2876 assert_eq!(identity.order_side, order.order_side());
2877 assert_eq!(identity.order_type, order.order_type());
2878 assert_eq!(identity.venue_position_id, Some(venue_position_id));
2879 }
2880
2881 let terminal_report = sample_order_status_report(
2882 client_order_id,
2883 instrument_id,
2884 OrderStatus::Filled,
2885 Some(venue_position_id),
2886 );
2887 client.cache_reconciliation_order_identity(&terminal_report);
2888
2889 assert!(
2890 client
2891 .dispatch_state
2892 .order_identities
2893 .get(&client_order_id)
2894 .is_none()
2895 );
2896 }
2897
2898 #[rstest]
2899 fn test_cache_reconciliation_order_identity_keeps_one_way_local_report() {
2900 let (client, cache) = test_execution_client();
2901 let client_order_id = ClientOrderId::from("O-ONEWAY-RECON");
2902 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
2903 let mut builder = OrderTestBuilder::new(OrderType::Limit);
2904 let order = builder
2905 .instrument_id(instrument_id)
2906 .client_order_id(client_order_id)
2907 .side(OrderSide::Buy)
2908 .quantity(Quantity::from("1"))
2909 .price(Price::from("10000.00"))
2910 .build();
2911 cache
2912 .borrow_mut()
2913 .add_order(order, None, None, false)
2914 .unwrap();
2915
2916 let report =
2917 sample_order_status_report(client_order_id, instrument_id, OrderStatus::Accepted, None);
2918 client.cache_reconciliation_order_identity(&report);
2919
2920 let identity = client
2921 .dispatch_state
2922 .order_identities
2923 .get(&client_order_id)
2924 .unwrap();
2925 assert_eq!(identity.instrument_id, instrument_id);
2926 assert_eq!(identity.venue_position_id, None);
2927 }
2928
2929 #[rstest]
2930 #[case::spot_market_base(
2931 BybitProductType::Spot,
2932 BybitOrderType::Market,
2933 false,
2934 Some(BybitMarketUnit::BaseCoin)
2935 )]
2936 #[case::spot_market_quote(
2937 BybitProductType::Spot,
2938 BybitOrderType::Market,
2939 true,
2940 Some(BybitMarketUnit::QuoteCoin)
2941 )]
2942 #[case::spot_limit(BybitProductType::Spot, BybitOrderType::Limit, true, None)]
2943 #[case::linear_market(BybitProductType::Linear, BybitOrderType::Market, true, None)]
2944 fn test_ws_params_market_unit(
2945 #[case] product_type: BybitProductType,
2946 #[case] order_type: BybitOrderType,
2947 #[case] is_quote_quantity: bool,
2948 #[case] expected: Option<BybitMarketUnit>,
2949 ) {
2950 let params = BybitWsPlaceOrderParams {
2951 category: product_type,
2952 symbol: ustr::Ustr::from("BTCUSDT"),
2953 side: BybitOrderSide::Buy,
2954 order_type,
2955 qty: "1.0".to_string(),
2956 is_leverage: None,
2957 market_unit: spot_market_unit(product_type, order_type, is_quote_quantity),
2958 price: None,
2959 time_in_force: None,
2960 order_link_id: None,
2961 reduce_only: None,
2962 close_on_trigger: None,
2963 trigger_price: None,
2964 trigger_by: None,
2965 trigger_direction: None,
2966 tpsl_mode: None,
2967 take_profit: None,
2968 stop_loss: None,
2969 tp_trigger_by: None,
2970 sl_trigger_by: None,
2971 sl_trigger_price: None,
2972 tp_trigger_price: None,
2973 sl_order_type: None,
2974 tp_order_type: None,
2975 sl_limit_price: None,
2976 tp_limit_price: None,
2977 order_iv: None,
2978 smp_type: None,
2979 mmp: None,
2980 position_idx: None,
2981 bbo_side_type: None,
2982 bbo_level: None,
2983 };
2984
2985 assert_eq!(params.market_unit, expected);
2986 }
2987
2988 #[rstest]
2989 #[case::market(OrderType::Market, BybitOrderType::Market, false)]
2990 #[case::limit(OrderType::Limit, BybitOrderType::Limit, false)]
2991 #[case::stop_market(OrderType::StopMarket, BybitOrderType::Market, true)]
2992 #[case::stop_limit(OrderType::StopLimit, BybitOrderType::Limit, true)]
2993 #[case::market_if_touched(OrderType::MarketIfTouched, BybitOrderType::Market, true)]
2994 #[case::limit_if_touched(OrderType::LimitIfTouched, BybitOrderType::Limit, true)]
2995 fn test_map_order_type(
2996 #[case] input: OrderType,
2997 #[case] expected_type: BybitOrderType,
2998 #[case] expected_conditional: bool,
2999 ) {
3000 let (bybit_type, is_conditional) = BybitExecutionClient::map_order_type(input).unwrap();
3001 assert_eq!(bybit_type, expected_type);
3002 assert_eq!(is_conditional, expected_conditional);
3003 }
3004
3005 #[rstest]
3006 fn test_map_order_type_rejects_trailing_stop() {
3007 BybitExecutionClient::map_order_type(OrderType::TrailingStopMarket).unwrap_err();
3008 }
3009
3010 #[rstest]
3011 #[case::linear("BTCUSDT-LINEAR", true)]
3012 #[case::inverse("BTCUSD-INVERSE", true)]
3013 #[case::spot("BTCUSDT-SPOT", false)]
3014 #[case::option("BTC-30JUN25-100000-C-OPTION", false)]
3015 fn test_parse_derivative_symbol_filters_product_type(
3016 #[case] symbol_str: &str,
3017 #[case] keeps: bool,
3018 ) {
3019 let result = BybitExecutionClient::parse_derivative_symbol(symbol_str);
3020 assert_eq!(result.is_some(), keeps);
3021 }
3022
3023 #[rstest]
3024 fn test_parse_derivative_symbol_rejects_malformed() {
3025 assert!(BybitExecutionClient::parse_derivative_symbol("not-a-real-symbol").is_none());
3026 }
3027
3028 #[rstest]
3029 #[case::matches_msg("Position mode has not been modified", "110025", true)]
3030 #[case::matches_code("retCode 110025: noop", "110025", true)]
3031 #[case::matches_msg_only("Already not been modified", "", true)]
3032 #[case::wrong_code("retCode 99999: other", "110025", false)]
3033 #[case::empty_no_modified_msg("retCode 99999", "", false)]
3034 fn test_is_unchanged_error(#[case] msg: &str, #[case] code: &str, #[case] expected: bool) {
3035 let err = anyhow::anyhow!("{msg}");
3036 assert_eq!(
3037 BybitExecutionClient::is_unchanged_error(&err, code),
3038 expected
3039 );
3040 }
3041
3042 #[rstest]
3043 #[case::matches("Margin needs to be equal to or greater than 0.5", true)]
3044 #[case::no_match("Some other error", false)]
3045 fn test_is_low_margin_error(#[case] msg: &str, #[case] expected: bool) {
3046 let err = anyhow::anyhow!("{msg}");
3047 assert_eq!(BybitExecutionClient::is_low_margin_error(&err), expected);
3048 }
3049
3050 #[rstest]
3051 fn test_submit_rejection_reason_matches_confirmed_rejection() {
3052 let err = anyhow::Error::from(BybitSubmitOrderError::Rejected {
3053 reason: "EC_PostOnlyWillTakeLiquidity".to_string(),
3054 });
3055
3056 assert_eq!(
3057 submit_rejection_reason(&err),
3058 Some("EC_PostOnlyWillTakeLiquidity"),
3059 );
3060 }
3061
3062 #[rstest]
3063 fn test_submit_rejection_reason_ignores_post_submit_lookup_failure() {
3064 let err = anyhow::Error::from(BybitSubmitOrderError::PostSubmitLookup {
3065 source: anyhow::Error::from(BybitHttpError::BybitError {
3066 error_code: 110017,
3067 message: "current position is zero, cannot fix reduce-only order qty".to_string(),
3068 }),
3069 })
3070 .context("Submit order failed");
3071
3072 assert_eq!(submit_rejection_reason(&err), None);
3073 }
3074
3075 #[rstest]
3076 fn test_submit_rejection_reason_ignores_missing_order_id() {
3077 let err = anyhow::Error::from(BybitSubmitOrderError::MissingOrderId);
3078
3079 assert_eq!(submit_rejection_reason(&err), None);
3080 }
3081
3082 #[rstest]
3083 fn test_submit_rejection_reason_matches_venue_http_error() {
3084 let err = anyhow::Error::from(BybitHttpError::BybitError {
3085 error_code: 110017,
3086 message: "current position is zero, cannot fix reduce-only order qty".to_string(),
3087 });
3088
3089 assert_eq!(
3090 submit_rejection_reason(&err),
3091 Some("current position is zero, cannot fix reduce-only order qty"),
3092 );
3093 }
3094
3095 #[rstest]
3096 fn test_submit_rejection_reason_ignores_ambiguous_http_error() {
3097 let err = anyhow::Error::from(BybitHttpError::BybitError {
3098 error_code: 10016,
3099 message: "rate limit exceeded".to_string(),
3100 });
3101
3102 assert_eq!(submit_rejection_reason(&err), None);
3103 }
3104
3105 #[rstest]
3106 #[case::buy(Some(OrderSide::Buy), vec![0, 2])]
3107 #[case::sell(Some(OrderSide::Sell), vec![1, 3])]
3108 #[tokio::test]
3109 async fn test_cancel_all_orders_filters_by_side_and_preserves_owners(
3110 #[case] order_side: Option<OrderSide>,
3111 #[case] expected_indices: Vec<usize>,
3112 ) {
3113 let (mut client, cache) = test_execution_client();
3114 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3115 client.emitter.set_sender(tx);
3116 client.ws_trade.close().await.unwrap();
3117
3118 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3119 let other_instrument_id = InstrumentId::from("ETHUSDT-LINEAR.BYBIT");
3120 let account_id = client.core.account_id;
3121 let mut orders = Vec::new();
3122
3123 for (index, (instrument, owner, side, open)) in [
3124 (instrument_id, "S-001", OrderSide::Buy, true),
3125 (instrument_id, "S-001", OrderSide::Sell, true),
3126 (instrument_id, "S-002", OrderSide::Buy, true),
3127 (instrument_id, "S-002", OrderSide::Sell, true),
3128 (other_instrument_id, "S-001", OrderSide::Buy, true),
3129 (other_instrument_id, "S-002", OrderSide::Sell, true),
3130 (instrument_id, "S-001", OrderSide::Buy, false),
3131 (instrument_id, "S-002", OrderSide::Sell, false),
3132 ]
3133 .into_iter()
3134 .enumerate()
3135 {
3136 let client_order_id = ClientOrderId::from(format!("O-CANCEL-ALL-{index}"));
3137 let mut builder = OrderTestBuilder::new(OrderType::Limit);
3138 let order = builder
3139 .instrument_id(instrument)
3140 .client_order_id(client_order_id)
3141 .strategy_id(StrategyId::from(owner))
3142 .side(side)
3143 .quantity(Quantity::from("1"))
3144 .price(Price::from("10000.00"))
3145 .build();
3146 let venue_order_id = VenueOrderId::from(format!("BYBIT-{index}"));
3147 let accepted = TestOrderEventStubs::accepted(&order, account_id, venue_order_id);
3148 cache
3149 .borrow_mut()
3150 .add_order(order.clone(), None, Some(*BYBIT_CLIENT_ID), false)
3151 .unwrap();
3152 let order = cache.borrow_mut().update_order(&accepted).unwrap();
3153
3154 if !open {
3155 let canceled =
3156 TestOrderEventStubs::canceled(&order, account_id, Some(venue_order_id));
3157 cache.borrow_mut().update_order(&canceled).unwrap();
3158 }
3159
3160 orders.push((order, venue_order_id));
3161 }
3162
3163 client
3164 .cancel_all_orders(CancelAllOrders::new(
3165 TraderId::from("TESTER-001"),
3166 Some(*BYBIT_CLIENT_ID),
3167 StrategyId::from("S-001"),
3168 instrument_id,
3169 order_side,
3170 UUID4::new(),
3171 UnixNanos::default(),
3172 None,
3173 None,
3174 ))
3175 .unwrap();
3176 wait_for_spawned_tasks(&client).await;
3177
3178 let mut actual = Vec::new();
3181
3182 for _ in &expected_indices {
3183 let event = rx.try_recv().expect("expected OrderCancelRejected event");
3184 match event {
3185 ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
3186 actual.push((rejected.client_order_id, rejected.strategy_id));
3187 }
3188 event => panic!("expected OrderCancelRejected, was {event:?}"),
3189 }
3190 }
3191
3192 let mut expected: Vec<_> = expected_indices
3193 .iter()
3194 .map(|&index| {
3195 let (order, _) = &orders[index];
3196 (order.client_order_id(), order.strategy_id())
3197 })
3198 .collect();
3199
3200 actual.sort();
3201 expected.sort();
3202
3203 assert_eq!(actual, expected);
3204 assert!(rx.try_recv().is_err());
3205 }
3206
3207 #[rstest]
3208 #[tokio::test]
3209 async fn test_cancel_all_orders_with_side_and_empty_cache_sends_nothing() {
3210 let (mut client, _cache) = test_execution_client();
3211 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3212 client.emitter.set_sender(tx);
3213 client.ws_trade.close().await.unwrap();
3214
3215 client
3216 .cancel_all_orders(CancelAllOrders::new(
3217 TraderId::from("TESTER-001"),
3218 Some(*BYBIT_CLIENT_ID),
3219 StrategyId::from("S-001"),
3220 InstrumentId::from("BTCUSDT-LINEAR.BYBIT"),
3221 Some(OrderSide::Buy),
3222 UUID4::new(),
3223 UnixNanos::default(),
3224 None,
3225 None,
3226 ))
3227 .unwrap();
3228 wait_for_spawned_tasks(&client).await;
3229
3230 assert!(rx.try_recv().is_err());
3231 assert!(client.dispatch_state.pending_requests.is_empty());
3232 }
3233
3234 #[rstest]
3235 #[tokio::test]
3236 async fn test_batch_cancel_orders_failure_preserves_owning_strategies() {
3237 let (mut client, _cache) = test_execution_client();
3238 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3239 client.emitter.set_sender(tx);
3240 client.ws_trade.close().await.unwrap();
3241
3242 let instrument_id = InstrumentId::from("BTCUSDT-LINEAR.BYBIT");
3243
3244 let cancels = ["S-001", "S-002"]
3245 .into_iter()
3246 .enumerate()
3247 .map(|(index, owner)| {
3248 CancelOrder::new(
3249 TraderId::from("TESTER-001"),
3250 Some(*BYBIT_CLIENT_ID),
3251 StrategyId::from(owner),
3252 instrument_id,
3253 ClientOrderId::from(format!("O-BATCH-OWNER-{index}")),
3254 Some(VenueOrderId::from(format!("BYBIT-BATCH-{index}"))),
3255 UUID4::new(),
3256 UnixNanos::default(),
3257 None,
3258 None,
3259 )
3260 })
3261 .collect::<Vec<_>>();
3262
3263 client
3264 .batch_cancel_orders(BatchCancelOrders::new(
3265 TraderId::from("TESTER-001"),
3266 Some(*BYBIT_CLIENT_ID),
3267 StrategyId::from("S-001"),
3268 instrument_id,
3269 cancels.clone(),
3270 UUID4::new(),
3271 UnixNanos::default(),
3272 None,
3273 None,
3274 ))
3275 .unwrap();
3276 wait_for_spawned_tasks(&client).await;
3277
3278 let mut actual = Vec::new();
3279
3280 for _ in &cancels {
3281 let event = rx.try_recv().expect("expected OrderCancelRejected event");
3282 match event {
3283 ExecutionEvent::Order(OrderEventAny::CancelRejected(rejected)) => {
3284 actual.push((rejected.client_order_id, rejected.strategy_id));
3285 }
3286 event => panic!("expected OrderCancelRejected, was {event:?}"),
3287 }
3288 }
3289
3290 let mut expected: Vec<_> = cancels
3291 .iter()
3292 .map(|cancel| (cancel.client_order_id, cancel.strategy_id))
3293 .collect();
3294
3295 actual.sort();
3296 expected.sort();
3297
3298 assert_eq!(actual, expected);
3299 assert!(rx.try_recv().is_err());
3300 }
3301}