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