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