nautilus_bybit/http/
client.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//! Provides the HTTP client integration for the [Bybit](https://bybit.com) REST API.
17//!
18//! Bybit API reference <https://bybit-exchange.github.io/docs/>.
19
20use std::{
21    collections::HashMap,
22    fmt::Debug,
23    num::NonZeroU32,
24    sync::{
25        Arc, LazyLock,
26        atomic::{AtomicBool, Ordering},
27    },
28};
29
30use ahash::{AHashMap, AHashSet};
31use chrono::{DateTime, Utc};
32use dashmap::DashMap;
33use nautilus_core::{
34    consts::NAUTILUS_USER_AGENT, env::get_or_env_var_opt, nanos::UnixNanos,
35    time::get_atomic_clock_realtime,
36};
37use nautilus_model::{
38    data::{Bar, BarType, TradeTick},
39    enums::{OrderSide, OrderType, PositionSideSpecified, TimeInForce},
40    events::account::state::AccountState,
41    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
42    instruments::{Instrument, InstrumentAny},
43    reports::{FillReport, OrderStatusReport, PositionStatusReport},
44    types::{Price, Quantity},
45};
46use nautilus_network::{
47    http::{HttpClient, Method, USER_AGENT},
48    ratelimiter::quota::Quota,
49    retry::{RetryConfig, RetryManager},
50};
51use rust_decimal::Decimal;
52use serde::{Serialize, de::DeserializeOwned};
53use tokio_util::sync::CancellationToken;
54use ustr::Ustr;
55
56use super::{
57    error::BybitHttpError,
58    models::{
59        BybitAccountDetailsResponse, BybitBorrowResponse, BybitFeeRate, BybitFeeRateResponse,
60        BybitInstrumentInverseResponse, BybitInstrumentLinearResponse,
61        BybitInstrumentOptionResponse, BybitInstrumentSpotResponse, BybitKlinesResponse,
62        BybitNoConvertRepayResponse, BybitOpenOrdersResponse, BybitOrderHistoryResponse,
63        BybitPlaceOrderResponse, BybitPositionListResponse, BybitServerTimeResponse,
64        BybitSetLeverageResponse, BybitSetMarginModeResponse, BybitSetTradingStopResponse,
65        BybitSwitchModeResponse, BybitTickerData, BybitTradeHistoryResponse, BybitTradesResponse,
66        BybitWalletBalanceResponse,
67    },
68    query::{
69        BybitAmendOrderParamsBuilder, BybitBatchAmendOrderEntryBuilder,
70        BybitBatchCancelOrderEntryBuilder, BybitBatchCancelOrderParamsBuilder,
71        BybitBatchPlaceOrderEntryBuilder, BybitBorrowParamsBuilder,
72        BybitCancelAllOrdersParamsBuilder, BybitCancelOrderParamsBuilder, BybitFeeRateParams,
73        BybitInstrumentsInfoParams, BybitKlinesParams, BybitKlinesParamsBuilder,
74        BybitNoConvertRepayParamsBuilder, BybitOpenOrdersParamsBuilder,
75        BybitOrderHistoryParamsBuilder, BybitPlaceOrderParamsBuilder, BybitPositionListParams,
76        BybitSetLeverageParamsBuilder, BybitSetMarginModeParamsBuilder, BybitSetTradingStopParams,
77        BybitSwitchModeParamsBuilder, BybitTickersParams, BybitTradeHistoryParams,
78        BybitTradesParams, BybitTradesParamsBuilder, BybitWalletBalanceParams,
79    },
80};
81use crate::{
82    common::{
83        consts::BYBIT_NAUTILUS_BROKER_ID,
84        credential::Credential,
85        enums::{
86            BybitAccountType, BybitEnvironment, BybitMarginMode, BybitOpenOnly, BybitOrderFilter,
87            BybitOrderSide, BybitOrderType, BybitPositionMode, BybitProductType, BybitTimeInForce,
88        },
89        models::{BybitErrorCheck, BybitResponseCheck},
90        parse::{
91            bar_spec_to_bybit_interval, make_bybit_symbol, parse_account_state, parse_fill_report,
92            parse_inverse_instrument, parse_kline_bar, parse_linear_instrument,
93            parse_option_instrument, parse_order_status_report, parse_position_status_report,
94            parse_spot_instrument, parse_trade_tick,
95        },
96        symbol::BybitSymbol,
97        urls::bybit_http_base_url,
98    },
99    http::query::BybitFeeRateParamsBuilder,
100};
101
102const DEFAULT_RECV_WINDOW_MS: u64 = 5_000;
103
104/// Default Bybit REST API rate limit.
105///
106/// Bybit implements rate limiting per endpoint with varying limits.
107/// We use a conservative 10 requests per second as a general default.
108pub static BYBIT_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
109    Quota::per_second(NonZeroU32::new(10).expect("Should be a valid non-zero u32"))
110});
111
112/// Bybit repay endpoint rate limit.
113///
114/// Conservative limit to avoid hitting API restrictions when repaying small borrows.
115pub static BYBIT_REPAY_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
116    Quota::per_second(NonZeroU32::new(1).expect("Should be a valid non-zero u32"))
117});
118
119const BYBIT_GLOBAL_RATE_KEY: &str = "bybit:global";
120const BYBIT_REPAY_ROUTE_KEY: &str = "bybit:/v5/account/no-convert-repay";
121
122/// Raw HTTP client for low-level Bybit API operations.
123///
124/// This client handles request/response operations with the Bybit API,
125/// returning venue-specific response types. It does not parse to Nautilus domain types.
126#[cfg_attr(
127    feature = "python",
128    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.bybit")
129)]
130#[derive(Clone)]
131pub struct BybitRawHttpClient {
132    base_url: String,
133    client: HttpClient,
134    credential: Option<Credential>,
135    recv_window_ms: u64,
136    retry_manager: RetryManager<BybitHttpError>,
137    cancellation_token: CancellationToken,
138}
139
140impl Default for BybitRawHttpClient {
141    fn default() -> Self {
142        Self::new(None, Some(60), None, None, None, None, None)
143            .expect("Failed to create default BybitRawHttpClient")
144    }
145}
146
147impl Debug for BybitRawHttpClient {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct(stringify!(BybitRawHttpClient))
150            .field("base_url", &self.base_url)
151            .field("has_credentials", &self.credential.is_some())
152            .field("recv_window_ms", &self.recv_window_ms)
153            .finish()
154    }
155}
156
157impl BybitRawHttpClient {
158    /// Cancel all pending HTTP requests.
159    pub fn cancel_all_requests(&self) {
160        self.cancellation_token.cancel();
161    }
162
163    /// Get the cancellation token for this client.
164    pub fn cancellation_token(&self) -> &CancellationToken {
165        &self.cancellation_token
166    }
167
168    /// Creates a new [`BybitRawHttpClient`] using the default Bybit HTTP URL.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the retry manager cannot be created.
173    #[allow(clippy::too_many_arguments)]
174    pub fn new(
175        base_url: Option<String>,
176        timeout_secs: Option<u64>,
177        max_retries: Option<u32>,
178        retry_delay_ms: Option<u64>,
179        retry_delay_max_ms: Option<u64>,
180        recv_window_ms: Option<u64>,
181        proxy_url: Option<String>,
182    ) -> Result<Self, BybitHttpError> {
183        let retry_config = RetryConfig {
184            max_retries: max_retries.unwrap_or(3),
185            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
186            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
187            backoff_factor: 2.0,
188            jitter_ms: 1000,
189            operation_timeout_ms: Some(60_000),
190            immediate_first: false,
191            max_elapsed_ms: Some(180_000),
192        };
193
194        let retry_manager = RetryManager::new(retry_config);
195
196        Ok(Self {
197            base_url: base_url
198                .unwrap_or_else(|| bybit_http_base_url(BybitEnvironment::Mainnet).to_string()),
199            client: HttpClient::new(
200                Self::default_headers(),
201                vec![],
202                Self::rate_limiter_quotas(),
203                Some(*BYBIT_REST_QUOTA),
204                timeout_secs,
205                proxy_url,
206            )
207            .map_err(|e| {
208                BybitHttpError::NetworkError(format!("Failed to create HTTP client: {e}"))
209            })?,
210            credential: None,
211            recv_window_ms: recv_window_ms.unwrap_or(DEFAULT_RECV_WINDOW_MS),
212            retry_manager,
213            cancellation_token: CancellationToken::new(),
214        })
215    }
216
217    /// Creates a new [`BybitRawHttpClient`] configured with credentials.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the HTTP client cannot be created.
222    #[allow(clippy::too_many_arguments)]
223    pub fn with_credentials(
224        api_key: String,
225        api_secret: String,
226        base_url: Option<String>,
227        timeout_secs: Option<u64>,
228        max_retries: Option<u32>,
229        retry_delay_ms: Option<u64>,
230        retry_delay_max_ms: Option<u64>,
231        recv_window_ms: Option<u64>,
232        proxy_url: Option<String>,
233    ) -> Result<Self, BybitHttpError> {
234        let retry_config = RetryConfig {
235            max_retries: max_retries.unwrap_or(3),
236            initial_delay_ms: retry_delay_ms.unwrap_or(1000),
237            max_delay_ms: retry_delay_max_ms.unwrap_or(10_000),
238            backoff_factor: 2.0,
239            jitter_ms: 1000,
240            operation_timeout_ms: Some(60_000),
241            immediate_first: false,
242            max_elapsed_ms: Some(180_000),
243        };
244
245        let retry_manager = RetryManager::new(retry_config);
246
247        Ok(Self {
248            base_url: base_url
249                .unwrap_or_else(|| bybit_http_base_url(BybitEnvironment::Mainnet).to_string()),
250            client: HttpClient::new(
251                Self::default_headers(),
252                vec![],
253                Self::rate_limiter_quotas(),
254                Some(*BYBIT_REST_QUOTA),
255                timeout_secs,
256                proxy_url,
257            )
258            .map_err(|e| {
259                BybitHttpError::NetworkError(format!("Failed to create HTTP client: {e}"))
260            })?,
261            credential: Some(Credential::new(api_key, api_secret)),
262            recv_window_ms: recv_window_ms.unwrap_or(DEFAULT_RECV_WINDOW_MS),
263            retry_manager,
264            cancellation_token: CancellationToken::new(),
265        })
266    }
267
268    /// Creates a new [`BybitRawHttpClient`] with environment variable credential resolution.
269    ///
270    /// If `api_key` or `api_secret` are not provided, they will be loaded from
271    /// environment variables based on the environment flags:
272    /// - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
273    /// - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
274    /// - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the HTTP client cannot be created.
279    #[allow(clippy::too_many_arguments)]
280    pub fn new_with_env(
281        api_key: Option<String>,
282        api_secret: Option<String>,
283        base_url: Option<String>,
284        demo: bool,
285        testnet: bool,
286        timeout_secs: Option<u64>,
287        max_retries: Option<u32>,
288        retry_delay_ms: Option<u64>,
289        retry_delay_max_ms: Option<u64>,
290        recv_window_ms: Option<u64>,
291        proxy_url: Option<String>,
292    ) -> Result<Self, BybitHttpError> {
293        let (api_key_env, api_secret_env) = if demo {
294            ("BYBIT_DEMO_API_KEY", "BYBIT_DEMO_API_SECRET")
295        } else if testnet {
296            ("BYBIT_TESTNET_API_KEY", "BYBIT_TESTNET_API_SECRET")
297        } else {
298            ("BYBIT_API_KEY", "BYBIT_API_SECRET")
299        };
300
301        let key = get_or_env_var_opt(api_key, api_key_env);
302        let secret = get_or_env_var_opt(api_secret, api_secret_env);
303
304        if let (Some(k), Some(s)) = (key, secret) {
305            Self::with_credentials(
306                k,
307                s,
308                base_url,
309                timeout_secs,
310                max_retries,
311                retry_delay_ms,
312                retry_delay_max_ms,
313                recv_window_ms,
314                proxy_url,
315            )
316        } else {
317            Self::new(
318                base_url,
319                timeout_secs,
320                max_retries,
321                retry_delay_ms,
322                retry_delay_max_ms,
323                recv_window_ms,
324                proxy_url,
325            )
326        }
327    }
328
329    fn default_headers() -> HashMap<String, String> {
330        HashMap::from([
331            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
332            ("Referer".to_string(), BYBIT_NAUTILUS_BROKER_ID.to_string()),
333        ])
334    }
335
336    fn rate_limiter_quotas() -> Vec<(String, Quota)> {
337        vec![
338            (BYBIT_GLOBAL_RATE_KEY.to_string(), *BYBIT_REST_QUOTA),
339            (BYBIT_REPAY_ROUTE_KEY.to_string(), *BYBIT_REPAY_QUOTA),
340        ]
341    }
342
343    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
344        let normalized = endpoint.split('?').next().unwrap_or(endpoint);
345        let route = format!("bybit:{normalized}");
346
347        vec![BYBIT_GLOBAL_RATE_KEY.to_string(), route]
348    }
349
350    fn sign_request(
351        &self,
352        timestamp: &str,
353        params: Option<&str>,
354    ) -> Result<HashMap<String, String>, BybitHttpError> {
355        let credential = self
356            .credential
357            .as_ref()
358            .ok_or(BybitHttpError::MissingCredentials)?;
359
360        let signature = credential.sign_with_payload(timestamp, self.recv_window_ms, params);
361
362        let mut headers = HashMap::new();
363        headers.insert(
364            "X-BAPI-API-KEY".to_string(),
365            credential.api_key().to_string(),
366        );
367        headers.insert("X-BAPI-TIMESTAMP".to_string(), timestamp.to_string());
368        headers.insert("X-BAPI-SIGN".to_string(), signature);
369        headers.insert(
370            "X-BAPI-RECV-WINDOW".to_string(),
371            self.recv_window_ms.to_string(),
372        );
373
374        Ok(headers)
375    }
376
377    async fn send_request<T: DeserializeOwned + BybitResponseCheck, P: Serialize>(
378        &self,
379        method: Method,
380        endpoint: &str,
381        params: Option<&P>,
382        body: Option<Vec<u8>>,
383        authenticate: bool,
384    ) -> Result<T, BybitHttpError> {
385        let endpoint = endpoint.to_string();
386        let url = format!("{}{endpoint}", self.base_url);
387        let method_clone = method.clone();
388        let body_clone = body.clone();
389
390        // Serialize params before closure to avoid reference lifetime issues
391        let params_str = if method == Method::GET {
392            params
393                .map(serde_urlencoded::to_string)
394                .transpose()
395                .map_err(|e| {
396                    BybitHttpError::JsonError(format!("Failed to serialize params: {e}"))
397                })?
398        } else {
399            None
400        };
401
402        let operation = || {
403            let url = url.clone();
404            let method = method_clone.clone();
405            let body = body_clone.clone();
406            let endpoint = endpoint.clone();
407            let params_str = params_str.clone();
408
409            async move {
410                let mut headers = Self::default_headers();
411
412                if authenticate {
413                    let timestamp = get_atomic_clock_realtime().get_time_ms().to_string();
414
415                    let sign_payload = if method == Method::GET {
416                        params_str.as_deref()
417                    } else {
418                        body.as_ref().and_then(|b| std::str::from_utf8(b).ok())
419                    };
420
421                    let auth_headers = self.sign_request(&timestamp, sign_payload)?;
422                    headers.extend(auth_headers);
423                }
424
425                if method == Method::POST || method == Method::PUT {
426                    headers.insert("Content-Type".to_string(), "application/json".to_string());
427                }
428
429                let full_url = if let Some(ref query) = params_str {
430                    if query.is_empty() {
431                        url
432                    } else {
433                        format!("{url}?{query}")
434                    }
435                } else {
436                    url
437                };
438
439                let rate_limit_keys = Self::rate_limit_keys(&endpoint);
440
441                let response = self
442                    .client
443                    .request(
444                        method,
445                        full_url,
446                        None,
447                        Some(headers),
448                        body,
449                        None,
450                        Some(rate_limit_keys),
451                    )
452                    .await?;
453
454                if response.status.as_u16() >= 400 {
455                    let body = String::from_utf8_lossy(&response.body).to_string();
456                    return Err(BybitHttpError::UnexpectedStatus {
457                        status: response.status.as_u16(),
458                        body,
459                    });
460                }
461
462                // Try to deserialize into the target type
463                match serde_json::from_slice::<T>(&response.body) {
464                    Ok(result) => {
465                        // Check for API-level errors
466                        if result.ret_code() != 0 {
467                            return Err(BybitHttpError::BybitError {
468                                error_code: result.ret_code() as i32,
469                                message: result.ret_msg().to_string(),
470                            });
471                        }
472                        Ok(result)
473                    }
474                    Err(json_err) => {
475                        // Deserialization failed - check if it's a Bybit error response
476                        // (error responses often have result: null which fails typed deserialization)
477                        if let Ok(error_check) =
478                            serde_json::from_slice::<BybitErrorCheck>(&response.body)
479                            && error_check.ret_code != 0
480                        {
481                            return Err(BybitHttpError::BybitError {
482                                error_code: error_check.ret_code as i32,
483                                message: error_check.ret_msg,
484                            });
485                        }
486                        // Not a Bybit error, propagate the JSON parse error
487                        Err(json_err.into())
488                    }
489                }
490            }
491        };
492
493        let should_retry = |error: &BybitHttpError| -> bool {
494            match error {
495                BybitHttpError::NetworkError(_) => true,
496                BybitHttpError::UnexpectedStatus { status, .. } => *status >= 500,
497                _ => false,
498            }
499        };
500
501        let create_error = |msg: String| -> BybitHttpError {
502            if msg == "canceled" {
503                BybitHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
504            } else {
505                BybitHttpError::NetworkError(msg)
506            }
507        };
508
509        self.retry_manager
510            .execute_with_retry_with_cancel(
511                endpoint.as_str(),
512                operation,
513                should_retry,
514                create_error,
515                &self.cancellation_token,
516            )
517            .await
518    }
519
520    #[cfg(test)]
521    fn build_path<S: Serialize>(base: &str, params: &S) -> Result<String, BybitHttpError> {
522        let query = serde_urlencoded::to_string(params)
523            .map_err(|e| BybitHttpError::JsonError(e.to_string()))?;
524        if query.is_empty() {
525            Ok(base.to_owned())
526        } else {
527            Ok(format!("{base}?{query}"))
528        }
529    }
530
531    /// Fetches the current server time from Bybit.
532    ///
533    /// # Errors
534    ///
535    /// Returns an error if the request fails or the response cannot be parsed.
536    ///
537    /// # References
538    ///
539    /// - <https://bybit-exchange.github.io/docs/v5/market/time>
540    pub async fn get_server_time(&self) -> Result<BybitServerTimeResponse, BybitHttpError> {
541        self.send_request::<_, ()>(Method::GET, "/v5/market/time", None, None, false)
542            .await
543    }
544
545    /// Fetches instrument information from Bybit for a given product category.
546    ///
547    /// # Errors
548    ///
549    /// Returns an error if the request fails or the response cannot be parsed.
550    ///
551    /// # References
552    ///
553    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
554    pub async fn get_instruments<T: DeserializeOwned + BybitResponseCheck>(
555        &self,
556        params: &BybitInstrumentsInfoParams,
557    ) -> Result<T, BybitHttpError> {
558        self.send_request(
559            Method::GET,
560            "/v5/market/instruments-info",
561            Some(params),
562            None,
563            false,
564        )
565        .await
566    }
567
568    /// Fetches spot instrument information from Bybit.
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the request fails or the response cannot be parsed.
573    ///
574    /// # References
575    ///
576    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
577    pub async fn get_instruments_spot(
578        &self,
579        params: &BybitInstrumentsInfoParams,
580    ) -> Result<BybitInstrumentSpotResponse, BybitHttpError> {
581        self.get_instruments(params).await
582    }
583
584    /// Fetches linear instrument information from Bybit.
585    ///
586    /// # Errors
587    ///
588    /// Returns an error if the request fails or the response cannot be parsed.
589    ///
590    /// # References
591    ///
592    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
593    pub async fn get_instruments_linear(
594        &self,
595        params: &BybitInstrumentsInfoParams,
596    ) -> Result<BybitInstrumentLinearResponse, BybitHttpError> {
597        self.get_instruments(params).await
598    }
599
600    /// Fetches inverse instrument information from Bybit.
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if the request fails or the response cannot be parsed.
605    ///
606    /// # References
607    ///
608    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
609    pub async fn get_instruments_inverse(
610        &self,
611        params: &BybitInstrumentsInfoParams,
612    ) -> Result<BybitInstrumentInverseResponse, BybitHttpError> {
613        self.get_instruments(params).await
614    }
615
616    /// Fetches option instrument information from Bybit.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if the request fails or the response cannot be parsed.
621    ///
622    /// # References
623    ///
624    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
625    pub async fn get_instruments_option(
626        &self,
627        params: &BybitInstrumentsInfoParams,
628    ) -> Result<BybitInstrumentOptionResponse, BybitHttpError> {
629        self.get_instruments(params).await
630    }
631
632    /// Fetches kline/candlestick data from Bybit.
633    ///
634    /// # Errors
635    ///
636    /// Returns an error if the request fails or the response cannot be parsed.
637    ///
638    /// # References
639    ///
640    /// - <https://bybit-exchange.github.io/docs/v5/market/kline>
641    pub async fn get_klines(
642        &self,
643        params: &BybitKlinesParams,
644    ) -> Result<BybitKlinesResponse, BybitHttpError> {
645        self.send_request(Method::GET, "/v5/market/kline", Some(params), None, false)
646            .await
647    }
648
649    /// Fetches recent trades from Bybit.
650    ///
651    /// # Errors
652    ///
653    /// Returns an error if the request fails or the response cannot be parsed.
654    ///
655    /// # References
656    ///
657    /// - <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
658    pub async fn get_recent_trades(
659        &self,
660        params: &BybitTradesParams,
661    ) -> Result<BybitTradesResponse, BybitHttpError> {
662        self.send_request(
663            Method::GET,
664            "/v5/market/recent-trade",
665            Some(params),
666            None,
667            false,
668        )
669        .await
670    }
671
672    /// Fetches open orders (requires authentication).
673    ///
674    /// # Errors
675    ///
676    /// Returns an error if the request fails or the response cannot be parsed.
677    ///
678    /// # Panics
679    ///
680    /// Panics if the parameter builder fails (should never happen with valid inputs).
681    ///
682    /// # References
683    ///
684    /// - <https://bybit-exchange.github.io/docs/v5/order/open-order>
685    #[allow(clippy::too_many_arguments)]
686    pub async fn get_open_orders(
687        &self,
688        category: BybitProductType,
689        symbol: Option<String>,
690        base_coin: Option<String>,
691        settle_coin: Option<String>,
692        order_id: Option<String>,
693        order_link_id: Option<String>,
694        open_only: Option<BybitOpenOnly>,
695        order_filter: Option<BybitOrderFilter>,
696        limit: Option<u32>,
697        cursor: Option<String>,
698    ) -> Result<BybitOpenOrdersResponse, BybitHttpError> {
699        let mut builder = BybitOpenOrdersParamsBuilder::default();
700        builder.category(category);
701
702        if let Some(s) = symbol {
703            builder.symbol(s);
704        }
705        if let Some(bc) = base_coin {
706            builder.base_coin(bc);
707        }
708        if let Some(sc) = settle_coin {
709            builder.settle_coin(sc);
710        }
711        if let Some(oi) = order_id {
712            builder.order_id(oi);
713        }
714        if let Some(ol) = order_link_id {
715            builder.order_link_id(ol);
716        }
717        if let Some(oo) = open_only {
718            builder.open_only(oo);
719        }
720        if let Some(of) = order_filter {
721            builder.order_filter(of);
722        }
723        if let Some(l) = limit {
724            builder.limit(l);
725        }
726        if let Some(c) = cursor {
727            builder.cursor(c);
728        }
729
730        let params = builder
731            .build()
732            .expect("Failed to build BybitOpenOrdersParams");
733
734        self.send_request(Method::GET, "/v5/order/realtime", Some(&params), None, true)
735            .await
736    }
737
738    /// Places a new order (requires authentication).
739    ///
740    /// # Errors
741    ///
742    /// Returns an error if the request fails or the response cannot be parsed.
743    ///
744    /// # References
745    ///
746    /// - <https://bybit-exchange.github.io/docs/v5/order/create-order>
747    pub async fn place_order(
748        &self,
749        request: &serde_json::Value,
750    ) -> Result<BybitPlaceOrderResponse, BybitHttpError> {
751        let body = serde_json::to_vec(request)?;
752        self.send_request::<_, ()>(Method::POST, "/v5/order/create", None, Some(body), true)
753            .await
754    }
755
756    /// Fetches wallet balance (requires authentication).
757    ///
758    /// # Errors
759    ///
760    /// Returns an error if the request fails or the response cannot be parsed.
761    ///
762    /// # References
763    ///
764    /// - <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
765    pub async fn get_wallet_balance(
766        &self,
767        params: &BybitWalletBalanceParams,
768    ) -> Result<BybitWalletBalanceResponse, BybitHttpError> {
769        self.send_request(
770            Method::GET,
771            "/v5/account/wallet-balance",
772            Some(params),
773            None,
774            true,
775        )
776        .await
777    }
778
779    /// Fetches account details (requires authentication).
780    ///
781    /// # Errors
782    ///
783    /// Returns an error if the request fails or the response cannot be parsed.
784    ///
785    /// # References
786    ///
787    /// - <https://bybit-exchange.github.io/docs/v5/user/apikey-info>
788    pub async fn get_account_details(&self) -> Result<BybitAccountDetailsResponse, BybitHttpError> {
789        self.send_request::<_, ()>(Method::GET, "/v5/user/query-api", None, None, true)
790            .await
791    }
792
793    /// Fetches trading fee rates for symbols.
794    ///
795    /// # Errors
796    ///
797    /// Returns an error if the request fails or the response cannot be parsed.
798    ///
799    /// # References
800    ///
801    /// - <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
802    pub async fn get_fee_rate(
803        &self,
804        params: &BybitFeeRateParams,
805    ) -> Result<BybitFeeRateResponse, BybitHttpError> {
806        self.send_request(
807            Method::GET,
808            "/v5/account/fee-rate",
809            Some(params),
810            None,
811            true,
812        )
813        .await
814    }
815
816    /// Sets the margin mode for the account.
817    ///
818    /// # Errors
819    ///
820    /// Returns an error if:
821    /// - Credentials are missing.
822    /// - The request fails.
823    /// - The API returns an error.
824    ///
825    /// # Panics
826    ///
827    /// Panics if required parameters are not provided (should not happen with current implementation).
828    ///
829    /// # References
830    ///
831    /// - <https://bybit-exchange.github.io/docs/v5/account/set-margin-mode>
832    pub async fn set_margin_mode(
833        &self,
834        margin_mode: BybitMarginMode,
835    ) -> Result<BybitSetMarginModeResponse, BybitHttpError> {
836        let params = BybitSetMarginModeParamsBuilder::default()
837            .set_margin_mode(margin_mode)
838            .build()
839            .expect("Failed to build BybitSetMarginModeParams");
840
841        let body = serde_json::to_vec(&params)?;
842        self.send_request::<_, ()>(
843            Method::POST,
844            "/v5/account/set-margin-mode",
845            None,
846            Some(body),
847            true,
848        )
849        .await
850    }
851
852    /// Sets leverage for a symbol.
853    ///
854    /// # Errors
855    ///
856    /// Returns an error if:
857    /// - Credentials are missing.
858    /// - The request fails.
859    /// - The API returns an error.
860    ///
861    /// # Panics
862    ///
863    /// Panics if required parameters are not provided (should not happen with current implementation).
864    ///
865    /// # References
866    ///
867    /// - <https://bybit-exchange.github.io/docs/v5/position/leverage>
868    pub async fn set_leverage(
869        &self,
870        product_type: BybitProductType,
871        symbol: &str,
872        buy_leverage: &str,
873        sell_leverage: &str,
874    ) -> Result<BybitSetLeverageResponse, BybitHttpError> {
875        let params = BybitSetLeverageParamsBuilder::default()
876            .category(product_type)
877            .symbol(symbol.to_string())
878            .buy_leverage(buy_leverage.to_string())
879            .sell_leverage(sell_leverage.to_string())
880            .build()
881            .expect("Failed to build BybitSetLeverageParams");
882
883        let body = serde_json::to_vec(&params)?;
884        self.send_request::<_, ()>(
885            Method::POST,
886            "/v5/position/set-leverage",
887            None,
888            Some(body),
889            true,
890        )
891        .await
892    }
893
894    /// Switches position mode for a product type.
895    ///
896    /// # Errors
897    ///
898    /// Returns an error if:
899    /// - Credentials are missing.
900    /// - The request fails.
901    /// - The API returns an error.
902    ///
903    /// # Panics
904    ///
905    /// Panics if required parameters are not provided (should not happen with current implementation).
906    ///
907    /// # References
908    ///
909    /// - <https://bybit-exchange.github.io/docs/v5/position/position-mode>
910    pub async fn switch_mode(
911        &self,
912        product_type: BybitProductType,
913        mode: BybitPositionMode,
914        symbol: Option<String>,
915        coin: Option<String>,
916    ) -> Result<BybitSwitchModeResponse, BybitHttpError> {
917        let mut builder = BybitSwitchModeParamsBuilder::default();
918        builder.category(product_type);
919        builder.mode(mode);
920
921        if let Some(s) = symbol {
922            builder.symbol(s);
923        }
924        if let Some(c) = coin {
925            builder.coin(c);
926        }
927
928        let params = builder
929            .build()
930            .expect("Failed to build BybitSwitchModeParams");
931
932        let body = serde_json::to_vec(&params)?;
933        self.send_request::<_, ()>(
934            Method::POST,
935            "/v5/position/switch-mode",
936            None,
937            Some(body),
938            true,
939        )
940        .await
941    }
942
943    /// Sets trading stop parameters including trailing stops.
944    ///
945    /// # Errors
946    ///
947    /// Returns an error if:
948    /// - Credentials are missing.
949    /// - The request fails.
950    /// - The API returns an error.
951    ///
952    /// # References
953    ///
954    /// - <https://bybit-exchange.github.io/docs/v5/position/trading-stop>
955    pub async fn set_trading_stop(
956        &self,
957        params: &BybitSetTradingStopParams,
958    ) -> Result<BybitSetTradingStopResponse, BybitHttpError> {
959        let body = serde_json::to_vec(params)?;
960        self.send_request::<_, ()>(
961            Method::POST,
962            "/v5/position/trading-stop",
963            None,
964            Some(body),
965            true,
966        )
967        .await
968    }
969
970    /// Manually borrows coins for margin trading.
971    ///
972    /// # Errors
973    ///
974    /// Returns an error if:
975    /// - Credentials are missing.
976    /// - The request fails.
977    /// - Insufficient collateral for the borrow.
978    ///
979    /// # Panics
980    ///
981    /// Panics if the parameter builder fails (should never happen with valid inputs).
982    ///
983    /// # References
984    ///
985    /// - <https://bybit-exchange.github.io/docs/v5/account/borrow>
986    pub async fn borrow(
987        &self,
988        coin: &str,
989        amount: &str,
990    ) -> Result<BybitBorrowResponse, BybitHttpError> {
991        let params = BybitBorrowParamsBuilder::default()
992            .coin(coin.to_string())
993            .amount(amount.to_string())
994            .build()
995            .expect("Failed to build BybitBorrowParams");
996
997        let body = serde_json::to_vec(&params)?;
998        self.send_request::<_, ()>(Method::POST, "/v5/account/borrow", None, Some(body), true)
999            .await
1000    }
1001
1002    /// Manually repays borrowed coins without asset conversion.
1003    ///
1004    /// # Errors
1005    ///
1006    /// Returns an error if:
1007    /// - Credentials are missing.
1008    /// - The request fails.
1009    /// - Called between 04:00-05:30 UTC (interest calculation window).
1010    /// - Insufficient spot balance for repayment.
1011    ///
1012    /// # Panics
1013    ///
1014    /// Panics if the parameter builder fails (should never happen with valid inputs).
1015    ///
1016    /// # References
1017    ///
1018    /// - <https://bybit-exchange.github.io/docs/v5/account/no-convert-repay>
1019    pub async fn no_convert_repay(
1020        &self,
1021        coin: &str,
1022        amount: Option<&str>,
1023    ) -> Result<BybitNoConvertRepayResponse, BybitHttpError> {
1024        let mut builder = BybitNoConvertRepayParamsBuilder::default();
1025        builder.coin(coin.to_string());
1026
1027        if let Some(amt) = amount {
1028            builder.amount(amt.to_string());
1029        }
1030
1031        let params = builder
1032            .build()
1033            .expect("Failed to build BybitNoConvertRepayParams");
1034
1035        // TODO: Logging for visibility during development
1036        if let Ok(params_json) = serde_json::to_string(&params) {
1037            log::debug!("Repay request params: {params_json}");
1038        }
1039
1040        let body = serde_json::to_vec(&params)?;
1041        let result = self
1042            .send_request::<_, ()>(
1043                Method::POST,
1044                "/v5/account/no-convert-repay",
1045                None,
1046                Some(body),
1047                true,
1048            )
1049            .await;
1050
1051        // TODO: Logging for visibility during development
1052        if let Err(ref e) = result
1053            && let Ok(params_json) = serde_json::to_string(&params)
1054        {
1055            log::error!("Repay request failed with params {params_json}: {e}");
1056        }
1057
1058        result
1059    }
1060
1061    /// Fetches tickers for market data.
1062    ///
1063    /// # Errors
1064    ///
1065    /// Returns an error if the request fails or the response cannot be parsed.
1066    ///
1067    /// # References
1068    ///
1069    /// - <https://bybit-exchange.github.io/docs/v5/market/tickers>
1070    pub async fn get_tickers<T: DeserializeOwned + BybitResponseCheck>(
1071        &self,
1072        params: &BybitTickersParams,
1073    ) -> Result<T, BybitHttpError> {
1074        self.send_request(Method::GET, "/v5/market/tickers", Some(params), None, false)
1075            .await
1076    }
1077
1078    /// Fetches trade execution history (requires authentication).
1079    ///
1080    /// # Errors
1081    ///
1082    /// Returns an error if the request fails or the response cannot be parsed.
1083    ///
1084    /// # References
1085    ///
1086    /// - <https://bybit-exchange.github.io/docs/v5/order/execution>
1087    pub async fn get_trade_history(
1088        &self,
1089        params: &BybitTradeHistoryParams,
1090    ) -> Result<BybitTradeHistoryResponse, BybitHttpError> {
1091        self.send_request(Method::GET, "/v5/execution/list", Some(params), None, true)
1092            .await
1093    }
1094
1095    /// Fetches position information (requires authentication).
1096    ///
1097    /// # Errors
1098    ///
1099    /// This function returns an error if:
1100    /// - Credentials are missing.
1101    /// - The request fails.
1102    /// - The API returns an error.
1103    ///
1104    /// # References
1105    ///
1106    /// - <https://bybit-exchange.github.io/docs/v5/position>
1107    pub async fn get_positions(
1108        &self,
1109        params: &BybitPositionListParams,
1110    ) -> Result<BybitPositionListResponse, BybitHttpError> {
1111        self.send_request(Method::GET, "/v5/position/list", Some(params), None, true)
1112            .await
1113    }
1114
1115    /// Returns the base URL used for requests.
1116    #[must_use]
1117    pub fn base_url(&self) -> &str {
1118        &self.base_url
1119    }
1120
1121    /// Returns the configured receive window in milliseconds.
1122    #[must_use]
1123    pub fn recv_window_ms(&self) -> u64 {
1124        self.recv_window_ms
1125    }
1126
1127    /// Returns the API credential if configured.
1128    #[must_use]
1129    pub fn credential(&self) -> Option<&Credential> {
1130        self.credential.as_ref()
1131    }
1132}
1133
1134/// Provides a HTTP client for connecting to the [Bybit](https://bybit.com) REST API.
1135#[cfg_attr(
1136    feature = "python",
1137    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.bybit")
1138)]
1139/// High-level HTTP client that wraps the raw client and provides Nautilus domain types.
1140///
1141/// This client maintains an instrument cache and uses it to parse venue responses
1142/// into Nautilus domain objects.
1143pub struct BybitHttpClient {
1144    pub(crate) inner: Arc<BybitRawHttpClient>,
1145    pub(crate) instruments_cache: Arc<DashMap<Ustr, InstrumentAny>>,
1146    cache_initialized: Arc<AtomicBool>,
1147    use_spot_position_reports: Arc<AtomicBool>,
1148}
1149
1150impl Clone for BybitHttpClient {
1151    fn clone(&self) -> Self {
1152        Self {
1153            inner: self.inner.clone(),
1154            instruments_cache: self.instruments_cache.clone(),
1155            cache_initialized: self.cache_initialized.clone(),
1156            use_spot_position_reports: self.use_spot_position_reports.clone(),
1157        }
1158    }
1159}
1160
1161impl Default for BybitHttpClient {
1162    fn default() -> Self {
1163        Self::new(None, Some(60), None, None, None, None, None)
1164            .expect("Failed to create default BybitHttpClient")
1165    }
1166}
1167
1168impl Debug for BybitHttpClient {
1169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1170        f.debug_struct(stringify!(BybitHttpClient))
1171            .field("inner", &self.inner)
1172            .finish()
1173    }
1174}
1175
1176impl BybitHttpClient {
1177    /// Creates a new [`BybitHttpClient`] using the default Bybit HTTP URL.
1178    ///
1179    /// # Errors
1180    ///
1181    /// Returns an error if the retry manager cannot be created.
1182    #[allow(clippy::too_many_arguments)]
1183    pub fn new(
1184        base_url: Option<String>,
1185        timeout_secs: Option<u64>,
1186        max_retries: Option<u32>,
1187        retry_delay_ms: Option<u64>,
1188        retry_delay_max_ms: Option<u64>,
1189        recv_window_ms: Option<u64>,
1190        proxy_url: Option<String>,
1191    ) -> Result<Self, BybitHttpError> {
1192        Ok(Self {
1193            inner: Arc::new(BybitRawHttpClient::new(
1194                base_url,
1195                timeout_secs,
1196                max_retries,
1197                retry_delay_ms,
1198                retry_delay_max_ms,
1199                recv_window_ms,
1200                proxy_url,
1201            )?),
1202            instruments_cache: Arc::new(DashMap::new()),
1203            cache_initialized: Arc::new(AtomicBool::new(false)),
1204            use_spot_position_reports: Arc::new(AtomicBool::new(false)),
1205        })
1206    }
1207
1208    /// Creates a new [`BybitHttpClient`] configured with credentials.
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns an error if the retry manager cannot be created.
1213    #[allow(clippy::too_many_arguments)]
1214    pub fn with_credentials(
1215        api_key: String,
1216        api_secret: String,
1217        base_url: Option<String>,
1218        timeout_secs: Option<u64>,
1219        max_retries: Option<u32>,
1220        retry_delay_ms: Option<u64>,
1221        retry_delay_max_ms: Option<u64>,
1222        recv_window_ms: Option<u64>,
1223        proxy_url: Option<String>,
1224    ) -> Result<Self, BybitHttpError> {
1225        Ok(Self {
1226            inner: Arc::new(BybitRawHttpClient::with_credentials(
1227                api_key,
1228                api_secret,
1229                base_url,
1230                timeout_secs,
1231                max_retries,
1232                retry_delay_ms,
1233                retry_delay_max_ms,
1234                recv_window_ms,
1235                proxy_url,
1236            )?),
1237            instruments_cache: Arc::new(DashMap::new()),
1238            cache_initialized: Arc::new(AtomicBool::new(false)),
1239            use_spot_position_reports: Arc::new(AtomicBool::new(false)),
1240        })
1241    }
1242
1243    /// Creates a new [`BybitHttpClient`] with optional credentials resolved from environment variables.
1244    ///
1245    /// Credentials are resolved in the following order:
1246    /// 1. Use provided `api_key`/`api_secret` if `Some`
1247    /// 2. Fall back to environment variables based on environment:
1248    ///    - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
1249    ///    - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
1250    ///    - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
1251    ///
1252    /// # Errors
1253    ///
1254    /// Returns an error if the retry manager cannot be created.
1255    #[allow(clippy::too_many_arguments)]
1256    pub fn new_with_env(
1257        api_key: Option<String>,
1258        api_secret: Option<String>,
1259        base_url: Option<String>,
1260        demo: bool,
1261        testnet: bool,
1262        timeout_secs: Option<u64>,
1263        max_retries: Option<u32>,
1264        retry_delay_ms: Option<u64>,
1265        retry_delay_max_ms: Option<u64>,
1266        recv_window_ms: Option<u64>,
1267        proxy_url: Option<String>,
1268    ) -> Result<Self, BybitHttpError> {
1269        let (api_key_env, api_secret_env) = if demo {
1270            ("BYBIT_DEMO_API_KEY", "BYBIT_DEMO_API_SECRET")
1271        } else if testnet {
1272            ("BYBIT_TESTNET_API_KEY", "BYBIT_TESTNET_API_SECRET")
1273        } else {
1274            ("BYBIT_API_KEY", "BYBIT_API_SECRET")
1275        };
1276
1277        let key = get_or_env_var_opt(api_key, api_key_env);
1278        let secret = get_or_env_var_opt(api_secret, api_secret_env);
1279
1280        match (key, secret) {
1281            (Some(k), Some(s)) => Self::with_credentials(
1282                k,
1283                s,
1284                base_url,
1285                timeout_secs,
1286                max_retries,
1287                retry_delay_ms,
1288                retry_delay_max_ms,
1289                recv_window_ms,
1290                proxy_url,
1291            ),
1292            _ => Self::new(
1293                base_url,
1294                timeout_secs,
1295                max_retries,
1296                retry_delay_ms,
1297                retry_delay_max_ms,
1298                recv_window_ms,
1299                proxy_url,
1300            ),
1301        }
1302    }
1303
1304    #[must_use]
1305    pub fn base_url(&self) -> &str {
1306        self.inner.base_url()
1307    }
1308
1309    #[must_use]
1310    pub fn recv_window_ms(&self) -> u64 {
1311        self.inner.recv_window_ms()
1312    }
1313
1314    #[must_use]
1315    pub fn credential(&self) -> Option<&Credential> {
1316        self.inner.credential()
1317    }
1318
1319    pub fn set_use_spot_position_reports(&self, use_spot_position_reports: bool) {
1320        self.use_spot_position_reports
1321            .store(use_spot_position_reports, Ordering::Relaxed);
1322    }
1323
1324    pub fn cancel_all_requests(&self) {
1325        self.inner.cancel_all_requests();
1326    }
1327
1328    pub fn cancellation_token(&self) -> &CancellationToken {
1329        self.inner.cancellation_token()
1330    }
1331
1332    /// Any existing instrument with the same symbol will be replaced.
1333    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1334        self.instruments_cache
1335            .insert(instrument.symbol().inner(), instrument);
1336        self.cache_initialized.store(true, Ordering::Release);
1337    }
1338
1339    /// Any existing instruments with the same symbols will be replaced.
1340    pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
1341        for instrument in instruments {
1342            self.instruments_cache
1343                .insert(instrument.symbol().inner(), instrument);
1344        }
1345        self.cache_initialized.store(true, Ordering::Release);
1346    }
1347
1348    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1349        self.instruments_cache
1350            .get(symbol)
1351            .map(|entry| entry.value().clone())
1352    }
1353
1354    fn instrument_from_cache(&self, symbol: &Symbol) -> anyhow::Result<InstrumentAny> {
1355        self.get_instrument(&symbol.inner()).ok_or_else(|| {
1356            anyhow::anyhow!(
1357                "Instrument {symbol} not found in cache, ensure instruments loaded first"
1358            )
1359        })
1360    }
1361
1362    #[must_use]
1363    fn generate_ts_init(&self) -> UnixNanos {
1364        get_atomic_clock_realtime().get_time_ns()
1365    }
1366
1367    /// Fetches the current server time from Bybit.
1368    ///
1369    /// # Errors
1370    ///
1371    /// Returns an error if:
1372    /// - The request fails.
1373    /// - The response cannot be parsed.
1374    ///
1375    /// # References
1376    ///
1377    /// - <https://bybit-exchange.github.io/docs/v5/market/time>
1378    pub async fn get_server_time(&self) -> Result<BybitServerTimeResponse, BybitHttpError> {
1379        self.inner.get_server_time().await
1380    }
1381
1382    /// Fetches instrument information from Bybit for a given product category.
1383    ///
1384    /// # Errors
1385    ///
1386    /// Returns an error if:
1387    /// - The request fails.
1388    /// - The response cannot be parsed.
1389    ///
1390    /// # References
1391    ///
1392    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1393    pub async fn get_instruments<T: DeserializeOwned + BybitResponseCheck>(
1394        &self,
1395        params: &BybitInstrumentsInfoParams,
1396    ) -> Result<T, BybitHttpError> {
1397        self.inner.get_instruments(params).await
1398    }
1399
1400    /// Fetches spot instrument information from Bybit.
1401    ///
1402    /// # Errors
1403    ///
1404    /// Returns an error if:
1405    /// - The request fails.
1406    /// - The response cannot be parsed.
1407    ///
1408    /// # References
1409    ///
1410    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1411    pub async fn get_instruments_spot(
1412        &self,
1413        params: &BybitInstrumentsInfoParams,
1414    ) -> Result<BybitInstrumentSpotResponse, BybitHttpError> {
1415        self.inner.get_instruments_spot(params).await
1416    }
1417
1418    /// Fetches linear instrument information from Bybit.
1419    ///
1420    /// # Errors
1421    ///
1422    /// Returns an error if:
1423    /// - The request fails.
1424    /// - The response cannot be parsed.
1425    ///
1426    /// # References
1427    ///
1428    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1429    pub async fn get_instruments_linear(
1430        &self,
1431        params: &BybitInstrumentsInfoParams,
1432    ) -> Result<BybitInstrumentLinearResponse, BybitHttpError> {
1433        self.inner.get_instruments_linear(params).await
1434    }
1435
1436    /// Fetches inverse instrument information from Bybit.
1437    ///
1438    /// # Errors
1439    ///
1440    /// Returns an error if:
1441    /// - The request fails.
1442    /// - The response cannot be parsed.
1443    ///
1444    /// # References
1445    ///
1446    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1447    pub async fn get_instruments_inverse(
1448        &self,
1449        params: &BybitInstrumentsInfoParams,
1450    ) -> Result<BybitInstrumentInverseResponse, BybitHttpError> {
1451        self.inner.get_instruments_inverse(params).await
1452    }
1453
1454    /// Fetches option instrument information from Bybit.
1455    ///
1456    /// # Errors
1457    ///
1458    /// Returns an error if:
1459    /// - The request fails.
1460    /// - The response cannot be parsed.
1461    ///
1462    /// # References
1463    ///
1464    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1465    pub async fn get_instruments_option(
1466        &self,
1467        params: &BybitInstrumentsInfoParams,
1468    ) -> Result<BybitInstrumentOptionResponse, BybitHttpError> {
1469        self.inner.get_instruments_option(params).await
1470    }
1471
1472    /// Fetches kline/candlestick data from Bybit.
1473    ///
1474    /// # Errors
1475    ///
1476    /// Returns an error if:
1477    /// - The request fails.
1478    /// - The response cannot be parsed.
1479    ///
1480    /// # References
1481    ///
1482    /// - <https://bybit-exchange.github.io/docs/v5/market/kline>
1483    pub async fn get_klines(
1484        &self,
1485        params: &BybitKlinesParams,
1486    ) -> Result<BybitKlinesResponse, BybitHttpError> {
1487        self.inner.get_klines(params).await
1488    }
1489
1490    /// Fetches recent trades from Bybit.
1491    ///
1492    /// # Errors
1493    ///
1494    /// Returns an error if:
1495    /// - The request fails.
1496    /// - The response cannot be parsed.
1497    ///
1498    /// # References
1499    ///
1500    /// - <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
1501    pub async fn get_recent_trades(
1502        &self,
1503        params: &BybitTradesParams,
1504    ) -> Result<BybitTradesResponse, BybitHttpError> {
1505        self.inner.get_recent_trades(params).await
1506    }
1507
1508    /// Fetches open orders (requires authentication).
1509    ///
1510    /// # Errors
1511    ///
1512    /// Returns an error if:
1513    /// - The request fails.
1514    /// - The response cannot be parsed.
1515    ///
1516    /// # References
1517    ///
1518    /// - <https://bybit-exchange.github.io/docs/v5/order/open-order>
1519    #[allow(clippy::too_many_arguments)]
1520    pub async fn get_open_orders(
1521        &self,
1522        category: BybitProductType,
1523        symbol: Option<String>,
1524        base_coin: Option<String>,
1525        settle_coin: Option<String>,
1526        order_id: Option<String>,
1527        order_link_id: Option<String>,
1528        open_only: Option<BybitOpenOnly>,
1529        order_filter: Option<BybitOrderFilter>,
1530        limit: Option<u32>,
1531        cursor: Option<String>,
1532    ) -> Result<BybitOpenOrdersResponse, BybitHttpError> {
1533        self.inner
1534            .get_open_orders(
1535                category,
1536                symbol,
1537                base_coin,
1538                settle_coin,
1539                order_id,
1540                order_link_id,
1541                open_only,
1542                order_filter,
1543                limit,
1544                cursor,
1545            )
1546            .await
1547    }
1548
1549    /// Places a new order (requires authentication).
1550    ///
1551    /// # Errors
1552    ///
1553    /// Returns an error if:
1554    /// - The request fails.
1555    /// - The response cannot be parsed.
1556    ///
1557    /// # References
1558    ///
1559    /// - <https://bybit-exchange.github.io/docs/v5/order/create-order>
1560    pub async fn place_order(
1561        &self,
1562        request: &serde_json::Value,
1563    ) -> Result<BybitPlaceOrderResponse, BybitHttpError> {
1564        self.inner.place_order(request).await
1565    }
1566
1567    /// Fetches wallet balance (requires authentication).
1568    ///
1569    /// # Errors
1570    ///
1571    /// Returns an error if:
1572    /// - The request fails.
1573    /// - The response cannot be parsed.
1574    ///
1575    /// # References
1576    ///
1577    /// - <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
1578    pub async fn get_wallet_balance(
1579        &self,
1580        params: &BybitWalletBalanceParams,
1581    ) -> Result<BybitWalletBalanceResponse, BybitHttpError> {
1582        self.inner.get_wallet_balance(params).await
1583    }
1584
1585    /// Fetches API key information including account details (requires authentication).
1586    ///
1587    /// # Errors
1588    ///
1589    /// Returns an error if:
1590    /// - The request fails.
1591    /// - The response cannot be parsed.
1592    ///
1593    /// # References
1594    ///
1595    /// - <https://bybit-exchange.github.io/docs/v5/user/apikey-info>
1596    pub async fn get_account_details(&self) -> Result<BybitAccountDetailsResponse, BybitHttpError> {
1597        self.inner.get_account_details().await
1598    }
1599
1600    /// Fetches position information (requires authentication).
1601    ///
1602    /// # Errors
1603    ///
1604    /// Returns an error if:
1605    /// - Credentials are missing.
1606    /// - The request fails.
1607    /// - The API returns an error.
1608    ///
1609    /// # References
1610    ///
1611    /// - <https://bybit-exchange.github.io/docs/v5/position>
1612    pub async fn get_positions(
1613        &self,
1614        params: &BybitPositionListParams,
1615    ) -> Result<BybitPositionListResponse, BybitHttpError> {
1616        self.inner.get_positions(params).await
1617    }
1618
1619    /// Fetches fee rate (requires authentication).
1620    ///
1621    /// # Errors
1622    ///
1623    /// Returns an error if:
1624    /// - Credentials are missing.
1625    /// - The request fails.
1626    /// - The API returns an error.
1627    ///
1628    /// # References
1629    ///
1630    /// - <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
1631    pub async fn get_fee_rate(
1632        &self,
1633        params: &BybitFeeRateParams,
1634    ) -> Result<BybitFeeRateResponse, BybitHttpError> {
1635        self.inner.get_fee_rate(params).await
1636    }
1637
1638    /// Sets margin mode (requires authentication).
1639    ///
1640    /// # Errors
1641    ///
1642    /// Returns an error if:
1643    /// - Credentials are missing.
1644    /// - The request fails.
1645    /// - The API returns an error.
1646    ///
1647    /// # References
1648    ///
1649    /// - <https://bybit-exchange.github.io/docs/v5/account/set-margin-mode>
1650    pub async fn set_margin_mode(
1651        &self,
1652        margin_mode: BybitMarginMode,
1653    ) -> Result<BybitSetMarginModeResponse, BybitHttpError> {
1654        self.inner.set_margin_mode(margin_mode).await
1655    }
1656
1657    /// Sets leverage for a symbol (requires authentication).
1658    ///
1659    /// # Errors
1660    ///
1661    /// Returns an error if:
1662    /// - Credentials are missing.
1663    /// - The request fails.
1664    /// - The API returns an error.
1665    ///
1666    /// # References
1667    ///
1668    /// - <https://bybit-exchange.github.io/docs/v5/position/leverage>
1669    pub async fn set_leverage(
1670        &self,
1671        product_type: BybitProductType,
1672        symbol: &str,
1673        buy_leverage: &str,
1674        sell_leverage: &str,
1675    ) -> Result<BybitSetLeverageResponse, BybitHttpError> {
1676        self.inner
1677            .set_leverage(product_type, symbol, buy_leverage, sell_leverage)
1678            .await
1679    }
1680
1681    /// Switches position mode (requires authentication).
1682    ///
1683    /// # Errors
1684    ///
1685    /// Returns an error if:
1686    /// - Credentials are missing.
1687    /// - The request fails.
1688    /// - The API returns an error.
1689    ///
1690    /// # References
1691    ///
1692    /// - <https://bybit-exchange.github.io/docs/v5/position/position-mode>
1693    pub async fn switch_mode(
1694        &self,
1695        product_type: BybitProductType,
1696        mode: BybitPositionMode,
1697        symbol: Option<String>,
1698        coin: Option<String>,
1699    ) -> Result<BybitSwitchModeResponse, BybitHttpError> {
1700        self.inner
1701            .switch_mode(product_type, mode, symbol, coin)
1702            .await
1703    }
1704
1705    /// Sets trading stop parameters including trailing stops (requires authentication).
1706    ///
1707    /// # Errors
1708    ///
1709    /// Returns an error if:
1710    /// - Credentials are missing.
1711    /// - The request fails.
1712    /// - The API returns an error.
1713    ///
1714    /// # References
1715    ///
1716    /// - <https://bybit-exchange.github.io/docs/v5/position/trading-stop>
1717    pub async fn set_trading_stop(
1718        &self,
1719        params: &BybitSetTradingStopParams,
1720    ) -> Result<BybitSetTradingStopResponse, BybitHttpError> {
1721        self.inner.set_trading_stop(params).await
1722    }
1723
1724    /// Get the outstanding spot borrow amount for a specific coin.
1725    ///
1726    /// Returns zero if no borrow exists.
1727    ///
1728    /// # Parameters
1729    ///
1730    /// - `coin`: The coin to check (e.g., "BTC", "ETH")
1731    ///
1732    /// # Errors
1733    ///
1734    /// Returns an error if:
1735    /// - Credentials are missing.
1736    /// - The request fails.
1737    /// - The coin is not found in the wallet.
1738    pub async fn get_spot_borrow_amount(&self, coin: &str) -> anyhow::Result<Decimal> {
1739        let params = BybitWalletBalanceParams {
1740            account_type: BybitAccountType::Unified,
1741            coin: Some(coin.to_string()),
1742        };
1743
1744        let response = self.inner.get_wallet_balance(&params).await?;
1745
1746        let borrow_amount = response
1747            .result
1748            .list
1749            .first()
1750            .and_then(|wallet| wallet.coin.iter().find(|c| c.coin.as_str() == coin))
1751            .map_or(Decimal::ZERO, |balance| balance.spot_borrow);
1752
1753        Ok(borrow_amount)
1754    }
1755
1756    /// Borrows coins for spot margin trading.
1757    ///
1758    /// This should be called before opening short spot positions.
1759    ///
1760    /// # Parameters
1761    ///
1762    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
1763    /// - `amount`: Optional amount to borrow. If None, repays all outstanding borrows.
1764    ///
1765    /// # Errors
1766    ///
1767    /// Returns an error if:
1768    /// - Credentials are missing.
1769    /// - The request fails.
1770    /// - Insufficient collateral for the borrow.
1771    pub async fn borrow_spot(
1772        &self,
1773        coin: &str,
1774        amount: Quantity,
1775    ) -> anyhow::Result<BybitBorrowResponse> {
1776        let amount_str = amount.to_string();
1777        self.inner
1778            .borrow(coin, &amount_str)
1779            .await
1780            .map_err(|e| anyhow::anyhow!("Failed to borrow {amount} {coin}: {e}"))
1781    }
1782
1783    /// Repays spot borrows for a specific coin.
1784    ///
1785    /// This should be called after closing short spot positions to avoid accruing interest.
1786    ///
1787    /// # Parameters
1788    ///
1789    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
1790    /// - `amount`: Optional amount to repay. If None, repays all outstanding borrows.
1791    ///
1792    /// # Errors
1793    ///
1794    /// Returns an error if:
1795    /// - Credentials are missing.
1796    /// - The request fails.
1797    /// - Called between 04:00-05:30 UTC (interest calculation window).
1798    /// - Insufficient spot balance for repayment.
1799    pub async fn repay_spot_borrow(
1800        &self,
1801        coin: &str,
1802        amount: Option<Quantity>,
1803    ) -> anyhow::Result<BybitNoConvertRepayResponse> {
1804        let amount_str = amount.as_ref().map(|q| q.to_string());
1805        self.inner
1806            .no_convert_repay(coin, amount_str.as_deref())
1807            .await
1808            .map_err(|e| anyhow::anyhow!("Failed to repay spot borrow for {coin}: {e}"))
1809    }
1810
1811    /// Generate SPOT position reports from wallet balances.
1812    ///
1813    /// # Errors
1814    ///
1815    /// Returns an error if:
1816    /// - The wallet balance request fails.
1817    /// - Parsing fails.
1818    async fn generate_spot_position_reports_from_wallet(
1819        &self,
1820        account_id: AccountId,
1821        instrument_id: Option<InstrumentId>,
1822    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1823        let params = BybitWalletBalanceParams {
1824            account_type: BybitAccountType::Unified,
1825            coin: None,
1826        };
1827
1828        let response = self.inner.get_wallet_balance(&params).await?;
1829        let ts_init = self.generate_ts_init();
1830
1831        let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
1832
1833        for wallet in &response.result.list {
1834            for coin_balance in &wallet.coin {
1835                let balance = coin_balance.wallet_balance - coin_balance.spot_borrow;
1836                *wallet_by_coin
1837                    .entry(coin_balance.coin)
1838                    .or_insert(Decimal::ZERO) += balance;
1839            }
1840        }
1841
1842        let mut reports = Vec::new();
1843
1844        if let Some(instrument_id) = instrument_id {
1845            if let Some(instrument) = self.instruments_cache.get(&instrument_id.symbol.inner()) {
1846                let base_currency = instrument
1847                    .base_currency()
1848                    .expect("SPOT instrument should have base currency");
1849                let coin = base_currency.code;
1850                let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
1851
1852                let side = if wallet_balance > Decimal::ZERO {
1853                    PositionSideSpecified::Long
1854                } else if wallet_balance < Decimal::ZERO {
1855                    PositionSideSpecified::Short
1856                } else {
1857                    PositionSideSpecified::Flat
1858                };
1859
1860                let abs_balance = wallet_balance.abs();
1861                let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
1862
1863                let report = PositionStatusReport::new(
1864                    account_id,
1865                    instrument_id,
1866                    side,
1867                    quantity,
1868                    ts_init,
1869                    ts_init,
1870                    None,
1871                    None,
1872                    None,
1873                );
1874
1875                reports.push(report);
1876            }
1877        } else {
1878            // Generate reports for all SPOT instruments with non-zero balance
1879            for entry in self.instruments_cache.iter() {
1880                let symbol = entry.key();
1881                let instrument = entry.value();
1882                // Only consider SPOT instruments
1883                if !symbol.as_str().ends_with("-SPOT") {
1884                    continue;
1885                }
1886
1887                let base_currency = match instrument.base_currency() {
1888                    Some(currency) => currency,
1889                    None => continue,
1890                };
1891
1892                let coin = base_currency.code;
1893                let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
1894
1895                if wallet_balance.is_zero() {
1896                    continue;
1897                }
1898
1899                let side = if wallet_balance > Decimal::ZERO {
1900                    PositionSideSpecified::Long
1901                } else if wallet_balance < Decimal::ZERO {
1902                    PositionSideSpecified::Short
1903                } else {
1904                    PositionSideSpecified::Flat
1905                };
1906
1907                let abs_balance = wallet_balance.abs();
1908                let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
1909
1910                if quantity.is_zero() {
1911                    continue;
1912                }
1913
1914                let report = PositionStatusReport::new(
1915                    account_id,
1916                    instrument.id(),
1917                    side,
1918                    quantity,
1919                    ts_init,
1920                    ts_init,
1921                    None,
1922                    None,
1923                    None,
1924                );
1925
1926                reports.push(report);
1927            }
1928        }
1929
1930        Ok(reports)
1931    }
1932
1933    /// Submit a new order.
1934    ///
1935    /// # Errors
1936    ///
1937    /// Returns an error if:
1938    /// - Credentials are missing.
1939    /// - The request fails.
1940    /// - Order validation fails.
1941    /// - The order is rejected.
1942    /// - The API returns an error.
1943    #[allow(clippy::too_many_arguments)]
1944    pub async fn submit_order(
1945        &self,
1946        account_id: AccountId,
1947        product_type: BybitProductType,
1948        instrument_id: InstrumentId,
1949        client_order_id: ClientOrderId,
1950        order_side: OrderSide,
1951        order_type: OrderType,
1952        quantity: Quantity,
1953        time_in_force: TimeInForce,
1954        price: Option<Price>,
1955        reduce_only: bool,
1956        is_leverage: bool,
1957    ) -> anyhow::Result<OrderStatusReport> {
1958        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
1959        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
1960
1961        let bybit_side = match order_side {
1962            OrderSide::Buy => BybitOrderSide::Buy,
1963            OrderSide::Sell => BybitOrderSide::Sell,
1964            _ => anyhow::bail!("Invalid order side: {order_side:?}"),
1965        };
1966
1967        let bybit_order_type = match order_type {
1968            OrderType::Market => BybitOrderType::Market,
1969            OrderType::Limit => BybitOrderType::Limit,
1970            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
1971        };
1972
1973        let bybit_tif = match time_in_force {
1974            TimeInForce::Gtc => BybitTimeInForce::Gtc,
1975            TimeInForce::Ioc => BybitTimeInForce::Ioc,
1976            TimeInForce::Fok => BybitTimeInForce::Fok,
1977            _ => anyhow::bail!("Unsupported time in force: {time_in_force:?}"),
1978        };
1979
1980        let mut order_entry = BybitBatchPlaceOrderEntryBuilder::default();
1981        order_entry.symbol(bybit_symbol.raw_symbol().to_string());
1982        order_entry.side(bybit_side);
1983        order_entry.order_type(bybit_order_type);
1984        order_entry.qty(quantity.to_string());
1985        order_entry.time_in_force(Some(bybit_tif));
1986        order_entry.order_link_id(client_order_id.to_string());
1987
1988        if let Some(price) = price {
1989            order_entry.price(Some(price.to_string()));
1990        }
1991
1992        if reduce_only {
1993            order_entry.reduce_only(Some(true));
1994        }
1995
1996        // Only SPOT products support is_leverage parameter
1997        let is_leverage_value = if product_type == BybitProductType::Spot {
1998            Some(i32::from(is_leverage))
1999        } else {
2000            None
2001        };
2002        order_entry.is_leverage(is_leverage_value);
2003
2004        let order_entry = order_entry.build().map_err(|e| anyhow::anyhow!(e))?;
2005
2006        let mut params = BybitPlaceOrderParamsBuilder::default();
2007        params.category(product_type);
2008        params.order(order_entry);
2009
2010        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2011
2012        let body = serde_json::to_value(&params)?;
2013        let response = self.inner.place_order(&body).await?;
2014
2015        let order_id = response
2016            .result
2017            .order_id
2018            .ok_or_else(|| anyhow::anyhow!("No order_id in response"))?;
2019
2020        // Query the order to get full details
2021        let mut query_params = BybitOpenOrdersParamsBuilder::default();
2022        query_params.category(product_type);
2023        query_params.order_id(order_id.as_str().to_string());
2024
2025        let query_params = query_params.build().map_err(|e| anyhow::anyhow!(e))?;
2026        let order_response: BybitOpenOrdersResponse = self
2027            .inner
2028            .send_request(
2029                Method::GET,
2030                "/v5/order/realtime",
2031                Some(&query_params),
2032                None,
2033                true,
2034            )
2035            .await?;
2036
2037        let order = order_response
2038            .result
2039            .list
2040            .into_iter()
2041            .next()
2042            .ok_or_else(|| anyhow::anyhow!("No order returned after submission"))?;
2043
2044        // Only bail on rejection if there are no fills
2045        // If the order has fills (cum_exec_qty > 0), let the parser remap Rejected -> Canceled
2046        if order.order_status == crate::common::enums::BybitOrderStatus::Rejected
2047            && (order.cum_exec_qty.as_str() == "0" || order.cum_exec_qty.is_empty())
2048        {
2049            anyhow::bail!("Order rejected: {}", order.reject_reason);
2050        }
2051
2052        let ts_init = self.generate_ts_init();
2053
2054        parse_order_status_report(&order, &instrument, account_id, ts_init)
2055    }
2056
2057    /// Cancel an order.
2058    ///
2059    /// # Errors
2060    ///
2061    /// Returns an error if:
2062    /// - Credentials are missing.
2063    /// - The request fails.
2064    /// - The order doesn't exist.
2065    /// - The API returns an error.
2066    pub async fn cancel_order(
2067        &self,
2068        account_id: AccountId,
2069        product_type: BybitProductType,
2070        instrument_id: InstrumentId,
2071        client_order_id: Option<ClientOrderId>,
2072        venue_order_id: Option<VenueOrderId>,
2073    ) -> anyhow::Result<OrderStatusReport> {
2074        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2075        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2076
2077        let mut cancel_entry = BybitBatchCancelOrderEntryBuilder::default();
2078        cancel_entry.symbol(bybit_symbol.raw_symbol().to_string());
2079
2080        if let Some(venue_order_id) = venue_order_id {
2081            cancel_entry.order_id(venue_order_id.to_string());
2082        } else if let Some(client_order_id) = client_order_id {
2083            cancel_entry.order_link_id(client_order_id.to_string());
2084        } else {
2085            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2086        }
2087
2088        let cancel_entry = cancel_entry.build().map_err(|e| anyhow::anyhow!(e))?;
2089
2090        let mut params = BybitCancelOrderParamsBuilder::default();
2091        params.category(product_type);
2092        params.order(cancel_entry);
2093
2094        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2095        let body = serde_json::to_vec(&params)?;
2096
2097        let response: BybitPlaceOrderResponse = self
2098            .inner
2099            .send_request::<_, ()>(Method::POST, "/v5/order/cancel", None, Some(body), true)
2100            .await?;
2101
2102        let order_id = response
2103            .result
2104            .order_id
2105            .ok_or_else(|| anyhow::anyhow!("No order_id in cancel response"))?;
2106
2107        // Query the order to get full details after cancellation
2108        let mut query_params = BybitOpenOrdersParamsBuilder::default();
2109        query_params.category(product_type);
2110        query_params.order_id(order_id.as_str().to_string());
2111
2112        let query_params = query_params.build().map_err(|e| anyhow::anyhow!(e))?;
2113        let order_response: BybitOrderHistoryResponse = self
2114            .inner
2115            .send_request(
2116                Method::GET,
2117                "/v5/order/history",
2118                Some(&query_params),
2119                None,
2120                true,
2121            )
2122            .await?;
2123
2124        let order = order_response
2125            .result
2126            .list
2127            .into_iter()
2128            .next()
2129            .ok_or_else(|| anyhow::anyhow!("No order returned in cancel response"))?;
2130
2131        let ts_init = self.generate_ts_init();
2132
2133        parse_order_status_report(&order, &instrument, account_id, ts_init)
2134    }
2135
2136    /// Batch cancel multiple orders.
2137    ///
2138    /// # Errors
2139    ///
2140    /// Returns an error if:
2141    /// - Credentials are missing.
2142    /// - The request fails.
2143    /// - Any of the orders don't exist.
2144    /// - The API returns an error.
2145    pub async fn batch_cancel_orders(
2146        &self,
2147        account_id: AccountId,
2148        product_type: BybitProductType,
2149        instrument_ids: Vec<InstrumentId>,
2150        client_order_ids: Vec<Option<ClientOrderId>>,
2151        venue_order_ids: Vec<Option<VenueOrderId>>,
2152    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2153        if instrument_ids.len() != client_order_ids.len()
2154            || instrument_ids.len() != venue_order_ids.len()
2155        {
2156            anyhow::bail!(
2157                "instrument_ids, client_order_ids, and venue_order_ids must have the same length"
2158            );
2159        }
2160
2161        if instrument_ids.is_empty() {
2162            return Ok(Vec::new());
2163        }
2164
2165        if instrument_ids.len() > 20 {
2166            anyhow::bail!("Batch cancel limit is 20 orders per request");
2167        }
2168
2169        let mut cancel_entries = Vec::new();
2170
2171        for ((instrument_id, client_order_id), venue_order_id) in instrument_ids
2172            .iter()
2173            .zip(client_order_ids.iter())
2174            .zip(venue_order_ids.iter())
2175        {
2176            let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2177            let mut cancel_entry = BybitBatchCancelOrderEntryBuilder::default();
2178            cancel_entry.symbol(bybit_symbol.raw_symbol().to_string());
2179
2180            if let Some(venue_order_id) = venue_order_id {
2181                cancel_entry.order_id(venue_order_id.to_string());
2182            } else if let Some(client_order_id) = client_order_id {
2183                cancel_entry.order_link_id(client_order_id.to_string());
2184            } else {
2185                anyhow::bail!(
2186                    "Either client_order_id or venue_order_id must be provided for each order"
2187                );
2188            }
2189
2190            cancel_entries.push(cancel_entry.build().map_err(|e| anyhow::anyhow!(e))?);
2191        }
2192
2193        let mut params = BybitBatchCancelOrderParamsBuilder::default();
2194        params.category(product_type);
2195        params.request(cancel_entries);
2196
2197        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2198        let body = serde_json::to_vec(&params)?;
2199
2200        let _response: BybitPlaceOrderResponse = self
2201            .inner
2202            .send_request::<_, ()>(
2203                Method::POST,
2204                "/v5/order/cancel-batch",
2205                None,
2206                Some(body),
2207                true,
2208            )
2209            .await?;
2210
2211        // Query each order to get full details after cancellation
2212        let mut reports = Vec::new();
2213        for (instrument_id, (client_order_id, venue_order_id)) in instrument_ids
2214            .iter()
2215            .zip(client_order_ids.iter().zip(venue_order_ids.iter()))
2216        {
2217            let Ok(instrument) = self.instrument_from_cache(&instrument_id.symbol) else {
2218                log::debug!(
2219                    "Skipping cancelled order report for instrument not in cache: symbol={}",
2220                    instrument_id.symbol
2221                );
2222                continue;
2223            };
2224
2225            let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2226
2227            let mut query_params = BybitOpenOrdersParamsBuilder::default();
2228            query_params.category(product_type);
2229            query_params.symbol(bybit_symbol.raw_symbol().to_string());
2230
2231            if let Some(venue_order_id) = venue_order_id {
2232                query_params.order_id(venue_order_id.to_string());
2233            } else if let Some(client_order_id) = client_order_id {
2234                query_params.order_link_id(client_order_id.to_string());
2235            }
2236
2237            let query_params = query_params.build().map_err(|e| anyhow::anyhow!(e))?;
2238            let order_response: BybitOrderHistoryResponse = self
2239                .inner
2240                .send_request(
2241                    Method::GET,
2242                    "/v5/order/history",
2243                    Some(&query_params),
2244                    None,
2245                    true,
2246                )
2247                .await?;
2248
2249            if let Some(order) = order_response.result.list.into_iter().next() {
2250                let ts_init = self.generate_ts_init();
2251                let report = parse_order_status_report(&order, &instrument, account_id, ts_init)?;
2252                reports.push(report);
2253            }
2254        }
2255
2256        Ok(reports)
2257    }
2258
2259    /// Cancel all orders for an instrument.
2260    ///
2261    /// # Errors
2262    ///
2263    /// Returns an error if:
2264    /// - Credentials are missing.
2265    /// - The request fails.
2266    /// - The API returns an error.
2267    pub async fn cancel_all_orders(
2268        &self,
2269        account_id: AccountId,
2270        product_type: BybitProductType,
2271        instrument_id: InstrumentId,
2272    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2273        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2274        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2275
2276        let mut params = BybitCancelAllOrdersParamsBuilder::default();
2277        params.category(product_type);
2278        params.symbol(bybit_symbol.raw_symbol().to_string());
2279
2280        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2281        let body = serde_json::to_vec(&params)?;
2282
2283        let _response: crate::common::models::BybitListResponse<serde_json::Value> = self
2284            .inner
2285            .send_request::<_, ()>(Method::POST, "/v5/order/cancel-all", None, Some(body), true)
2286            .await?;
2287
2288        // Query the order history to get all canceled orders
2289        let mut query_params = BybitOrderHistoryParamsBuilder::default();
2290        query_params.category(product_type);
2291        query_params.symbol(bybit_symbol.raw_symbol().to_string());
2292        query_params.limit(50u32);
2293
2294        let query_params = query_params.build().map_err(|e| anyhow::anyhow!(e))?;
2295        let order_response: BybitOrderHistoryResponse = self
2296            .inner
2297            .send_request(
2298                Method::GET,
2299                "/v5/order/history",
2300                Some(&query_params),
2301                None,
2302                true,
2303            )
2304            .await?;
2305
2306        let ts_init = self.generate_ts_init();
2307
2308        let mut reports = Vec::new();
2309        for order in order_response.result.list {
2310            if let Ok(report) = parse_order_status_report(&order, &instrument, account_id, ts_init)
2311            {
2312                reports.push(report);
2313            }
2314        }
2315
2316        Ok(reports)
2317    }
2318
2319    /// Modify an existing order.
2320    ///
2321    /// # Errors
2322    ///
2323    /// Returns an error if:
2324    /// - Credentials are missing.
2325    /// - The request fails.
2326    /// - The order doesn't exist.
2327    /// - The order is already closed.
2328    /// - The API returns an error.
2329    #[allow(clippy::too_many_arguments)]
2330    pub async fn modify_order(
2331        &self,
2332        account_id: AccountId,
2333        product_type: BybitProductType,
2334        instrument_id: InstrumentId,
2335        client_order_id: Option<ClientOrderId>,
2336        venue_order_id: Option<VenueOrderId>,
2337        quantity: Option<Quantity>,
2338        price: Option<Price>,
2339    ) -> anyhow::Result<OrderStatusReport> {
2340        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2341        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2342
2343        let mut amend_entry = BybitBatchAmendOrderEntryBuilder::default();
2344        amend_entry.symbol(bybit_symbol.raw_symbol().to_string());
2345
2346        if let Some(venue_order_id) = venue_order_id {
2347            amend_entry.order_id(venue_order_id.to_string());
2348        } else if let Some(client_order_id) = client_order_id {
2349            amend_entry.order_link_id(client_order_id.to_string());
2350        } else {
2351            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2352        }
2353
2354        if let Some(quantity) = quantity {
2355            amend_entry.qty(Some(quantity.to_string()));
2356        }
2357
2358        if let Some(price) = price {
2359            amend_entry.price(Some(price.to_string()));
2360        }
2361
2362        let amend_entry = amend_entry.build().map_err(|e| anyhow::anyhow!(e))?;
2363
2364        let mut params = BybitAmendOrderParamsBuilder::default();
2365        params.category(product_type);
2366        params.order(amend_entry);
2367
2368        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2369        let body = serde_json::to_vec(&params)?;
2370
2371        let response: BybitPlaceOrderResponse = self
2372            .inner
2373            .send_request::<_, ()>(Method::POST, "/v5/order/amend", None, Some(body), true)
2374            .await?;
2375
2376        let order_id = response
2377            .result
2378            .order_id
2379            .ok_or_else(|| anyhow::anyhow!("No order_id in amend response"))?;
2380
2381        // Query the order to get full details after amendment
2382        let mut query_params = BybitOpenOrdersParamsBuilder::default();
2383        query_params.category(product_type);
2384        query_params.order_id(order_id.as_str().to_string());
2385
2386        let query_params = query_params.build().map_err(|e| anyhow::anyhow!(e))?;
2387        let order_response: BybitOpenOrdersResponse = self
2388            .inner
2389            .send_request(
2390                Method::GET,
2391                "/v5/order/realtime",
2392                Some(&query_params),
2393                None,
2394                true,
2395            )
2396            .await?;
2397
2398        let order = order_response
2399            .result
2400            .list
2401            .into_iter()
2402            .next()
2403            .ok_or_else(|| anyhow::anyhow!("No order returned after modification"))?;
2404
2405        let ts_init = self.generate_ts_init();
2406
2407        parse_order_status_report(&order, &instrument, account_id, ts_init)
2408    }
2409
2410    /// Query a single order by client order ID or venue order ID.
2411    ///
2412    /// # Errors
2413    ///
2414    /// Returns an error if:
2415    /// - Credentials are missing.
2416    /// - The request fails.
2417    /// - The API returns an error.
2418    pub async fn query_order(
2419        &self,
2420        account_id: AccountId,
2421        product_type: BybitProductType,
2422        instrument_id: InstrumentId,
2423        client_order_id: Option<ClientOrderId>,
2424        venue_order_id: Option<VenueOrderId>,
2425    ) -> anyhow::Result<Option<OrderStatusReport>> {
2426        log::debug!(
2427            "query_order: instrument_id={instrument_id}, client_order_id={client_order_id:?}, venue_order_id={venue_order_id:?}"
2428        );
2429
2430        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2431
2432        let mut params = BybitOpenOrdersParamsBuilder::default();
2433        params.category(product_type);
2434        // Use the raw Bybit symbol (e.g., "ETHUSDT") not the full instrument symbol
2435        params.symbol(bybit_symbol.raw_symbol().to_string());
2436
2437        if let Some(venue_order_id) = venue_order_id {
2438            params.order_id(venue_order_id.to_string());
2439        } else if let Some(client_order_id) = client_order_id {
2440            params.order_link_id(client_order_id.to_string());
2441        } else {
2442            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2443        }
2444
2445        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2446        let mut response: BybitOpenOrdersResponse = self
2447            .inner
2448            .send_request(Method::GET, "/v5/order/realtime", Some(&params), None, true)
2449            .await?;
2450
2451        if response.result.list.is_empty() {
2452            log::debug!("Order not found in open orders, trying with StopOrder filter");
2453
2454            let mut stop_params = BybitOpenOrdersParamsBuilder::default();
2455            stop_params.category(product_type);
2456            stop_params.symbol(bybit_symbol.raw_symbol().to_string());
2457            stop_params.order_filter(BybitOrderFilter::StopOrder);
2458
2459            if let Some(venue_order_id) = venue_order_id {
2460                stop_params.order_id(venue_order_id.to_string());
2461            } else if let Some(client_order_id) = client_order_id {
2462                stop_params.order_link_id(client_order_id.to_string());
2463            }
2464
2465            let stop_params = stop_params.build().map_err(|e| anyhow::anyhow!(e))?;
2466            response = self
2467                .inner
2468                .send_request(
2469                    Method::GET,
2470                    "/v5/order/realtime",
2471                    Some(&stop_params),
2472                    None,
2473                    true,
2474                )
2475                .await?;
2476        }
2477
2478        // If not found in open orders, check order history
2479        if response.result.list.is_empty() {
2480            log::debug!("Order not found in open orders, checking order history");
2481
2482            let mut history_params = BybitOrderHistoryParamsBuilder::default();
2483            history_params.category(product_type);
2484            history_params.symbol(bybit_symbol.raw_symbol().to_string());
2485
2486            if let Some(venue_order_id) = venue_order_id {
2487                history_params.order_id(venue_order_id.to_string());
2488            } else if let Some(client_order_id) = client_order_id {
2489                history_params.order_link_id(client_order_id.to_string());
2490            }
2491
2492            let history_params = history_params.build().map_err(|e| anyhow::anyhow!(e))?;
2493
2494            let mut history_response: BybitOrderHistoryResponse = self
2495                .inner
2496                .send_request(
2497                    Method::GET,
2498                    "/v5/order/history",
2499                    Some(&history_params),
2500                    None,
2501                    true,
2502                )
2503                .await?;
2504
2505            if history_response.result.list.is_empty() {
2506                log::debug!("Order not found in order history, trying with StopOrder filter");
2507
2508                let mut stop_history_params = BybitOrderHistoryParamsBuilder::default();
2509                stop_history_params.category(product_type);
2510                stop_history_params.symbol(bybit_symbol.raw_symbol().to_string());
2511                stop_history_params.order_filter(BybitOrderFilter::StopOrder);
2512
2513                if let Some(venue_order_id) = venue_order_id {
2514                    stop_history_params.order_id(venue_order_id.to_string());
2515                } else if let Some(client_order_id) = client_order_id {
2516                    stop_history_params.order_link_id(client_order_id.to_string());
2517                }
2518
2519                let stop_history_params = stop_history_params
2520                    .build()
2521                    .map_err(|e| anyhow::anyhow!(e))?;
2522
2523                history_response = self
2524                    .inner
2525                    .send_request(
2526                        Method::GET,
2527                        "/v5/order/history",
2528                        Some(&stop_history_params),
2529                        None,
2530                        true,
2531                    )
2532                    .await?;
2533
2534                if history_response.result.list.is_empty() {
2535                    log::debug!("Order not found in order history with StopOrder filter either");
2536                    return Ok(None);
2537                }
2538            }
2539
2540            // Move the order from history response to the response list
2541            response.result.list = history_response.result.list;
2542        }
2543
2544        let order = &response.result.list[0];
2545        let ts_init = self.generate_ts_init();
2546
2547        log::debug!(
2548            "Query order response: symbol={}, order_id={}, order_link_id={}",
2549            order.symbol.as_str(),
2550            order.order_id.as_str(),
2551            order.order_link_id.as_str()
2552        );
2553
2554        let instrument = self
2555            .instrument_from_cache(&instrument_id.symbol)
2556            .map_err(|e| {
2557                log::error!(
2558                    "Instrument cache miss for symbol '{}': {}",
2559                    instrument_id.symbol.as_str(),
2560                    e
2561                );
2562                anyhow::anyhow!(
2563                    "Failed to query order {}: {}",
2564                    client_order_id
2565                        .as_ref()
2566                        .map(|id| id.to_string())
2567                        .or_else(|| venue_order_id.as_ref().map(|id| id.to_string()))
2568                        .unwrap_or_else(|| "unknown".to_string()),
2569                    e
2570                )
2571            })?;
2572
2573        log::debug!("Retrieved instrument from cache: id={}", instrument.id());
2574
2575        let report =
2576            parse_order_status_report(order, &instrument, account_id, ts_init).map_err(|e| {
2577                log::error!(
2578                    "Failed to parse order status report for {}: {}",
2579                    order.order_link_id.as_str(),
2580                    e
2581                );
2582                e
2583            })?;
2584
2585        log::debug!(
2586            "Successfully created OrderStatusReport for {}",
2587            order.order_link_id.as_str()
2588        );
2589
2590        Ok(Some(report))
2591    }
2592
2593    /// Request instruments for a given product type.
2594    ///
2595    /// # Errors
2596    ///
2597    /// Returns an error if the request fails or parsing fails.
2598    pub async fn request_instruments(
2599        &self,
2600        product_type: BybitProductType,
2601        symbol: Option<String>,
2602    ) -> anyhow::Result<Vec<InstrumentAny>> {
2603        let ts_init = self.generate_ts_init();
2604
2605        let mut instruments = Vec::new();
2606
2607        let default_fee_rate = |symbol: ustr::Ustr| BybitFeeRate {
2608            symbol,
2609            taker_fee_rate: "0.001".to_string(),
2610            maker_fee_rate: "0.001".to_string(),
2611            base_coin: None,
2612        };
2613
2614        match product_type {
2615            BybitProductType::Spot => {
2616                // Try to get fee rates, use defaults if credentials are missing
2617                let fee_map: AHashMap<_, _> = {
2618                    let mut fee_params = BybitFeeRateParamsBuilder::default();
2619                    fee_params.category(product_type);
2620                    if let Ok(params) = fee_params.build() {
2621                        match self.inner.get_fee_rate(&params).await {
2622                            Ok(fee_response) => fee_response
2623                                .result
2624                                .list
2625                                .into_iter()
2626                                .map(|f| (f.symbol, f))
2627                                .collect(),
2628                            Err(BybitHttpError::MissingCredentials) => {
2629                                log::warn!("Missing credentials for fee rates, using defaults");
2630                                AHashMap::new()
2631                            }
2632                            Err(e) => return Err(e.into()),
2633                        }
2634                    } else {
2635                        AHashMap::new()
2636                    }
2637                };
2638
2639                let mut cursor: Option<String> = None;
2640
2641                loop {
2642                    let params = BybitInstrumentsInfoParams {
2643                        category: product_type,
2644                        symbol: symbol.clone(),
2645                        status: None,
2646                        base_coin: None,
2647                        limit: Some(1000),
2648                        cursor: cursor.clone(),
2649                    };
2650
2651                    let response: BybitInstrumentSpotResponse =
2652                        self.inner.get_instruments(&params).await?;
2653
2654                    for definition in response.result.list {
2655                        let fee_rate = fee_map
2656                            .get(&definition.symbol)
2657                            .cloned()
2658                            .unwrap_or_else(|| default_fee_rate(definition.symbol));
2659                        if let Ok(instrument) =
2660                            parse_spot_instrument(&definition, &fee_rate, ts_init, ts_init)
2661                        {
2662                            instruments.push(instrument);
2663                        }
2664                    }
2665
2666                    cursor = response.result.next_page_cursor;
2667                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2668                        break;
2669                    }
2670                }
2671            }
2672            BybitProductType::Linear => {
2673                // Try to get fee rates, use defaults if credentials are missing
2674                let fee_map: AHashMap<_, _> = {
2675                    let mut fee_params = BybitFeeRateParamsBuilder::default();
2676                    fee_params.category(product_type);
2677                    if let Ok(params) = fee_params.build() {
2678                        match self.inner.get_fee_rate(&params).await {
2679                            Ok(fee_response) => fee_response
2680                                .result
2681                                .list
2682                                .into_iter()
2683                                .map(|f| (f.symbol, f))
2684                                .collect(),
2685                            Err(BybitHttpError::MissingCredentials) => {
2686                                log::warn!("Missing credentials for fee rates, using defaults");
2687                                AHashMap::new()
2688                            }
2689                            Err(e) => return Err(e.into()),
2690                        }
2691                    } else {
2692                        AHashMap::new()
2693                    }
2694                };
2695
2696                let mut cursor: Option<String> = None;
2697
2698                loop {
2699                    let params = BybitInstrumentsInfoParams {
2700                        category: product_type,
2701                        symbol: symbol.clone(),
2702                        status: None,
2703                        base_coin: None,
2704                        limit: Some(1000),
2705                        cursor: cursor.clone(),
2706                    };
2707
2708                    let response: BybitInstrumentLinearResponse =
2709                        self.inner.get_instruments(&params).await?;
2710
2711                    for definition in response.result.list {
2712                        let fee_rate = fee_map
2713                            .get(&definition.symbol)
2714                            .cloned()
2715                            .unwrap_or_else(|| default_fee_rate(definition.symbol));
2716                        if let Ok(instrument) =
2717                            parse_linear_instrument(&definition, &fee_rate, ts_init, ts_init)
2718                        {
2719                            instruments.push(instrument);
2720                        }
2721                    }
2722
2723                    cursor = response.result.next_page_cursor;
2724                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2725                        break;
2726                    }
2727                }
2728            }
2729            BybitProductType::Inverse => {
2730                // Try to get fee rates, use defaults if credentials are missing
2731                let fee_map: AHashMap<_, _> = {
2732                    let mut fee_params = BybitFeeRateParamsBuilder::default();
2733                    fee_params.category(product_type);
2734                    if let Ok(params) = fee_params.build() {
2735                        match self.inner.get_fee_rate(&params).await {
2736                            Ok(fee_response) => fee_response
2737                                .result
2738                                .list
2739                                .into_iter()
2740                                .map(|f| (f.symbol, f))
2741                                .collect(),
2742                            Err(BybitHttpError::MissingCredentials) => {
2743                                log::warn!("Missing credentials for fee rates, using defaults");
2744                                AHashMap::new()
2745                            }
2746                            Err(e) => return Err(e.into()),
2747                        }
2748                    } else {
2749                        AHashMap::new()
2750                    }
2751                };
2752
2753                let mut cursor: Option<String> = None;
2754
2755                loop {
2756                    let params = BybitInstrumentsInfoParams {
2757                        category: product_type,
2758                        symbol: symbol.clone(),
2759                        status: None,
2760                        base_coin: None,
2761                        limit: Some(1000),
2762                        cursor: cursor.clone(),
2763                    };
2764
2765                    let response: BybitInstrumentInverseResponse =
2766                        self.inner.get_instruments(&params).await?;
2767
2768                    for definition in response.result.list {
2769                        let fee_rate = fee_map
2770                            .get(&definition.symbol)
2771                            .cloned()
2772                            .unwrap_or_else(|| default_fee_rate(definition.symbol));
2773                        if let Ok(instrument) =
2774                            parse_inverse_instrument(&definition, &fee_rate, ts_init, ts_init)
2775                        {
2776                            instruments.push(instrument);
2777                        }
2778                    }
2779
2780                    cursor = response.result.next_page_cursor;
2781                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2782                        break;
2783                    }
2784                }
2785            }
2786            BybitProductType::Option => {
2787                let mut cursor: Option<String> = None;
2788
2789                loop {
2790                    let params = BybitInstrumentsInfoParams {
2791                        category: product_type,
2792                        symbol: symbol.clone(),
2793                        status: None,
2794                        base_coin: None,
2795                        limit: Some(1000),
2796                        cursor: cursor.clone(),
2797                    };
2798
2799                    let response: BybitInstrumentOptionResponse =
2800                        self.inner.get_instruments(&params).await?;
2801
2802                    for definition in response.result.list {
2803                        if let Ok(instrument) =
2804                            parse_option_instrument(&definition, ts_init, ts_init)
2805                        {
2806                            instruments.push(instrument);
2807                        }
2808                    }
2809
2810                    cursor = response.result.next_page_cursor;
2811                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2812                        break;
2813                    }
2814                }
2815            }
2816        }
2817
2818        for instrument in &instruments {
2819            self.cache_instrument(instrument.clone());
2820        }
2821
2822        Ok(instruments)
2823    }
2824
2825    /// Request ticker information for market data.
2826    ///
2827    /// Fetches ticker data from Bybit's `/v5/market/tickers` endpoint and returns
2828    /// a unified `BybitTickerData` structure compatible with all product types.
2829    ///
2830    /// # Errors
2831    ///
2832    /// Returns an error if the request fails or parsing fails.
2833    ///
2834    /// # References
2835    ///
2836    /// <https://bybit-exchange.github.io/docs/v5/market/tickers>
2837    pub async fn request_tickers(
2838        &self,
2839        params: &BybitTickersParams,
2840    ) -> anyhow::Result<Vec<BybitTickerData>> {
2841        use super::models::{
2842            BybitTickersLinearResponse, BybitTickersOptionResponse, BybitTickersSpotResponse,
2843        };
2844
2845        match params.category {
2846            BybitProductType::Spot => {
2847                let response: BybitTickersSpotResponse = self.inner.get_tickers(params).await?;
2848                Ok(response.result.list.into_iter().map(Into::into).collect())
2849            }
2850            BybitProductType::Linear | BybitProductType::Inverse => {
2851                let response: BybitTickersLinearResponse = self.inner.get_tickers(params).await?;
2852                Ok(response.result.list.into_iter().map(Into::into).collect())
2853            }
2854            BybitProductType::Option => {
2855                let response: BybitTickersOptionResponse = self.inner.get_tickers(params).await?;
2856                Ok(response.result.list.into_iter().map(Into::into).collect())
2857            }
2858        }
2859    }
2860
2861    /// Request recent trade tick history for a given symbol.
2862    ///
2863    /// Returns the most recent public trades from Bybit's `/v5/market/recent-trade` endpoint.
2864    /// This endpoint only provides recent trades (up to 1000 most recent), typically covering
2865    /// only the last few minutes for active markets.
2866    ///
2867    /// **Note**: For historical trade data with time ranges, use the klines endpoint instead.
2868    /// The Bybit public API does not support fetching historical trades by time range.
2869    ///
2870    /// # Errors
2871    ///
2872    /// Returns an error if:
2873    /// - The instrument is not found in cache.
2874    /// - The request fails.
2875    /// - Parsing fails.
2876    ///
2877    /// # References
2878    ///
2879    /// <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
2880    pub async fn request_trades(
2881        &self,
2882        product_type: BybitProductType,
2883        instrument_id: InstrumentId,
2884        limit: Option<u32>,
2885    ) -> anyhow::Result<Vec<TradeTick>> {
2886        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2887        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2888
2889        let mut params_builder = BybitTradesParamsBuilder::default();
2890        params_builder.category(product_type);
2891        params_builder.symbol(bybit_symbol.raw_symbol().to_string());
2892        if let Some(limit_val) = limit {
2893            params_builder.limit(limit_val);
2894        }
2895
2896        let params = params_builder.build().map_err(|e| anyhow::anyhow!(e))?;
2897        let response = self.inner.get_recent_trades(&params).await?;
2898
2899        let ts_init = self.generate_ts_init();
2900        let mut trades = Vec::new();
2901
2902        for trade in response.result.list {
2903            if let Ok(trade_tick) = parse_trade_tick(&trade, &instrument, ts_init) {
2904                trades.push(trade_tick);
2905            }
2906        }
2907
2908        Ok(trades)
2909    }
2910
2911    /// Request bar/kline history for a given symbol.
2912    ///
2913    /// # Errors
2914    ///
2915    /// Returns an error if:
2916    /// - The instrument is not found in cache.
2917    /// - The request fails.
2918    /// - Parsing fails.
2919    ///
2920    /// # References
2921    ///
2922    /// <https://bybit-exchange.github.io/docs/v5/market/kline>
2923    pub async fn request_bars(
2924        &self,
2925        product_type: BybitProductType,
2926        bar_type: BarType,
2927        start: Option<DateTime<Utc>>,
2928        end: Option<DateTime<Utc>>,
2929        limit: Option<u32>,
2930        timestamp_on_close: bool,
2931    ) -> anyhow::Result<Vec<Bar>> {
2932        let instrument = self.instrument_from_cache(&bar_type.instrument_id().symbol)?;
2933        let bybit_symbol = BybitSymbol::new(bar_type.instrument_id().symbol.as_str())?;
2934
2935        // Convert Nautilus BarSpec to Bybit interval
2936        let interval = bar_spec_to_bybit_interval(
2937            bar_type.spec().aggregation,
2938            bar_type.spec().step.get() as u64,
2939        )?;
2940
2941        let start_ms = start.map(|dt| dt.timestamp_millis());
2942        let mut seen_timestamps: AHashSet<i64> = AHashSet::new();
2943        let current_time_ms = get_atomic_clock_realtime().get_time_ms() as i64;
2944
2945        // Pagination strategy: work backwards from end time
2946        // - Each page fetched is older than the previous page
2947        // - Within each page, bars are in chronological order (oldest to newest)
2948        // - We collect pages in reverse order (newest first) then reverse at the end
2949        // Example with 2 pages:
2950        //   Page 1 (most recent): bars [T=2000..2999]
2951        //   Page 2 (older):       bars [T=1000..1999]
2952        //   Collected: [[T=2000..2999], [T=1000..1999]]
2953        //   After reverse + flatten: [T=1000..1999, T=2000..2999] ✓ chronological
2954        let mut pages: Vec<Vec<Bar>> = Vec::new();
2955        let mut total_bars = 0usize;
2956        let mut current_end = end.map(|dt| dt.timestamp_millis());
2957        let mut page_count = 0;
2958
2959        loop {
2960            page_count += 1;
2961
2962            let mut params_builder = BybitKlinesParamsBuilder::default();
2963            params_builder.category(product_type);
2964            params_builder.symbol(bybit_symbol.raw_symbol().to_string());
2965            params_builder.interval(interval);
2966            params_builder.limit(1000u32); // Limit for data size per page (maximum for the Bybit API)
2967
2968            if let Some(start_val) = start_ms {
2969                params_builder.start(start_val);
2970            }
2971            if let Some(end_val) = current_end {
2972                params_builder.end(end_val);
2973            }
2974
2975            let params = params_builder.build().map_err(|e| anyhow::anyhow!(e))?;
2976            let response = self.inner.get_klines(&params).await?;
2977
2978            let klines = response.result.list;
2979            if klines.is_empty() {
2980                break;
2981            }
2982
2983            // Parse timestamps once and pair with klines for sorting
2984            let mut klines_with_ts: Vec<(i64, _)> = klines
2985                .into_iter()
2986                .filter_map(|k| k.start.parse::<i64>().ok().map(|ts| (ts, k)))
2987                .collect();
2988
2989            klines_with_ts.sort_by_key(|(ts, _)| *ts);
2990
2991            // Check if we have any new timestamps
2992            let has_new = klines_with_ts
2993                .iter()
2994                .any(|(ts, _)| !seen_timestamps.contains(ts));
2995            if !has_new {
2996                break;
2997            }
2998
2999            let ts_init = self.generate_ts_init();
3000            let mut page_bars = Vec::with_capacity(klines_with_ts.len());
3001
3002            let mut earliest_ts: Option<i64> = None;
3003
3004            for (start_time, kline) in &klines_with_ts {
3005                // Track earliest timestamp for pagination
3006                if earliest_ts.is_none_or(|ts| *start_time < ts) {
3007                    earliest_ts = Some(*start_time);
3008                }
3009
3010                let bar_end_time = interval.bar_end_time_ms(*start_time);
3011                if bar_end_time > current_time_ms {
3012                    continue;
3013                }
3014
3015                if !seen_timestamps.contains(start_time)
3016                    && let Ok(bar) =
3017                        parse_kline_bar(kline, &instrument, bar_type, timestamp_on_close, ts_init)
3018                {
3019                    page_bars.push(bar);
3020                    seen_timestamps.insert(*start_time);
3021                }
3022            }
3023
3024            // page_bars may be empty if all klines were partial, but pagination
3025            // continues to fetch older closed bars
3026            total_bars += page_bars.len();
3027            pages.push(page_bars);
3028
3029            // Check if we've reached the requested limit
3030            if let Some(limit_val) = limit
3031                && total_bars >= limit_val as usize
3032            {
3033                break;
3034            }
3035
3036            // Move end time backwards to get earlier data
3037            // Set new end to be 1ms before the first bar of this page
3038            let Some(earliest_bar_time) = earliest_ts else {
3039                break;
3040            };
3041            if let Some(start_val) = start_ms
3042                && earliest_bar_time <= start_val
3043            {
3044                break;
3045            }
3046
3047            current_end = Some(earliest_bar_time - 1);
3048
3049            // Safety check to prevent infinite loops
3050            if page_count > 100 {
3051                break;
3052            }
3053        }
3054
3055        // Reverse pages and flatten to get chronological order (oldest to newest)
3056        let mut all_bars: Vec<Bar> = Vec::with_capacity(total_bars);
3057        for page in pages.into_iter().rev() {
3058            all_bars.extend(page);
3059        }
3060
3061        // If limit is specified and we have more bars, return the last N bars (most recent)
3062        if let Some(limit_val) = limit {
3063            let limit_usize = limit_val as usize;
3064            if all_bars.len() > limit_usize {
3065                let start_idx = all_bars.len() - limit_usize;
3066                return Ok(all_bars[start_idx..].to_vec());
3067            }
3068        }
3069
3070        Ok(all_bars)
3071    }
3072
3073    /// Requests trading fee rates for the specified product type and optional filters.
3074    ///
3075    /// # Errors
3076    ///
3077    /// Returns an error if:
3078    /// - The request fails.
3079    /// - Parsing fails.
3080    ///
3081    /// # References
3082    ///
3083    /// <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
3084    pub async fn request_fee_rates(
3085        &self,
3086        product_type: BybitProductType,
3087        symbol: Option<String>,
3088        base_coin: Option<String>,
3089    ) -> anyhow::Result<Vec<BybitFeeRate>> {
3090        let params = BybitFeeRateParams {
3091            category: product_type,
3092            symbol,
3093            base_coin,
3094        };
3095
3096        let response = self.inner.get_fee_rate(&params).await?;
3097        Ok(response.result.list)
3098    }
3099
3100    /// Requests the current account state for the specified account type.
3101    ///
3102    /// # Errors
3103    ///
3104    /// Returns an error if:
3105    /// - The request fails.
3106    /// - Parsing fails.
3107    ///
3108    /// # References
3109    ///
3110    /// <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
3111    pub async fn request_account_state(
3112        &self,
3113        account_type: BybitAccountType,
3114        account_id: AccountId,
3115    ) -> anyhow::Result<AccountState> {
3116        let params = BybitWalletBalanceParams {
3117            account_type,
3118            coin: None,
3119        };
3120
3121        let response = self.inner.get_wallet_balance(&params).await?;
3122        let ts_init = self.generate_ts_init();
3123
3124        // Take the first wallet balance from the list
3125        let wallet_balance = response
3126            .result
3127            .list
3128            .first()
3129            .ok_or_else(|| anyhow::anyhow!("No wallet balance found in response"))?;
3130
3131        parse_account_state(wallet_balance, account_id, ts_init)
3132    }
3133
3134    /// Request multiple order status reports.
3135    ///
3136    /// Orders for instruments not currently loaded in cache will be skipped.
3137    ///
3138    /// # Errors
3139    ///
3140    /// Returns an error if:
3141    /// - Credentials are missing.
3142    /// - The request fails.
3143    /// - The API returns an error.
3144    #[allow(clippy::too_many_arguments)]
3145    pub async fn request_order_status_reports(
3146        &self,
3147        account_id: AccountId,
3148        product_type: BybitProductType,
3149        instrument_id: Option<InstrumentId>,
3150        open_only: bool,
3151        start: Option<DateTime<Utc>>,
3152        end: Option<DateTime<Utc>>,
3153        limit: Option<u32>,
3154    ) -> anyhow::Result<Vec<OrderStatusReport>> {
3155        // Extract symbol parameter from instrument_id if provided
3156        let symbol_param = if let Some(id) = instrument_id.as_ref() {
3157            let symbol_str = id.symbol.as_str();
3158            if symbol_str.is_empty() {
3159                None
3160            } else {
3161                Some(BybitSymbol::new(symbol_str)?.raw_symbol().to_string())
3162            }
3163        } else {
3164            None
3165        };
3166
3167        // For LINEAR without symbol, query all settle coins to avoid filtering
3168        // For INVERSE, never use settle_coin parameter
3169        let settle_coins_to_query: Vec<Option<String>> =
3170            if product_type == BybitProductType::Linear && symbol_param.is_none() {
3171                vec![Some("USDT".to_string()), Some("USDC".to_string())]
3172            } else {
3173                match product_type {
3174                    BybitProductType::Inverse => vec![None],
3175                    _ => vec![None],
3176                }
3177            };
3178
3179        let mut all_collected_orders = Vec::new();
3180        let mut total_collected_across_coins = 0;
3181
3182        for settle_coin in settle_coins_to_query {
3183            let remaining_limit = if let Some(limit) = limit {
3184                let remaining = (limit as usize).saturating_sub(total_collected_across_coins);
3185                if remaining == 0 {
3186                    break;
3187                }
3188                Some(remaining as u32)
3189            } else {
3190                None
3191            };
3192
3193            let orders_for_coin = if open_only {
3194                let mut all_orders = Vec::new();
3195                let mut cursor: Option<String> = None;
3196                let mut total_orders = 0;
3197
3198                loop {
3199                    let remaining = if let Some(limit) = remaining_limit {
3200                        (limit as usize).saturating_sub(total_orders)
3201                    } else {
3202                        usize::MAX
3203                    };
3204
3205                    if remaining == 0 {
3206                        break;
3207                    }
3208
3209                    // Max 50 per Bybit API
3210                    let page_limit = std::cmp::min(remaining, 50);
3211
3212                    let mut p = BybitOpenOrdersParamsBuilder::default();
3213                    p.category(product_type);
3214                    if let Some(symbol) = symbol_param.clone() {
3215                        p.symbol(symbol);
3216                    }
3217                    if let Some(coin) = settle_coin.clone() {
3218                        p.settle_coin(coin);
3219                    }
3220                    p.limit(page_limit as u32);
3221                    if let Some(c) = cursor {
3222                        p.cursor(c);
3223                    }
3224                    let params = p.build().map_err(|e| anyhow::anyhow!(e))?;
3225                    let response: BybitOpenOrdersResponse = self
3226                        .inner
3227                        .send_request(Method::GET, "/v5/order/realtime", Some(&params), None, true)
3228                        .await?;
3229
3230                    total_orders += response.result.list.len();
3231                    all_orders.extend(response.result.list);
3232
3233                    cursor = response.result.next_page_cursor;
3234                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
3235                        break;
3236                    }
3237                }
3238
3239                all_orders
3240            } else {
3241                // Query both realtime and history endpoints
3242                // Realtime has current open orders, history may lag for recent orders
3243                let mut all_orders = Vec::new();
3244                let mut open_orders = Vec::new();
3245                let mut cursor: Option<String> = None;
3246                let mut total_open_orders = 0;
3247
3248                loop {
3249                    let remaining = if let Some(limit) = remaining_limit {
3250                        (limit as usize).saturating_sub(total_open_orders)
3251                    } else {
3252                        usize::MAX
3253                    };
3254
3255                    if remaining == 0 {
3256                        break;
3257                    }
3258
3259                    // Max 50 per Bybit API
3260                    let page_limit = std::cmp::min(remaining, 50);
3261
3262                    let mut open_params = BybitOpenOrdersParamsBuilder::default();
3263                    open_params.category(product_type);
3264                    if let Some(symbol) = symbol_param.clone() {
3265                        open_params.symbol(symbol);
3266                    }
3267                    if let Some(coin) = settle_coin.clone() {
3268                        open_params.settle_coin(coin);
3269                    }
3270                    open_params.limit(page_limit as u32);
3271                    if let Some(c) = cursor {
3272                        open_params.cursor(c);
3273                    }
3274                    let open_params = open_params.build().map_err(|e| anyhow::anyhow!(e))?;
3275                    let open_response: BybitOpenOrdersResponse = self
3276                        .inner
3277                        .send_request(
3278                            Method::GET,
3279                            "/v5/order/realtime",
3280                            Some(&open_params),
3281                            None,
3282                            true,
3283                        )
3284                        .await?;
3285
3286                    total_open_orders += open_response.result.list.len();
3287                    open_orders.extend(open_response.result.list);
3288
3289                    cursor = open_response.result.next_page_cursor;
3290                    if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3291                        break;
3292                    }
3293                }
3294
3295                let seen_order_ids: AHashSet<Ustr> =
3296                    open_orders.iter().map(|o| o.order_id).collect();
3297
3298                all_orders.extend(open_orders);
3299
3300                let mut cursor: Option<String> = None;
3301                let mut total_history_orders = 0;
3302
3303                loop {
3304                    let total_orders = total_open_orders + total_history_orders;
3305                    let remaining = if let Some(limit) = remaining_limit {
3306                        (limit as usize).saturating_sub(total_orders)
3307                    } else {
3308                        usize::MAX
3309                    };
3310
3311                    if remaining == 0 {
3312                        break;
3313                    }
3314
3315                    // Max 50 per Bybit API
3316                    let page_limit = std::cmp::min(remaining, 50);
3317
3318                    let mut history_params = BybitOrderHistoryParamsBuilder::default();
3319                    history_params.category(product_type);
3320                    if let Some(symbol) = symbol_param.clone() {
3321                        history_params.symbol(symbol);
3322                    }
3323                    if let Some(coin) = settle_coin.clone() {
3324                        history_params.settle_coin(coin);
3325                    }
3326                    if let Some(start) = start {
3327                        history_params.start_time(start.timestamp_millis());
3328                    }
3329                    if let Some(end) = end {
3330                        history_params.end_time(end.timestamp_millis());
3331                    }
3332                    history_params.limit(page_limit as u32);
3333                    if let Some(c) = cursor {
3334                        history_params.cursor(c);
3335                    }
3336                    let history_params = history_params.build().map_err(|e| anyhow::anyhow!(e))?;
3337                    let history_response: BybitOrderHistoryResponse = self
3338                        .inner
3339                        .send_request(
3340                            Method::GET,
3341                            "/v5/order/history",
3342                            Some(&history_params),
3343                            None,
3344                            true,
3345                        )
3346                        .await?;
3347
3348                    // Open orders might appear in both realtime and history
3349                    for order in history_response.result.list {
3350                        if !seen_order_ids.contains(&order.order_id) {
3351                            all_orders.push(order);
3352                            total_history_orders += 1;
3353                        }
3354                    }
3355
3356                    cursor = history_response.result.next_page_cursor;
3357                    if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3358                        break;
3359                    }
3360                }
3361
3362                all_orders
3363            };
3364
3365            total_collected_across_coins += orders_for_coin.len();
3366            all_collected_orders.extend(orders_for_coin);
3367        }
3368
3369        let ts_init = self.generate_ts_init();
3370
3371        let mut reports = Vec::new();
3372        for order in all_collected_orders {
3373            if let Some(ref instrument_id) = instrument_id {
3374                let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
3375                if let Ok(report) =
3376                    parse_order_status_report(&order, &instrument, account_id, ts_init)
3377                {
3378                    reports.push(report);
3379                }
3380            } else {
3381                // Bybit returns raw symbol (e.g. "ETHUSDT"), need to add product suffix for cache lookup
3382                // Note: instruments are stored in cache by symbol only (without venue)
3383                if !order.symbol.is_empty() {
3384                    let symbol_with_product =
3385                        Symbol::from_ustr_unchecked(make_bybit_symbol(order.symbol, product_type));
3386
3387                    let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
3388                        log::debug!(
3389                            "Skipping order report for instrument not in cache: symbol={}, full_symbol={}",
3390                            order.symbol,
3391                            symbol_with_product
3392                        );
3393                        continue;
3394                    };
3395
3396                    match parse_order_status_report(&order, &instrument, account_id, ts_init) {
3397                        Ok(report) => reports.push(report),
3398                        Err(e) => {
3399                            log::error!("Failed to parse order status report: {e}");
3400                        }
3401                    }
3402                }
3403            }
3404        }
3405
3406        Ok(reports)
3407    }
3408
3409    /// Fetches execution history (fills) for the account and returns a list of [`FillReport`]s.
3410    ///
3411    /// Executions for instruments not currently loaded in cache will be skipped.
3412    ///
3413    /// # Errors
3414    ///
3415    /// This function returns an error if the request fails.
3416    ///
3417    /// # References
3418    ///
3419    /// <https://bybit-exchange.github.io/docs/v5/order/execution>
3420    pub async fn request_fill_reports(
3421        &self,
3422        account_id: AccountId,
3423        product_type: BybitProductType,
3424        instrument_id: Option<InstrumentId>,
3425        start: Option<i64>,
3426        end: Option<i64>,
3427        limit: Option<u32>,
3428    ) -> anyhow::Result<Vec<FillReport>> {
3429        // Build query parameters
3430        let symbol = if let Some(id) = instrument_id {
3431            let bybit_symbol = BybitSymbol::new(id.symbol.as_str())?;
3432            Some(bybit_symbol.raw_symbol().to_string())
3433        } else {
3434            None
3435        };
3436
3437        // Fetch all executions with pagination
3438        let mut all_executions = Vec::new();
3439        let mut cursor: Option<String> = None;
3440        let mut total_executions = 0;
3441
3442        loop {
3443            // Calculate how many more executions we can request
3444            let remaining = if let Some(limit) = limit {
3445                (limit as usize).saturating_sub(total_executions)
3446            } else {
3447                usize::MAX
3448            };
3449
3450            // If we've reached the limit, stop
3451            if remaining == 0 {
3452                break;
3453            }
3454
3455            // Size the page request to respect caller's limit (max 100 per Bybit API)
3456            let page_limit = std::cmp::min(remaining, 100);
3457
3458            let params = BybitTradeHistoryParams {
3459                category: product_type,
3460                symbol: symbol.clone(),
3461                base_coin: None,
3462                order_id: None,
3463                order_link_id: None,
3464                start_time: start,
3465                end_time: end,
3466                exec_type: None,
3467                limit: Some(page_limit as u32),
3468                cursor: cursor.clone(),
3469            };
3470
3471            let response = self.inner.get_trade_history(&params).await?;
3472            let list_len = response.result.list.len();
3473            all_executions.extend(response.result.list);
3474            total_executions += list_len;
3475
3476            cursor = response.result.next_page_cursor;
3477            if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3478                break;
3479            }
3480        }
3481
3482        let ts_init = self.generate_ts_init();
3483        let mut reports = Vec::new();
3484
3485        for execution in all_executions {
3486            // Get instrument for this execution
3487            // Bybit returns raw symbol (e.g. "ETHUSDT"), need to add product suffix for cache lookup
3488            let symbol_with_product =
3489                Symbol::from_ustr_unchecked(make_bybit_symbol(execution.symbol, product_type));
3490
3491            let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
3492                log::debug!(
3493                    "Skipping fill report for instrument not in cache: symbol={}, full_symbol={}",
3494                    execution.symbol,
3495                    symbol_with_product
3496                );
3497                continue;
3498            };
3499
3500            match parse_fill_report(&execution, account_id, &instrument, ts_init) {
3501                Ok(report) => reports.push(report),
3502                Err(e) => {
3503                    log::error!("Failed to parse fill report: {e}");
3504                }
3505            }
3506        }
3507
3508        Ok(reports)
3509    }
3510
3511    /// Fetches position information for the account and returns a list of [`PositionStatusReport`]s.
3512    ///
3513    /// Positions for instruments not currently loaded in cache will be skipped.
3514    ///
3515    /// # Errors
3516    ///
3517    /// This function returns an error if the request fails.
3518    ///
3519    /// # References
3520    ///
3521    /// <https://bybit-exchange.github.io/docs/v5/position>
3522    pub async fn request_position_status_reports(
3523        &self,
3524        account_id: AccountId,
3525        product_type: BybitProductType,
3526        instrument_id: Option<InstrumentId>,
3527    ) -> anyhow::Result<Vec<PositionStatusReport>> {
3528        // Handle SPOT position reports via wallet balances if flag is enabled
3529        if product_type == BybitProductType::Spot {
3530            if self.use_spot_position_reports.load(Ordering::Relaxed) {
3531                return self
3532                    .generate_spot_position_reports_from_wallet(account_id, instrument_id)
3533                    .await;
3534            } else {
3535                // Return empty vector when SPOT position reports are disabled
3536                return Ok(Vec::new());
3537            }
3538        }
3539
3540        let ts_init = self.generate_ts_init();
3541        let mut reports = Vec::new();
3542
3543        // Build query parameters based on whether a specific instrument is requested
3544        let symbol = if let Some(id) = instrument_id {
3545            let symbol_str = id.symbol.as_str();
3546            if symbol_str.is_empty() {
3547                anyhow::bail!("InstrumentId symbol is empty");
3548            }
3549            let bybit_symbol = BybitSymbol::new(symbol_str)?;
3550            Some(bybit_symbol.raw_symbol().to_string())
3551        } else {
3552            None
3553        };
3554
3555        // For LINEAR category, the API requires either symbol OR settleCoin
3556        // When querying all positions (no symbol), we must iterate through settle coins
3557        if product_type == BybitProductType::Linear && symbol.is_none() {
3558            // Query positions for each known settle coin with pagination
3559            for settle_coin in ["USDT", "USDC"] {
3560                let mut cursor: Option<String> = None;
3561
3562                loop {
3563                    let params = BybitPositionListParams {
3564                        category: product_type,
3565                        symbol: None,
3566                        base_coin: None,
3567                        settle_coin: Some(settle_coin.to_string()),
3568                        limit: Some(200), // Max 200 per request
3569                        cursor: cursor.clone(),
3570                    };
3571
3572                    let response = self.inner.get_positions(&params).await?;
3573
3574                    for position in response.result.list {
3575                        if position.symbol.is_empty() {
3576                            continue;
3577                        }
3578
3579                        let symbol_with_product = Symbol::new(format!(
3580                            "{}{}",
3581                            position.symbol.as_str(),
3582                            product_type.suffix()
3583                        ));
3584
3585                        let Ok(instrument) = self.instrument_from_cache(&symbol_with_product)
3586                        else {
3587                            log::debug!(
3588                                "Skipping position report for instrument not in cache: symbol={}, full_symbol={}",
3589                                position.symbol,
3590                                symbol_with_product
3591                            );
3592                            continue;
3593                        };
3594
3595                        match parse_position_status_report(
3596                            &position,
3597                            account_id,
3598                            &instrument,
3599                            ts_init,
3600                        ) {
3601                            Ok(report) => reports.push(report),
3602                            Err(e) => {
3603                                log::error!("Failed to parse position status report: {e}");
3604                            }
3605                        }
3606                    }
3607
3608                    cursor = response.result.next_page_cursor;
3609                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
3610                        break;
3611                    }
3612                }
3613            }
3614        } else {
3615            // For other product types or when a specific symbol is requested with pagination
3616            let mut cursor: Option<String> = None;
3617
3618            loop {
3619                let params = BybitPositionListParams {
3620                    category: product_type,
3621                    symbol: symbol.clone(),
3622                    base_coin: None,
3623                    settle_coin: None,
3624                    limit: Some(200), // Max 200 per request
3625                    cursor: cursor.clone(),
3626                };
3627
3628                let response = self.inner.get_positions(&params).await?;
3629
3630                for position in response.result.list {
3631                    if position.symbol.is_empty() {
3632                        continue;
3633                    }
3634
3635                    let symbol_with_product = Symbol::new(format!(
3636                        "{}{}",
3637                        position.symbol.as_str(),
3638                        product_type.suffix()
3639                    ));
3640
3641                    let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
3642                        log::debug!(
3643                            "Skipping position report for instrument not in cache: symbol={}, full_symbol={}",
3644                            position.symbol,
3645                            symbol_with_product
3646                        );
3647                        continue;
3648                    };
3649
3650                    match parse_position_status_report(&position, account_id, &instrument, ts_init)
3651                    {
3652                        Ok(report) => reports.push(report),
3653                        Err(e) => {
3654                            log::error!("Failed to parse position status report: {e}");
3655                        }
3656                    }
3657                }
3658
3659                cursor = response.result.next_page_cursor;
3660                if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3661                    break;
3662                }
3663            }
3664        }
3665
3666        Ok(reports)
3667    }
3668}
3669
3670#[cfg(test)]
3671mod tests {
3672    use rstest::rstest;
3673
3674    use super::*;
3675
3676    #[rstest]
3677    fn test_client_creation() {
3678        let client = BybitHttpClient::new(None, Some(60), None, None, None, None, None);
3679        assert!(client.is_ok());
3680
3681        let client = client.unwrap();
3682        assert!(client.base_url().contains("bybit.com"));
3683        assert!(client.credential().is_none());
3684    }
3685
3686    #[rstest]
3687    fn test_client_with_credentials() {
3688        let client = BybitHttpClient::with_credentials(
3689            "test_key".to_string(),
3690            "test_secret".to_string(),
3691            Some("https://api-testnet.bybit.com".to_string()),
3692            Some(60),
3693            None,
3694            None,
3695            None,
3696            None,
3697            None,
3698        );
3699        assert!(client.is_ok());
3700
3701        let client = client.unwrap();
3702        assert!(client.credential().is_some());
3703    }
3704
3705    #[rstest]
3706    fn test_build_path_with_params() {
3707        #[derive(Serialize)]
3708        struct TestParams {
3709            category: String,
3710            symbol: String,
3711        }
3712
3713        let params = TestParams {
3714            category: "linear".to_string(),
3715            symbol: "BTCUSDT".to_string(),
3716        };
3717
3718        let path = BybitRawHttpClient::build_path("/v5/market/test", &params);
3719        assert!(path.is_ok());
3720        assert!(path.unwrap().contains("category=linear"));
3721    }
3722
3723    #[rstest]
3724    fn test_build_path_without_params() {
3725        let params = ();
3726        let path = BybitRawHttpClient::build_path("/v5/market/time", &params);
3727        assert!(path.is_ok());
3728        assert_eq!(path.unwrap(), "/v5/market/time");
3729    }
3730
3731    #[rstest]
3732    fn test_params_serialization_matches_build_path() {
3733        // This test ensures our new serialization produces the same result as the old build_path
3734        #[derive(Serialize)]
3735        struct TestParams {
3736            category: String,
3737            limit: u32,
3738        }
3739
3740        let params = TestParams {
3741            category: "spot".to_string(),
3742            limit: 50,
3743        };
3744
3745        // Old way: build_path serialized params
3746        let old_path = BybitRawHttpClient::build_path("/v5/order/realtime", &params).unwrap();
3747        let old_query = old_path.split('?').nth(1).unwrap_or("");
3748
3749        // New way: direct serialization
3750        let new_query = serde_urlencoded::to_string(&params).unwrap();
3751
3752        // They must match for signatures to work
3753        assert_eq!(old_query, new_query);
3754    }
3755
3756    #[rstest]
3757    fn test_params_serialization_order() {
3758        // Verify that serialization order is deterministic
3759        #[derive(Serialize)]
3760        struct OrderParams {
3761            category: String,
3762            symbol: String,
3763            limit: u32,
3764        }
3765
3766        let params = OrderParams {
3767            category: "spot".to_string(),
3768            symbol: "BTCUSDT".to_string(),
3769            limit: 50,
3770        };
3771
3772        // Serialize multiple times to ensure consistent ordering
3773        let query1 = serde_urlencoded::to_string(&params).unwrap();
3774        let query2 = serde_urlencoded::to_string(&params).unwrap();
3775        let query3 = serde_urlencoded::to_string(&params).unwrap();
3776
3777        assert_eq!(query1, query2);
3778        assert_eq!(query2, query3);
3779
3780        // The query should contain all params
3781        assert!(query1.contains("category=spot"));
3782        assert!(query1.contains("symbol=BTCUSDT"));
3783        assert!(query1.contains("limit=50"));
3784    }
3785}