nautilus_bybit/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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, Formatter},
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.adapters")
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 Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("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            tracing::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            tracing::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.adapters")
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 Formatter<'_>) -> std::fmt::Result {
1170        f.debug_struct("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                tracing::debug!(
2219                    symbol = %instrument_id.symbol,
2220                    "Skipping cancelled order report for instrument not in cache"
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        tracing::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            tracing::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            tracing::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                tracing::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                    tracing::debug!(
2536                        "Order not found in order history with StopOrder filter either"
2537                    );
2538                    return Ok(None);
2539                }
2540            }
2541
2542            // Move the order from history response to the response list
2543            response.result.list = history_response.result.list;
2544        }
2545
2546        let order = &response.result.list[0];
2547        let ts_init = self.generate_ts_init();
2548
2549        tracing::debug!(
2550            "Query order response: symbol={}, order_id={}, order_link_id={}",
2551            order.symbol.as_str(),
2552            order.order_id.as_str(),
2553            order.order_link_id.as_str()
2554        );
2555
2556        let instrument = self
2557            .instrument_from_cache(&instrument_id.symbol)
2558            .map_err(|e| {
2559                tracing::error!(
2560                    "Instrument cache miss for symbol '{}': {}",
2561                    instrument_id.symbol.as_str(),
2562                    e
2563                );
2564                anyhow::anyhow!(
2565                    "Failed to query order {}: {}",
2566                    client_order_id
2567                        .as_ref()
2568                        .map(|id| id.to_string())
2569                        .or_else(|| venue_order_id.as_ref().map(|id| id.to_string()))
2570                        .unwrap_or_else(|| "unknown".to_string()),
2571                    e
2572                )
2573            })?;
2574
2575        tracing::debug!("Retrieved instrument from cache: id={}", instrument.id());
2576
2577        let report =
2578            parse_order_status_report(order, &instrument, account_id, ts_init).map_err(|e| {
2579                tracing::error!(
2580                    "Failed to parse order status report for {}: {}",
2581                    order.order_link_id.as_str(),
2582                    e
2583                );
2584                e
2585            })?;
2586
2587        tracing::debug!(
2588            "Successfully created OrderStatusReport for {}",
2589            order.order_link_id.as_str()
2590        );
2591
2592        Ok(Some(report))
2593    }
2594
2595    /// Request instruments for a given product type.
2596    ///
2597    /// # Errors
2598    ///
2599    /// Returns an error if the request fails or parsing fails.
2600    pub async fn request_instruments(
2601        &self,
2602        product_type: BybitProductType,
2603        symbol: Option<String>,
2604    ) -> anyhow::Result<Vec<InstrumentAny>> {
2605        let ts_init = self.generate_ts_init();
2606
2607        let mut instruments = Vec::new();
2608
2609        let default_fee_rate = |symbol: ustr::Ustr| BybitFeeRate {
2610            symbol,
2611            taker_fee_rate: "0.001".to_string(),
2612            maker_fee_rate: "0.001".to_string(),
2613            base_coin: None,
2614        };
2615
2616        match product_type {
2617            BybitProductType::Spot => {
2618                // Try to get fee rates, use defaults if credentials are missing
2619                let fee_map: AHashMap<_, _> = {
2620                    let mut fee_params = BybitFeeRateParamsBuilder::default();
2621                    fee_params.category(product_type);
2622                    if let Ok(params) = fee_params.build() {
2623                        match self.inner.get_fee_rate(&params).await {
2624                            Ok(fee_response) => fee_response
2625                                .result
2626                                .list
2627                                .into_iter()
2628                                .map(|f| (f.symbol, f))
2629                                .collect(),
2630                            Err(BybitHttpError::MissingCredentials) => {
2631                                tracing::warn!("Missing credentials for fee rates, using defaults");
2632                                AHashMap::new()
2633                            }
2634                            Err(e) => return Err(e.into()),
2635                        }
2636                    } else {
2637                        AHashMap::new()
2638                    }
2639                };
2640
2641                let mut cursor: Option<String> = None;
2642
2643                loop {
2644                    let params = BybitInstrumentsInfoParams {
2645                        category: product_type,
2646                        symbol: symbol.clone(),
2647                        status: None,
2648                        base_coin: None,
2649                        limit: Some(1000),
2650                        cursor: cursor.clone(),
2651                    };
2652
2653                    let response: BybitInstrumentSpotResponse =
2654                        self.inner.get_instruments(&params).await?;
2655
2656                    for definition in response.result.list {
2657                        let fee_rate = fee_map
2658                            .get(&definition.symbol)
2659                            .cloned()
2660                            .unwrap_or_else(|| default_fee_rate(definition.symbol));
2661                        if let Ok(instrument) =
2662                            parse_spot_instrument(&definition, &fee_rate, ts_init, ts_init)
2663                        {
2664                            instruments.push(instrument);
2665                        }
2666                    }
2667
2668                    cursor = response.result.next_page_cursor;
2669                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2670                        break;
2671                    }
2672                }
2673            }
2674            BybitProductType::Linear => {
2675                // Try to get fee rates, use defaults if credentials are missing
2676                let fee_map: AHashMap<_, _> = {
2677                    let mut fee_params = BybitFeeRateParamsBuilder::default();
2678                    fee_params.category(product_type);
2679                    if let Ok(params) = fee_params.build() {
2680                        match self.inner.get_fee_rate(&params).await {
2681                            Ok(fee_response) => fee_response
2682                                .result
2683                                .list
2684                                .into_iter()
2685                                .map(|f| (f.symbol, f))
2686                                .collect(),
2687                            Err(BybitHttpError::MissingCredentials) => {
2688                                tracing::warn!("Missing credentials for fee rates, using defaults");
2689                                AHashMap::new()
2690                            }
2691                            Err(e) => return Err(e.into()),
2692                        }
2693                    } else {
2694                        AHashMap::new()
2695                    }
2696                };
2697
2698                let mut cursor: Option<String> = None;
2699
2700                loop {
2701                    let params = BybitInstrumentsInfoParams {
2702                        category: product_type,
2703                        symbol: symbol.clone(),
2704                        status: None,
2705                        base_coin: None,
2706                        limit: Some(1000),
2707                        cursor: cursor.clone(),
2708                    };
2709
2710                    let response: BybitInstrumentLinearResponse =
2711                        self.inner.get_instruments(&params).await?;
2712
2713                    for definition in response.result.list {
2714                        let fee_rate = fee_map
2715                            .get(&definition.symbol)
2716                            .cloned()
2717                            .unwrap_or_else(|| default_fee_rate(definition.symbol));
2718                        if let Ok(instrument) =
2719                            parse_linear_instrument(&definition, &fee_rate, ts_init, ts_init)
2720                        {
2721                            instruments.push(instrument);
2722                        }
2723                    }
2724
2725                    cursor = response.result.next_page_cursor;
2726                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2727                        break;
2728                    }
2729                }
2730            }
2731            BybitProductType::Inverse => {
2732                // Try to get fee rates, use defaults if credentials are missing
2733                let fee_map: AHashMap<_, _> = {
2734                    let mut fee_params = BybitFeeRateParamsBuilder::default();
2735                    fee_params.category(product_type);
2736                    if let Ok(params) = fee_params.build() {
2737                        match self.inner.get_fee_rate(&params).await {
2738                            Ok(fee_response) => fee_response
2739                                .result
2740                                .list
2741                                .into_iter()
2742                                .map(|f| (f.symbol, f))
2743                                .collect(),
2744                            Err(BybitHttpError::MissingCredentials) => {
2745                                tracing::warn!("Missing credentials for fee rates, using defaults");
2746                                AHashMap::new()
2747                            }
2748                            Err(e) => return Err(e.into()),
2749                        }
2750                    } else {
2751                        AHashMap::new()
2752                    }
2753                };
2754
2755                let mut cursor: Option<String> = None;
2756
2757                loop {
2758                    let params = BybitInstrumentsInfoParams {
2759                        category: product_type,
2760                        symbol: symbol.clone(),
2761                        status: None,
2762                        base_coin: None,
2763                        limit: Some(1000),
2764                        cursor: cursor.clone(),
2765                    };
2766
2767                    let response: BybitInstrumentInverseResponse =
2768                        self.inner.get_instruments(&params).await?;
2769
2770                    for definition in response.result.list {
2771                        let fee_rate = fee_map
2772                            .get(&definition.symbol)
2773                            .cloned()
2774                            .unwrap_or_else(|| default_fee_rate(definition.symbol));
2775                        if let Ok(instrument) =
2776                            parse_inverse_instrument(&definition, &fee_rate, ts_init, ts_init)
2777                        {
2778                            instruments.push(instrument);
2779                        }
2780                    }
2781
2782                    cursor = response.result.next_page_cursor;
2783                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2784                        break;
2785                    }
2786                }
2787            }
2788            BybitProductType::Option => {
2789                let mut cursor: Option<String> = None;
2790
2791                loop {
2792                    let params = BybitInstrumentsInfoParams {
2793                        category: product_type,
2794                        symbol: symbol.clone(),
2795                        status: None,
2796                        base_coin: None,
2797                        limit: Some(1000),
2798                        cursor: cursor.clone(),
2799                    };
2800
2801                    let response: BybitInstrumentOptionResponse =
2802                        self.inner.get_instruments(&params).await?;
2803
2804                    for definition in response.result.list {
2805                        if let Ok(instrument) =
2806                            parse_option_instrument(&definition, ts_init, ts_init)
2807                        {
2808                            instruments.push(instrument);
2809                        }
2810                    }
2811
2812                    cursor = response.result.next_page_cursor;
2813                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
2814                        break;
2815                    }
2816                }
2817            }
2818        }
2819
2820        for instrument in &instruments {
2821            self.cache_instrument(instrument.clone());
2822        }
2823
2824        Ok(instruments)
2825    }
2826
2827    /// Request ticker information for market data.
2828    ///
2829    /// Fetches ticker data from Bybit's `/v5/market/tickers` endpoint and returns
2830    /// a unified `BybitTickerData` structure compatible with all product types.
2831    ///
2832    /// # Errors
2833    ///
2834    /// Returns an error if the request fails or parsing fails.
2835    ///
2836    /// # References
2837    ///
2838    /// <https://bybit-exchange.github.io/docs/v5/market/tickers>
2839    pub async fn request_tickers(
2840        &self,
2841        params: &BybitTickersParams,
2842    ) -> anyhow::Result<Vec<BybitTickerData>> {
2843        use super::models::{
2844            BybitTickersLinearResponse, BybitTickersOptionResponse, BybitTickersSpotResponse,
2845        };
2846
2847        match params.category {
2848            BybitProductType::Spot => {
2849                let response: BybitTickersSpotResponse = self.inner.get_tickers(params).await?;
2850                Ok(response.result.list.into_iter().map(Into::into).collect())
2851            }
2852            BybitProductType::Linear | BybitProductType::Inverse => {
2853                let response: BybitTickersLinearResponse = self.inner.get_tickers(params).await?;
2854                Ok(response.result.list.into_iter().map(Into::into).collect())
2855            }
2856            BybitProductType::Option => {
2857                let response: BybitTickersOptionResponse = self.inner.get_tickers(params).await?;
2858                Ok(response.result.list.into_iter().map(Into::into).collect())
2859            }
2860        }
2861    }
2862
2863    /// Request recent trade tick history for a given symbol.
2864    ///
2865    /// Returns the most recent public trades from Bybit's `/v5/market/recent-trade` endpoint.
2866    /// This endpoint only provides recent trades (up to 1000 most recent), typically covering
2867    /// only the last few minutes for active markets.
2868    ///
2869    /// **Note**: For historical trade data with time ranges, use the klines endpoint instead.
2870    /// The Bybit public API does not support fetching historical trades by time range.
2871    ///
2872    /// # Errors
2873    ///
2874    /// Returns an error if:
2875    /// - The instrument is not found in cache.
2876    /// - The request fails.
2877    /// - Parsing fails.
2878    ///
2879    /// # References
2880    ///
2881    /// <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
2882    pub async fn request_trades(
2883        &self,
2884        product_type: BybitProductType,
2885        instrument_id: InstrumentId,
2886        limit: Option<u32>,
2887    ) -> anyhow::Result<Vec<TradeTick>> {
2888        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2889        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2890
2891        let mut params_builder = BybitTradesParamsBuilder::default();
2892        params_builder.category(product_type);
2893        params_builder.symbol(bybit_symbol.raw_symbol().to_string());
2894        if let Some(limit_val) = limit {
2895            params_builder.limit(limit_val);
2896        }
2897
2898        let params = params_builder.build().map_err(|e| anyhow::anyhow!(e))?;
2899        let response = self.inner.get_recent_trades(&params).await?;
2900
2901        let ts_init = self.generate_ts_init();
2902        let mut trades = Vec::new();
2903
2904        for trade in response.result.list {
2905            if let Ok(trade_tick) = parse_trade_tick(&trade, &instrument, ts_init) {
2906                trades.push(trade_tick);
2907            }
2908        }
2909
2910        Ok(trades)
2911    }
2912
2913    /// Request bar/kline history for a given symbol.
2914    ///
2915    /// # Errors
2916    ///
2917    /// Returns an error if:
2918    /// - The instrument is not found in cache.
2919    /// - The request fails.
2920    /// - Parsing fails.
2921    ///
2922    /// # References
2923    ///
2924    /// <https://bybit-exchange.github.io/docs/v5/market/kline>
2925    pub async fn request_bars(
2926        &self,
2927        product_type: BybitProductType,
2928        bar_type: BarType,
2929        start: Option<DateTime<Utc>>,
2930        end: Option<DateTime<Utc>>,
2931        limit: Option<u32>,
2932        timestamp_on_close: bool,
2933    ) -> anyhow::Result<Vec<Bar>> {
2934        let instrument = self.instrument_from_cache(&bar_type.instrument_id().symbol)?;
2935        let bybit_symbol = BybitSymbol::new(bar_type.instrument_id().symbol.as_str())?;
2936
2937        // Convert Nautilus BarSpec to Bybit interval
2938        let interval = bar_spec_to_bybit_interval(
2939            bar_type.spec().aggregation,
2940            bar_type.spec().step.get() as u64,
2941        )?;
2942
2943        let start_ms = start.map(|dt| dt.timestamp_millis());
2944        let mut seen_timestamps: AHashSet<i64> = AHashSet::new();
2945        let current_time_ms = get_atomic_clock_realtime().get_time_ms() as i64;
2946
2947        // Pagination strategy: work backwards from end time
2948        // - Each page fetched is older than the previous page
2949        // - Within each page, bars are in chronological order (oldest to newest)
2950        // - We collect pages in reverse order (newest first) then reverse at the end
2951        // Example with 2 pages:
2952        //   Page 1 (most recent): bars [T=2000..2999]
2953        //   Page 2 (older):       bars [T=1000..1999]
2954        //   Collected: [[T=2000..2999], [T=1000..1999]]
2955        //   After reverse + flatten: [T=1000..1999, T=2000..2999] ✓ chronological
2956        let mut pages: Vec<Vec<Bar>> = Vec::new();
2957        let mut total_bars = 0usize;
2958        let mut current_end = end.map(|dt| dt.timestamp_millis());
2959        let mut page_count = 0;
2960
2961        loop {
2962            page_count += 1;
2963
2964            let mut params_builder = BybitKlinesParamsBuilder::default();
2965            params_builder.category(product_type);
2966            params_builder.symbol(bybit_symbol.raw_symbol().to_string());
2967            params_builder.interval(interval);
2968            params_builder.limit(1000u32); // Limit for data size per page (maximum for the Bybit API)
2969
2970            if let Some(start_val) = start_ms {
2971                params_builder.start(start_val);
2972            }
2973            if let Some(end_val) = current_end {
2974                params_builder.end(end_val);
2975            }
2976
2977            let params = params_builder.build().map_err(|e| anyhow::anyhow!(e))?;
2978            let response = self.inner.get_klines(&params).await?;
2979
2980            let klines = response.result.list;
2981            if klines.is_empty() {
2982                break;
2983            }
2984
2985            // Parse timestamps once and pair with klines for sorting
2986            let mut klines_with_ts: Vec<(i64, _)> = klines
2987                .into_iter()
2988                .filter_map(|k| k.start.parse::<i64>().ok().map(|ts| (ts, k)))
2989                .collect();
2990
2991            klines_with_ts.sort_by_key(|(ts, _)| *ts);
2992
2993            // Check if we have any new timestamps
2994            let has_new = klines_with_ts
2995                .iter()
2996                .any(|(ts, _)| !seen_timestamps.contains(ts));
2997            if !has_new {
2998                break;
2999            }
3000
3001            let ts_init = self.generate_ts_init();
3002            let mut page_bars = Vec::with_capacity(klines_with_ts.len());
3003
3004            let mut earliest_ts: Option<i64> = None;
3005
3006            for (start_time, kline) in &klines_with_ts {
3007                // Track earliest timestamp for pagination
3008                if earliest_ts.is_none_or(|ts| *start_time < ts) {
3009                    earliest_ts = Some(*start_time);
3010                }
3011
3012                let bar_end_time = interval.bar_end_time_ms(*start_time);
3013                if bar_end_time > current_time_ms {
3014                    continue;
3015                }
3016
3017                if !seen_timestamps.contains(start_time)
3018                    && let Ok(bar) =
3019                        parse_kline_bar(kline, &instrument, bar_type, timestamp_on_close, ts_init)
3020                {
3021                    page_bars.push(bar);
3022                    seen_timestamps.insert(*start_time);
3023                }
3024            }
3025
3026            // page_bars may be empty if all klines were partial, but pagination
3027            // continues to fetch older closed bars
3028            total_bars += page_bars.len();
3029            pages.push(page_bars);
3030
3031            // Check if we've reached the requested limit
3032            if let Some(limit_val) = limit
3033                && total_bars >= limit_val as usize
3034            {
3035                break;
3036            }
3037
3038            // Move end time backwards to get earlier data
3039            // Set new end to be 1ms before the first bar of this page
3040            let Some(earliest_bar_time) = earliest_ts else {
3041                break;
3042            };
3043            if let Some(start_val) = start_ms
3044                && earliest_bar_time <= start_val
3045            {
3046                break;
3047            }
3048
3049            current_end = Some(earliest_bar_time - 1);
3050
3051            // Safety check to prevent infinite loops
3052            if page_count > 100 {
3053                break;
3054            }
3055        }
3056
3057        // Reverse pages and flatten to get chronological order (oldest to newest)
3058        let mut all_bars: Vec<Bar> = Vec::with_capacity(total_bars);
3059        for page in pages.into_iter().rev() {
3060            all_bars.extend(page);
3061        }
3062
3063        // If limit is specified and we have more bars, return the last N bars (most recent)
3064        if let Some(limit_val) = limit {
3065            let limit_usize = limit_val as usize;
3066            if all_bars.len() > limit_usize {
3067                let start_idx = all_bars.len() - limit_usize;
3068                return Ok(all_bars[start_idx..].to_vec());
3069            }
3070        }
3071
3072        Ok(all_bars)
3073    }
3074
3075    /// Requests trading fee rates for the specified product type and optional filters.
3076    ///
3077    /// # Errors
3078    ///
3079    /// Returns an error if:
3080    /// - The request fails.
3081    /// - Parsing fails.
3082    ///
3083    /// # References
3084    ///
3085    /// <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
3086    pub async fn request_fee_rates(
3087        &self,
3088        product_type: BybitProductType,
3089        symbol: Option<String>,
3090        base_coin: Option<String>,
3091    ) -> anyhow::Result<Vec<BybitFeeRate>> {
3092        let params = BybitFeeRateParams {
3093            category: product_type,
3094            symbol,
3095            base_coin,
3096        };
3097
3098        let response = self.inner.get_fee_rate(&params).await?;
3099        Ok(response.result.list)
3100    }
3101
3102    /// Requests the current account state for the specified account type.
3103    ///
3104    /// # Errors
3105    ///
3106    /// Returns an error if:
3107    /// - The request fails.
3108    /// - Parsing fails.
3109    ///
3110    /// # References
3111    ///
3112    /// <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
3113    pub async fn request_account_state(
3114        &self,
3115        account_type: BybitAccountType,
3116        account_id: AccountId,
3117    ) -> anyhow::Result<AccountState> {
3118        let params = BybitWalletBalanceParams {
3119            account_type,
3120            coin: None,
3121        };
3122
3123        let response = self.inner.get_wallet_balance(&params).await?;
3124        let ts_init = self.generate_ts_init();
3125
3126        // Take the first wallet balance from the list
3127        let wallet_balance = response
3128            .result
3129            .list
3130            .first()
3131            .ok_or_else(|| anyhow::anyhow!("No wallet balance found in response"))?;
3132
3133        parse_account_state(wallet_balance, account_id, ts_init)
3134    }
3135
3136    /// Request multiple order status reports.
3137    ///
3138    /// Orders for instruments not currently loaded in cache will be skipped.
3139    ///
3140    /// # Errors
3141    ///
3142    /// Returns an error if:
3143    /// - Credentials are missing.
3144    /// - The request fails.
3145    /// - The API returns an error.
3146    #[allow(clippy::too_many_arguments)]
3147    pub async fn request_order_status_reports(
3148        &self,
3149        account_id: AccountId,
3150        product_type: BybitProductType,
3151        instrument_id: Option<InstrumentId>,
3152        open_only: bool,
3153        start: Option<DateTime<Utc>>,
3154        end: Option<DateTime<Utc>>,
3155        limit: Option<u32>,
3156    ) -> anyhow::Result<Vec<OrderStatusReport>> {
3157        // Extract symbol parameter from instrument_id if provided
3158        let symbol_param = if let Some(id) = instrument_id.as_ref() {
3159            let symbol_str = id.symbol.as_str();
3160            if symbol_str.is_empty() {
3161                None
3162            } else {
3163                Some(BybitSymbol::new(symbol_str)?.raw_symbol().to_string())
3164            }
3165        } else {
3166            None
3167        };
3168
3169        // For LINEAR without symbol, query all settle coins to avoid filtering
3170        // For INVERSE, never use settle_coin parameter
3171        let settle_coins_to_query: Vec<Option<String>> =
3172            if product_type == BybitProductType::Linear && symbol_param.is_none() {
3173                vec![Some("USDT".to_string()), Some("USDC".to_string())]
3174            } else {
3175                match product_type {
3176                    BybitProductType::Inverse => vec![None],
3177                    _ => vec![None],
3178                }
3179            };
3180
3181        let mut all_collected_orders = Vec::new();
3182        let mut total_collected_across_coins = 0;
3183
3184        for settle_coin in settle_coins_to_query {
3185            let remaining_limit = if let Some(limit) = limit {
3186                let remaining = (limit as usize).saturating_sub(total_collected_across_coins);
3187                if remaining == 0 {
3188                    break;
3189                }
3190                Some(remaining as u32)
3191            } else {
3192                None
3193            };
3194
3195            let orders_for_coin = if open_only {
3196                let mut all_orders = Vec::new();
3197                let mut cursor: Option<String> = None;
3198                let mut total_orders = 0;
3199
3200                loop {
3201                    let remaining = if let Some(limit) = remaining_limit {
3202                        (limit as usize).saturating_sub(total_orders)
3203                    } else {
3204                        usize::MAX
3205                    };
3206
3207                    if remaining == 0 {
3208                        break;
3209                    }
3210
3211                    // Max 50 per Bybit API
3212                    let page_limit = std::cmp::min(remaining, 50);
3213
3214                    let mut p = BybitOpenOrdersParamsBuilder::default();
3215                    p.category(product_type);
3216                    if let Some(symbol) = symbol_param.clone() {
3217                        p.symbol(symbol);
3218                    }
3219                    if let Some(coin) = settle_coin.clone() {
3220                        p.settle_coin(coin);
3221                    }
3222                    p.limit(page_limit as u32);
3223                    if let Some(c) = cursor {
3224                        p.cursor(c);
3225                    }
3226                    let params = p.build().map_err(|e| anyhow::anyhow!(e))?;
3227                    let response: BybitOpenOrdersResponse = self
3228                        .inner
3229                        .send_request(Method::GET, "/v5/order/realtime", Some(&params), None, true)
3230                        .await?;
3231
3232                    total_orders += response.result.list.len();
3233                    all_orders.extend(response.result.list);
3234
3235                    cursor = response.result.next_page_cursor;
3236                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
3237                        break;
3238                    }
3239                }
3240
3241                all_orders
3242            } else {
3243                // Query both realtime and history endpoints
3244                // Realtime has current open orders, history may lag for recent orders
3245                let mut all_orders = Vec::new();
3246                let mut open_orders = Vec::new();
3247                let mut cursor: Option<String> = None;
3248                let mut total_open_orders = 0;
3249
3250                loop {
3251                    let remaining = if let Some(limit) = remaining_limit {
3252                        (limit as usize).saturating_sub(total_open_orders)
3253                    } else {
3254                        usize::MAX
3255                    };
3256
3257                    if remaining == 0 {
3258                        break;
3259                    }
3260
3261                    // Max 50 per Bybit API
3262                    let page_limit = std::cmp::min(remaining, 50);
3263
3264                    let mut open_params = BybitOpenOrdersParamsBuilder::default();
3265                    open_params.category(product_type);
3266                    if let Some(symbol) = symbol_param.clone() {
3267                        open_params.symbol(symbol);
3268                    }
3269                    if let Some(coin) = settle_coin.clone() {
3270                        open_params.settle_coin(coin);
3271                    }
3272                    open_params.limit(page_limit as u32);
3273                    if let Some(c) = cursor {
3274                        open_params.cursor(c);
3275                    }
3276                    let open_params = open_params.build().map_err(|e| anyhow::anyhow!(e))?;
3277                    let open_response: BybitOpenOrdersResponse = self
3278                        .inner
3279                        .send_request(
3280                            Method::GET,
3281                            "/v5/order/realtime",
3282                            Some(&open_params),
3283                            None,
3284                            true,
3285                        )
3286                        .await?;
3287
3288                    total_open_orders += open_response.result.list.len();
3289                    open_orders.extend(open_response.result.list);
3290
3291                    cursor = open_response.result.next_page_cursor;
3292                    if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3293                        break;
3294                    }
3295                }
3296
3297                let seen_order_ids: AHashSet<Ustr> =
3298                    open_orders.iter().map(|o| o.order_id).collect();
3299
3300                all_orders.extend(open_orders);
3301
3302                let mut cursor: Option<String> = None;
3303                let mut total_history_orders = 0;
3304
3305                loop {
3306                    let total_orders = total_open_orders + total_history_orders;
3307                    let remaining = if let Some(limit) = remaining_limit {
3308                        (limit as usize).saturating_sub(total_orders)
3309                    } else {
3310                        usize::MAX
3311                    };
3312
3313                    if remaining == 0 {
3314                        break;
3315                    }
3316
3317                    // Max 50 per Bybit API
3318                    let page_limit = std::cmp::min(remaining, 50);
3319
3320                    let mut history_params = BybitOrderHistoryParamsBuilder::default();
3321                    history_params.category(product_type);
3322                    if let Some(symbol) = symbol_param.clone() {
3323                        history_params.symbol(symbol);
3324                    }
3325                    if let Some(coin) = settle_coin.clone() {
3326                        history_params.settle_coin(coin);
3327                    }
3328                    if let Some(start) = start {
3329                        history_params.start_time(start.timestamp_millis());
3330                    }
3331                    if let Some(end) = end {
3332                        history_params.end_time(end.timestamp_millis());
3333                    }
3334                    history_params.limit(page_limit as u32);
3335                    if let Some(c) = cursor {
3336                        history_params.cursor(c);
3337                    }
3338                    let history_params = history_params.build().map_err(|e| anyhow::anyhow!(e))?;
3339                    let history_response: BybitOrderHistoryResponse = self
3340                        .inner
3341                        .send_request(
3342                            Method::GET,
3343                            "/v5/order/history",
3344                            Some(&history_params),
3345                            None,
3346                            true,
3347                        )
3348                        .await?;
3349
3350                    // Open orders might appear in both realtime and history
3351                    for order in history_response.result.list {
3352                        if !seen_order_ids.contains(&order.order_id) {
3353                            all_orders.push(order);
3354                            total_history_orders += 1;
3355                        }
3356                    }
3357
3358                    cursor = history_response.result.next_page_cursor;
3359                    if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3360                        break;
3361                    }
3362                }
3363
3364                all_orders
3365            };
3366
3367            total_collected_across_coins += orders_for_coin.len();
3368            all_collected_orders.extend(orders_for_coin);
3369        }
3370
3371        let ts_init = self.generate_ts_init();
3372
3373        let mut reports = Vec::new();
3374        for order in all_collected_orders {
3375            if let Some(ref instrument_id) = instrument_id {
3376                let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
3377                if let Ok(report) =
3378                    parse_order_status_report(&order, &instrument, account_id, ts_init)
3379                {
3380                    reports.push(report);
3381                }
3382            } else {
3383                // Bybit returns raw symbol (e.g. "ETHUSDT"), need to add product suffix for cache lookup
3384                // Note: instruments are stored in cache by symbol only (without venue)
3385                if !order.symbol.is_empty() {
3386                    let symbol_with_product =
3387                        Symbol::from_ustr_unchecked(make_bybit_symbol(order.symbol, product_type));
3388
3389                    let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
3390                        tracing::debug!(
3391                            symbol = %order.symbol,
3392                            full_symbol = %symbol_with_product,
3393                            "Skipping order report for instrument not in cache"
3394                        );
3395                        continue;
3396                    };
3397
3398                    match parse_order_status_report(&order, &instrument, account_id, ts_init) {
3399                        Ok(report) => reports.push(report),
3400                        Err(e) => {
3401                            tracing::error!("Failed to parse order status report: {e}");
3402                        }
3403                    }
3404                }
3405            }
3406        }
3407
3408        Ok(reports)
3409    }
3410
3411    /// Fetches execution history (fills) for the account and returns a list of [`FillReport`]s.
3412    ///
3413    /// Executions for instruments not currently loaded in cache will be skipped.
3414    ///
3415    /// # Errors
3416    ///
3417    /// This function returns an error if the request fails.
3418    ///
3419    /// # References
3420    ///
3421    /// <https://bybit-exchange.github.io/docs/v5/order/execution>
3422    pub async fn request_fill_reports(
3423        &self,
3424        account_id: AccountId,
3425        product_type: BybitProductType,
3426        instrument_id: Option<InstrumentId>,
3427        start: Option<i64>,
3428        end: Option<i64>,
3429        limit: Option<u32>,
3430    ) -> anyhow::Result<Vec<FillReport>> {
3431        // Build query parameters
3432        let symbol = if let Some(id) = instrument_id {
3433            let bybit_symbol = BybitSymbol::new(id.symbol.as_str())?;
3434            Some(bybit_symbol.raw_symbol().to_string())
3435        } else {
3436            None
3437        };
3438
3439        // Fetch all executions with pagination
3440        let mut all_executions = Vec::new();
3441        let mut cursor: Option<String> = None;
3442        let mut total_executions = 0;
3443
3444        loop {
3445            // Calculate how many more executions we can request
3446            let remaining = if let Some(limit) = limit {
3447                (limit as usize).saturating_sub(total_executions)
3448            } else {
3449                usize::MAX
3450            };
3451
3452            // If we've reached the limit, stop
3453            if remaining == 0 {
3454                break;
3455            }
3456
3457            // Size the page request to respect caller's limit (max 100 per Bybit API)
3458            let page_limit = std::cmp::min(remaining, 100);
3459
3460            let params = BybitTradeHistoryParams {
3461                category: product_type,
3462                symbol: symbol.clone(),
3463                base_coin: None,
3464                order_id: None,
3465                order_link_id: None,
3466                start_time: start,
3467                end_time: end,
3468                exec_type: None,
3469                limit: Some(page_limit as u32),
3470                cursor: cursor.clone(),
3471            };
3472
3473            let response = self.inner.get_trade_history(&params).await?;
3474            let list_len = response.result.list.len();
3475            all_executions.extend(response.result.list);
3476            total_executions += list_len;
3477
3478            cursor = response.result.next_page_cursor;
3479            if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3480                break;
3481            }
3482        }
3483
3484        let ts_init = self.generate_ts_init();
3485        let mut reports = Vec::new();
3486
3487        for execution in all_executions {
3488            // Get instrument for this execution
3489            // Bybit returns raw symbol (e.g. "ETHUSDT"), need to add product suffix for cache lookup
3490            let symbol_with_product =
3491                Symbol::from_ustr_unchecked(make_bybit_symbol(execution.symbol, product_type));
3492
3493            let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
3494                tracing::debug!(
3495                    symbol = %execution.symbol,
3496                    full_symbol = %symbol_with_product,
3497                    "Skipping fill report for instrument not in cache"
3498                );
3499                continue;
3500            };
3501
3502            match parse_fill_report(&execution, account_id, &instrument, ts_init) {
3503                Ok(report) => reports.push(report),
3504                Err(e) => {
3505                    tracing::error!("Failed to parse fill report: {e}");
3506                }
3507            }
3508        }
3509
3510        Ok(reports)
3511    }
3512
3513    /// Fetches position information for the account and returns a list of [`PositionStatusReport`]s.
3514    ///
3515    /// Positions for instruments not currently loaded in cache will be skipped.
3516    ///
3517    /// # Errors
3518    ///
3519    /// This function returns an error if the request fails.
3520    ///
3521    /// # References
3522    ///
3523    /// <https://bybit-exchange.github.io/docs/v5/position>
3524    pub async fn request_position_status_reports(
3525        &self,
3526        account_id: AccountId,
3527        product_type: BybitProductType,
3528        instrument_id: Option<InstrumentId>,
3529    ) -> anyhow::Result<Vec<PositionStatusReport>> {
3530        // Handle SPOT position reports via wallet balances if flag is enabled
3531        if product_type == BybitProductType::Spot {
3532            if self.use_spot_position_reports.load(Ordering::Relaxed) {
3533                return self
3534                    .generate_spot_position_reports_from_wallet(account_id, instrument_id)
3535                    .await;
3536            } else {
3537                // Return empty vector when SPOT position reports are disabled
3538                return Ok(Vec::new());
3539            }
3540        }
3541
3542        let ts_init = self.generate_ts_init();
3543        let mut reports = Vec::new();
3544
3545        // Build query parameters based on whether a specific instrument is requested
3546        let symbol = if let Some(id) = instrument_id {
3547            let symbol_str = id.symbol.as_str();
3548            if symbol_str.is_empty() {
3549                anyhow::bail!("InstrumentId symbol is empty");
3550            }
3551            let bybit_symbol = BybitSymbol::new(symbol_str)?;
3552            Some(bybit_symbol.raw_symbol().to_string())
3553        } else {
3554            None
3555        };
3556
3557        // For LINEAR category, the API requires either symbol OR settleCoin
3558        // When querying all positions (no symbol), we must iterate through settle coins
3559        if product_type == BybitProductType::Linear && symbol.is_none() {
3560            // Query positions for each known settle coin with pagination
3561            for settle_coin in ["USDT", "USDC"] {
3562                let mut cursor: Option<String> = None;
3563
3564                loop {
3565                    let params = BybitPositionListParams {
3566                        category: product_type,
3567                        symbol: None,
3568                        base_coin: None,
3569                        settle_coin: Some(settle_coin.to_string()),
3570                        limit: Some(200), // Max 200 per request
3571                        cursor: cursor.clone(),
3572                    };
3573
3574                    let response = self.inner.get_positions(&params).await?;
3575
3576                    for position in response.result.list {
3577                        if position.symbol.is_empty() {
3578                            continue;
3579                        }
3580
3581                        let symbol_with_product = Symbol::new(format!(
3582                            "{}{}",
3583                            position.symbol.as_str(),
3584                            product_type.suffix()
3585                        ));
3586
3587                        let Ok(instrument) = self.instrument_from_cache(&symbol_with_product)
3588                        else {
3589                            tracing::debug!(
3590                                symbol = %position.symbol,
3591                                full_symbol = %symbol_with_product,
3592                                "Skipping position report for instrument not in cache"
3593                            );
3594                            continue;
3595                        };
3596
3597                        match parse_position_status_report(
3598                            &position,
3599                            account_id,
3600                            &instrument,
3601                            ts_init,
3602                        ) {
3603                            Ok(report) => reports.push(report),
3604                            Err(e) => {
3605                                tracing::error!("Failed to parse position status report: {e}");
3606                            }
3607                        }
3608                    }
3609
3610                    cursor = response.result.next_page_cursor;
3611                    if cursor.as_ref().is_none_or(|c| c.is_empty()) {
3612                        break;
3613                    }
3614                }
3615            }
3616        } else {
3617            // For other product types or when a specific symbol is requested with pagination
3618            let mut cursor: Option<String> = None;
3619
3620            loop {
3621                let params = BybitPositionListParams {
3622                    category: product_type,
3623                    symbol: symbol.clone(),
3624                    base_coin: None,
3625                    settle_coin: None,
3626                    limit: Some(200), // Max 200 per request
3627                    cursor: cursor.clone(),
3628                };
3629
3630                let response = self.inner.get_positions(&params).await?;
3631
3632                for position in response.result.list {
3633                    if position.symbol.is_empty() {
3634                        continue;
3635                    }
3636
3637                    let symbol_with_product = Symbol::new(format!(
3638                        "{}{}",
3639                        position.symbol.as_str(),
3640                        product_type.suffix()
3641                    ));
3642
3643                    let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
3644                        tracing::debug!(
3645                            symbol = %position.symbol,
3646                            full_symbol = %symbol_with_product,
3647                            "Skipping position report for instrument not in cache"
3648                        );
3649                        continue;
3650                    };
3651
3652                    match parse_position_status_report(&position, account_id, &instrument, ts_init)
3653                    {
3654                        Ok(report) => reports.push(report),
3655                        Err(e) => {
3656                            tracing::error!("Failed to parse position status report: {e}");
3657                        }
3658                    }
3659                }
3660
3661                cursor = response.result.next_page_cursor;
3662                if cursor.is_none() || cursor.as_ref().is_none_or(|c| c.is_empty()) {
3663                    break;
3664                }
3665            }
3666        }
3667
3668        Ok(reports)
3669    }
3670}
3671
3672#[cfg(test)]
3673mod tests {
3674    use rstest::rstest;
3675
3676    use super::*;
3677
3678    #[rstest]
3679    fn test_client_creation() {
3680        let client = BybitHttpClient::new(None, Some(60), None, None, None, None, None);
3681        assert!(client.is_ok());
3682
3683        let client = client.unwrap();
3684        assert!(client.base_url().contains("bybit.com"));
3685        assert!(client.credential().is_none());
3686    }
3687
3688    #[rstest]
3689    fn test_client_with_credentials() {
3690        let client = BybitHttpClient::with_credentials(
3691            "test_key".to_string(),
3692            "test_secret".to_string(),
3693            Some("https://api-testnet.bybit.com".to_string()),
3694            Some(60),
3695            None,
3696            None,
3697            None,
3698            None,
3699            None,
3700        );
3701        assert!(client.is_ok());
3702
3703        let client = client.unwrap();
3704        assert!(client.credential().is_some());
3705    }
3706
3707    #[rstest]
3708    fn test_build_path_with_params() {
3709        #[derive(Serialize)]
3710        struct TestParams {
3711            category: String,
3712            symbol: String,
3713        }
3714
3715        let params = TestParams {
3716            category: "linear".to_string(),
3717            symbol: "BTCUSDT".to_string(),
3718        };
3719
3720        let path = BybitRawHttpClient::build_path("/v5/market/test", &params);
3721        assert!(path.is_ok());
3722        assert!(path.unwrap().contains("category=linear"));
3723    }
3724
3725    #[rstest]
3726    fn test_build_path_without_params() {
3727        let params = ();
3728        let path = BybitRawHttpClient::build_path("/v5/market/time", &params);
3729        assert!(path.is_ok());
3730        assert_eq!(path.unwrap(), "/v5/market/time");
3731    }
3732
3733    #[rstest]
3734    fn test_params_serialization_matches_build_path() {
3735        // This test ensures our new serialization produces the same result as the old build_path
3736        #[derive(Serialize)]
3737        struct TestParams {
3738            category: String,
3739            limit: u32,
3740        }
3741
3742        let params = TestParams {
3743            category: "spot".to_string(),
3744            limit: 50,
3745        };
3746
3747        // Old way: build_path serialized params
3748        let old_path = BybitRawHttpClient::build_path("/v5/order/realtime", &params).unwrap();
3749        let old_query = old_path.split('?').nth(1).unwrap_or("");
3750
3751        // New way: direct serialization
3752        let new_query = serde_urlencoded::to_string(&params).unwrap();
3753
3754        // They must match for signatures to work
3755        assert_eq!(old_query, new_query);
3756    }
3757
3758    #[rstest]
3759    fn test_params_serialization_order() {
3760        // Verify that serialization order is deterministic
3761        #[derive(Serialize)]
3762        struct OrderParams {
3763            category: String,
3764            symbol: String,
3765            limit: u32,
3766        }
3767
3768        let params = OrderParams {
3769            category: "spot".to_string(),
3770            symbol: "BTCUSDT".to_string(),
3771            limit: 50,
3772        };
3773
3774        // Serialize multiple times to ensure consistent ordering
3775        let query1 = serde_urlencoded::to_string(&params).unwrap();
3776        let query2 = serde_urlencoded::to_string(&params).unwrap();
3777        let query3 = serde_urlencoded::to_string(&params).unwrap();
3778
3779        assert_eq!(query1, query2);
3780        assert_eq!(query2, query3);
3781
3782        // The query should contain all params
3783        assert!(query1.contains("category=spot"));
3784        assert!(query1.contains("symbol=BTCUSDT"));
3785        assert!(query1.contains("limit=50"));
3786    }
3787}