Skip to main content

nautilus_bybit/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides the HTTP client integration for the [Bybit](https://bybit.com) REST API.
17//!
18//! Bybit API reference <https://bybit-exchange.github.io/docs/>.
19
20use std::{
21    cmp::Reverse,
22    collections::HashMap,
23    fmt::{Debug, Display},
24    num::NonZeroU32,
25    sync::{
26        Arc, LazyLock,
27        atomic::{AtomicBool, AtomicU64, Ordering},
28    },
29};
30
31use ahash::{AHashMap, AHashSet};
32use anyhow::Context;
33use arc_swap::ArcSwap;
34use jiff::Timestamp;
35use nautilus_common::cache::InstrumentLookupError;
36use nautilus_core::{
37    AtomicMap, AtomicTime, env::get_or_env_var_opt, nanos::UnixNanos,
38    time::get_atomic_clock_realtime,
39};
40use nautilus_model::{
41    data::{Bar, BarType, FundingRateUpdate, OrderBookDeltas, TradeTick},
42    enums::{MarketStatusAction, OrderSide, OrderType, PositionSide, TimeInForce},
43    events::account::state::AccountState,
44    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
45    instruments::{Instrument, InstrumentAny},
46    reports::{FillReport, OrderStatusReport, PositionStatusReport},
47    types::{Price, Quantity},
48};
49use nautilus_network::{
50    http::{HttpClient, HttpRedirectPolicy, Method, create_standard_nautilus_headers},
51    ratelimiter::quota::Quota,
52    retry::{RetryConfig, RetryError, RetryManager},
53};
54use rust_decimal::Decimal;
55use serde::{Serialize, de::DeserializeOwned};
56use tokio_util::sync::CancellationToken;
57use ustr::Ustr;
58
59use super::{
60    error::{BybitCancelOrderError, BybitHttpError, BybitModifyOrderError, BybitSubmitOrderError},
61    models::{
62        BybitAccountDetailsResponse, BybitAccountInfoResponse, BybitBorrowResponse,
63        BybitEscrowSubMembersResponse, BybitFeeRate, BybitFeeRateResponse, BybitFundingResponse,
64        BybitInstrumentInverse, BybitInstrumentInverseResponse, BybitInstrumentLinear,
65        BybitInstrumentLinearResponse, BybitInstrumentOption, BybitInstrumentOptionResponse,
66        BybitInstrumentSpot, BybitInstrumentSpotResponse, BybitKlinesResponse,
67        BybitNoConvertRepayResponse, BybitOpenOrdersResponse, BybitOrder,
68        BybitOrderHistoryResponse, BybitOrderbookResponse, BybitPlaceOrderResponse,
69        BybitPositionListResponse, BybitRepayResponse, BybitServerTimeResponse,
70        BybitSetLeverageResponse, BybitSetMarginModeResponse, BybitSetTradingStopResponse,
71        BybitSubApiKeyInfo, BybitSubApiKeysResponse, BybitSubMember, BybitSubMembersPagedResponse,
72        BybitSubMembersResponse, BybitSwitchModeResponse, BybitTickerData, BybitTickerOption,
73        BybitTickersOptionResponse, BybitTradeHistoryResponse, BybitTradesResponse,
74        BybitUpdateMasterApiResponse, BybitUpdateSubApiResponse, BybitWalletBalanceResponse,
75    },
76    query::{
77        BybitAmendOrderParamsBuilder, BybitBatchAmendOrderEntryBuilder,
78        BybitBatchCancelOrderEntryBuilder, BybitBatchCancelOrderParamsBuilder,
79        BybitBatchPlaceOrderEntryBuilder, BybitBorrowParamsBuilder,
80        BybitCancelAllOrdersParamsBuilder, BybitCancelOrderParamsBuilder, BybitFeeRateParams,
81        BybitFundingParams, BybitFundingParamsBuilder, BybitInstrumentsInfoParams,
82        BybitKlinesParams, BybitKlinesParamsBuilder, BybitNativeTpSlParams,
83        BybitNoConvertRepayParamsBuilder, BybitOpenOrdersParamsBuilder,
84        BybitOrderHistoryParamsBuilder, BybitOrderbookParams, BybitOrderbookParamsBuilder,
85        BybitPlaceOrderParamsBuilder, BybitPositionListParams, BybitRepayParamsBuilder,
86        BybitSetLeverageParamsBuilder, BybitSetMarginModeParamsBuilder, BybitSetTradingStopParams,
87        BybitSubApiKeysParams, BybitSubMembersPageParams, BybitSwitchModeParamsBuilder,
88        BybitTickersParams, BybitTradeHistoryParams, BybitTradesParams, BybitTradesParamsBuilder,
89        BybitUpdateMasterApiParams, BybitUpdateSubApiParams, BybitWalletBalanceParams,
90    },
91};
92use crate::common::{
93    consts::{BYBIT_NAUTILUS_BROKER_ID, BYBIT_VENUE},
94    credential::{Credential, credential_env_vars},
95    enums::{
96        BybitAccountType, BybitBboSideType, BybitContractType, BybitEnvironment, BybitExecType,
97        BybitMarginMode, BybitOpenOnly, BybitOrderFilter, BybitOrderSide, BybitOrderSmpType,
98        BybitOrderType, BybitPositionIdx, BybitPositionMode, BybitProductType, BybitRepayStatus,
99        BybitTpSlMode,
100    },
101    models::{BybitCursorListResponse, BybitErrorCheck, BybitResponseCheck},
102    parse::{
103        bar_spec_to_bybit_interval, bybit_rejection_due_post_only, make_bybit_symbol,
104        map_time_in_force, parse_account_state, parse_fill_report, parse_funding_rate,
105        parse_inverse_instrument, parse_kline_bar, parse_linear_instrument,
106        parse_option_instrument, parse_order_status_report, parse_orderbook,
107        parse_position_status_report, parse_spot_instrument, parse_trade_tick, spot_leverage,
108        spot_market_unit, trigger_direction,
109    },
110    rate_limit::{
111        BYBIT_RATE_LIMIT_HEADER, BYBIT_RATE_LIMIT_RESET_HEADER, BYBIT_RATE_LIMIT_STATUS_HEADER,
112        BybitRateLimiter, batch_call_limit, batch_endpoint_limit, batch_send_limit, batch_weight,
113        category_from_payload,
114    },
115    retry::should_retry_http,
116    symbol::BybitSymbol,
117    urls::bybit_http_base_url,
118};
119
120const DEFAULT_RECV_WINDOW_MS: u64 = 5_000;
121
122trait BuilderResultExt<T> {
123    fn build_anyhow(self) -> anyhow::Result<T>;
124}
125
126impl<T, E: Display> BuilderResultExt<T> for Result<T, E> {
127    fn build_anyhow(self) -> anyhow::Result<T> {
128        self.map_err(|e| anyhow::anyhow!("{e}"))
129    }
130}
131
132const BYBIT_INSTRUMENTS_INFO: &str = "/v5/market/instruments-info";
133const BYBIT_ORDER_REALTIME: &str = "/v5/order/realtime";
134const BYBIT_ORDER_HISTORY: &str = "/v5/order/history";
135const BYBIT_EXECUTION_LIST: &str = "/v5/execution/list";
136const BYBIT_POSITION_LIST: &str = "/v5/position/list";
137
138/// Tracks the cursors one paginated walk has already followed.
139///
140/// A venue can serve a page whose `nextPageCursor` addresses that same page. Requesting it
141/// returns the same rows and the same cursor, so an empty cursor never arrives and the walk does
142/// not terminate. A cursor that does not advance, or that the walk has already followed, has no
143/// further page to offer.
144///
145/// The two cases are separated because they describe different venue behavior: a cursor that
146/// repeats the one just used has stopped advancing, while a cursor seen earlier in the walk means
147/// the pages have cycled. `nautilus-polymarket` draws the same distinction in
148/// `http::pagination::PaginationError`.
149#[derive(Debug, Default)]
150struct CursorWalk {
151    followed: AHashSet<String>,
152    last: Option<String>,
153}
154
155impl CursorWalk {
156    /// Returns the cursor to follow next, or `None` when the walk is complete.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the cursor stops advancing or repeats one already followed. Stopping
161    /// with the rows gathered so far would hand the caller a truncated result it cannot distinguish
162    /// from a complete one.
163    fn advance(
164        &mut self,
165        endpoint: &str,
166        cursor: Option<String>,
167    ) -> anyhow::Result<Option<String>> {
168        let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) else {
169            return Ok(None);
170        };
171
172        anyhow::ensure!(
173            self.last.as_deref() != Some(cursor.as_str()),
174            "{endpoint} pagination cursor did not advance from {cursor:?}",
175        );
176        anyhow::ensure!(
177            self.followed.insert(cursor.clone()),
178            "{endpoint} pagination repeated cursor {cursor:?}",
179        );
180
181        self.last = Some(cursor.clone());
182
183        Ok(Some(cursor))
184    }
185}
186
187/// Legacy conservative Bybit REST quota retained for source compatibility.
188pub static BYBIT_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
189    Quota::per_second(NonZeroU32::new(10).expect("non-zero")).expect("valid constant")
190});
191
192/// Legacy Bybit repayment quota retained for source compatibility.
193pub static BYBIT_REPAY_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
194    Quota::per_second(NonZeroU32::new(1).expect("non-zero")).expect("valid constant")
195});
196
197/// Raw HTTP client for low-level Bybit API operations.
198///
199/// This client handles request/response operations with the Bybit API,
200/// returning venue-specific response types. It does not parse to Nautilus domain types.
201#[cfg_attr(
202    feature = "python",
203    pyo3::pyclass(module = "nautilus_trader.adapters.bybit", from_py_object)
204)]
205#[cfg_attr(
206    feature = "python",
207    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
208)]
209#[derive(Clone)]
210pub struct BybitRawHttpClient {
211    base_url: String,
212    client: Arc<ArcSwap<HttpClient>>,
213    rate_limiter: BybitRateLimiter,
214    credential: Option<Credential>,
215    recv_window_ms: u64,
216    timeout_secs: u64,
217    proxy_url: Option<String>,
218    session_generation: Arc<AtomicU64>,
219    retry_manager: RetryManager<BybitHttpError>,
220    cancellation_token: Arc<parking_lot::Mutex<CancellationToken>>,
221}
222
223impl Default for BybitRawHttpClient {
224    fn default() -> Self {
225        Self::new(None, 60, 3, 1000, 10_000, DEFAULT_RECV_WINDOW_MS, None)
226            .expect("Failed to create default BybitRawHttpClient")
227    }
228}
229
230impl Debug for BybitRawHttpClient {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        f.debug_struct(stringify!(BybitRawHttpClient))
233            .field("base_url", &self.base_url)
234            .field("has_credentials", &self.credential.is_some())
235            .field("recv_window_ms", &self.recv_window_ms)
236            .finish()
237    }
238}
239
240impl BybitRawHttpClient {
241    /// Cancels all pending HTTP requests.
242    pub fn cancel_all_requests(&self) {
243        self.cancellation_token.lock().cancel();
244    }
245
246    /// Replaces the cancelled token with a fresh one so subsequent
247    /// requests are not immediately short-circuited.
248    pub fn reset_cancellation_token(&self) {
249        let mut guard = self.cancellation_token.lock();
250        *guard = CancellationToken::new();
251    }
252
253    /// Returns a clone of the current cancellation token.
254    pub fn cancellation_token(&self) -> CancellationToken {
255        self.cancellation_token.lock().clone()
256    }
257
258    /// Creates a new [`BybitRawHttpClient`] using the default Bybit HTTP URL.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the retry manager cannot be created.
263    pub fn new(
264        base_url: Option<String>,
265        timeout_secs: u64,
266        max_retries: u32,
267        retry_delay_ms: u64,
268        retry_delay_max_ms: u64,
269        recv_window_ms: u64,
270        proxy_url: Option<String>,
271    ) -> Result<Self, BybitHttpError> {
272        let retry_config = RetryConfig {
273            max_retries,
274            initial_delay_ms: retry_delay_ms,
275            max_delay_ms: retry_delay_max_ms,
276            backoff_factor: 2.0,
277            jitter_ms: 1000,
278            operation_timeout_ms: Some(60_000),
279            immediate_first: false,
280            max_elapsed_ms: Some(180_000),
281        };
282
283        let retry_manager = RetryManager::new(retry_config);
284        let base_url =
285            base_url.unwrap_or_else(|| bybit_http_base_url(BybitEnvironment::Mainnet).to_string());
286        let rate_limiter = BybitRateLimiter::for_http(&base_url, None, proxy_url.as_deref());
287        let client = Self::build_http_client(timeout_secs, proxy_url.clone())?;
288        let session_generation = rate_limiter.http_session_generation();
289
290        Ok(Self {
291            base_url,
292            client: Arc::new(ArcSwap::from_pointee(client)),
293            rate_limiter,
294            credential: None,
295            recv_window_ms,
296            timeout_secs,
297            proxy_url,
298            session_generation: Arc::new(AtomicU64::new(session_generation)),
299            retry_manager,
300            cancellation_token: Arc::new(parking_lot::Mutex::new(CancellationToken::new())),
301        })
302    }
303
304    /// Creates a new [`BybitRawHttpClient`] configured with credentials.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the HTTP client cannot be created.
309    #[expect(clippy::too_many_arguments)]
310    pub fn with_credentials(
311        api_key: String,
312        api_secret: String,
313        base_url: Option<String>,
314        timeout_secs: u64,
315        max_retries: u32,
316        retry_delay_ms: u64,
317        retry_delay_max_ms: u64,
318        recv_window_ms: u64,
319        proxy_url: Option<String>,
320    ) -> Result<Self, BybitHttpError> {
321        let retry_config = RetryConfig {
322            max_retries,
323            initial_delay_ms: retry_delay_ms,
324            max_delay_ms: retry_delay_max_ms,
325            backoff_factor: 2.0,
326            jitter_ms: 1000,
327            operation_timeout_ms: Some(60_000),
328            immediate_first: false,
329            max_elapsed_ms: Some(180_000),
330        };
331
332        let retry_manager = RetryManager::new(retry_config);
333        let base_url =
334            base_url.unwrap_or_else(|| bybit_http_base_url(BybitEnvironment::Mainnet).to_string());
335        let credential = Credential::new(api_key, api_secret);
336        let rate_limiter =
337            BybitRateLimiter::for_http(&base_url, Some(credential.api_key()), proxy_url.as_deref());
338        let client = Self::build_http_client(timeout_secs, proxy_url.clone())?;
339        let session_generation = rate_limiter.http_session_generation();
340
341        Ok(Self {
342            base_url,
343            client: Arc::new(ArcSwap::from_pointee(client)),
344            rate_limiter,
345            credential: Some(credential),
346            recv_window_ms,
347            timeout_secs,
348            proxy_url,
349            session_generation: Arc::new(AtomicU64::new(session_generation)),
350            retry_manager,
351            cancellation_token: Arc::new(parking_lot::Mutex::new(CancellationToken::new())),
352        })
353    }
354
355    /// Creates a new [`BybitRawHttpClient`] with environment variable credential resolution.
356    ///
357    /// If `api_key` or `api_secret` are not provided, they will be loaded from
358    /// environment variables based on the environment flags:
359    /// - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
360    /// - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
361    /// - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if the HTTP client cannot be created.
366    #[expect(clippy::too_many_arguments)]
367    pub fn new_with_env(
368        api_key: Option<String>,
369        api_secret: Option<String>,
370        base_url: Option<String>,
371        demo: bool,
372        testnet: bool,
373        timeout_secs: u64,
374        max_retries: u32,
375        retry_delay_ms: u64,
376        retry_delay_max_ms: u64,
377        recv_window_ms: u64,
378        proxy_url: Option<String>,
379    ) -> Result<Self, BybitHttpError> {
380        let environment = if demo {
381            BybitEnvironment::Demo
382        } else if testnet {
383            BybitEnvironment::Testnet
384        } else {
385            BybitEnvironment::Mainnet
386        };
387        let base_url =
388            Some(base_url.unwrap_or_else(|| bybit_http_base_url(environment).to_string()));
389        let (key_var, secret_var) = credential_env_vars(environment);
390        let key = get_or_env_var_opt(api_key, key_var);
391        let secret = get_or_env_var_opt(api_secret, secret_var);
392
393        if let (Some(k), Some(s)) = (key, secret) {
394            Self::with_credentials(
395                k,
396                s,
397                base_url,
398                timeout_secs,
399                max_retries,
400                retry_delay_ms,
401                retry_delay_max_ms,
402                recv_window_ms,
403                proxy_url,
404            )
405        } else {
406            Self::new(
407                base_url,
408                timeout_secs,
409                max_retries,
410                retry_delay_ms,
411                retry_delay_max_ms,
412                recv_window_ms,
413                proxy_url,
414            )
415        }
416    }
417
418    fn default_headers() -> HashMap<String, String> {
419        let mut headers: HashMap<String, String> =
420            create_standard_nautilus_headers().into_iter().collect();
421        headers.insert(
422            "X-Referer".to_string(),
423            BYBIT_NAUTILUS_BROKER_ID.to_string(),
424        );
425        headers
426    }
427
428    fn build_http_client(
429        timeout_secs: u64,
430        proxy_url: Option<String>,
431    ) -> Result<HttpClient, BybitHttpError> {
432        HttpClient::builder()
433            .redirect_policy(HttpRedirectPolicy::Reject)
434            .headers(Self::default_headers())
435            .header_keys(vec![
436                BYBIT_RATE_LIMIT_HEADER.to_string(),
437                BYBIT_RATE_LIMIT_STATUS_HEADER.to_string(),
438                BYBIT_RATE_LIMIT_RESET_HEADER.to_string(),
439            ])
440            .timeout_secs(timeout_secs)
441            .maybe_proxy_url(proxy_url)
442            .rate_limiters(Vec::new())
443            .build()
444            .map_err(|e| BybitHttpError::NetworkError(format!("Failed to create HTTP client: {e}")))
445    }
446
447    fn refresh_http_session(&self, generation: u64) -> Result<(), BybitHttpError> {
448        let current = self.session_generation.load(Ordering::Acquire);
449        if current == generation {
450            return Ok(());
451        }
452
453        let client = Self::build_http_client(self.timeout_secs, self.proxy_url.clone())?;
454        self.client.store(Arc::new(client));
455        self.session_generation.store(generation, Ordering::Release);
456        Ok(())
457    }
458
459    fn request_rate_limit(
460        endpoint: &str,
461        payload: Option<&str>,
462    ) -> (Option<BybitProductType>, u32) {
463        let category = category_from_payload(payload);
464        let weight = if endpoint.ends_with("-batch") {
465            let order_count = payload
466                .and_then(|payload| serde_json::from_str::<serde_json::Value>(payload).ok())
467                .and_then(|value| value.get("request")?.as_array().map(Vec::len))
468                .unwrap_or(1);
469            category.map_or(1, |category| batch_weight(category, order_count))
470        } else {
471            1
472        };
473        (category, weight)
474    }
475
476    fn observe_rate_limit_headers(
477        &self,
478        endpoint: &str,
479        category: Option<BybitProductType>,
480        headers: &HashMap<String, String>,
481    ) {
482        let Some(limit) = headers
483            .get(BYBIT_RATE_LIMIT_HEADER)
484            .and_then(|value| value.parse::<u32>().ok())
485        else {
486            return;
487        };
488        let Some(remaining) = headers
489            .get(BYBIT_RATE_LIMIT_STATUS_HEADER)
490            .and_then(|value| value.parse::<u32>().ok())
491        else {
492            return;
493        };
494        let reset_timestamp_ms = headers
495            .get(BYBIT_RATE_LIMIT_RESET_HEADER)
496            .and_then(|value| value.parse::<i64>().ok());
497        self.rate_limiter
498            .observe_account(endpoint, category, limit, remaining, reset_timestamp_ms);
499    }
500
501    fn is_rate_limit_403(status: u16, body: &str) -> bool {
502        status == 403 && body.to_ascii_lowercase().contains("access too frequent")
503    }
504
505    fn sign_request(
506        &self,
507        timestamp: &str,
508        params: Option<&str>,
509    ) -> Result<HashMap<String, String>, BybitHttpError> {
510        let credential = self
511            .credential
512            .as_ref()
513            .ok_or(BybitHttpError::MissingCredentials)?;
514
515        let signature = credential.sign_with_payload(timestamp, self.recv_window_ms, params);
516
517        let mut headers = HashMap::new();
518        headers.insert(
519            "X-BAPI-API-KEY".to_string(),
520            credential.api_key().to_string(),
521        );
522        headers.insert("X-BAPI-TIMESTAMP".to_string(), timestamp.to_string());
523        headers.insert("X-BAPI-SIGN".to_string(), signature);
524        headers.insert(
525            "X-BAPI-RECV-WINDOW".to_string(),
526            self.recv_window_ms.to_string(),
527        );
528
529        Ok(headers)
530    }
531
532    async fn send_request<T: DeserializeOwned + BybitResponseCheck, P: Serialize>(
533        &self,
534        method: Method,
535        endpoint: &str,
536        params: Option<&P>,
537        body: Option<Vec<u8>>,
538        authenticate: bool,
539    ) -> Result<T, BybitHttpError> {
540        let endpoint = endpoint.to_string();
541        let url = format!("{}{endpoint}", self.base_url);
542        let method_clone = method.clone();
543        let body_clone = body.clone();
544
545        // Serialize params before closure to avoid reference lifetime issues
546        let params_str = if method == Method::GET {
547            params
548                .map(serde_urlencoded::to_string)
549                .transpose()
550                .map_err(|e| {
551                    BybitHttpError::JsonError(format!("Failed to serialize params: {e}"))
552                })?
553        } else {
554            None
555        };
556
557        let operation = || {
558            let url = url.clone();
559            let method = method_clone.clone();
560            let body = body_clone.clone();
561            let endpoint = endpoint.clone();
562            let params_str = params_str.clone();
563
564            async move {
565                let full_url = if let Some(ref query) = params_str {
566                    if query.is_empty() {
567                        url
568                    } else {
569                        format!("{url}?{query}")
570                    }
571                } else {
572                    url
573                };
574
575                let sign_payload = if method == Method::GET {
576                    params_str.as_deref()
577                } else {
578                    body.as_ref()
579                        .and_then(|body| std::str::from_utf8(body).ok())
580                };
581                let (category, weight) = Self::request_rate_limit(&endpoint, sign_payload);
582
583                let generation = self.rate_limiter.http_session_generation();
584                self.refresh_http_session(generation)?;
585                self.rate_limiter
586                    .acquire_http(&endpoint, category, weight, authenticate)
587                    .await
588                    .map_err(BybitHttpError::ValidationError)?;
589                let generation = self.rate_limiter.http_session_generation();
590                self.refresh_http_session(generation)?;
591
592                let mut headers = Self::default_headers();
593
594                if authenticate {
595                    let timestamp = get_atomic_clock_realtime().get_time_ms().to_string();
596                    let auth_headers = self.sign_request(&timestamp, sign_payload)?;
597                    headers.extend(auth_headers);
598                }
599
600                if method == Method::POST || method == Method::PUT {
601                    headers.insert("Content-Type".to_string(), "application/json".to_string());
602                }
603
604                let response = self
605                    .client
606                    .load()
607                    .request(method, full_url, None, Some(headers), body, None, None)
608                    .await?;
609
610                self.observe_rate_limit_headers(&endpoint, category, &response.headers);
611
612                if response.status.as_u16() >= 400 {
613                    let body = String::from_utf8_lossy(&response.body).to_string();
614                    if Self::is_rate_limit_403(response.status.as_u16(), &body) {
615                        let generation = self.rate_limiter.reset_http_sessions();
616                        self.refresh_http_session(generation)?;
617                    }
618                    return Err(BybitHttpError::UnexpectedStatus {
619                        status: response.status.as_u16(),
620                        body,
621                    });
622                }
623
624                // Try to deserialize into the target type
625                match serde_json::from_slice::<T>(&response.body) {
626                    Ok(result) => {
627                        // Check for API-level errors
628                        if result.ret_code() != 0 {
629                            return Err(BybitHttpError::BybitError {
630                                error_code: result.ret_code() as i32,
631                                message: result.ret_msg().to_string(),
632                            });
633                        }
634                        Ok(result)
635                    }
636                    Err(json_err) => {
637                        // Deserialization failed - check if it's a Bybit error response
638                        // (error responses often have result: null which fails typed deserialization)
639                        if let Ok(error_check) =
640                            serde_json::from_slice::<BybitErrorCheck>(&response.body)
641                            && error_check.ret_code != 0
642                        {
643                            return Err(BybitHttpError::BybitError {
644                                error_code: error_check.ret_code as i32,
645                                message: error_check.ret_msg,
646                            });
647                        }
648                        // Not a Bybit error, propagate the JSON parse error
649                        Err(json_err.into())
650                    }
651                }
652            }
653        };
654
655        let create_error = |error: RetryError| -> BybitHttpError {
656            match error {
657                RetryError::Canceled => {
658                    BybitHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
659                }
660                error => BybitHttpError::NetworkError(error.to_string()),
661            }
662        };
663
664        let token = self.cancellation_token();
665
666        self.retry_manager
667            .invocation(
668                endpoint.as_str(),
669                operation,
670                should_retry_http,
671                create_error,
672            )
673            .cancellation_token(&token)
674            .execute()
675            .await
676    }
677
678    #[cfg(test)]
679    fn build_path<S: Serialize>(base: &str, params: &S) -> Result<String, BybitHttpError> {
680        let query = serde_urlencoded::to_string(params)
681            .map_err(|e| BybitHttpError::JsonError(e.to_string()))?;
682
683        if query.is_empty() {
684            Ok(base.to_owned())
685        } else {
686            Ok(format!("{base}?{query}"))
687        }
688    }
689
690    /// Fetches the current server time from Bybit.
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if the request fails or the response cannot be parsed.
695    ///
696    /// # References
697    ///
698    /// - <https://bybit-exchange.github.io/docs/v5/market/time>
699    pub async fn get_server_time(&self) -> Result<BybitServerTimeResponse, BybitHttpError> {
700        self.send_request::<_, ()>(Method::GET, "/v5/market/time", None, None, false)
701            .await
702    }
703
704    /// Fetches instrument information from Bybit for a given product category.
705    ///
706    /// # Errors
707    ///
708    /// Returns an error if the request fails or the response cannot be parsed.
709    ///
710    /// # References
711    ///
712    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
713    pub async fn get_instruments<T: DeserializeOwned + BybitResponseCheck>(
714        &self,
715        params: &BybitInstrumentsInfoParams,
716    ) -> Result<T, BybitHttpError> {
717        self.send_request(
718            Method::GET,
719            BYBIT_INSTRUMENTS_INFO,
720            Some(params),
721            None,
722            false,
723        )
724        .await
725    }
726
727    /// Fetches spot instrument information from Bybit.
728    ///
729    /// # Errors
730    ///
731    /// Returns an error if the request fails or the response cannot be parsed.
732    ///
733    /// # References
734    ///
735    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
736    pub async fn get_instruments_spot(
737        &self,
738        params: &BybitInstrumentsInfoParams,
739    ) -> Result<BybitInstrumentSpotResponse, BybitHttpError> {
740        self.get_instruments(params).await
741    }
742
743    /// Fetches linear instrument information from Bybit.
744    ///
745    /// # Errors
746    ///
747    /// Returns an error if the request fails or the response cannot be parsed.
748    ///
749    /// # References
750    ///
751    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
752    pub async fn get_instruments_linear(
753        &self,
754        params: &BybitInstrumentsInfoParams,
755    ) -> Result<BybitInstrumentLinearResponse, BybitHttpError> {
756        self.get_instruments(params).await
757    }
758
759    /// Fetches inverse instrument information from Bybit.
760    ///
761    /// # Errors
762    ///
763    /// Returns an error if the request fails or the response cannot be parsed.
764    ///
765    /// # References
766    ///
767    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
768    pub async fn get_instruments_inverse(
769        &self,
770        params: &BybitInstrumentsInfoParams,
771    ) -> Result<BybitInstrumentInverseResponse, BybitHttpError> {
772        self.get_instruments(params).await
773    }
774
775    /// Fetches option instrument information from Bybit.
776    ///
777    /// # Errors
778    ///
779    /// Returns an error if the request fails or the response cannot be parsed.
780    ///
781    /// # References
782    ///
783    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
784    pub async fn get_instruments_option(
785        &self,
786        params: &BybitInstrumentsInfoParams,
787    ) -> Result<BybitInstrumentOptionResponse, BybitHttpError> {
788        self.get_instruments(params).await
789    }
790
791    /// Fetches kline/candlestick data from Bybit.
792    ///
793    /// # Errors
794    ///
795    /// Returns an error if the request fails or the response cannot be parsed.
796    ///
797    /// # References
798    ///
799    /// - <https://bybit-exchange.github.io/docs/v5/market/kline>
800    pub async fn get_klines(
801        &self,
802        params: &BybitKlinesParams,
803    ) -> Result<BybitKlinesResponse, BybitHttpError> {
804        self.send_request(Method::GET, "/v5/market/kline", Some(params), None, false)
805            .await
806    }
807
808    /// Fetches recent trades from Bybit.
809    ///
810    /// # Errors
811    ///
812    /// Returns an error if the request fails or the response cannot be parsed.
813    ///
814    /// # References
815    ///
816    /// - <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
817    pub async fn get_recent_trades(
818        &self,
819        params: &BybitTradesParams,
820    ) -> Result<BybitTradesResponse, BybitHttpError> {
821        self.send_request(
822            Method::GET,
823            "/v5/market/recent-trade",
824            Some(params),
825            None,
826            false,
827        )
828        .await
829    }
830
831    /// Fetches funding data from Bybit.
832    ///
833    /// # Errors
834    ///
835    /// Returns an error if the request fails or the response cannot be parsed.
836    ///
837    /// # References
838    ///
839    /// - <https://bybit-exchange.github.io/docs/v5/market/history-fund-rate>
840    pub async fn get_funding_history(
841        &self,
842        params: &BybitFundingParams,
843    ) -> Result<BybitFundingResponse, BybitHttpError> {
844        self.send_request(
845            Method::GET,
846            "/v5/market/funding/history",
847            Some(params),
848            None,
849            false,
850        )
851        .await
852    }
853
854    /// Fetches orderbook from Bybit.
855    ///
856    /// # Errors
857    ///
858    /// Returns an error if the request fails or the response cannot be parsed.
859    ///
860    /// # References
861    ///
862    /// - <https://bybit-exchange.github.io/docs/v5/market/orderbook>
863    pub async fn get_orderbook(
864        &self,
865        params: &BybitOrderbookParams,
866    ) -> Result<BybitOrderbookResponse, BybitHttpError> {
867        self.send_request(
868            Method::GET,
869            "/v5/market/orderbook",
870            Some(params),
871            None,
872            false,
873        )
874        .await
875    }
876
877    /// Fetches open orders (requires authentication).
878    ///
879    /// # Errors
880    ///
881    /// Returns an error if the request fails or the response cannot be parsed.
882    ///
883    /// # Panics
884    ///
885    /// Panics if the parameter builder fails (should never happen with valid inputs).
886    ///
887    /// # References
888    ///
889    /// - <https://bybit-exchange.github.io/docs/v5/order/open-order>
890    #[expect(clippy::too_many_arguments)]
891    pub async fn get_open_orders(
892        &self,
893        category: BybitProductType,
894        symbol: Option<String>,
895        base_coin: Option<String>,
896        settle_coin: Option<String>,
897        order_id: Option<String>,
898        order_link_id: Option<String>,
899        open_only: Option<BybitOpenOnly>,
900        order_filter: Option<BybitOrderFilter>,
901        limit: Option<u32>,
902        cursor: Option<String>,
903    ) -> Result<BybitOpenOrdersResponse, BybitHttpError> {
904        let mut builder = BybitOpenOrdersParamsBuilder::default();
905        builder.category(category);
906
907        if let Some(s) = symbol {
908            builder.symbol(s);
909        }
910
911        if let Some(bc) = base_coin {
912            builder.base_coin(bc);
913        }
914
915        if let Some(sc) = settle_coin {
916            builder.settle_coin(sc);
917        }
918
919        if let Some(oi) = order_id {
920            builder.order_id(oi);
921        }
922
923        if let Some(ol) = order_link_id {
924            builder.order_link_id(ol);
925        }
926
927        if let Some(oo) = open_only {
928            builder.open_only(oo);
929        }
930
931        if let Some(of) = order_filter {
932            builder.order_filter(of);
933        }
934
935        if let Some(l) = limit {
936            builder.limit(l);
937        }
938
939        if let Some(c) = cursor {
940            builder.cursor(c);
941        }
942
943        let params = builder
944            .build()
945            .expect("Failed to build BybitOpenOrdersParams");
946
947        self.send_request(Method::GET, BYBIT_ORDER_REALTIME, Some(&params), None, true)
948            .await
949    }
950
951    /// Places a new order (requires authentication).
952    ///
953    /// # Errors
954    ///
955    /// Returns an error if the request fails or the response cannot be parsed.
956    ///
957    /// # References
958    ///
959    /// - <https://bybit-exchange.github.io/docs/v5/order/create-order>
960    pub async fn place_order(
961        &self,
962        request: &serde_json::Value,
963    ) -> Result<BybitPlaceOrderResponse, BybitHttpError> {
964        let body = serde_json::to_vec(request)?;
965        self.send_request::<_, ()>(Method::POST, "/v5/order/create", None, Some(body), true)
966            .await
967    }
968
969    /// Fetches wallet balance (requires authentication).
970    ///
971    /// # Errors
972    ///
973    /// Returns an error if the request fails or the response cannot be parsed.
974    ///
975    /// # References
976    ///
977    /// - <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
978    pub async fn get_wallet_balance(
979        &self,
980        params: &BybitWalletBalanceParams,
981    ) -> Result<BybitWalletBalanceResponse, BybitHttpError> {
982        self.send_request(
983            Method::GET,
984            "/v5/account/wallet-balance",
985            Some(params),
986            None,
987            true,
988        )
989        .await
990    }
991
992    /// Fetches account information (requires authentication).
993    ///
994    /// # Errors
995    ///
996    /// Returns an error if the request fails or the response cannot be parsed.
997    ///
998    /// # References
999    ///
1000    /// - <https://bybit-exchange.github.io/docs/v5/account/account-info>
1001    pub async fn get_account_info(&self) -> Result<BybitAccountInfoResponse, BybitHttpError> {
1002        self.send_request::<_, ()>(Method::GET, "/v5/account/info", None, None, true)
1003            .await
1004    }
1005
1006    /// Fetches account details (requires authentication).
1007    ///
1008    /// # Errors
1009    ///
1010    /// Returns an error if the request fails or the response cannot be parsed.
1011    ///
1012    /// # References
1013    ///
1014    /// - <https://bybit-exchange.github.io/docs/v5/user/apikey-info>
1015    pub async fn get_account_details(&self) -> Result<BybitAccountDetailsResponse, BybitHttpError> {
1016        self.send_request::<_, ()>(Method::GET, "/v5/user/query-api", None, None, true)
1017            .await
1018    }
1019
1020    /// Modifies a sub-account API key (requires authentication).
1021    ///
1022    /// # Errors
1023    ///
1024    /// Returns an error if the request fails or the response cannot be parsed.
1025    ///
1026    /// # References
1027    ///
1028    /// - <https://bybit-exchange.github.io/docs/v5/user/modify-sub-apikey>
1029    pub async fn update_sub_api_key(
1030        &self,
1031        params: &BybitUpdateSubApiParams,
1032    ) -> Result<BybitUpdateSubApiResponse, BybitHttpError> {
1033        let body = serde_json::to_vec(params)?;
1034        self.send_request::<_, ()>(
1035            Method::POST,
1036            "/v5/user/update-sub-api",
1037            None,
1038            Some(body),
1039            true,
1040        )
1041        .await
1042    }
1043
1044    /// Modifies the master API key that issued the request (requires authentication).
1045    ///
1046    /// # Errors
1047    ///
1048    /// Returns an error if the request fails or the response cannot be parsed.
1049    ///
1050    /// # References
1051    ///
1052    /// - <https://bybit-exchange.github.io/docs/v5/user/modify-master-apikey>
1053    pub async fn update_master_api_key(
1054        &self,
1055        params: &BybitUpdateMasterApiParams,
1056    ) -> Result<BybitUpdateMasterApiResponse, BybitHttpError> {
1057        let body = serde_json::to_vec(params)?;
1058        self.send_request::<_, ()>(Method::POST, "/v5/user/update-api", None, Some(body), true)
1059            .await
1060    }
1061
1062    /// Fetches the sub-account list (up to 1000 rows, non-paginated).
1063    ///
1064    /// # Errors
1065    ///
1066    /// Returns an error if the request fails or the response cannot be parsed.
1067    ///
1068    /// # References
1069    ///
1070    /// - <https://bybit-exchange.github.io/docs/v5/user/subuid-list>
1071    pub async fn get_sub_members(&self) -> Result<BybitSubMembersResponse, BybitHttpError> {
1072        self.send_request::<_, ()>(Method::GET, "/v5/user/query-sub-members", None, None, true)
1073            .await
1074    }
1075
1076    /// Fetches a cursor-paginated sub-account list (`/v5/user/submembers`).
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns an error if the request fails or the response cannot be parsed.
1081    ///
1082    /// # References
1083    ///
1084    /// - <https://bybit-exchange.github.io/docs/v5/user/page-subuid>
1085    pub async fn get_sub_members_paged(
1086        &self,
1087        params: &BybitSubMembersPageParams,
1088    ) -> Result<BybitSubMembersPagedResponse, BybitHttpError> {
1089        self.send_request(Method::GET, "/v5/user/submembers", Some(params), None, true)
1090            .await
1091    }
1092
1093    /// Fetches fund-custodial sub-accounts (`/v5/user/escrow_sub_members`).
1094    ///
1095    /// # Errors
1096    ///
1097    /// Returns an error if the request fails or the response cannot be parsed.
1098    ///
1099    /// # References
1100    ///
1101    /// - <https://bybit-exchange.github.io/docs/v5/user/fund-subuid-list>
1102    pub async fn get_escrow_sub_members(
1103        &self,
1104        params: &BybitSubMembersPageParams,
1105    ) -> Result<BybitEscrowSubMembersResponse, BybitHttpError> {
1106        self.send_request(
1107            Method::GET,
1108            "/v5/user/escrow_sub_members",
1109            Some(params),
1110            None,
1111            true,
1112        )
1113        .await
1114    }
1115
1116    /// Fetches all API keys belonging to a given sub-account.
1117    ///
1118    /// # Errors
1119    ///
1120    /// Returns an error if the request fails or the response cannot be parsed.
1121    ///
1122    /// # References
1123    ///
1124    /// - <https://bybit-exchange.github.io/docs/v5/user/list-sub-apikeys>
1125    pub async fn get_sub_api_keys(
1126        &self,
1127        params: &BybitSubApiKeysParams,
1128    ) -> Result<BybitSubApiKeysResponse, BybitHttpError> {
1129        self.send_request(
1130            Method::GET,
1131            "/v5/user/sub-apikeys",
1132            Some(params),
1133            None,
1134            true,
1135        )
1136        .await
1137    }
1138
1139    /// Fetches every sub-account page via `/v5/user/submembers` and returns the
1140    /// flattened list. Walks the cursor until Bybit signals end-of-pages.
1141    ///
1142    /// # Errors
1143    ///
1144    /// Returns an error if any page request fails or the response cannot be parsed.
1145    pub async fn fetch_all_sub_members_paged(
1146        &self,
1147        page_size: Option<u32>,
1148    ) -> Result<Vec<BybitSubMember>, BybitHttpError> {
1149        let mut members = Vec::new();
1150        let mut cursor: Option<String> = None;
1151
1152        loop {
1153            let params = BybitSubMembersPageParams {
1154                page_size,
1155                next_cursor: cursor.take(),
1156            };
1157            let mut page = self.get_sub_members_paged(&params).await?;
1158            let next = page.result.continuation_cursor().map(str::to_owned);
1159            members.append(&mut page.result.sub_members);
1160
1161            match next {
1162                Some(c) => cursor = Some(c),
1163                None => break,
1164            }
1165        }
1166
1167        Ok(members)
1168    }
1169
1170    /// Fetches every fund-custodial page via `/v5/user/escrow_sub_members` and
1171    /// returns the flattened list. Walks the cursor until Bybit signals
1172    /// end-of-pages.
1173    ///
1174    /// # Errors
1175    ///
1176    /// Returns an error if any page request fails or the response cannot be parsed.
1177    pub async fn fetch_all_escrow_sub_members(
1178        &self,
1179        page_size: Option<u32>,
1180    ) -> Result<Vec<BybitSubMember>, BybitHttpError> {
1181        let mut members = Vec::new();
1182        let mut cursor: Option<String> = None;
1183
1184        loop {
1185            let params = BybitSubMembersPageParams {
1186                page_size,
1187                next_cursor: cursor.take(),
1188            };
1189            let mut page = self.get_escrow_sub_members(&params).await?;
1190            let next = page.result.continuation_cursor().map(str::to_owned);
1191            members.append(&mut page.result.sub_members);
1192
1193            match next {
1194                Some(c) => cursor = Some(c),
1195                None => break,
1196            }
1197        }
1198
1199        Ok(members)
1200    }
1201
1202    /// Fetches every page of sub-account API keys for `sub_member_id` and
1203    /// returns the flattened list. Walks the cursor until Bybit signals
1204    /// end-of-pages.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns an error if any page request fails or the response cannot be parsed.
1209    pub async fn fetch_all_sub_api_keys(
1210        &self,
1211        sub_member_id: impl Into<String>,
1212        limit: Option<u32>,
1213    ) -> Result<Vec<BybitSubApiKeyInfo>, BybitHttpError> {
1214        let sub_member_id = sub_member_id.into();
1215        let mut keys = Vec::new();
1216        let mut cursor: Option<String> = None;
1217
1218        loop {
1219            let params = BybitSubApiKeysParams {
1220                sub_member_id: sub_member_id.clone(),
1221                limit,
1222                cursor: cursor.take(),
1223            };
1224            let mut page = self.get_sub_api_keys(&params).await?;
1225            let next = page.result.continuation_cursor().map(str::to_owned);
1226            keys.append(&mut page.result.keys);
1227
1228            match next {
1229                Some(c) => cursor = Some(c),
1230                None => break,
1231            }
1232        }
1233
1234        Ok(keys)
1235    }
1236
1237    /// Fetches trading fee rates for symbols.
1238    ///
1239    /// # Errors
1240    ///
1241    /// Returns an error if the request fails or the response cannot be parsed.
1242    ///
1243    /// # References
1244    ///
1245    /// - <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
1246    pub async fn get_fee_rate(
1247        &self,
1248        params: &BybitFeeRateParams,
1249    ) -> Result<BybitFeeRateResponse, BybitHttpError> {
1250        self.send_request(
1251            Method::GET,
1252            "/v5/account/fee-rate",
1253            Some(params),
1254            None,
1255            true,
1256        )
1257        .await
1258    }
1259
1260    /// Sets the margin mode for the account.
1261    ///
1262    /// # Errors
1263    ///
1264    /// Returns an error if:
1265    /// - Credentials are missing.
1266    /// - The request fails.
1267    /// - The API returns an error.
1268    ///
1269    /// # Panics
1270    ///
1271    /// Panics if required parameters are not provided (should not happen with current implementation).
1272    ///
1273    /// # References
1274    ///
1275    /// - <https://bybit-exchange.github.io/docs/v5/account/set-margin-mode>
1276    pub async fn set_margin_mode(
1277        &self,
1278        margin_mode: BybitMarginMode,
1279    ) -> Result<BybitSetMarginModeResponse, BybitHttpError> {
1280        let params = BybitSetMarginModeParamsBuilder::default()
1281            .set_margin_mode(margin_mode)
1282            .build()
1283            .expect("Failed to build BybitSetMarginModeParams");
1284
1285        let body = serde_json::to_vec(&params)?;
1286        self.send_request::<_, ()>(
1287            Method::POST,
1288            "/v5/account/set-margin-mode",
1289            None,
1290            Some(body),
1291            true,
1292        )
1293        .await
1294    }
1295
1296    /// Sets leverage for a symbol.
1297    ///
1298    /// # Errors
1299    ///
1300    /// Returns an error if:
1301    /// - Credentials are missing.
1302    /// - The request fails.
1303    /// - The API returns an error.
1304    ///
1305    /// # Panics
1306    ///
1307    /// Panics if required parameters are not provided (should not happen with current implementation).
1308    ///
1309    /// # References
1310    ///
1311    /// - <https://bybit-exchange.github.io/docs/v5/position/leverage>
1312    pub async fn set_leverage(
1313        &self,
1314        product_type: BybitProductType,
1315        symbol: &str,
1316        buy_leverage: &str,
1317        sell_leverage: &str,
1318    ) -> Result<BybitSetLeverageResponse, BybitHttpError> {
1319        let params = BybitSetLeverageParamsBuilder::default()
1320            .category(product_type)
1321            .symbol(symbol.to_string())
1322            .buy_leverage(buy_leverage.to_string())
1323            .sell_leverage(sell_leverage.to_string())
1324            .build()
1325            .expect("Failed to build BybitSetLeverageParams");
1326
1327        let body = serde_json::to_vec(&params)?;
1328        self.send_request::<_, ()>(
1329            Method::POST,
1330            "/v5/position/set-leverage",
1331            None,
1332            Some(body),
1333            true,
1334        )
1335        .await
1336    }
1337
1338    /// Switches position mode for a product type.
1339    ///
1340    /// # Errors
1341    ///
1342    /// Returns an error if:
1343    /// - Credentials are missing.
1344    /// - The request fails.
1345    /// - The API returns an error.
1346    ///
1347    /// # Panics
1348    ///
1349    /// Panics if required parameters are not provided (should not happen with current implementation).
1350    ///
1351    /// # References
1352    ///
1353    /// - <https://bybit-exchange.github.io/docs/v5/position/position-mode>
1354    pub async fn switch_mode(
1355        &self,
1356        product_type: BybitProductType,
1357        mode: BybitPositionMode,
1358        symbol: Option<String>,
1359        coin: Option<String>,
1360    ) -> Result<BybitSwitchModeResponse, BybitHttpError> {
1361        let mut builder = BybitSwitchModeParamsBuilder::default();
1362        builder.category(product_type);
1363        builder.mode(mode);
1364
1365        if let Some(s) = symbol {
1366            builder.symbol(s);
1367        }
1368
1369        if let Some(c) = coin {
1370            builder.coin(c);
1371        }
1372
1373        let params = builder
1374            .build()
1375            .expect("Failed to build BybitSwitchModeParams");
1376
1377        let body = serde_json::to_vec(&params)?;
1378        self.send_request::<_, ()>(
1379            Method::POST,
1380            "/v5/position/switch-mode",
1381            None,
1382            Some(body),
1383            true,
1384        )
1385        .await
1386    }
1387
1388    /// Sets trading stop parameters including trailing stops.
1389    ///
1390    /// # Errors
1391    ///
1392    /// Returns an error if:
1393    /// - Credentials are missing.
1394    /// - The request fails.
1395    /// - The API returns an error.
1396    ///
1397    /// # References
1398    ///
1399    /// - <https://bybit-exchange.github.io/docs/v5/position/trading-stop>
1400    pub async fn set_trading_stop(
1401        &self,
1402        params: &BybitSetTradingStopParams,
1403    ) -> Result<BybitSetTradingStopResponse, BybitHttpError> {
1404        let body = serde_json::to_vec(params)?;
1405        self.send_request::<_, ()>(
1406            Method::POST,
1407            "/v5/position/trading-stop",
1408            None,
1409            Some(body),
1410            true,
1411        )
1412        .await
1413    }
1414
1415    /// Manually borrows coins for margin trading.
1416    ///
1417    /// # Errors
1418    ///
1419    /// Returns an error if:
1420    /// - Credentials are missing.
1421    /// - The request fails.
1422    /// - Insufficient collateral for the borrow.
1423    ///
1424    /// # Panics
1425    ///
1426    /// Panics if the parameter builder fails (should never happen with valid inputs).
1427    ///
1428    /// # References
1429    ///
1430    /// - <https://bybit-exchange.github.io/docs/v5/account/borrow>
1431    pub async fn borrow(
1432        &self,
1433        coin: &str,
1434        amount: &str,
1435    ) -> Result<BybitBorrowResponse, BybitHttpError> {
1436        let params = BybitBorrowParamsBuilder::default()
1437            .coin(coin.to_string())
1438            .amount(amount.to_string())
1439            .build()
1440            .expect("Failed to build BybitBorrowParams");
1441
1442        let body = serde_json::to_vec(&params)?;
1443        self.send_request::<_, ()>(Method::POST, "/v5/account/borrow", None, Some(body), true)
1444            .await
1445    }
1446
1447    /// Manually repays borrowed coins without asset conversion.
1448    ///
1449    /// # Errors
1450    ///
1451    /// Returns an error if:
1452    /// - Credentials are missing.
1453    /// - The request fails.
1454    /// - Called during the hourly interest-calculation window (mm:04:00-mm:05:30 UTC each hour).
1455    /// - Insufficient spot balance for repayment.
1456    ///
1457    /// # Panics
1458    ///
1459    /// Panics if the parameter builder fails (should never happen with valid inputs).
1460    ///
1461    /// # References
1462    ///
1463    /// - <https://bybit-exchange.github.io/docs/v5/account/no-convert-repay>
1464    pub async fn no_convert_repay(
1465        &self,
1466        coin: &str,
1467        amount: Option<&str>,
1468    ) -> Result<BybitNoConvertRepayResponse, BybitHttpError> {
1469        let mut builder = BybitNoConvertRepayParamsBuilder::default();
1470        builder.coin(coin.to_string());
1471
1472        if let Some(amt) = amount {
1473            builder.amount(amt.to_string());
1474        }
1475
1476        let params = builder
1477            .build()
1478            .expect("Failed to build BybitNoConvertRepayParams");
1479
1480        if let Ok(params_json) = serde_json::to_string(&params) {
1481            log::debug!("Repay request params: {params_json}");
1482        }
1483
1484        let body = serde_json::to_vec(&params)?;
1485        let result = self
1486            .send_request::<_, ()>(
1487                Method::POST,
1488                "/v5/account/no-convert-repay",
1489                None,
1490                Some(body),
1491                true,
1492            )
1493            .await;
1494
1495        if let Err(ref e) = result
1496            && let Ok(params_json) = serde_json::to_string(&params)
1497        {
1498            log::debug!("Repay request failed with params {params_json}: {e}");
1499        }
1500
1501        result
1502    }
1503
1504    /// Manually repays borrowed coins, converting other assets if required.
1505    ///
1506    /// # Errors
1507    ///
1508    /// Returns an error if:
1509    /// - Credentials are missing.
1510    /// - The request fails.
1511    /// - Called during the hourly interest-calculation window (mm:04:00-mm:05:30 UTC each hour).
1512    /// - Insufficient balance for repayment.
1513    ///
1514    /// # Panics
1515    ///
1516    /// Panics if the parameter builder fails (should never happen with valid inputs).
1517    ///
1518    /// # References
1519    ///
1520    /// - <https://bybit-exchange.github.io/docs/v5/account/repay>
1521    pub async fn repay(
1522        &self,
1523        coin: Option<&str>,
1524        amount: Option<&str>,
1525    ) -> Result<BybitRepayResponse, BybitHttpError> {
1526        let mut builder = BybitRepayParamsBuilder::default();
1527
1528        if let Some(coin) = coin {
1529            builder.coin(coin.to_string());
1530        }
1531
1532        if let Some(amt) = amount {
1533            builder.amount(amt.to_string());
1534        }
1535
1536        let params = builder.build().expect("Failed to build BybitRepayParams");
1537
1538        if let Ok(params_json) = serde_json::to_string(&params) {
1539            log::debug!("Repay request params: {params_json}");
1540        }
1541
1542        let body = serde_json::to_vec(&params)?;
1543        let result = self
1544            .send_request::<_, ()>(Method::POST, "/v5/account/repay", None, Some(body), true)
1545            .await;
1546
1547        if let Err(ref e) = result
1548            && let Ok(params_json) = serde_json::to_string(&params)
1549        {
1550            log::debug!("Repay request failed with params {params_json}: {e}");
1551        }
1552
1553        result
1554    }
1555
1556    /// Fetches tickers for market data.
1557    ///
1558    /// # Errors
1559    ///
1560    /// Returns an error if the request fails or the response cannot be parsed.
1561    ///
1562    /// # References
1563    ///
1564    /// - <https://bybit-exchange.github.io/docs/v5/market/tickers>
1565    pub async fn get_tickers<T: DeserializeOwned + BybitResponseCheck>(
1566        &self,
1567        params: &BybitTickersParams,
1568    ) -> Result<T, BybitHttpError> {
1569        self.send_request(Method::GET, "/v5/market/tickers", Some(params), None, false)
1570            .await
1571    }
1572
1573    /// Fetches trade execution history (requires authentication).
1574    ///
1575    /// # Errors
1576    ///
1577    /// Returns an error if the request fails or the response cannot be parsed.
1578    ///
1579    /// # References
1580    ///
1581    /// - <https://bybit-exchange.github.io/docs/v5/order/execution>
1582    pub async fn get_trade_history(
1583        &self,
1584        params: &BybitTradeHistoryParams,
1585    ) -> Result<BybitTradeHistoryResponse, BybitHttpError> {
1586        self.send_request(Method::GET, BYBIT_EXECUTION_LIST, Some(params), None, true)
1587            .await
1588    }
1589
1590    /// Fetches position information (requires authentication).
1591    ///
1592    /// # Errors
1593    ///
1594    /// Returns an error if:
1595    /// - Credentials are missing.
1596    /// - The request fails.
1597    /// - The API returns an error.
1598    ///
1599    /// # References
1600    ///
1601    /// - <https://bybit-exchange.github.io/docs/v5/position>
1602    pub async fn get_positions(
1603        &self,
1604        params: &BybitPositionListParams,
1605    ) -> Result<BybitPositionListResponse, BybitHttpError> {
1606        self.send_request(Method::GET, BYBIT_POSITION_LIST, Some(params), None, true)
1607            .await
1608    }
1609
1610    /// Returns the base URL used for requests.
1611    #[must_use]
1612    pub fn base_url(&self) -> &str {
1613        &self.base_url
1614    }
1615
1616    /// Returns the configured receive window in milliseconds.
1617    #[must_use]
1618    pub fn recv_window_ms(&self) -> u64 {
1619        self.recv_window_ms
1620    }
1621
1622    /// Returns the API credential if configured.
1623    #[must_use]
1624    pub fn credential(&self) -> Option<&Credential> {
1625        self.credential.as_ref()
1626    }
1627}
1628
1629/// Provides a HTTP client for connecting to the [Bybit](https://bybit.com) REST API.
1630#[cfg_attr(
1631    feature = "python",
1632    pyo3::pyclass(module = "nautilus_trader.adapters.bybit", from_py_object)
1633)]
1634#[cfg_attr(
1635    feature = "python",
1636    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bybit")
1637)]
1638/// High-level HTTP client that wraps the raw client and provides Nautilus domain types.
1639///
1640/// This client maintains an instrument cache and uses it to parse venue responses
1641/// into Nautilus domain objects.
1642pub struct BybitHttpClient {
1643    pub(crate) inner: Arc<BybitRawHttpClient>,
1644    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
1645    clock: &'static AtomicTime,
1646    cache_initialized: Arc<AtomicBool>,
1647    use_spot_position_reports: Arc<AtomicBool>,
1648}
1649
1650impl Clone for BybitHttpClient {
1651    fn clone(&self) -> Self {
1652        Self {
1653            inner: self.inner.clone(),
1654            instruments_cache: self.instruments_cache.clone(),
1655            cache_initialized: self.cache_initialized.clone(),
1656            use_spot_position_reports: self.use_spot_position_reports.clone(),
1657            clock: self.clock,
1658        }
1659    }
1660}
1661
1662impl Default for BybitHttpClient {
1663    fn default() -> Self {
1664        Self::new(None, 60, 3, 1000, 10_000, DEFAULT_RECV_WINDOW_MS, None)
1665            .expect("Failed to create default BybitHttpClient")
1666    }
1667}
1668
1669impl Debug for BybitHttpClient {
1670    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1671        f.debug_struct(stringify!(BybitHttpClient))
1672            .field("inner", &self.inner)
1673            .finish()
1674    }
1675}
1676
1677impl BybitHttpClient {
1678    /// Creates a new [`BybitHttpClient`] using the default Bybit HTTP URL.
1679    ///
1680    /// # Errors
1681    ///
1682    /// Returns an error if the retry manager cannot be created.
1683    pub fn new(
1684        base_url: Option<String>,
1685        timeout_secs: u64,
1686        max_retries: u32,
1687        retry_delay_ms: u64,
1688        retry_delay_max_ms: u64,
1689        recv_window_ms: u64,
1690        proxy_url: Option<String>,
1691    ) -> Result<Self, BybitHttpError> {
1692        Ok(Self {
1693            inner: Arc::new(BybitRawHttpClient::new(
1694                base_url,
1695                timeout_secs,
1696                max_retries,
1697                retry_delay_ms,
1698                retry_delay_max_ms,
1699                recv_window_ms,
1700                proxy_url,
1701            )?),
1702            instruments_cache: Arc::new(AtomicMap::new()),
1703            cache_initialized: Arc::new(AtomicBool::new(false)),
1704            use_spot_position_reports: Arc::new(AtomicBool::new(false)),
1705            clock: get_atomic_clock_realtime(),
1706        })
1707    }
1708
1709    /// Creates a new [`BybitHttpClient`] configured with credentials.
1710    ///
1711    /// # Errors
1712    ///
1713    /// Returns an error if the retry manager cannot be created.
1714    #[expect(clippy::too_many_arguments)]
1715    pub fn with_credentials(
1716        api_key: String,
1717        api_secret: String,
1718        base_url: Option<String>,
1719        timeout_secs: u64,
1720        max_retries: u32,
1721        retry_delay_ms: u64,
1722        retry_delay_max_ms: u64,
1723        recv_window_ms: u64,
1724        proxy_url: Option<String>,
1725    ) -> Result<Self, BybitHttpError> {
1726        Ok(Self {
1727            inner: Arc::new(BybitRawHttpClient::with_credentials(
1728                api_key,
1729                api_secret,
1730                base_url,
1731                timeout_secs,
1732                max_retries,
1733                retry_delay_ms,
1734                retry_delay_max_ms,
1735                recv_window_ms,
1736                proxy_url,
1737            )?),
1738            instruments_cache: Arc::new(AtomicMap::new()),
1739            cache_initialized: Arc::new(AtomicBool::new(false)),
1740            use_spot_position_reports: Arc::new(AtomicBool::new(false)),
1741            clock: get_atomic_clock_realtime(),
1742        })
1743    }
1744
1745    /// Creates a new [`BybitHttpClient`] with optional credentials resolved from environment variables.
1746    ///
1747    /// Credentials are resolved in the following order:
1748    /// 1. Use provided `api_key`/`api_secret` if `Some`
1749    /// 2. Fall back to environment variables based on environment:
1750    ///    - Demo: `BYBIT_DEMO_API_KEY`, `BYBIT_DEMO_API_SECRET`
1751    ///    - Testnet: `BYBIT_TESTNET_API_KEY`, `BYBIT_TESTNET_API_SECRET`
1752    ///    - Mainnet: `BYBIT_API_KEY`, `BYBIT_API_SECRET`
1753    ///
1754    /// # Errors
1755    ///
1756    /// Returns an error if the retry manager cannot be created.
1757    #[expect(clippy::too_many_arguments)]
1758    pub fn new_with_env(
1759        api_key: Option<String>,
1760        api_secret: Option<String>,
1761        base_url: Option<String>,
1762        demo: bool,
1763        testnet: bool,
1764        timeout_secs: u64,
1765        max_retries: u32,
1766        retry_delay_ms: u64,
1767        retry_delay_max_ms: u64,
1768        recv_window_ms: u64,
1769        proxy_url: Option<String>,
1770    ) -> Result<Self, BybitHttpError> {
1771        Ok(Self {
1772            inner: Arc::new(BybitRawHttpClient::new_with_env(
1773                api_key,
1774                api_secret,
1775                base_url,
1776                demo,
1777                testnet,
1778                timeout_secs,
1779                max_retries,
1780                retry_delay_ms,
1781                retry_delay_max_ms,
1782                recv_window_ms,
1783                proxy_url,
1784            )?),
1785            instruments_cache: Arc::new(AtomicMap::new()),
1786            cache_initialized: Arc::new(AtomicBool::new(false)),
1787            use_spot_position_reports: Arc::new(AtomicBool::new(false)),
1788            clock: get_atomic_clock_realtime(),
1789        })
1790    }
1791
1792    #[must_use]
1793    pub fn base_url(&self) -> &str {
1794        self.inner.base_url()
1795    }
1796
1797    #[must_use]
1798    pub fn recv_window_ms(&self) -> u64 {
1799        self.inner.recv_window_ms()
1800    }
1801
1802    #[must_use]
1803    pub fn credential(&self) -> Option<&Credential> {
1804        self.inner.credential()
1805    }
1806
1807    pub fn set_use_spot_position_reports(&self, use_spot_position_reports: bool) {
1808        self.use_spot_position_reports
1809            .store(use_spot_position_reports, Ordering::Relaxed);
1810    }
1811
1812    pub fn cancel_all_requests(&self) {
1813        self.inner.cancel_all_requests();
1814    }
1815
1816    pub fn reset_cancellation_token(&self) {
1817        self.inner.reset_cancellation_token();
1818    }
1819
1820    pub fn cancellation_token(&self) -> CancellationToken {
1821        self.inner.cancellation_token()
1822    }
1823
1824    /// Any existing instrument with the same symbol will be replaced.
1825    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1826        self.instruments_cache
1827            .insert(instrument.symbol().inner(), instrument);
1828        self.cache_initialized.store(true, Ordering::Release);
1829    }
1830
1831    /// Any existing instruments with the same symbols will be replaced.
1832    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1833        self.instruments_cache.rcu(|m| {
1834            for instrument in instruments {
1835                m.insert(instrument.symbol().inner(), instrument.clone());
1836            }
1837        });
1838        self.cache_initialized.store(true, Ordering::Release);
1839    }
1840
1841    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1842        self.instruments_cache.get_cloned(symbol)
1843    }
1844
1845    fn instrument_from_cache(&self, symbol: &Symbol) -> anyhow::Result<InstrumentAny> {
1846        self.get_instrument(&symbol.inner()).ok_or_else(|| {
1847            anyhow::anyhow!(
1848                "Instrument {symbol} not found in cache, ensure instruments loaded first"
1849            )
1850        })
1851    }
1852
1853    #[must_use]
1854    fn generate_ts_init(&self) -> UnixNanos {
1855        self.clock.get_time_ns()
1856    }
1857
1858    /// Fetches the current server time from Bybit.
1859    ///
1860    /// # Errors
1861    ///
1862    /// Returns an error if:
1863    /// - The request fails.
1864    /// - The response cannot be parsed.
1865    ///
1866    /// # References
1867    ///
1868    /// - <https://bybit-exchange.github.io/docs/v5/market/time>
1869    pub async fn get_server_time(&self) -> Result<BybitServerTimeResponse, BybitHttpError> {
1870        self.inner.get_server_time().await
1871    }
1872
1873    /// Fetches instrument information from Bybit for a given product category.
1874    ///
1875    /// # Errors
1876    ///
1877    /// Returns an error if:
1878    /// - The request fails.
1879    /// - The response cannot be parsed.
1880    ///
1881    /// # References
1882    ///
1883    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1884    pub async fn get_instruments<T: DeserializeOwned + BybitResponseCheck>(
1885        &self,
1886        params: &BybitInstrumentsInfoParams,
1887    ) -> Result<T, BybitHttpError> {
1888        self.inner.get_instruments(params).await
1889    }
1890
1891    /// Fetches spot instrument information from Bybit.
1892    ///
1893    /// # Errors
1894    ///
1895    /// Returns an error if:
1896    /// - The request fails.
1897    /// - The response cannot be parsed.
1898    ///
1899    /// # References
1900    ///
1901    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1902    pub async fn get_instruments_spot(
1903        &self,
1904        params: &BybitInstrumentsInfoParams,
1905    ) -> Result<BybitInstrumentSpotResponse, BybitHttpError> {
1906        self.inner.get_instruments_spot(params).await
1907    }
1908
1909    /// Fetches linear instrument information from Bybit.
1910    ///
1911    /// # Errors
1912    ///
1913    /// Returns an error if:
1914    /// - The request fails.
1915    /// - The response cannot be parsed.
1916    ///
1917    /// # References
1918    ///
1919    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1920    pub async fn get_instruments_linear(
1921        &self,
1922        params: &BybitInstrumentsInfoParams,
1923    ) -> Result<BybitInstrumentLinearResponse, BybitHttpError> {
1924        self.inner.get_instruments_linear(params).await
1925    }
1926
1927    /// Fetches inverse instrument information from Bybit.
1928    ///
1929    /// # Errors
1930    ///
1931    /// Returns an error if:
1932    /// - The request fails.
1933    /// - The response cannot be parsed.
1934    ///
1935    /// # References
1936    ///
1937    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1938    pub async fn get_instruments_inverse(
1939        &self,
1940        params: &BybitInstrumentsInfoParams,
1941    ) -> Result<BybitInstrumentInverseResponse, BybitHttpError> {
1942        self.inner.get_instruments_inverse(params).await
1943    }
1944
1945    /// Fetches option instrument information from Bybit.
1946    ///
1947    /// # Errors
1948    ///
1949    /// Returns an error if:
1950    /// - The request fails.
1951    /// - The response cannot be parsed.
1952    ///
1953    /// # References
1954    ///
1955    /// - <https://bybit-exchange.github.io/docs/v5/market/instrument>
1956    pub async fn get_instruments_option(
1957        &self,
1958        params: &BybitInstrumentsInfoParams,
1959    ) -> Result<BybitInstrumentOptionResponse, BybitHttpError> {
1960        self.inner.get_instruments_option(params).await
1961    }
1962
1963    /// Fetches kline/candlestick data from Bybit.
1964    ///
1965    /// # Errors
1966    ///
1967    /// Returns an error if:
1968    /// - The request fails.
1969    /// - The response cannot be parsed.
1970    ///
1971    /// # References
1972    ///
1973    /// - <https://bybit-exchange.github.io/docs/v5/market/kline>
1974    pub async fn get_klines(
1975        &self,
1976        params: &BybitKlinesParams,
1977    ) -> Result<BybitKlinesResponse, BybitHttpError> {
1978        self.inner.get_klines(params).await
1979    }
1980
1981    /// Fetches recent trades from Bybit.
1982    ///
1983    /// # Errors
1984    ///
1985    /// Returns an error if:
1986    /// - The request fails.
1987    /// - The response cannot be parsed.
1988    ///
1989    /// # References
1990    ///
1991    /// - <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
1992    pub async fn get_recent_trades(
1993        &self,
1994        params: &BybitTradesParams,
1995    ) -> Result<BybitTradesResponse, BybitHttpError> {
1996        self.inner.get_recent_trades(params).await
1997    }
1998
1999    /// Fetches open orders (requires authentication).
2000    ///
2001    /// # Errors
2002    ///
2003    /// Returns an error if:
2004    /// - The request fails.
2005    /// - The response cannot be parsed.
2006    ///
2007    /// # References
2008    ///
2009    /// - <https://bybit-exchange.github.io/docs/v5/order/open-order>
2010    #[expect(clippy::too_many_arguments)]
2011    pub async fn get_open_orders(
2012        &self,
2013        category: BybitProductType,
2014        symbol: Option<String>,
2015        base_coin: Option<String>,
2016        settle_coin: Option<String>,
2017        order_id: Option<String>,
2018        order_link_id: Option<String>,
2019        open_only: Option<BybitOpenOnly>,
2020        order_filter: Option<BybitOrderFilter>,
2021        limit: Option<u32>,
2022        cursor: Option<String>,
2023    ) -> Result<BybitOpenOrdersResponse, BybitHttpError> {
2024        self.inner
2025            .get_open_orders(
2026                category,
2027                symbol,
2028                base_coin,
2029                settle_coin,
2030                order_id,
2031                order_link_id,
2032                open_only,
2033                order_filter,
2034                limit,
2035                cursor,
2036            )
2037            .await
2038    }
2039
2040    /// Places a new order (requires authentication).
2041    ///
2042    /// # Errors
2043    ///
2044    /// Returns an error if:
2045    /// - The request fails.
2046    /// - The response cannot be parsed.
2047    ///
2048    /// # References
2049    ///
2050    /// - <https://bybit-exchange.github.io/docs/v5/order/create-order>
2051    pub async fn place_order(
2052        &self,
2053        request: &serde_json::Value,
2054    ) -> Result<BybitPlaceOrderResponse, BybitHttpError> {
2055        self.inner.place_order(request).await
2056    }
2057
2058    /// Fetches wallet balance (requires authentication).
2059    ///
2060    /// # Errors
2061    ///
2062    /// Returns an error if:
2063    /// - The request fails.
2064    /// - The response cannot be parsed.
2065    ///
2066    /// # References
2067    ///
2068    /// - <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
2069    pub async fn get_wallet_balance(
2070        &self,
2071        params: &BybitWalletBalanceParams,
2072    ) -> Result<BybitWalletBalanceResponse, BybitHttpError> {
2073        self.inner.get_wallet_balance(params).await
2074    }
2075
2076    /// Fetches account information (requires authentication).
2077    ///
2078    /// # Errors
2079    ///
2080    /// Returns an error if:
2081    /// - The request fails.
2082    /// - The response cannot be parsed.
2083    ///
2084    /// # References
2085    ///
2086    /// - <https://bybit-exchange.github.io/docs/v5/account/account-info>
2087    pub async fn get_account_info(&self) -> Result<BybitAccountInfoResponse, BybitHttpError> {
2088        self.inner.get_account_info().await
2089    }
2090
2091    /// Fetches API key information including account details (requires authentication).
2092    ///
2093    /// # Errors
2094    ///
2095    /// Returns an error if:
2096    /// - The request fails.
2097    /// - The response cannot be parsed.
2098    ///
2099    /// # References
2100    ///
2101    /// - <https://bybit-exchange.github.io/docs/v5/user/apikey-info>
2102    pub async fn get_account_details(&self) -> Result<BybitAccountDetailsResponse, BybitHttpError> {
2103        self.inner.get_account_details().await
2104    }
2105
2106    /// Modifies a sub-account API key (requires authentication).
2107    ///
2108    /// # Errors
2109    ///
2110    /// Returns an error if:
2111    /// - The request fails.
2112    /// - The response cannot be parsed.
2113    ///
2114    /// # References
2115    ///
2116    /// - <https://bybit-exchange.github.io/docs/v5/user/modify-sub-apikey>
2117    pub async fn update_sub_api_key(
2118        &self,
2119        params: &BybitUpdateSubApiParams,
2120    ) -> Result<BybitUpdateSubApiResponse, BybitHttpError> {
2121        self.inner.update_sub_api_key(params).await
2122    }
2123
2124    /// Modifies the master API key that issued the request (requires authentication).
2125    ///
2126    /// # Errors
2127    ///
2128    /// Returns an error if:
2129    /// - The request fails.
2130    /// - The response cannot be parsed.
2131    ///
2132    /// # References
2133    ///
2134    /// - <https://bybit-exchange.github.io/docs/v5/user/modify-master-apikey>
2135    pub async fn update_master_api_key(
2136        &self,
2137        params: &BybitUpdateMasterApiParams,
2138    ) -> Result<BybitUpdateMasterApiResponse, BybitHttpError> {
2139        self.inner.update_master_api_key(params).await
2140    }
2141
2142    /// Fetches the sub-account list (up to 1000 rows, non-paginated).
2143    ///
2144    /// # Errors
2145    ///
2146    /// Returns an error if:
2147    /// - The request fails.
2148    /// - The response cannot be parsed.
2149    ///
2150    /// # References
2151    ///
2152    /// - <https://bybit-exchange.github.io/docs/v5/user/subuid-list>
2153    pub async fn get_sub_members(&self) -> Result<BybitSubMembersResponse, BybitHttpError> {
2154        self.inner.get_sub_members().await
2155    }
2156
2157    /// Fetches a cursor-paginated sub-account list.
2158    ///
2159    /// # Errors
2160    ///
2161    /// Returns an error if:
2162    /// - The request fails.
2163    /// - The response cannot be parsed.
2164    ///
2165    /// # References
2166    ///
2167    /// - <https://bybit-exchange.github.io/docs/v5/user/page-subuid>
2168    pub async fn get_sub_members_paged(
2169        &self,
2170        params: &BybitSubMembersPageParams,
2171    ) -> Result<BybitSubMembersPagedResponse, BybitHttpError> {
2172        self.inner.get_sub_members_paged(params).await
2173    }
2174
2175    /// Fetches fund-custodial sub-accounts.
2176    ///
2177    /// # Errors
2178    ///
2179    /// Returns an error if:
2180    /// - The request fails.
2181    /// - The response cannot be parsed.
2182    ///
2183    /// # References
2184    ///
2185    /// - <https://bybit-exchange.github.io/docs/v5/user/fund-subuid-list>
2186    pub async fn get_escrow_sub_members(
2187        &self,
2188        params: &BybitSubMembersPageParams,
2189    ) -> Result<BybitEscrowSubMembersResponse, BybitHttpError> {
2190        self.inner.get_escrow_sub_members(params).await
2191    }
2192
2193    /// Fetches all API keys belonging to a given sub-account.
2194    ///
2195    /// # Errors
2196    ///
2197    /// Returns an error if:
2198    /// - The request fails.
2199    /// - The response cannot be parsed.
2200    ///
2201    /// # References
2202    ///
2203    /// - <https://bybit-exchange.github.io/docs/v5/user/list-sub-apikeys>
2204    pub async fn get_sub_api_keys(
2205        &self,
2206        params: &BybitSubApiKeysParams,
2207    ) -> Result<BybitSubApiKeysResponse, BybitHttpError> {
2208        self.inner.get_sub_api_keys(params).await
2209    }
2210
2211    /// Fetches position information (requires authentication).
2212    ///
2213    /// # Errors
2214    ///
2215    /// Returns an error if:
2216    /// - Credentials are missing.
2217    /// - The request fails.
2218    /// - The API returns an error.
2219    ///
2220    /// # References
2221    ///
2222    /// - <https://bybit-exchange.github.io/docs/v5/position>
2223    pub async fn get_positions(
2224        &self,
2225        params: &BybitPositionListParams,
2226    ) -> Result<BybitPositionListResponse, BybitHttpError> {
2227        self.inner.get_positions(params).await
2228    }
2229
2230    /// Fetches fee rate (requires authentication).
2231    ///
2232    /// # Errors
2233    ///
2234    /// Returns an error if:
2235    /// - Credentials are missing.
2236    /// - The request fails.
2237    /// - The API returns an error.
2238    ///
2239    /// # References
2240    ///
2241    /// - <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
2242    pub async fn get_fee_rate(
2243        &self,
2244        params: &BybitFeeRateParams,
2245    ) -> Result<BybitFeeRateResponse, BybitHttpError> {
2246        self.inner.get_fee_rate(params).await
2247    }
2248
2249    /// Sets margin mode (requires authentication).
2250    ///
2251    /// # Errors
2252    ///
2253    /// Returns an error if:
2254    /// - Credentials are missing.
2255    /// - The request fails.
2256    /// - The API returns an error.
2257    ///
2258    /// # References
2259    ///
2260    /// - <https://bybit-exchange.github.io/docs/v5/account/set-margin-mode>
2261    pub async fn set_margin_mode(
2262        &self,
2263        margin_mode: BybitMarginMode,
2264    ) -> Result<BybitSetMarginModeResponse, BybitHttpError> {
2265        self.inner.set_margin_mode(margin_mode).await
2266    }
2267
2268    /// Sets leverage for a symbol (requires authentication).
2269    ///
2270    /// # Errors
2271    ///
2272    /// Returns an error if:
2273    /// - Credentials are missing.
2274    /// - The request fails.
2275    /// - The API returns an error.
2276    ///
2277    /// # References
2278    ///
2279    /// - <https://bybit-exchange.github.io/docs/v5/position/leverage>
2280    pub async fn set_leverage(
2281        &self,
2282        product_type: BybitProductType,
2283        symbol: &str,
2284        buy_leverage: &str,
2285        sell_leverage: &str,
2286    ) -> Result<BybitSetLeverageResponse, BybitHttpError> {
2287        self.inner
2288            .set_leverage(product_type, symbol, buy_leverage, sell_leverage)
2289            .await
2290    }
2291
2292    /// Switches position mode (requires authentication).
2293    ///
2294    /// # Errors
2295    ///
2296    /// Returns an error if:
2297    /// - Credentials are missing.
2298    /// - The request fails.
2299    /// - The API returns an error.
2300    ///
2301    /// # References
2302    ///
2303    /// - <https://bybit-exchange.github.io/docs/v5/position/position-mode>
2304    pub async fn switch_mode(
2305        &self,
2306        product_type: BybitProductType,
2307        mode: BybitPositionMode,
2308        symbol: Option<String>,
2309        coin: Option<String>,
2310    ) -> Result<BybitSwitchModeResponse, BybitHttpError> {
2311        self.inner
2312            .switch_mode(product_type, mode, symbol, coin)
2313            .await
2314    }
2315
2316    /// Sets trading stop parameters including trailing stops (requires authentication).
2317    ///
2318    /// # Errors
2319    ///
2320    /// Returns an error if:
2321    /// - Credentials are missing.
2322    /// - The request fails.
2323    /// - The API returns an error.
2324    ///
2325    /// # References
2326    ///
2327    /// - <https://bybit-exchange.github.io/docs/v5/position/trading-stop>
2328    pub async fn set_trading_stop(
2329        &self,
2330        params: &BybitSetTradingStopParams,
2331    ) -> Result<BybitSetTradingStopResponse, BybitHttpError> {
2332        self.inner.set_trading_stop(params).await
2333    }
2334
2335    /// Get the outstanding spot borrow amount for a specific coin.
2336    ///
2337    /// Returns zero if no borrow exists.
2338    ///
2339    /// # Parameters
2340    ///
2341    /// - `coin`: The coin to check (e.g., "BTC", "ETH")
2342    ///
2343    /// # Errors
2344    ///
2345    /// Returns an error if:
2346    /// - Credentials are missing.
2347    /// - The request fails.
2348    /// - The coin is not found in the wallet.
2349    pub async fn get_spot_borrow_amount(&self, coin: &str) -> anyhow::Result<Decimal> {
2350        let params = BybitWalletBalanceParams {
2351            account_type: BybitAccountType::Unified,
2352            coin: Some(coin.to_string()),
2353        };
2354
2355        let response = self.inner.get_wallet_balance(&params).await?;
2356
2357        let borrow_amount = response
2358            .result
2359            .list
2360            .first()
2361            .and_then(|wallet| wallet.coin.iter().find(|c| c.coin == coin))
2362            .map_or(Decimal::ZERO, |balance| balance.spot_borrow);
2363
2364        Ok(borrow_amount)
2365    }
2366
2367    /// Borrows coins for spot margin trading.
2368    ///
2369    /// This should be called before opening short spot positions.
2370    ///
2371    /// # Parameters
2372    ///
2373    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
2374    /// - `amount`: Optional amount to borrow. If None, repays all outstanding borrows.
2375    ///
2376    /// # Errors
2377    ///
2378    /// Returns an error if:
2379    /// - Credentials are missing.
2380    /// - The request fails.
2381    /// - Insufficient collateral for the borrow.
2382    pub async fn borrow_spot(
2383        &self,
2384        coin: &str,
2385        amount: Quantity,
2386    ) -> anyhow::Result<BybitBorrowResponse> {
2387        let amount_str = amount.to_string();
2388        self.inner
2389            .borrow(coin, &amount_str)
2390            .await
2391            .map_err(|e| anyhow::anyhow!("Failed to borrow {amount} {coin}: {e}"))
2392    }
2393
2394    /// Repays spot borrows for a specific coin.
2395    ///
2396    /// This should be called after closing short spot positions to avoid accruing interest.
2397    ///
2398    /// # Parameters
2399    ///
2400    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
2401    /// - `amount`: Optional amount to repay. If None, repays all outstanding borrows.
2402    ///
2403    /// # Errors
2404    ///
2405    /// Returns an error if:
2406    /// - Credentials are missing.
2407    /// - The request fails.
2408    /// - Called during the hourly interest-calculation window (mm:04:00-mm:05:30 UTC each hour).
2409    /// - Insufficient spot balance for repayment.
2410    pub async fn repay_spot_borrow(
2411        &self,
2412        coin: &str,
2413        amount: Option<Quantity>,
2414    ) -> anyhow::Result<BybitNoConvertRepayResponse> {
2415        let amount_str = amount.as_ref().map(|q| q.to_string());
2416        let response = self
2417            .inner
2418            .no_convert_repay(coin, amount_str.as_deref())
2419            .await
2420            .map_err(|e| anyhow::anyhow!("Failed to repay spot borrow for {coin}: {e}"))?;
2421        Self::ensure_repay_accepted(coin, response.result.result_status)?;
2422        Ok(response)
2423    }
2424
2425    /// Repays spot borrows for a specific coin, converting other assets if required.
2426    ///
2427    /// Unlike [`Self::repay_spot_borrow`], this uses the venue's manual repay endpoint,
2428    /// which may draw on other holdings when the debt coin's spot balance is insufficient.
2429    ///
2430    /// # Parameters
2431    ///
2432    /// - `coin`: The coin to repay (e.g., "BTC", "ETH")
2433    /// - `amount`: Optional amount to repay. If None, repays all outstanding borrows.
2434    ///
2435    /// # Errors
2436    ///
2437    /// Returns an error if:
2438    /// - Credentials are missing.
2439    /// - The request fails.
2440    /// - Called during the hourly interest-calculation window (mm:04:00-mm:05:30 UTC each hour).
2441    /// - Insufficient balance for repayment.
2442    pub async fn repay_spot_borrow_with_conversion(
2443        &self,
2444        coin: &str,
2445        amount: Option<Quantity>,
2446    ) -> anyhow::Result<BybitRepayResponse> {
2447        let amount_str = amount.as_ref().map(|q| q.to_string());
2448        let response = self
2449            .inner
2450            .repay(Some(coin), amount_str.as_deref())
2451            .await
2452            .map_err(|e| {
2453                anyhow::anyhow!("Failed to repay spot borrow (with conversion) for {coin}: {e}")
2454            })?;
2455        Self::ensure_repay_accepted(coin, response.result.result_status)?;
2456        Ok(response)
2457    }
2458
2459    fn ensure_repay_accepted(coin: &str, status: BybitRepayStatus) -> anyhow::Result<()> {
2460        anyhow::ensure!(
2461            status != BybitRepayStatus::Failed,
2462            "Bybit repay for {coin} returned result status {status}"
2463        );
2464        Ok(())
2465    }
2466
2467    /// Generate SPOT position reports from wallet balances.
2468    ///
2469    /// # Errors
2470    ///
2471    /// Returns an error if:
2472    /// - The wallet balance request fails.
2473    /// - Parsing fails.
2474    async fn generate_spot_position_reports_from_wallet(
2475        &self,
2476        account_id: AccountId,
2477        instrument_id: InstrumentId,
2478    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2479        let params = BybitWalletBalanceParams {
2480            account_type: BybitAccountType::Unified,
2481            coin: None,
2482        };
2483
2484        let response = self.inner.get_wallet_balance(&params).await?;
2485        let ts_init = self.generate_ts_init();
2486
2487        let mut wallet_by_coin: HashMap<Ustr, Decimal> = HashMap::new();
2488
2489        for wallet in &response.result.list {
2490            for coin_balance in &wallet.coin {
2491                let balance = coin_balance.wallet_balance - coin_balance.spot_borrow;
2492                *wallet_by_coin
2493                    .entry(coin_balance.coin)
2494                    .or_insert(Decimal::ZERO) += balance;
2495            }
2496        }
2497
2498        let mut reports = Vec::new();
2499
2500        if let Some(instrument) = self
2501            .instruments_cache
2502            .get_cloned(&instrument_id.symbol.inner())
2503        {
2504            let base_currency = instrument
2505                .base_currency()
2506                .expect("SPOT instrument should have base currency");
2507            let coin = base_currency.code;
2508            let wallet_balance = wallet_by_coin.get(&coin).copied().unwrap_or(Decimal::ZERO);
2509
2510            let side = if wallet_balance > Decimal::ZERO {
2511                PositionSide::Long
2512            } else if wallet_balance < Decimal::ZERO {
2513                PositionSide::Short
2514            } else {
2515                PositionSide::Flat
2516            };
2517
2518            let abs_balance = wallet_balance.abs();
2519            let quantity = Quantity::from_decimal_dp(abs_balance, instrument.size_precision())?;
2520
2521            let report = PositionStatusReport::new(
2522                account_id,
2523                instrument_id,
2524                side,
2525                quantity,
2526                ts_init,
2527                ts_init,
2528                None,
2529                None,
2530                None,
2531            );
2532
2533            reports.push(report);
2534        }
2535
2536        Ok(reports)
2537    }
2538
2539    /// Submit a new order.
2540    ///
2541    /// # Errors
2542    ///
2543    /// Returns an error if:
2544    /// - Credentials are missing.
2545    /// - The request fails.
2546    /// - Order validation fails.
2547    /// - The order is rejected.
2548    /// - The API returns an error.
2549    #[expect(clippy::too_many_arguments)]
2550    pub async fn submit_order(
2551        &self,
2552        account_id: AccountId,
2553        product_type: BybitProductType,
2554        instrument_id: InstrumentId,
2555        client_order_id: ClientOrderId,
2556        order_side: OrderSide,
2557        order_type: OrderType,
2558        quantity: Quantity,
2559        time_in_force: Option<TimeInForce>,
2560        price: Option<Price>,
2561        trigger_price: Option<Price>,
2562        post_only: Option<bool>,
2563        reduce_only: bool,
2564        is_quote_quantity: bool,
2565        is_leverage: bool,
2566        position_idx: Option<BybitPositionIdx>,
2567        bbo_side_type: Option<BybitBboSideType>,
2568        bbo_level: Option<String>,
2569        smp_type: Option<BybitOrderSmpType>,
2570        native_tp_sl: Option<&BybitNativeTpSlParams>,
2571    ) -> anyhow::Result<OrderStatusReport> {
2572        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2573        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2574
2575        let bybit_side = match order_side {
2576            OrderSide::Buy => BybitOrderSide::Buy,
2577            OrderSide::Sell => BybitOrderSide::Sell,
2578        };
2579
2580        // For stop/conditional orders, Bybit uses Market/Limit with trigger parameters
2581        let (bybit_order_type, is_stop_order) = match order_type {
2582            OrderType::Market => (BybitOrderType::Market, false),
2583            OrderType::Limit => (BybitOrderType::Limit, false),
2584            OrderType::StopMarket | OrderType::MarketIfTouched => (BybitOrderType::Market, true),
2585            OrderType::StopLimit | OrderType::LimitIfTouched => (BybitOrderType::Limit, true),
2586            _ => anyhow::bail!("Unsupported order type: {order_type:?}"),
2587        };
2588
2589        let bybit_tif = map_time_in_force(bybit_order_type, time_in_force, post_only)
2590            .map_err(|tif| anyhow::anyhow!("Unsupported time in force: {tif:?}"))?;
2591        let market_unit = spot_market_unit(product_type, bybit_order_type, is_quote_quantity);
2592        let trigger_dir = trigger_direction(order_type, order_side, is_stop_order);
2593
2594        let mut order_entry = BybitBatchPlaceOrderEntryBuilder::default();
2595        order_entry.symbol(bybit_symbol.raw_symbol().to_string());
2596        order_entry.side(bybit_side);
2597        order_entry.order_type(bybit_order_type);
2598        order_entry.qty(quantity.to_string());
2599        order_entry.time_in_force(bybit_tif);
2600        order_entry.order_link_id(client_order_id.to_string());
2601        order_entry.market_unit(market_unit);
2602        order_entry.trigger_direction(trigger_dir);
2603
2604        if bbo_side_type.is_none()
2605            && let Some(price) = price
2606        {
2607            order_entry.price(Some(price.to_string()));
2608        }
2609
2610        if let Some(trigger_price) = trigger_price {
2611            order_entry.trigger_price(Some(trigger_price.to_string()));
2612        }
2613
2614        if reduce_only {
2615            order_entry.reduce_only(Some(true));
2616        }
2617
2618        order_entry.is_leverage(spot_leverage(product_type, is_leverage));
2619
2620        if let Some(idx) = position_idx {
2621            order_entry.position_idx(Some(idx));
2622        }
2623
2624        order_entry.bbo_side_type(bbo_side_type);
2625        order_entry.bbo_level(bbo_level);
2626        order_entry.smp_type(smp_type);
2627
2628        if let Some(tp_sl) = native_tp_sl {
2629            if let Some(ref tp) = tp_sl.take_profit {
2630                order_entry.take_profit(Some(tp.clone()));
2631            }
2632
2633            if let Some(ref sl) = tp_sl.stop_loss {
2634                order_entry.stop_loss(Some(sl.clone()));
2635            }
2636
2637            if let Some(tp_trigger) = tp_sl.tp_trigger_by {
2638                order_entry.tp_trigger_by(Some(tp_trigger));
2639            }
2640
2641            if let Some(sl_trigger) = tp_sl.sl_trigger_by {
2642                order_entry.sl_trigger_by(Some(sl_trigger));
2643            }
2644
2645            if let Some(tp_ot) = tp_sl.tp_order_type {
2646                order_entry.tp_order_type(Some(tp_ot));
2647            }
2648
2649            if let Some(sl_ot) = tp_sl.sl_order_type {
2650                order_entry.sl_order_type(Some(sl_ot));
2651            }
2652
2653            if let Some(ref tp_lp) = tp_sl.tp_limit_price {
2654                order_entry.tp_limit_price(Some(tp_lp.clone()));
2655            }
2656
2657            if let Some(ref sl_lp) = tp_sl.sl_limit_price {
2658                order_entry.sl_limit_price(Some(sl_lp.clone()));
2659            }
2660
2661            // Default to `Full` when TP or SL is set without an explicit mode, mirroring the WS
2662            // path, so Bybit accepts the field instead of rejecting it.
2663            let mode = tp_sl.tpsl_mode.or_else(|| {
2664                (tp_sl.take_profit.is_some() || tp_sl.stop_loss.is_some())
2665                    .then_some(BybitTpSlMode::Full)
2666            });
2667
2668            if let Some(m) = mode {
2669                order_entry.tpsl_mode(Some(m));
2670            }
2671
2672            if let Some(close) = tp_sl.close_on_trigger {
2673                order_entry.close_on_trigger(Some(close));
2674            }
2675
2676            if let Some(ref iv) = tp_sl.order_iv {
2677                order_entry.order_iv(Some(iv.clone()));
2678            }
2679
2680            if let Some(mmp) = tp_sl.mmp {
2681                order_entry.mmp(Some(mmp));
2682            }
2683        }
2684
2685        let order_entry = order_entry.build().build_anyhow()?;
2686
2687        let mut params = BybitPlaceOrderParamsBuilder::default();
2688        params.category(product_type);
2689        params.order(order_entry);
2690
2691        let params = params.build().build_anyhow()?;
2692
2693        let body = serde_json::to_value(&params)?;
2694        let response = self.inner.place_order(&body).await?;
2695
2696        let order_id = response
2697            .result
2698            .order_id
2699            .ok_or(BybitSubmitOrderError::MissingOrderId)?;
2700
2701        let order = self
2702            .query_order_by_id(
2703                product_type,
2704                order_id.as_str(),
2705                BYBIT_ORDER_REALTIME,
2706                "after submission",
2707            )
2708            .await
2709            .map_err(|source| BybitSubmitOrderError::PostSubmitLookup { source })?;
2710
2711        // Only bail on rejection if there are no fills
2712        // If the order has fills (cum_exec_qty > 0), let the parser remap Rejected -> Canceled.
2713        // A post-only order that would take liquidity is reported as Cancelled with
2714        // rejectReason=EC_PostOnlyWillTakeLiquidity (not Rejected), so treat that as a rejection too.
2715        let is_rejection = order.order_status == crate::common::enums::BybitOrderStatus::Rejected
2716            || (order.order_status == crate::common::enums::BybitOrderStatus::Canceled
2717                && bybit_rejection_due_post_only(order.reject_reason.as_str()));
2718        if is_rejection && (order.cum_exec_qty.as_str() == "0" || order.cum_exec_qty.is_empty()) {
2719            return Err(BybitSubmitOrderError::Rejected {
2720                reason: order.reject_reason.to_string(),
2721            }
2722            .into());
2723        }
2724
2725        let ts_init = self.generate_ts_init();
2726
2727        parse_order_status_report(&order, &instrument, account_id, ts_init)
2728    }
2729
2730    /// Cancel an order.
2731    ///
2732    /// # Errors
2733    ///
2734    /// Returns an error if:
2735    /// - Credentials are missing.
2736    /// - The request fails.
2737    /// - The order doesn't exist.
2738    /// - The API returns an error.
2739    pub async fn cancel_order(
2740        &self,
2741        account_id: AccountId,
2742        product_type: BybitProductType,
2743        instrument_id: InstrumentId,
2744        client_order_id: Option<ClientOrderId>,
2745        venue_order_id: Option<VenueOrderId>,
2746    ) -> anyhow::Result<OrderStatusReport> {
2747        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2748        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2749
2750        let mut cancel_entry = BybitBatchCancelOrderEntryBuilder::default();
2751        cancel_entry.symbol(bybit_symbol.raw_symbol().to_string());
2752
2753        if let Some(venue_order_id) = venue_order_id {
2754            cancel_entry.order_id(venue_order_id.to_string());
2755        } else if let Some(client_order_id) = client_order_id {
2756            cancel_entry.order_link_id(client_order_id.to_string());
2757        } else {
2758            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2759        }
2760
2761        let cancel_entry = cancel_entry.build().build_anyhow()?;
2762
2763        let mut params = BybitCancelOrderParamsBuilder::default();
2764        params.category(product_type);
2765        params.order(cancel_entry);
2766
2767        let params = params.build().build_anyhow()?;
2768        let body = serde_json::to_vec(&params)?;
2769
2770        let response: BybitPlaceOrderResponse = self
2771            .inner
2772            .send_request::<_, ()>(Method::POST, "/v5/order/cancel", None, Some(body), true)
2773            .await?;
2774
2775        let order_id = response
2776            .result
2777            .order_id
2778            .ok_or(BybitCancelOrderError::MissingOrderId)?;
2779
2780        let order = self
2781            .query_order_by_id(
2782                product_type,
2783                order_id.as_str(),
2784                BYBIT_ORDER_HISTORY,
2785                "after cancellation",
2786            )
2787            .await
2788            .map_err(|source| BybitCancelOrderError::PostCancelLookup { source })?;
2789
2790        let ts_init = self.generate_ts_init();
2791
2792        parse_order_status_report(&order, &instrument, account_id, ts_init)
2793    }
2794
2795    /// Batch cancel multiple orders.
2796    ///
2797    /// # Errors
2798    ///
2799    /// Returns an error if:
2800    /// - Credentials are missing.
2801    /// - The request fails.
2802    /// - Any of the orders don't exist.
2803    /// - The API returns an error.
2804    pub async fn batch_cancel_orders(
2805        &self,
2806        account_id: AccountId,
2807        product_type: BybitProductType,
2808        instrument_ids: Vec<InstrumentId>,
2809        client_order_ids: Vec<Option<ClientOrderId>>,
2810        venue_order_ids: Vec<Option<VenueOrderId>>,
2811    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2812        if instrument_ids.len() != client_order_ids.len()
2813            || instrument_ids.len() != venue_order_ids.len()
2814        {
2815            anyhow::bail!(
2816                "instrument_ids, client_order_ids, and venue_order_ids must have the same length"
2817            );
2818        }
2819
2820        if instrument_ids.is_empty() {
2821            return Ok(Vec::new());
2822        }
2823
2824        let call_limit = batch_call_limit(product_type);
2825        if instrument_ids.len() > call_limit {
2826            anyhow::bail!(
2827                "Batch cancel limit is {call_limit} orders for {}",
2828                product_type.as_str()
2829            );
2830        }
2831
2832        let mut cancel_entries = Vec::new();
2833
2834        for ((instrument_id, client_order_id), venue_order_id) in instrument_ids
2835            .iter()
2836            .zip(client_order_ids.iter())
2837            .zip(venue_order_ids.iter())
2838        {
2839            let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2840            let mut cancel_entry = BybitBatchCancelOrderEntryBuilder::default();
2841            cancel_entry.symbol(bybit_symbol.raw_symbol().to_string());
2842
2843            if let Some(venue_order_id) = venue_order_id {
2844                cancel_entry.order_id(venue_order_id.to_string());
2845            } else if let Some(client_order_id) = client_order_id {
2846                cancel_entry.order_link_id(client_order_id.to_string());
2847            } else {
2848                anyhow::bail!(
2849                    "Either client_order_id or venue_order_id must be provided for each order"
2850                );
2851            }
2852
2853            cancel_entries.push(cancel_entry.build().build_anyhow()?);
2854        }
2855
2856        let chunk_limit = batch_endpoint_limit(product_type).min(batch_send_limit(product_type));
2857        for chunk in cancel_entries.chunks(chunk_limit) {
2858            let mut params = BybitBatchCancelOrderParamsBuilder::default();
2859            params.category(product_type);
2860            params.request(chunk.to_vec());
2861
2862            let params = params.build().build_anyhow()?;
2863            let body = serde_json::to_vec(&params)?;
2864
2865            let _response: BybitPlaceOrderResponse = self
2866                .inner
2867                .send_request::<_, ()>(
2868                    Method::POST,
2869                    "/v5/order/cancel-batch",
2870                    None,
2871                    Some(body),
2872                    true,
2873                )
2874                .await?;
2875        }
2876
2877        // Query each order to get full details after cancellation
2878        let mut reports = Vec::new();
2879
2880        for (instrument_id, (client_order_id, venue_order_id)) in instrument_ids
2881            .iter()
2882            .zip(client_order_ids.iter().zip(venue_order_ids.iter()))
2883        {
2884            let Ok(instrument) = self.instrument_from_cache(&instrument_id.symbol) else {
2885                log::debug!(
2886                    "Skipping cancelled order report for instrument not in cache: symbol={}",
2887                    instrument_id.symbol
2888                );
2889                continue;
2890            };
2891
2892            let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2893
2894            let mut query_params = BybitOpenOrdersParamsBuilder::default();
2895            query_params.category(product_type);
2896            query_params.symbol(bybit_symbol.raw_symbol().to_string());
2897
2898            if let Some(venue_order_id) = venue_order_id {
2899                query_params.order_id(venue_order_id.to_string());
2900            } else if let Some(client_order_id) = client_order_id {
2901                query_params.order_link_id(client_order_id.to_string());
2902            }
2903
2904            let query_params = query_params.build().build_anyhow()?;
2905            let order_response: BybitOrderHistoryResponse = self
2906                .inner
2907                .send_request(
2908                    Method::GET,
2909                    BYBIT_ORDER_HISTORY,
2910                    Some(&query_params),
2911                    None,
2912                    true,
2913                )
2914                .await?;
2915
2916            if let Some(order) = order_response.result.list.into_iter().next() {
2917                let ts_init = self.generate_ts_init();
2918                let report = parse_order_status_report(&order, &instrument, account_id, ts_init)?;
2919                reports.push(report);
2920            }
2921        }
2922
2923        Ok(reports)
2924    }
2925
2926    /// Cancel all orders for an instrument.
2927    ///
2928    /// # Errors
2929    ///
2930    /// Returns an error if:
2931    /// - Credentials are missing.
2932    /// - The request fails.
2933    /// - The API returns an error.
2934    pub async fn cancel_all_orders(
2935        &self,
2936        account_id: AccountId,
2937        product_type: BybitProductType,
2938        instrument_id: InstrumentId,
2939    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2940        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
2941        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
2942
2943        let mut params = BybitCancelAllOrdersParamsBuilder::default();
2944        params.category(product_type);
2945        params.symbol(bybit_symbol.raw_symbol().to_string());
2946
2947        let params = params.build().build_anyhow()?;
2948        let body = serde_json::to_vec(&params)?;
2949
2950        let _response: crate::common::models::BybitListResponse<serde_json::Value> = self
2951            .inner
2952            .send_request::<_, ()>(Method::POST, "/v5/order/cancel-all", None, Some(body), true)
2953            .await?;
2954
2955        // Query the order history to get all canceled orders
2956        let mut query_params = BybitOrderHistoryParamsBuilder::default();
2957        query_params.category(product_type);
2958        query_params.symbol(bybit_symbol.raw_symbol().to_string());
2959        query_params.limit(50u32);
2960
2961        let query_params = query_params.build().build_anyhow()?;
2962        let order_response: BybitOrderHistoryResponse = self
2963            .inner
2964            .send_request(
2965                Method::GET,
2966                BYBIT_ORDER_HISTORY,
2967                Some(&query_params),
2968                None,
2969                true,
2970            )
2971            .await?;
2972
2973        let ts_init = self.generate_ts_init();
2974
2975        let mut reports = Vec::new();
2976
2977        for order in order_response.result.list {
2978            if let Ok(report) = parse_order_status_report(&order, &instrument, account_id, ts_init)
2979            {
2980                reports.push(report);
2981            }
2982        }
2983
2984        Ok(reports)
2985    }
2986
2987    /// Modify an existing order.
2988    ///
2989    /// # Errors
2990    ///
2991    /// Returns an error if:
2992    /// - Credentials are missing.
2993    /// - The request fails.
2994    /// - The order doesn't exist.
2995    /// - The order is already closed.
2996    /// - The API returns an error.
2997    #[expect(clippy::too_many_arguments)]
2998    pub async fn modify_order(
2999        &self,
3000        account_id: AccountId,
3001        product_type: BybitProductType,
3002        instrument_id: InstrumentId,
3003        client_order_id: Option<ClientOrderId>,
3004        venue_order_id: Option<VenueOrderId>,
3005        quantity: Option<Quantity>,
3006        price: Option<Price>,
3007    ) -> anyhow::Result<OrderStatusReport> {
3008        let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
3009        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
3010
3011        let mut amend_entry = BybitBatchAmendOrderEntryBuilder::default();
3012        amend_entry.symbol(bybit_symbol.raw_symbol().to_string());
3013
3014        if let Some(venue_order_id) = venue_order_id {
3015            amend_entry.order_id(venue_order_id.to_string());
3016        } else if let Some(client_order_id) = client_order_id {
3017            amend_entry.order_link_id(client_order_id.to_string());
3018        } else {
3019            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3020        }
3021
3022        if let Some(quantity) = quantity {
3023            amend_entry.qty(Some(quantity.to_string()));
3024        }
3025
3026        if let Some(price) = price {
3027            amend_entry.price(Some(price.to_string()));
3028        }
3029
3030        let amend_entry = amend_entry.build().build_anyhow()?;
3031
3032        let mut params = BybitAmendOrderParamsBuilder::default();
3033        params.category(product_type);
3034        params.order(amend_entry);
3035
3036        let params = params.build().build_anyhow()?;
3037        let body = serde_json::to_vec(&params)?;
3038
3039        let response: BybitPlaceOrderResponse = self
3040            .inner
3041            .send_request::<_, ()>(Method::POST, "/v5/order/amend", None, Some(body), true)
3042            .await?;
3043
3044        let order_id = response
3045            .result
3046            .order_id
3047            .ok_or(BybitModifyOrderError::MissingOrderId)?;
3048
3049        let order = self
3050            .query_order_by_id(
3051                product_type,
3052                order_id.as_str(),
3053                BYBIT_ORDER_REALTIME,
3054                "after amendment",
3055            )
3056            .await
3057            .map_err(|source| BybitModifyOrderError::PostModifyLookup { source })?;
3058
3059        let ts_init = self.generate_ts_init();
3060
3061        parse_order_status_report(&order, &instrument, account_id, ts_init)
3062    }
3063
3064    /// Query a single order by client order ID or venue order ID.
3065    ///
3066    /// # Errors
3067    ///
3068    /// Returns an error if:
3069    /// - Credentials are missing.
3070    /// - The request fails.
3071    /// - The API returns an error.
3072    pub async fn query_order(
3073        &self,
3074        account_id: AccountId,
3075        product_type: BybitProductType,
3076        instrument_id: InstrumentId,
3077        client_order_id: Option<ClientOrderId>,
3078        venue_order_id: Option<VenueOrderId>,
3079    ) -> anyhow::Result<Option<OrderStatusReport>> {
3080        log::debug!(
3081            "query_order: instrument_id={instrument_id}, client_order_id={client_order_id:?}, venue_order_id={venue_order_id:?}"
3082        );
3083
3084        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
3085
3086        let mut params = BybitOpenOrdersParamsBuilder::default();
3087        params.category(product_type);
3088        // Use the raw Bybit symbol (e.g., "ETHUSDT") not the full instrument symbol
3089        params.symbol(bybit_symbol.raw_symbol().to_string());
3090
3091        if let Some(venue_order_id) = venue_order_id {
3092            params.order_id(venue_order_id.to_string());
3093        } else if let Some(client_order_id) = client_order_id {
3094            params.order_link_id(client_order_id.to_string());
3095        } else {
3096            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
3097        }
3098
3099        let params = params.build().build_anyhow()?;
3100        let mut response: BybitOpenOrdersResponse = self
3101            .inner
3102            .send_request(Method::GET, BYBIT_ORDER_REALTIME, Some(&params), None, true)
3103            .await?;
3104
3105        // Options do not support the StopOrder filter
3106        if response.result.list.is_empty() && product_type != BybitProductType::Option {
3107            log::debug!("Order not found in open orders, trying with StopOrder filter");
3108
3109            let mut stop_params = BybitOpenOrdersParamsBuilder::default();
3110            stop_params.category(product_type);
3111            stop_params.symbol(bybit_symbol.raw_symbol().to_string());
3112            stop_params.order_filter(BybitOrderFilter::StopOrder);
3113
3114            if let Some(venue_order_id) = venue_order_id {
3115                stop_params.order_id(venue_order_id.to_string());
3116            } else if let Some(client_order_id) = client_order_id {
3117                stop_params.order_link_id(client_order_id.to_string());
3118            }
3119
3120            let stop_params = stop_params.build().build_anyhow()?;
3121            response = self
3122                .inner
3123                .send_request(
3124                    Method::GET,
3125                    BYBIT_ORDER_REALTIME,
3126                    Some(&stop_params),
3127                    None,
3128                    true,
3129                )
3130                .await?;
3131        }
3132
3133        // If not found in open orders, check order history
3134        if response.result.list.is_empty() {
3135            log::debug!("Order not found in open orders, checking order history");
3136
3137            let mut history_params = BybitOrderHistoryParamsBuilder::default();
3138            history_params.category(product_type);
3139            history_params.symbol(bybit_symbol.raw_symbol().to_string());
3140
3141            if let Some(venue_order_id) = venue_order_id {
3142                history_params.order_id(venue_order_id.to_string());
3143            } else if let Some(client_order_id) = client_order_id {
3144                history_params.order_link_id(client_order_id.to_string());
3145            }
3146
3147            let history_params = history_params.build().build_anyhow()?;
3148
3149            let mut history_response: BybitOrderHistoryResponse = self
3150                .inner
3151                .send_request(
3152                    Method::GET,
3153                    BYBIT_ORDER_HISTORY,
3154                    Some(&history_params),
3155                    None,
3156                    true,
3157                )
3158                .await?;
3159
3160            if history_response.result.list.is_empty() && product_type == BybitProductType::Option {
3161                log::debug!("Option order not found in order history");
3162                return Ok(None);
3163            }
3164
3165            // Options do not support the StopOrder filter
3166            if history_response.result.list.is_empty() && product_type != BybitProductType::Option {
3167                log::debug!("Order not found in order history, trying with StopOrder filter");
3168
3169                let mut stop_history_params = BybitOrderHistoryParamsBuilder::default();
3170                stop_history_params.category(product_type);
3171                stop_history_params.symbol(bybit_symbol.raw_symbol().to_string());
3172                stop_history_params.order_filter(BybitOrderFilter::StopOrder);
3173
3174                if let Some(venue_order_id) = venue_order_id {
3175                    stop_history_params.order_id(venue_order_id.to_string());
3176                } else if let Some(client_order_id) = client_order_id {
3177                    stop_history_params.order_link_id(client_order_id.to_string());
3178                }
3179
3180                let stop_history_params = stop_history_params
3181                    .build()
3182                    .map_err(|e| anyhow::anyhow!(e))?;
3183
3184                history_response = self
3185                    .inner
3186                    .send_request(
3187                        Method::GET,
3188                        BYBIT_ORDER_HISTORY,
3189                        Some(&stop_history_params),
3190                        None,
3191                        true,
3192                    )
3193                    .await?;
3194
3195                if history_response.result.list.is_empty() {
3196                    log::debug!("Order not found in order history with StopOrder filter either");
3197                    return Ok(None);
3198                }
3199            }
3200
3201            // Move the order from history response to the response list
3202            response.result.list = history_response.result.list;
3203        }
3204
3205        let order = &response.result.list[0];
3206        let ts_init = self.generate_ts_init();
3207
3208        log::debug!(
3209            "Query order response: symbol={}, order_id={}, order_link_id={}",
3210            order.symbol.as_str(),
3211            order.order_id.as_str(),
3212            order.order_link_id.as_str()
3213        );
3214
3215        let instrument = self
3216            .instrument_from_cache(&instrument_id.symbol)
3217            .map_err(|e| {
3218                anyhow::anyhow!(
3219                    "Failed to query order {}: {}",
3220                    client_order_id
3221                        .as_ref()
3222                        .map(|id| id.to_string())
3223                        .or_else(|| venue_order_id.as_ref().map(|id| id.to_string()))
3224                        .unwrap_or_else(|| "unknown".to_string()),
3225                    e
3226                )
3227            })?;
3228
3229        log::debug!("Retrieved instrument from cache: id={}", instrument.id());
3230
3231        let report =
3232            parse_order_status_report(order, &instrument, account_id, ts_init).map_err(|e| {
3233                log::debug!(
3234                    "Failed to parse order status report for {}: {}",
3235                    order.order_link_id.as_str(),
3236                    e
3237                );
3238                e
3239            })?;
3240
3241        log::debug!(
3242            "Successfully created OrderStatusReport for {}",
3243            order.order_link_id.as_str()
3244        );
3245
3246        Ok(Some(report))
3247    }
3248
3249    async fn paginate_instruments<D, F>(
3250        &self,
3251        product_type: BybitProductType,
3252        symbol: &Option<String>,
3253        base_coin: Option<Ustr>,
3254        mut parse: F,
3255    ) -> anyhow::Result<Vec<InstrumentAny>>
3256    where
3257        D: DeserializeOwned,
3258        BybitCursorListResponse<D>: BybitResponseCheck,
3259        F: FnMut(&D) -> Option<InstrumentAny>,
3260    {
3261        let mut instruments = Vec::new();
3262        let mut cursor: Option<String> = None;
3263        let mut cursor_walk = CursorWalk::default();
3264
3265        loop {
3266            let params = BybitInstrumentsInfoParams {
3267                category: product_type,
3268                symbol: symbol.clone(),
3269                status: None,
3270                base_coin: base_coin.map(|u| u.to_string()),
3271                limit: Some(1000),
3272                cursor: cursor.clone(),
3273            };
3274
3275            let response: BybitCursorListResponse<D> = self.inner.get_instruments(&params).await?;
3276
3277            for definition in &response.result.list {
3278                if let Some(instrument) = parse(definition) {
3279                    instruments.push(instrument);
3280                }
3281            }
3282
3283            // A short instrument list is a visible gap, since the risk engine denies the next
3284            // order on a missing instrument by name, while a short reconciliation history is
3285            // silent, so the other walks return this error and this one keeps what it has.
3286            cursor = match cursor_walk
3287                .advance(BYBIT_INSTRUMENTS_INFO, response.result.next_page_cursor)
3288            {
3289                Ok(cursor) => cursor,
3290                Err(e) => {
3291                    log::warn!(
3292                        "{e}, keeping the {} instrument(s) already read",
3293                        instruments.len()
3294                    );
3295                    break;
3296                }
3297            };
3298
3299            if cursor.is_none() {
3300                break;
3301            }
3302        }
3303
3304        Ok(instruments)
3305    }
3306
3307    /// Fetches instrument info and returns the current status of each symbol.
3308    ///
3309    /// Paginates through the instruments endpoint collecting only
3310    /// `(InstrumentId, MarketStatusAction)` pairs. This avoids full instrument
3311    /// parsing.
3312    ///
3313    /// # Errors
3314    ///
3315    /// Returns an error if the request fails.
3316    pub async fn request_instrument_statuses(
3317        &self,
3318        product_type: BybitProductType,
3319    ) -> anyhow::Result<AHashMap<InstrumentId, MarketStatusAction>> {
3320        let mut statuses = AHashMap::new();
3321        let mut cursor: Option<String> = None;
3322        let mut cursor_walk = CursorWalk::default();
3323
3324        loop {
3325            let params = BybitInstrumentsInfoParams {
3326                category: product_type,
3327                symbol: None,
3328                status: None,
3329                base_coin: None,
3330                limit: Some(1000),
3331                cursor: cursor.clone(),
3332            };
3333
3334            match product_type {
3335                BybitProductType::Spot => {
3336                    let response: BybitCursorListResponse<BybitInstrumentSpot> =
3337                        self.inner.get_instruments(&params).await?;
3338
3339                    for def in &response.result.list {
3340                        let symbol = make_bybit_symbol(def.symbol, product_type);
3341                        let id = InstrumentId::new(Symbol::from(symbol), *BYBIT_VENUE);
3342                        statuses.insert(id, MarketStatusAction::from(def.status));
3343                    }
3344                    cursor = response.result.next_page_cursor;
3345                }
3346                BybitProductType::Linear => {
3347                    let response: BybitCursorListResponse<BybitInstrumentLinear> =
3348                        self.inner.get_instruments(&params).await?;
3349
3350                    for def in &response.result.list {
3351                        let symbol = make_bybit_symbol(def.symbol, product_type);
3352                        let id = InstrumentId::new(Symbol::from(symbol), *BYBIT_VENUE);
3353                        let status = MarketStatusAction::from(def.status);
3354                        if status == MarketStatusAction::Trading
3355                            && def.contract_type == BybitContractType::LinearPerpetual
3356                            && def.delivery_time != "0"
3357                        {
3358                            statuses.insert(id, MarketStatusAction::PreClose);
3359                        } else {
3360                            statuses.insert(id, status);
3361                        }
3362                    }
3363                    cursor = response.result.next_page_cursor;
3364                }
3365                BybitProductType::Inverse => {
3366                    let response: BybitCursorListResponse<BybitInstrumentInverse> =
3367                        self.inner.get_instruments(&params).await?;
3368
3369                    for def in &response.result.list {
3370                        let symbol = make_bybit_symbol(def.symbol, product_type);
3371                        let id = InstrumentId::new(Symbol::from(symbol), *BYBIT_VENUE);
3372                        let status = MarketStatusAction::from(def.status);
3373                        if status == MarketStatusAction::Trading
3374                            && def.contract_type == BybitContractType::InversePerpetual
3375                            && def.delivery_time != "0"
3376                        {
3377                            statuses.insert(id, MarketStatusAction::PreClose);
3378                        } else {
3379                            statuses.insert(id, status);
3380                        }
3381                    }
3382                    cursor = response.result.next_page_cursor;
3383                }
3384                BybitProductType::Option => {
3385                    let response: BybitCursorListResponse<BybitInstrumentOption> =
3386                        self.inner.get_instruments(&params).await?;
3387
3388                    for def in &response.result.list {
3389                        let symbol = make_bybit_symbol(def.symbol, product_type);
3390                        let id = InstrumentId::new(Symbol::from(symbol), *BYBIT_VENUE);
3391                        statuses.insert(id, MarketStatusAction::from(def.status));
3392                    }
3393                    cursor = response.result.next_page_cursor;
3394                }
3395            }
3396
3397            cursor = cursor_walk.advance(BYBIT_INSTRUMENTS_INFO, cursor)?;
3398            if cursor.is_none() {
3399                break;
3400            }
3401        }
3402
3403        Ok(statuses)
3404    }
3405
3406    /// Request instruments for a given product type.
3407    ///
3408    /// When `base_coin` is provided, the request is narrowed to that base coin.
3409    /// This is required for `Option`: Bybit's API returns only `BTC` options when
3410    /// `baseCoin` is omitted.
3411    ///
3412    /// # Errors
3413    ///
3414    /// Returns an error if the request fails or parsing fails.
3415    pub async fn request_instruments(
3416        &self,
3417        product_type: BybitProductType,
3418        symbol: Option<String>,
3419        base_coin: Option<Ustr>,
3420    ) -> anyhow::Result<Vec<InstrumentAny>> {
3421        let ts_init = self.generate_ts_init();
3422
3423        let instruments = match product_type {
3424            BybitProductType::Spot => {
3425                self.paginate_instruments::<BybitInstrumentSpot, _>(
3426                    product_type,
3427                    &symbol,
3428                    base_coin,
3429                    |def| parse_spot_instrument(def, ts_init, ts_init).ok(),
3430                )
3431                .await?
3432            }
3433            BybitProductType::Linear => {
3434                self.paginate_instruments::<BybitInstrumentLinear, _>(
3435                    product_type,
3436                    &symbol,
3437                    base_coin,
3438                    |def| parse_linear_instrument(def, ts_init, ts_init).ok(),
3439                )
3440                .await?
3441            }
3442            BybitProductType::Inverse => {
3443                self.paginate_instruments::<BybitInstrumentInverse, _>(
3444                    product_type,
3445                    &symbol,
3446                    base_coin,
3447                    |def| parse_inverse_instrument(def, ts_init, ts_init).ok(),
3448                )
3449                .await?
3450            }
3451            BybitProductType::Option => {
3452                self.paginate_instruments::<BybitInstrumentOption, _>(
3453                    product_type,
3454                    &symbol,
3455                    base_coin,
3456                    |def| parse_option_instrument(def, ts_init, ts_init).ok(),
3457                )
3458                .await?
3459            }
3460        };
3461
3462        self.cache_instruments(&instruments);
3463
3464        Ok(instruments)
3465    }
3466
3467    /// Requests full instrument definitions and their market statuses in a single endpoint pass.
3468    ///
3469    /// Both are parsed from the same `/v5/market/instruments-info` response, avoiding a second
3470    /// round-trip when a caller needs definitions and statuses together (e.g. the polling loop
3471    /// serving both instrument and status subscriptions).
3472    ///
3473    /// # Errors
3474    ///
3475    /// Returns an error if the request fails or parsing fails.
3476    pub async fn request_instruments_with_statuses(
3477        &self,
3478        product_type: BybitProductType,
3479    ) -> anyhow::Result<(
3480        Vec<InstrumentAny>,
3481        AHashMap<InstrumentId, MarketStatusAction>,
3482    )> {
3483        let ts_init = self.generate_ts_init();
3484        let mut statuses = AHashMap::new();
3485
3486        // A perp with a non-zero delivery time is scheduled for delisting.
3487        let perp_status = |status: MarketStatusAction, is_scheduled_perp: bool| {
3488            if status == MarketStatusAction::Trading && is_scheduled_perp {
3489                MarketStatusAction::PreClose
3490            } else {
3491                status
3492            }
3493        };
3494
3495        let instruments = match product_type {
3496            BybitProductType::Spot => {
3497                self.paginate_instruments::<BybitInstrumentSpot, _>(
3498                    product_type,
3499                    &None::<String>,
3500                    None,
3501                    |def| {
3502                        let id = InstrumentId::new(
3503                            Symbol::from(make_bybit_symbol(def.symbol, product_type)),
3504                            *BYBIT_VENUE,
3505                        );
3506                        statuses.insert(id, MarketStatusAction::from(def.status));
3507                        parse_spot_instrument(def, ts_init, ts_init).ok()
3508                    },
3509                )
3510                .await?
3511            }
3512            BybitProductType::Linear => {
3513                self.paginate_instruments::<BybitInstrumentLinear, _>(
3514                    product_type,
3515                    &None::<String>,
3516                    None,
3517                    |def| {
3518                        let id = InstrumentId::new(
3519                            Symbol::from(make_bybit_symbol(def.symbol, product_type)),
3520                            *BYBIT_VENUE,
3521                        );
3522                        let scheduled = def.contract_type == BybitContractType::LinearPerpetual
3523                            && def.delivery_time != "0";
3524                        statuses.insert(id, perp_status(def.status.into(), scheduled));
3525                        parse_linear_instrument(def, ts_init, ts_init).ok()
3526                    },
3527                )
3528                .await?
3529            }
3530            BybitProductType::Inverse => {
3531                self.paginate_instruments::<BybitInstrumentInverse, _>(
3532                    product_type,
3533                    &None::<String>,
3534                    None,
3535                    |def| {
3536                        let id = InstrumentId::new(
3537                            Symbol::from(make_bybit_symbol(def.symbol, product_type)),
3538                            *BYBIT_VENUE,
3539                        );
3540                        let scheduled = def.contract_type == BybitContractType::InversePerpetual
3541                            && def.delivery_time != "0";
3542                        statuses.insert(id, perp_status(def.status.into(), scheduled));
3543                        parse_inverse_instrument(def, ts_init, ts_init).ok()
3544                    },
3545                )
3546                .await?
3547            }
3548            BybitProductType::Option => {
3549                self.paginate_instruments::<BybitInstrumentOption, _>(
3550                    product_type,
3551                    &None::<String>,
3552                    None,
3553                    |def| {
3554                        let id = InstrumentId::new(
3555                            Symbol::from(make_bybit_symbol(def.symbol, product_type)),
3556                            *BYBIT_VENUE,
3557                        );
3558                        statuses.insert(id, MarketStatusAction::from(def.status));
3559                        parse_option_instrument(def, ts_init, ts_init).ok()
3560                    },
3561                )
3562                .await?
3563            }
3564        };
3565
3566        self.cache_instruments(&instruments);
3567
3568        Ok((instruments, statuses))
3569    }
3570
3571    /// Request ticker information for market data.
3572    ///
3573    /// Fetches ticker data from Bybit's `/v5/market/tickers` endpoint and returns
3574    /// a unified `BybitTickerData` structure compatible with all product types.
3575    ///
3576    /// # Errors
3577    ///
3578    /// Returns an error if the request fails or parsing fails.
3579    ///
3580    /// # References
3581    ///
3582    /// <https://bybit-exchange.github.io/docs/v5/market/tickers>
3583    pub async fn request_tickers(
3584        &self,
3585        params: &BybitTickersParams,
3586    ) -> anyhow::Result<Vec<BybitTickerData>> {
3587        use super::models::{
3588            BybitTickersLinearResponse, BybitTickersOptionResponse, BybitTickersSpotResponse,
3589        };
3590
3591        match params.category {
3592            BybitProductType::Spot => {
3593                let response: BybitTickersSpotResponse = self.inner.get_tickers(params).await?;
3594                Ok(response.result.list.into_iter().map(Into::into).collect())
3595            }
3596            BybitProductType::Linear | BybitProductType::Inverse => {
3597                let response: BybitTickersLinearResponse = self.inner.get_tickers(params).await?;
3598                Ok(response.result.list.into_iter().map(Into::into).collect())
3599            }
3600            BybitProductType::Option => {
3601                let response: BybitTickersOptionResponse = self.inner.get_tickers(params).await?;
3602                Ok(response.result.list.into_iter().map(Into::into).collect())
3603            }
3604        }
3605    }
3606
3607    /// Requests raw option tickers for a given base coin.
3608    ///
3609    /// Returns `Vec<BybitTickerOption>` with the raw fields including `underlying_price`.
3610    ///
3611    /// # Errors
3612    ///
3613    /// Returns an error if the request fails.
3614    pub async fn request_option_tickers_raw(
3615        &self,
3616        base_coin: &str,
3617    ) -> anyhow::Result<Vec<BybitTickerOption>> {
3618        let params = BybitTickersParams {
3619            category: BybitProductType::Option,
3620            symbol: None,
3621            base_coin: Some(base_coin.to_string()),
3622            exp_date: None,
3623        };
3624        let response: BybitTickersOptionResponse = self.inner.get_tickers(&params).await?;
3625        Ok(response.result.list)
3626    }
3627
3628    /// Request raw option tickers with custom params.
3629    ///
3630    /// This allows fetching a single instrument by setting `symbol` in the params,
3631    /// instead of fetching all options for a base coin.
3632    ///
3633    /// # Errors
3634    ///
3635    /// Returns an error if the request fails.
3636    pub async fn request_option_tickers_raw_with_params(
3637        &self,
3638        params: &BybitTickersParams,
3639    ) -> anyhow::Result<Vec<BybitTickerOption>> {
3640        let response: BybitTickersOptionResponse = self.inner.get_tickers(params).await?;
3641        Ok(response.result.list)
3642    }
3643
3644    /// Request recent trade tick history for a given symbol.
3645    ///
3646    /// Returns the most recent public trades from Bybit's `/v5/market/recent-trade` endpoint.
3647    /// This endpoint only provides recent trades (up to 1000 most recent), typically covering
3648    /// only the last few minutes for active markets.
3649    ///
3650    /// **Note**: For historical trade data with time ranges, use the klines endpoint instead.
3651    /// The Bybit public API does not support fetching historical trades by time range.
3652    ///
3653    /// # Errors
3654    ///
3655    /// Returns an error if:
3656    /// - The instrument is not found in cache.
3657    /// - The request fails.
3658    /// - Parsing fails.
3659    ///
3660    /// # References
3661    ///
3662    /// <https://bybit-exchange.github.io/docs/v5/market/recent-trade>
3663    pub async fn request_trades(
3664        &self,
3665        product_type: BybitProductType,
3666        instrument_id: InstrumentId,
3667        limit: Option<u32>,
3668    ) -> anyhow::Result<Vec<TradeTick>> {
3669        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
3670        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
3671
3672        let mut params_builder = BybitTradesParamsBuilder::default();
3673        params_builder.category(product_type);
3674        params_builder.symbol(bybit_symbol.raw_symbol().to_string());
3675
3676        if let Some(limit_val) = limit {
3677            params_builder.limit(limit_val);
3678        }
3679
3680        let params = params_builder.build().build_anyhow()?;
3681        let response = self.inner.get_recent_trades(&params).await?;
3682
3683        let mut trades = Vec::new();
3684
3685        for trade in response.result.list {
3686            if let Ok(trade_tick) = parse_trade_tick(&trade, &instrument, None) {
3687                trades.push(trade_tick);
3688            }
3689        }
3690
3691        Ok(trades)
3692    }
3693
3694    /// Request funding rate history for a given symbol.
3695    ///
3696    /// # Errors
3697    ///
3698    /// Returns an error if:
3699    /// - The instrument is not found in cache.
3700    /// - The request fails.
3701    /// - Parsing fails.
3702    ///
3703    /// # References
3704    ///
3705    /// <https://bybit-exchange.github.io/docs/v5/market/history-fund-rate>
3706    pub async fn request_funding_rates(
3707        &self,
3708        product_type: BybitProductType,
3709        instrument_id: InstrumentId,
3710        start: Option<Timestamp>,
3711        end: Option<Timestamp>,
3712        limit: Option<u32>,
3713    ) -> anyhow::Result<Vec<FundingRateUpdate>> {
3714        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
3715        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
3716
3717        let start_ms = start.map(|dt| dt.as_millisecond());
3718        let mut seen_timestamps: AHashSet<i64> = AHashSet::new();
3719
3720        let mut raw_funding_rates = Vec::new();
3721
3722        // Bybit requires endTime when startTime is provided
3723        let mut current_end_ms = match (start, end) {
3724            (Some(_), None) => Some(Timestamp::now().as_millisecond()),
3725            _ => end.map(|dt| dt.as_millisecond()),
3726        };
3727
3728        loop {
3729            let mut params_builder = BybitFundingParamsBuilder::default();
3730            params_builder.category(product_type);
3731            params_builder.symbol(bybit_symbol.raw_symbol().to_string());
3732            params_builder.limit(limit.unwrap_or(200).clamp(0, 200)); // 200 is the maximum for the Bybit API
3733
3734            if let Some(start_val) = start_ms {
3735                params_builder.start_time(start_val);
3736            }
3737
3738            if let Some(end_val) = current_end_ms {
3739                params_builder.end_time(end_val);
3740            }
3741
3742            let params = params_builder.build().build_anyhow()?;
3743            let response = self.inner.get_funding_history(&params).await?;
3744
3745            let funding_rates = response.result.list;
3746
3747            let mut new_funding_rates_with_ts: Vec<(i64, _)> = funding_rates
3748                .into_iter()
3749                .filter_map(|f| {
3750                    let Ok(ts) = f.funding_rate_timestamp.parse::<i64>() else {
3751                        return None;
3752                    };
3753
3754                    seen_timestamps.insert(ts).then_some((ts, f))
3755                })
3756                .collect();
3757
3758            new_funding_rates_with_ts.sort_by_key(|(ts, _)| Reverse(*ts));
3759
3760            let earliest_funding_time = match new_funding_rates_with_ts.last() {
3761                Some((last_ts, _)) => *last_ts,
3762                None => break,
3763            };
3764
3765            let new_funding_rates = new_funding_rates_with_ts.into_iter().map(|(_, f)| f);
3766            raw_funding_rates.extend(new_funding_rates);
3767
3768            // Check if we've reached the requested limit
3769            if let Some(limit_val) = limit
3770                && raw_funding_rates.len() >= limit_val as usize
3771            {
3772                break;
3773            }
3774
3775            if let Some(start_val) = start_ms
3776                && earliest_funding_time <= start_val
3777            {
3778                break;
3779            }
3780
3781            // Move end time backwards to get earlier data
3782            current_end_ms = Some(earliest_funding_time - 1);
3783        }
3784
3785        if let Some(limit_val) = limit {
3786            raw_funding_rates.truncate(limit_val as usize);
3787        }
3788        let mut rates: Vec<FundingRateUpdate> = Vec::with_capacity(raw_funding_rates.len());
3789
3790        for window in raw_funding_rates.windows(2) {
3791            let raw = &window[0];
3792            let timestamp = raw
3793                .funding_rate_timestamp
3794                .parse::<i64>()
3795                .map_err(|_| anyhow::anyhow!("invalid funding_rate_timestamp"))?;
3796            let older_timestamp = window[1]
3797                .funding_rate_timestamp
3798                .parse::<i64>()
3799                .map_err(|_| anyhow::anyhow!("invalid funding_rate_timestamp"))?;
3800
3801            let interval_millis = timestamp - older_timestamp;
3802            let rate = parse_funding_rate(raw, &instrument, Some(interval_millis))?;
3803
3804            rates.push(rate);
3805        }
3806
3807        if let Some(last_raw) = raw_funding_rates.last() {
3808            let rate = parse_funding_rate(last_raw, &instrument, None)?;
3809            rates.push(rate);
3810        }
3811
3812        rates.reverse();
3813
3814        Ok(rates)
3815    }
3816
3817    /// Request an orderbook snapshot for a given symbol.
3818    ///
3819    /// Bybit limits the amount of levels (depth) for each product type to:
3820    /// - Spot: `1..=200` (default: `1`)
3821    /// - Linear & Inverse: `1..=500` (default: `25`)
3822    /// - Options: `1..=25` (default: `1`)
3823    ///
3824    /// # Errors
3825    ///
3826    /// Returns an error if:
3827    /// - The instrument is not found in cache.
3828    /// - The request fails.
3829    /// - Parsing fails.
3830    ///
3831    /// # References
3832    ///
3833    /// <https://bybit-exchange.github.io/docs/v5/market/orderbook>
3834    pub async fn request_orderbook_snapshot(
3835        &self,
3836        product_type: BybitProductType,
3837        instrument_id: InstrumentId,
3838        limit: Option<u32>,
3839    ) -> anyhow::Result<OrderBookDeltas> {
3840        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
3841        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
3842
3843        let mut params_builder = BybitOrderbookParamsBuilder::default();
3844        params_builder.category(product_type);
3845        params_builder.symbol(bybit_symbol.raw_symbol().to_string());
3846
3847        if let Some(limit) = limit {
3848            let max_limit = match product_type {
3849                BybitProductType::Spot => 200,
3850                BybitProductType::Option => 25,
3851                BybitProductType::Linear | BybitProductType::Inverse => 500,
3852            };
3853            let clamped_limit = limit.min(max_limit);
3854            if limit > max_limit {
3855                log::warn!(
3856                    "Bybit orderbook snapshot request depth limit exceeds venue maximum; clamping: limit={limit}, clamped_limit={clamped_limit}",
3857                );
3858            }
3859            params_builder.limit(clamped_limit);
3860        }
3861
3862        let params = params_builder.build().build_anyhow()?;
3863        let response = self.inner.get_orderbook(&params).await?;
3864
3865        let deltas = parse_orderbook(&response.result, &instrument, None)?;
3866
3867        Ok(deltas)
3868    }
3869
3870    /// Request bar/kline history for a given symbol.
3871    ///
3872    /// # Errors
3873    ///
3874    /// Returns an error if:
3875    /// - The instrument is not found in cache.
3876    /// - The request fails.
3877    /// - Parsing fails.
3878    ///
3879    /// # References
3880    ///
3881    /// <https://bybit-exchange.github.io/docs/v5/market/kline>
3882    pub async fn request_bars(
3883        &self,
3884        product_type: BybitProductType,
3885        bar_type: BarType,
3886        start: Option<Timestamp>,
3887        end: Option<Timestamp>,
3888        limit: Option<u32>,
3889        timestamp_on_close: bool,
3890    ) -> anyhow::Result<Vec<Bar>> {
3891        let instrument_id = bar_type.instrument_id();
3892        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
3893        let bybit_symbol = BybitSymbol::new(instrument_id.symbol.as_str())?;
3894
3895        // Convert Nautilus BarSpec to Bybit interval
3896        let interval = bar_spec_to_bybit_interval(
3897            bar_type.spec().aggregation,
3898            bar_type.spec().step.get() as u64,
3899        )?;
3900
3901        let start_ms = start.map(|dt| dt.as_millisecond());
3902        let mut seen_timestamps: AHashSet<i64> = AHashSet::new();
3903        let current_time_ms = get_atomic_clock_realtime().get_time_ms() as i64;
3904
3905        // Pagination strategy: work backwards from end time
3906        // - Each page fetched is older than the previous page
3907        // - Within each page, bars are in chronological order (oldest to newest)
3908        // - We collect pages in reverse order (newest first) then reverse at the end
3909        // Example with 2 pages:
3910        //   Page 1 (most recent): bars [T=2000..2999]
3911        //   Page 2 (older):       bars [T=1000..1999]
3912        //   Collected: [[T=2000..2999], [T=1000..1999]]
3913        //   After reverse + flatten: [T=1000..1999, T=2000..2999] ✓ chronological
3914        let mut pages: Vec<Vec<Bar>> = Vec::new();
3915        let mut total_bars = 0usize;
3916        let mut current_end = end.map(|dt| dt.as_millisecond());
3917        let mut page_count = 0;
3918
3919        loop {
3920            page_count += 1;
3921
3922            let mut params_builder = BybitKlinesParamsBuilder::default();
3923            params_builder.category(product_type);
3924            params_builder.symbol(bybit_symbol.raw_symbol().to_string());
3925            params_builder.interval(interval);
3926            params_builder.limit(1000u32); // Limit for data size per page (maximum for the Bybit API)
3927
3928            if let Some(start_val) = start_ms {
3929                params_builder.start(start_val);
3930            }
3931
3932            if let Some(end_val) = current_end {
3933                params_builder.end(end_val);
3934            }
3935
3936            let params = params_builder.build().build_anyhow()?;
3937            let response = self.inner.get_klines(&params).await?;
3938
3939            let klines = response.result.list;
3940            if klines.is_empty() {
3941                break;
3942            }
3943
3944            // Parse timestamps once and pair with klines for sorting
3945            let mut klines_with_ts: Vec<(i64, _)> = klines
3946                .into_iter()
3947                .filter_map(|k| k.start.parse::<i64>().ok().map(|ts| (ts, k)))
3948                .collect();
3949
3950            klines_with_ts.sort_by_key(|(ts, _)| *ts);
3951
3952            // Check if we have any new timestamps
3953            let has_new = klines_with_ts
3954                .iter()
3955                .any(|(ts, _)| !seen_timestamps.contains(ts));
3956
3957            if !has_new {
3958                break;
3959            }
3960
3961            let mut page_bars = Vec::with_capacity(klines_with_ts.len());
3962
3963            let mut earliest_ts: Option<i64> = None;
3964
3965            for (start_time, kline) in &klines_with_ts {
3966                // Track earliest timestamp for pagination
3967                if earliest_ts.is_none_or(|ts| *start_time < ts) {
3968                    earliest_ts = Some(*start_time);
3969                }
3970
3971                let bar_end_time = interval.bar_end_time_ms(*start_time);
3972                if bar_end_time > current_time_ms {
3973                    continue;
3974                }
3975
3976                if !seen_timestamps.contains(start_time)
3977                    && let Ok(bar) =
3978                        parse_kline_bar(kline, &instrument, bar_type, timestamp_on_close, None)
3979                {
3980                    page_bars.push(bar);
3981                    seen_timestamps.insert(*start_time);
3982                }
3983            }
3984
3985            // page_bars may be empty if all klines were partial, but pagination
3986            // continues to fetch older closed bars
3987            total_bars += page_bars.len();
3988            pages.push(page_bars);
3989
3990            // Check if we've reached the requested limit
3991            if let Some(limit_val) = limit
3992                && total_bars >= limit_val as usize
3993            {
3994                break;
3995            }
3996
3997            // Move end time backwards to get earlier data
3998            // Set new end to be 1ms before the first bar of this page
3999            let Some(earliest_bar_time) = earliest_ts else {
4000                break;
4001            };
4002
4003            if let Some(start_val) = start_ms
4004                && earliest_bar_time <= start_val
4005            {
4006                break;
4007            }
4008
4009            current_end = Some(earliest_bar_time - 1);
4010
4011            // Safety check to prevent infinite loops
4012            if page_count > 100 {
4013                break;
4014            }
4015        }
4016
4017        // Reverse pages and flatten to get chronological order (oldest to newest)
4018        let mut all_bars: Vec<Bar> = Vec::with_capacity(total_bars);
4019        for page in pages.into_iter().rev() {
4020            all_bars.extend(page);
4021        }
4022
4023        // If limit is specified and we have more bars, return the last N bars (most recent)
4024        if let Some(limit_val) = limit {
4025            let limit_usize = limit_val as usize;
4026            if all_bars.len() > limit_usize {
4027                let start_idx = all_bars.len() - limit_usize;
4028                return Ok(all_bars[start_idx..].to_vec());
4029            }
4030        }
4031
4032        Ok(all_bars)
4033    }
4034
4035    fn instrument_from_cache_by_id(
4036        &self,
4037        instrument_id: InstrumentId,
4038    ) -> anyhow::Result<InstrumentAny> {
4039        self.get_instrument(&instrument_id.symbol.inner())
4040            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
4041    }
4042
4043    /// Requests trading fee rates for the specified product type and optional filters.
4044    ///
4045    /// # Errors
4046    ///
4047    /// Returns an error if:
4048    /// - The request fails.
4049    /// - Parsing fails.
4050    ///
4051    /// # References
4052    ///
4053    /// <https://bybit-exchange.github.io/docs/v5/account/fee-rate>
4054    pub async fn request_fee_rates(
4055        &self,
4056        product_type: BybitProductType,
4057        symbol: Option<String>,
4058        base_coin: Option<String>,
4059    ) -> anyhow::Result<Vec<BybitFeeRate>> {
4060        let params = BybitFeeRateParams {
4061            category: product_type,
4062            symbol,
4063            base_coin,
4064        };
4065
4066        let response = self.inner.get_fee_rate(&params).await?;
4067        Ok(response.result.list)
4068    }
4069
4070    /// Requests the current account state for the specified account type.
4071    ///
4072    /// # Errors
4073    ///
4074    /// Returns an error if:
4075    /// - The request fails.
4076    /// - Parsing fails.
4077    ///
4078    /// # References
4079    ///
4080    /// <https://bybit-exchange.github.io/docs/v5/account/wallet-balance>
4081    pub async fn request_account_state(
4082        &self,
4083        account_type: BybitAccountType,
4084        account_id: AccountId,
4085    ) -> anyhow::Result<AccountState> {
4086        let params = BybitWalletBalanceParams {
4087            account_type,
4088            coin: None,
4089        };
4090
4091        let response = self.inner.get_wallet_balance(&params).await?;
4092        let ts_init = self.generate_ts_init();
4093
4094        // Take the first wallet balance from the list
4095        let wallet_balance = response
4096            .result
4097            .list
4098            .first()
4099            .ok_or_else(|| anyhow::anyhow!("No wallet balance found in response"))?;
4100
4101        parse_account_state(wallet_balance, account_id, ts_init)
4102    }
4103
4104    /// Request multiple order status reports.
4105    ///
4106    /// Orders for instruments not currently loaded in cache will be skipped.
4107    ///
4108    /// When `open_only` is true the realtime endpoint is queried for currently
4109    /// open orders and again for recently closed orders, so terminal reports
4110    /// are included. The closed pass fetches the most recent page only and is
4111    /// not constrained by `start` or `end`.
4112    ///
4113    /// # Errors
4114    ///
4115    /// Returns an error if:
4116    /// - Credentials are missing.
4117    /// - The request fails.
4118    /// - The API returns an error.
4119    #[expect(clippy::too_many_arguments)]
4120    pub async fn request_order_status_reports(
4121        &self,
4122        account_id: AccountId,
4123        product_type: BybitProductType,
4124        instrument_id: Option<InstrumentId>,
4125        open_only: bool,
4126        start: Option<Timestamp>,
4127        end: Option<Timestamp>,
4128        limit: Option<u32>,
4129    ) -> anyhow::Result<Vec<OrderStatusReport>> {
4130        // Extract symbol parameter from instrument_id if provided
4131        let symbol_param = if let Some(id) = instrument_id.as_ref() {
4132            let symbol_str = id.symbol.as_str();
4133            if symbol_str.is_empty() {
4134                None
4135            } else {
4136                Some(BybitSymbol::new(symbol_str)?.raw_symbol().to_string())
4137            }
4138        } else {
4139            None
4140        };
4141
4142        // For LINEAR without symbol, query all settle coins to avoid filtering
4143        // For INVERSE, never use settle_coin parameter
4144        let settle_coins_to_query: Vec<Option<String>> =
4145            if product_type == BybitProductType::Linear && symbol_param.is_none() {
4146                vec![Some("USDT".to_string()), Some("USDC".to_string())]
4147            } else {
4148                match product_type {
4149                    BybitProductType::Inverse => vec![None],
4150                    _ => vec![None],
4151                }
4152            };
4153
4154        let mut all_collected_orders = Vec::new();
4155        let mut total_collected_across_coins = 0;
4156
4157        for settle_coin in settle_coins_to_query {
4158            let remaining_limit = if let Some(limit) = limit {
4159                let remaining = (limit as usize).saturating_sub(total_collected_across_coins);
4160                if remaining == 0 {
4161                    break;
4162                }
4163                Some(remaining as u32)
4164            } else {
4165                None
4166            };
4167
4168            let orders_for_coin = if open_only {
4169                let mut all_orders = Vec::new();
4170                let mut seen_ids: AHashSet<Ustr> = AHashSet::new();
4171
4172                // Query regular orders then conditional (stop/MIT) orders.
4173                // Options do not support the StopOrder filter.
4174                let order_filters: Vec<Option<BybitOrderFilter>> =
4175                    if product_type == BybitProductType::Option {
4176                        vec![None]
4177                    } else {
4178                        vec![None, Some(BybitOrderFilter::StopOrder)]
4179                    };
4180
4181                let open_only_modes = [None, Some(BybitOpenOnly::ClosedRecent)];
4182
4183                for oo in open_only_modes {
4184                    for order_filter in &order_filters {
4185                        let mut cursor: Option<String> = None;
4186                        let mut cursor_walk = CursorWalk::default();
4187
4188                        loop {
4189                            let remaining = if let Some(limit) = remaining_limit {
4190                                (limit as usize).saturating_sub(all_orders.len())
4191                            } else {
4192                                usize::MAX
4193                            };
4194
4195                            if remaining == 0 {
4196                                break;
4197                            }
4198
4199                            // Max 50 per Bybit API
4200                            let page_limit = std::cmp::min(remaining, 50);
4201
4202                            let mut p = BybitOpenOrdersParamsBuilder::default();
4203                            p.category(product_type);
4204
4205                            if let Some(symbol) = symbol_param.clone() {
4206                                p.symbol(symbol);
4207                            }
4208
4209                            if let Some(coin) = settle_coin.clone() {
4210                                p.settle_coin(coin);
4211                            }
4212
4213                            if let Some(of) = order_filter {
4214                                p.order_filter(*of);
4215                            }
4216
4217                            if let Some(oo) = oo {
4218                                p.open_only(oo);
4219                            }
4220                            p.limit(page_limit as u32);
4221
4222                            if let Some(c) = cursor {
4223                                p.cursor(c);
4224                            }
4225                            let params = p.build().build_anyhow()?;
4226                            let response: BybitOpenOrdersResponse = self
4227                                .inner
4228                                .send_request(
4229                                    Method::GET,
4230                                    BYBIT_ORDER_REALTIME,
4231                                    Some(&params),
4232                                    None,
4233                                    true,
4234                                )
4235                                .await?;
4236
4237                            for order in response.result.list {
4238                                if seen_ids.insert(order.order_id) {
4239                                    all_orders.push(order);
4240                                }
4241                            }
4242
4243                            // The closed pass only needs the most recent page
4244                            if oo.is_some() {
4245                                break;
4246                            }
4247
4248                            cursor = cursor_walk
4249                                .advance(BYBIT_ORDER_REALTIME, response.result.next_page_cursor)?;
4250
4251                            if cursor.is_none() {
4252                                break;
4253                            }
4254                        }
4255                    }
4256                }
4257
4258                all_orders
4259            } else {
4260                // Query both realtime and history endpoints
4261                // Realtime has current open orders, history may lag for recent orders
4262                let mut all_orders = Vec::new();
4263                let mut open_orders = Vec::new();
4264                let mut seen_open_ids: AHashSet<Ustr> = AHashSet::new();
4265
4266                // Query regular orders then conditional (stop/MIT) orders.
4267                // Options do not support the StopOrder filter.
4268                let order_filters: Vec<Option<BybitOrderFilter>> =
4269                    if product_type == BybitProductType::Option {
4270                        vec![None]
4271                    } else {
4272                        vec![None, Some(BybitOrderFilter::StopOrder)]
4273                    };
4274
4275                for order_filter in &order_filters {
4276                    let mut cursor: Option<String> = None;
4277                    let mut cursor_walk = CursorWalk::default();
4278
4279                    loop {
4280                        let remaining = if let Some(limit) = remaining_limit {
4281                            (limit as usize).saturating_sub(open_orders.len())
4282                        } else {
4283                            usize::MAX
4284                        };
4285
4286                        if remaining == 0 {
4287                            break;
4288                        }
4289
4290                        // Max 50 per Bybit API
4291                        let page_limit = std::cmp::min(remaining, 50);
4292
4293                        let mut open_params = BybitOpenOrdersParamsBuilder::default();
4294                        open_params.category(product_type);
4295
4296                        if let Some(symbol) = symbol_param.clone() {
4297                            open_params.symbol(symbol);
4298                        }
4299
4300                        if let Some(coin) = settle_coin.clone() {
4301                            open_params.settle_coin(coin);
4302                        }
4303
4304                        if let Some(of) = order_filter {
4305                            open_params.order_filter(*of);
4306                        }
4307                        open_params.limit(page_limit as u32);
4308
4309                        if let Some(c) = cursor {
4310                            open_params.cursor(c);
4311                        }
4312                        let open_params = open_params.build().build_anyhow()?;
4313                        let open_response: BybitOpenOrdersResponse = self
4314                            .inner
4315                            .send_request(
4316                                Method::GET,
4317                                BYBIT_ORDER_REALTIME,
4318                                Some(&open_params),
4319                                None,
4320                                true,
4321                            )
4322                            .await?;
4323
4324                        for order in open_response.result.list {
4325                            if !seen_open_ids.contains(&order.order_id) {
4326                                seen_open_ids.insert(order.order_id);
4327                                open_orders.push(order);
4328                            }
4329                        }
4330
4331                        cursor = cursor_walk
4332                            .advance(BYBIT_ORDER_REALTIME, open_response.result.next_page_cursor)?;
4333
4334                        if cursor.is_none() {
4335                            break;
4336                        }
4337                    }
4338                }
4339
4340                let seen_order_ids: AHashSet<Ustr> = seen_open_ids;
4341                let total_open_orders = open_orders.len();
4342
4343                all_orders.extend(open_orders);
4344
4345                let mut total_history_orders = 0;
4346
4347                for order_filter in &order_filters {
4348                    let mut cursor: Option<String> = None;
4349                    let mut cursor_walk = CursorWalk::default();
4350
4351                    loop {
4352                        let total_orders = total_open_orders + total_history_orders;
4353                        let remaining = if let Some(limit) = remaining_limit {
4354                            (limit as usize).saturating_sub(total_orders)
4355                        } else {
4356                            usize::MAX
4357                        };
4358
4359                        if remaining == 0 {
4360                            break;
4361                        }
4362
4363                        // Max 50 per Bybit API
4364                        let page_limit = std::cmp::min(remaining, 50);
4365
4366                        let mut history_params = BybitOrderHistoryParamsBuilder::default();
4367                        history_params.category(product_type);
4368
4369                        if let Some(symbol) = symbol_param.clone() {
4370                            history_params.symbol(symbol);
4371                        }
4372
4373                        if let Some(coin) = settle_coin.clone() {
4374                            history_params.settle_coin(coin);
4375                        }
4376
4377                        if let Some(of) = order_filter {
4378                            history_params.order_filter(*of);
4379                        }
4380
4381                        if let Some(start) = start {
4382                            history_params.start_time(start.as_millisecond());
4383                        }
4384
4385                        if let Some(end) = end {
4386                            history_params.end_time(end.as_millisecond());
4387                        }
4388                        history_params.limit(page_limit as u32);
4389
4390                        if let Some(c) = cursor {
4391                            history_params.cursor(c);
4392                        }
4393                        let history_params = history_params.build().build_anyhow()?;
4394                        let history_response: BybitOrderHistoryResponse = self
4395                            .inner
4396                            .send_request(
4397                                Method::GET,
4398                                BYBIT_ORDER_HISTORY,
4399                                Some(&history_params),
4400                                None,
4401                                true,
4402                            )
4403                            .await?;
4404
4405                        // Open orders might appear in both realtime and history
4406                        for order in history_response.result.list {
4407                            if !seen_order_ids.contains(&order.order_id) {
4408                                all_orders.push(order);
4409                                total_history_orders += 1;
4410                            }
4411                        }
4412
4413                        cursor = cursor_walk.advance(
4414                            BYBIT_ORDER_HISTORY,
4415                            history_response.result.next_page_cursor,
4416                        )?;
4417
4418                        if cursor.is_none() {
4419                            break;
4420                        }
4421                    }
4422                }
4423
4424                all_orders
4425            };
4426
4427            total_collected_across_coins += orders_for_coin.len();
4428            all_collected_orders.extend(orders_for_coin);
4429        }
4430
4431        let ts_init = self.generate_ts_init();
4432
4433        let mut reports = Vec::new();
4434
4435        for order in all_collected_orders {
4436            if let Some(ref instrument_id) = instrument_id {
4437                let instrument = self.instrument_from_cache(&instrument_id.symbol)?;
4438
4439                if let Ok(report) =
4440                    parse_order_status_report(&order, &instrument, account_id, ts_init)
4441                {
4442                    reports.push(report);
4443                }
4444            } else {
4445                // Bybit returns raw symbol (e.g. "ETHUSDT"), need to add product suffix for cache lookup
4446                // Note: instruments are stored in cache by symbol only (without venue)
4447                if !order.symbol.is_empty() {
4448                    let symbol_with_product =
4449                        Symbol::from_ustr_unchecked(make_bybit_symbol(order.symbol, product_type));
4450
4451                    let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
4452                        log::debug!(
4453                            "Skipping order report for instrument not in cache: symbol={}, full_symbol={}",
4454                            order.symbol,
4455                            symbol_with_product
4456                        );
4457                        continue;
4458                    };
4459
4460                    match parse_order_status_report(&order, &instrument, account_id, ts_init) {
4461                        Ok(report) => reports.push(report),
4462                        Err(e) => {
4463                            log::error!("Failed to parse order status report: {e}");
4464                        }
4465                    }
4466                }
4467            }
4468        }
4469
4470        Ok(reports)
4471    }
4472
4473    /// Fetches execution history (fills) for the account and returns a list of [`FillReport`]s.
4474    ///
4475    /// Executions for instruments not currently loaded in cache will be skipped.
4476    ///
4477    /// # Errors
4478    ///
4479    /// Returns an error if the request fails.
4480    ///
4481    /// # References
4482    ///
4483    /// <https://bybit-exchange.github.io/docs/v5/order/execution>
4484    pub async fn request_fill_reports(
4485        &self,
4486        account_id: AccountId,
4487        product_type: BybitProductType,
4488        instrument_id: Option<InstrumentId>,
4489        start: Option<i64>,
4490        end: Option<i64>,
4491        limit: Option<u32>,
4492    ) -> anyhow::Result<Vec<FillReport>> {
4493        // Build query parameters
4494        let symbol = if let Some(id) = instrument_id {
4495            let bybit_symbol = BybitSymbol::new(id.symbol.as_str())?;
4496            Some(bybit_symbol.raw_symbol().to_string())
4497        } else {
4498            None
4499        };
4500
4501        // Fetch all executions with pagination
4502        let mut all_executions = Vec::new();
4503        let mut cursor: Option<String> = None;
4504        let mut cursor_walk = CursorWalk::default();
4505        let mut total_executions = 0;
4506
4507        loop {
4508            // Calculate how many more executions we can request
4509            let remaining = if let Some(limit) = limit {
4510                (limit as usize).saturating_sub(total_executions)
4511            } else {
4512                usize::MAX
4513            };
4514
4515            // If we've reached the limit, stop
4516            if remaining == 0 {
4517                break;
4518            }
4519
4520            // Size the page request to respect caller's limit (max 100 per Bybit API)
4521            let page_limit = std::cmp::min(remaining, 100);
4522
4523            let params = BybitTradeHistoryParams {
4524                category: product_type,
4525                symbol: symbol.clone(),
4526                base_coin: None,
4527                order_id: None,
4528                order_link_id: None,
4529                start_time: start,
4530                end_time: end,
4531                exec_type: None,
4532                limit: Some(page_limit as u32),
4533                cursor: cursor.clone(),
4534            };
4535
4536            let response = self.inner.get_trade_history(&params).await?;
4537            for execution in response.result.list {
4538                if execution.exec_type == BybitExecType::Funding {
4539                    log::debug!(
4540                        "Skipping funding execution: symbol={}, order_id={}, exec_id={}",
4541                        execution.symbol,
4542                        execution.order_id,
4543                        execution.exec_id,
4544                    );
4545                    continue;
4546                }
4547
4548                all_executions.push(execution);
4549                total_executions += 1;
4550            }
4551
4552            cursor = cursor_walk.advance(BYBIT_EXECUTION_LIST, response.result.next_page_cursor)?;
4553            if cursor.is_none() {
4554                break;
4555            }
4556        }
4557
4558        let ts_init = self.generate_ts_init();
4559        let mut reports = Vec::new();
4560
4561        for execution in all_executions {
4562            // Get instrument for this execution
4563            // Bybit returns raw symbol (e.g. "ETHUSDT"), need to add product suffix for cache lookup
4564            let symbol_with_product =
4565                Symbol::from_ustr_unchecked(make_bybit_symbol(execution.symbol, product_type));
4566
4567            let Ok(instrument) = self.instrument_from_cache(&symbol_with_product) else {
4568                log::debug!(
4569                    "Skipping fill report for instrument not in cache: symbol={}, full_symbol={}",
4570                    execution.symbol,
4571                    symbol_with_product
4572                );
4573                continue;
4574            };
4575
4576            match parse_fill_report(&execution, account_id, &instrument, ts_init) {
4577                Ok(report) => reports.push(report),
4578                Err(e) => {
4579                    log::error!("Failed to parse fill report: {e}");
4580                }
4581            }
4582        }
4583
4584        Ok(reports)
4585    }
4586
4587    /// Fetches position information for the account and returns a list of [`PositionStatusReport`]s.
4588    ///
4589    /// Derivative position reports fail as a set if any row cannot be resolved or parsed.
4590    /// When SPOT position reports are enabled, an uncached instrument yields no report.
4591    ///
4592    /// # Errors
4593    ///
4594    /// Returns an error if:
4595    /// - The request fails.
4596    /// - The instrument ID is invalid.
4597    /// - Pagination cannot advance.
4598    /// - A derivative position row has no symbol.
4599    /// - A derivative position's instrument is not cached.
4600    /// - A derivative position row cannot be parsed.
4601    /// - SPOT position reports are enabled and no instrument is specified, because wallet balances
4602    ///   carry no pair identity.
4603    ///
4604    /// # References
4605    ///
4606    /// <https://bybit-exchange.github.io/docs/v5/position>
4607    pub async fn request_position_status_reports(
4608        &self,
4609        account_id: AccountId,
4610        product_type: BybitProductType,
4611        instrument_id: Option<InstrumentId>,
4612    ) -> anyhow::Result<Vec<PositionStatusReport>> {
4613        // Handle SPOT position reports via wallet balances if flag is enabled
4614        if product_type == BybitProductType::Spot {
4615            if self.use_spot_position_reports.load(Ordering::Relaxed) {
4616                let Some(instrument_id) = instrument_id else {
4617                    anyhow::bail!(
4618                        "SPOT wallet balances carry no pair identity and cannot be attributed for a bulk position report request"
4619                    );
4620                };
4621                return self
4622                    .generate_spot_position_reports_from_wallet(account_id, instrument_id)
4623                    .await;
4624            } else {
4625                // Return empty vector when SPOT position reports are disabled
4626                return Ok(Vec::new());
4627            }
4628        }
4629
4630        let ts_init = self.generate_ts_init();
4631        let mut reports = Vec::new();
4632
4633        // Build query parameters based on whether a specific instrument is requested
4634        let symbol = if let Some(id) = instrument_id {
4635            let symbol_str = id.symbol.as_str();
4636            if symbol_str.is_empty() {
4637                anyhow::bail!("InstrumentId symbol is empty");
4638            }
4639            let bybit_symbol = BybitSymbol::new(symbol_str)?;
4640            Some(bybit_symbol.raw_symbol().to_string())
4641        } else {
4642            None
4643        };
4644
4645        // For LINEAR category, the API requires either symbol OR settleCoin
4646        let settle_coins: &[Option<&str>] =
4647            if product_type == BybitProductType::Linear && symbol.is_none() {
4648                &[Some("USDT"), Some("USDC")]
4649            } else {
4650                &[None]
4651            };
4652
4653        for settle_coin in settle_coins {
4654            let params = BybitPositionListParams {
4655                category: product_type,
4656                symbol: symbol.clone(),
4657                base_coin: None,
4658                settle_coin: settle_coin.map(str::to_string),
4659                limit: Some(200), // Max 200 per request
4660                cursor: None,
4661            };
4662
4663            self.request_position_pages(account_id, params, ts_init, &mut reports)
4664                .await?;
4665        }
4666
4667        Ok(reports)
4668    }
4669
4670    async fn request_position_pages(
4671        &self,
4672        account_id: AccountId,
4673        mut params: BybitPositionListParams,
4674        ts_init: UnixNanos,
4675        reports: &mut Vec<PositionStatusReport>,
4676    ) -> anyhow::Result<()> {
4677        let mut cursor_walk = CursorWalk::default();
4678
4679        loop {
4680            let response = self.inner.get_positions(&params).await?;
4681
4682            for position in response.result.list {
4683                if position.symbol.is_empty() {
4684                    anyhow::bail!(
4685                        "Position symbol is empty, category={:?}, symbol={:?}, settle_coin={:?}",
4686                        params.category,
4687                        params.symbol,
4688                        params.settle_coin,
4689                    );
4690                }
4691
4692                let symbol = Symbol::new(format!(
4693                    "{}{}",
4694                    position.symbol.as_str(),
4695                    params.category.suffix()
4696                ));
4697                let instrument = self.instrument_from_cache(&symbol)?;
4698                let report =
4699                    parse_position_status_report(&position, account_id, &instrument, ts_init)
4700                        .with_context(|| format!("failed to parse position report for {symbol}"))?;
4701                reports.push(report);
4702            }
4703
4704            params.cursor =
4705                cursor_walk.advance(BYBIT_POSITION_LIST, response.result.next_page_cursor)?;
4706
4707            if params.cursor.is_none() {
4708                break;
4709            }
4710        }
4711
4712        Ok(())
4713    }
4714
4715    async fn query_order_by_id(
4716        &self,
4717        product_type: BybitProductType,
4718        order_id: &str,
4719        endpoint: &str,
4720        context: &str,
4721    ) -> anyhow::Result<BybitOrder> {
4722        let mut query_params = BybitOpenOrdersParamsBuilder::default();
4723        query_params.category(product_type);
4724        query_params.order_id(order_id.to_string());
4725
4726        let query_params = query_params.build().build_anyhow()?;
4727        let order_response: BybitOpenOrdersResponse = self
4728            .inner
4729            .send_request(Method::GET, endpoint, Some(&query_params), None, true)
4730            .await?;
4731
4732        order_response
4733            .result
4734            .list
4735            .into_iter()
4736            .next()
4737            .ok_or_else(|| anyhow::anyhow!("No order returned {context}"))
4738    }
4739}
4740
4741#[cfg(test)]
4742mod tests {
4743    use nautilus_testkit::http::assert_http_redirect_rejected;
4744    use rstest::rstest;
4745
4746    use super::*;
4747
4748    #[tokio::test]
4749    async fn test_authenticated_client_rejects_redirects() {
4750        let client = BybitRawHttpClient::build_http_client(3, None).unwrap();
4751        assert_http_redirect_rejected(|url| async move {
4752            client
4753                .get(url, None, None, Some(3), None)
4754                .await
4755                .unwrap()
4756                .status
4757                .as_u16()
4758        })
4759        .await;
4760    }
4761
4762    #[rstest]
4763    fn test_client_creation() {
4764        let client = BybitHttpClient::new(None, 60, 3, 1000, 10_000, 5_000, None);
4765        assert!(client.is_ok());
4766
4767        let client = client.unwrap();
4768        assert!(client.base_url().contains("bybit.com"));
4769        assert!(client.credential().is_none());
4770    }
4771
4772    #[rstest]
4773    fn test_client_with_credentials() {
4774        let client = BybitHttpClient::with_credentials(
4775            "test_key".to_string(),
4776            "test_secret".to_string(),
4777            Some("https://api-testnet.bybit.com".to_string()),
4778            60,
4779            3,
4780            1000,
4781            10_000,
4782            5_000,
4783            None,
4784        );
4785        assert!(client.is_ok());
4786
4787        let client = client.unwrap();
4788        assert!(client.credential().is_some());
4789    }
4790
4791    #[rstest]
4792    fn test_build_path_with_params() {
4793        #[derive(Serialize)]
4794        struct TestParams {
4795            category: String,
4796            symbol: String,
4797        }
4798
4799        let params = TestParams {
4800            category: "linear".to_string(),
4801            symbol: "BTCUSDT".to_string(),
4802        };
4803
4804        let path = BybitRawHttpClient::build_path("/v5/market/test", &params);
4805        assert!(path.is_ok());
4806        assert!(path.unwrap().contains("category=linear"));
4807    }
4808
4809    #[rstest]
4810    fn test_build_path_without_params() {
4811        let params = ();
4812        let path = BybitRawHttpClient::build_path("/v5/market/time", &params);
4813        assert!(path.is_ok());
4814        assert_eq!(path.unwrap(), "/v5/market/time");
4815    }
4816
4817    #[rstest]
4818    fn test_params_serialization_matches_build_path() {
4819        // This test ensures our new serialization produces the same result as the old build_path
4820        #[derive(Serialize)]
4821        struct TestParams {
4822            category: String,
4823            limit: u32,
4824        }
4825
4826        let params = TestParams {
4827            category: "spot".to_string(),
4828            limit: 50,
4829        };
4830
4831        // Old way: build_path serialized params
4832        let old_path = BybitRawHttpClient::build_path(BYBIT_ORDER_REALTIME, &params).unwrap();
4833        let old_query = old_path.split('?').nth(1).unwrap_or("");
4834
4835        // New way: direct serialization
4836        let new_query = serde_urlencoded::to_string(&params).unwrap();
4837
4838        // They must match for signatures to work
4839        assert_eq!(old_query, new_query);
4840    }
4841
4842    #[rstest]
4843    fn test_params_serialization_order() {
4844        // Verify that serialization order is deterministic
4845        #[derive(Serialize)]
4846        struct OrderParams {
4847            category: String,
4848            symbol: String,
4849            limit: u32,
4850        }
4851
4852        let params = OrderParams {
4853            category: "spot".to_string(),
4854            symbol: "BTCUSDT".to_string(),
4855            limit: 50,
4856        };
4857
4858        // Serialize multiple times to ensure consistent ordering
4859        let query1 = serde_urlencoded::to_string(&params).unwrap();
4860        let query2 = serde_urlencoded::to_string(&params).unwrap();
4861        let query3 = serde_urlencoded::to_string(&params).unwrap();
4862
4863        assert_eq!(query1, query2);
4864        assert_eq!(query2, query3);
4865
4866        // The query should contain all params
4867        assert!(query1.contains("category=spot"));
4868        assert!(query1.contains("symbol=BTCUSDT"));
4869        assert!(query1.contains("limit=50"));
4870    }
4871
4872    #[rstest]
4873    #[case(403, "Access too frequent", true)]
4874    #[case(403, "Forbidden", false)]
4875    #[case(429, "Access too frequent", false)]
4876    fn test_rate_limit_403_detection(
4877        #[case] status: u16,
4878        #[case] body: &str,
4879        #[case] expected: bool,
4880    ) {
4881        assert_eq!(
4882            BybitRawHttpClient::is_rate_limit_403(status, body),
4883            expected
4884        );
4885    }
4886
4887    #[rstest]
4888    fn test_cursor_walk_stops_on_absent_or_empty_cursor() {
4889        let mut walk = CursorWalk::default();
4890
4891        assert_eq!(walk.advance("/endpoint", None).unwrap(), None);
4892        assert_eq!(
4893            walk.advance("/endpoint", Some(String::new())).unwrap(),
4894            None
4895        );
4896        assert!(walk.followed.is_empty());
4897    }
4898
4899    #[rstest]
4900    fn test_cursor_walk_follows_advancing_cursors() {
4901        let mut walk = CursorWalk::default();
4902
4903        for page in ["page-2", "page-3", "page-4"] {
4904            assert_eq!(
4905                walk.advance("/endpoint", Some(page.to_string())).unwrap(),
4906                Some(page.to_string())
4907            );
4908        }
4909    }
4910
4911    #[rstest]
4912    fn test_cursor_walk_rejects_a_cursor_that_does_not_advance() {
4913        let mut walk = CursorWalk::default();
4914        walk.advance("/endpoint", Some("page-2".to_string()))
4915            .unwrap();
4916
4917        let error = walk
4918            .advance("/endpoint", Some("page-2".to_string()))
4919            .expect_err("a cursor that addresses the page it came with has no next page");
4920
4921        assert_eq!(
4922            error.to_string(),
4923            r#"/endpoint pagination cursor did not advance from "page-2""#
4924        );
4925    }
4926
4927    #[rstest]
4928    #[case::two_cycle(vec!["page-2", "page-3"], "page-2")]
4929    #[case::long_cycle(vec!["page-2", "page-3", "page-4", "page-5"], "page-3")]
4930    fn test_cursor_walk_rejects_a_cursor_already_followed(
4931        #[case] followed: Vec<&str>,
4932        #[case] repeated: &str,
4933    ) {
4934        let mut walk = CursorWalk::default();
4935        for cursor in followed {
4936            walk.advance("/endpoint", Some(cursor.to_string())).unwrap();
4937        }
4938
4939        let error = walk
4940            .advance("/endpoint", Some(repeated.to_string()))
4941            .expect_err("a cursor already followed has no further page to offer");
4942
4943        assert_eq!(
4944            error.to_string(),
4945            format!(r#"/endpoint pagination repeated cursor "{repeated}""#)
4946        );
4947    }
4948}