Skip to main content

nautilus_bybit/
execution.rs

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