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