Skip to main content

nautilus_bitmex/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 [BitMEX](https://www.bitmex.com) REST API.
17//!
18//! This module defines and implements a [`BitmexHttpClient`] for
19//! sending requests to various BitMEX endpoints. It handles request signing
20//! (when credentials are provided), constructs valid HTTP requests
21//! using the [`HttpClient`], and parses the responses back into structured data or a [`BitmexHttpError`].
22//!
23//! BitMEX API reference <https://www.bitmex.com/api/explorer/#/default>.
24
25use std::{
26    collections::HashMap,
27    num::NonZeroU32,
28    sync::{
29        Arc, LazyLock,
30        atomic::{AtomicBool, Ordering},
31    },
32};
33
34use dashmap::DashMap;
35use jiff::{Timestamp, tz::Offset};
36use nautilus_common::cache::InstrumentLookupError;
37use nautilus_core::{
38    AtomicMap, AtomicTime, UUID4, UnixNanos,
39    consts::{NAUTILUS_TRADER, NAUTILUS_USER_AGENT},
40    env::get_or_env_var_opt,
41    time::get_atomic_clock_realtime,
42};
43use nautilus_model::{
44    data::{
45        Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
46    },
47    enums::{
48        AccountType, AggregationSource, BarAggregation, BookAction, BookType, ContingencyType,
49        OrderSide, OrderType, PriceType, RecordFlag, TimeInForce, TrailingOffsetType, TriggerType,
50    },
51    events::AccountState,
52    identifiers::{AccountId, ClientOrderId, InstrumentId, OrderListId, VenueOrderId},
53    instruments::{Instrument as InstrumentTrait, InstrumentAny},
54    orderbook::OrderBook,
55    reports::{FillReport, OrderStatusReport, PositionStatusReport},
56    types::{MarginBalance, Money, Price, Quantity},
57};
58use nautilus_network::{
59    http::{HttpClient, HttpRedirectPolicy, Method, StatusCode, USER_AGENT},
60    ratelimiter::quota::Quota,
61    retry::{RetryConfig, RetryError, RetryManager},
62};
63use parking_lot::RwLock;
64use rust_decimal::Decimal;
65use serde::{Deserialize, Serialize, de::DeserializeOwned};
66use serde_json::Value;
67use tokio_util::sync::CancellationToken;
68use ustr::Ustr;
69
70use super::{
71    error::{BitmexErrorResponse, BitmexHttpError},
72    models::{
73        BitmexApiInfo, BitmexExecution, BitmexFunding, BitmexInstrument, BitmexMargin, BitmexOrder,
74        BitmexOrderBookL2, BitmexPosition, BitmexTrade, BitmexTradeBin, BitmexWallet,
75    },
76    query::{
77        DeleteAllOrdersParams, DeleteOrderParams, GetExecutionParams, GetExecutionParamsBuilder,
78        GetFundingParams, GetFundingParamsBuilder, GetOrderBookL2Params,
79        GetOrderBookL2ParamsBuilder, GetOrderParams, GetPositionParams, GetPositionParamsBuilder,
80        GetTradeBucketedParams, GetTradeBucketedParamsBuilder, GetTradeParams,
81        GetTradeParamsBuilder, PostCancelAllAfterParams, PostOrderParams,
82        PostPositionLeverageParams, PutOrderParams,
83    },
84};
85use crate::{
86    common::{
87        consts::{BITMEX_HTTP_TESTNET_URL, BITMEX_HTTP_URL},
88        credential::{Credential, credential_env_vars},
89        enums::{
90            BitmexContingencyType, BitmexEnvironment, BitmexExecInstruction, BitmexOrderStatus,
91            BitmexOrderType, BitmexPegPriceType, BitmexSide, BitmexTimeInForce,
92        },
93        parse::{
94            bitmex_account_id, bitmex_currency_divisor, parse_account_balance,
95            parse_contracts_quantity, quantity_to_u32,
96        },
97    },
98    http::{
99        parse::{
100            InstrumentParseResult, parse_fill_report, parse_instrument_any,
101            parse_order_status_report, parse_position_report, parse_trade, parse_trade_bin,
102        },
103        query::{DeleteAllOrdersParamsBuilder, GetOrderParamsBuilder, PutOrderParamsBuilder},
104    },
105    websocket::messages::BitmexMarginMsg,
106};
107
108/// Default BitMEX REST API rate limits.
109///
110/// BitMEX implements a dual-layer rate limiting system:
111/// - Primary limit: 120 requests per minute for authenticated users (30 for unauthenticated).
112/// - Secondary limit: 10 requests per second burst limit for specific endpoints.
113const BITMEX_DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 10;
114const BITMEX_DEFAULT_RATE_LIMIT_PER_MINUTE_AUTHENTICATED: u32 = 120;
115const BITMEX_DEFAULT_RATE_LIMIT_PER_MINUTE_UNAUTHENTICATED: u32 = 30;
116const BITMEX_MAX_TABLE_COUNT: u32 = 500;
117
118const BITMEX_GLOBAL_RATE_KEY: &str = "bitmex:global";
119const BITMEX_MINUTE_RATE_KEY: &str = "bitmex:minute";
120
121static RATE_LIMIT_KEYS: LazyLock<Vec<Ustr>> = LazyLock::new(|| {
122    vec![
123        Ustr::from(BITMEX_GLOBAL_RATE_KEY),
124        Ustr::from(BITMEX_MINUTE_RATE_KEY),
125    ]
126});
127
128/// Represents a BitMEX HTTP response.
129#[derive(Debug, Serialize, Deserialize)]
130pub struct BitmexResponse<T> {
131    /// The typed data returned by the BitMEX endpoint.
132    pub data: Vec<T>,
133}
134
135/// Provides a lower-level HTTP client for connecting to the
136/// [BitMEX](https://www.bitmex.com) REST API.
137///
138/// This client wraps the underlying [`HttpClient`] to handle functionality
139/// specific to BitMEX, such as request signing (for authenticated endpoints),
140/// forming request URLs, and deserializing responses into specific data models.
141///
142/// # Connection Management
143///
144/// The client uses HTTP keep-alive for connection pooling with a 90-second idle timeout,
145/// which matches BitMEX's server-side keep-alive timeout. Connections are automatically
146/// reused for subsequent requests to minimize latency.
147///
148/// # Rate Limiting
149///
150/// BitMEX enforces the following rate limits:
151/// - 120 requests per minute for authenticated users (30 for unauthenticated).
152/// - 10 requests per second burst limit for certain endpoints (order management).
153///
154/// The client automatically respects these limits through the configured quota.
155#[derive(Debug, Clone)]
156pub struct BitmexRawHttpClient {
157    base_url: String,
158    client: HttpClient,
159    credential: Option<Credential>,
160    recv_window_ms: u64,
161    retry_manager: RetryManager<BitmexHttpError>,
162    cancellation_token: Arc<RwLock<CancellationToken>>,
163}
164
165impl Default for BitmexRawHttpClient {
166    fn default() -> Self {
167        Self::new(
168            None,
169            60,
170            3,
171            1000,
172            10_000,
173            10_000,
174            BITMEX_DEFAULT_RATE_LIMIT_PER_SECOND,
175            BITMEX_DEFAULT_RATE_LIMIT_PER_MINUTE_UNAUTHENTICATED,
176            None,
177        )
178        .expect("Failed to create default BitmexHttpInnerClient")
179    }
180}
181
182impl BitmexRawHttpClient {
183    /// Creates a new [`BitmexRawHttpClient`] using the default BitMEX HTTP URL,
184    /// optionally overridden with a custom base URL.
185    ///
186    /// This version of the client has **no credentials**, so it can only
187    /// call publicly accessible endpoints.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if the retry manager cannot be created.
192    #[expect(clippy::too_many_arguments)]
193    pub fn new(
194        base_url: Option<String>,
195        timeout_secs: u64,
196        max_retries: u32,
197        retry_delay_ms: u64,
198        retry_delay_max_ms: u64,
199        recv_window_ms: u64,
200        max_requests_per_second: u32,
201        max_requests_per_minute: u32,
202        proxy_url: Option<String>,
203    ) -> Result<Self, BitmexHttpError> {
204        let retry_config = RetryConfig {
205            max_retries,
206            initial_delay_ms: retry_delay_ms,
207            max_delay_ms: retry_delay_max_ms,
208            backoff_factor: 2.0,
209            jitter_ms: 1000,
210            operation_timeout_ms: Some(60_000),
211            immediate_first: false,
212            max_elapsed_ms: Some(180_000),
213        };
214
215        let retry_manager = RetryManager::new(retry_config);
216
217        Ok(Self {
218            base_url: base_url.unwrap_or(BITMEX_HTTP_URL.to_string()),
219            client: HttpClient::builder()
220                .headers(Self::default_headers())
221                .keyed_quotas(Self::rate_limiter_quotas(
222                    max_requests_per_second,
223                    max_requests_per_minute,
224                )?)
225                .default_quota(Self::default_quota(max_requests_per_second)?)
226                .timeout_secs(timeout_secs)
227                .maybe_proxy_url(proxy_url)
228                .build()
229                .map_err(|e| {
230                    BitmexHttpError::NetworkError(format!("Failed to create HTTP client: {e}"))
231                })?,
232            credential: None,
233            recv_window_ms,
234            retry_manager,
235            cancellation_token: Arc::new(RwLock::new(CancellationToken::new())),
236        })
237    }
238
239    /// Creates a new [`BitmexRawHttpClient`] configured with credentials
240    /// for authenticated requests, optionally using a custom base URL.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if the retry manager cannot be created.
245    #[expect(clippy::too_many_arguments)]
246    pub fn with_credentials(
247        api_key: String,
248        api_secret: String,
249        base_url: String,
250        timeout_secs: u64,
251        max_retries: u32,
252        retry_delay_ms: u64,
253        retry_delay_max_ms: u64,
254        recv_window_ms: u64,
255        max_requests_per_second: u32,
256        max_requests_per_minute: u32,
257        proxy_url: Option<String>,
258    ) -> Result<Self, BitmexHttpError> {
259        let retry_config = RetryConfig {
260            max_retries,
261            initial_delay_ms: retry_delay_ms,
262            max_delay_ms: retry_delay_max_ms,
263            backoff_factor: 2.0,
264            jitter_ms: 1000,
265            operation_timeout_ms: Some(60_000),
266            immediate_first: false,
267            max_elapsed_ms: Some(180_000),
268        };
269
270        let retry_manager = RetryManager::new(retry_config);
271
272        Ok(Self {
273            base_url,
274            client: HttpClient::builder()
275                .redirect_policy(HttpRedirectPolicy::Reject)
276                .headers(Self::default_headers())
277                .keyed_quotas(Self::rate_limiter_quotas(
278                    max_requests_per_second,
279                    max_requests_per_minute,
280                )?)
281                .default_quota(Self::default_quota(max_requests_per_second)?)
282                .timeout_secs(timeout_secs)
283                .maybe_proxy_url(proxy_url)
284                .build()
285                .map_err(|e| {
286                    BitmexHttpError::NetworkError(format!("Failed to create HTTP client: {e}"))
287                })?,
288            credential: Some(Credential::new(api_key, api_secret)),
289            recv_window_ms,
290            retry_manager,
291            cancellation_token: Arc::new(RwLock::new(CancellationToken::new())),
292        })
293    }
294
295    fn default_headers() -> HashMap<String, String> {
296        HashMap::from([(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())])
297    }
298
299    fn default_quota(max_requests_per_second: u32) -> Result<Quota, BitmexHttpError> {
300        let burst = NonZeroU32::new(max_requests_per_second)
301            .unwrap_or(NonZeroU32::new(BITMEX_DEFAULT_RATE_LIMIT_PER_SECOND).expect("non-zero"));
302        Quota::per_second(burst).ok_or_else(|| {
303            BitmexHttpError::ValidationError(format!(
304                "Invalid max_requests_per_second: {max_requests_per_second} exceeds maximum"
305            ))
306        })
307    }
308
309    fn rate_limiter_quotas(
310        max_requests_per_second: u32,
311        max_requests_per_minute: u32,
312    ) -> Result<Vec<(String, Quota)>, BitmexHttpError> {
313        let per_sec_quota = Self::default_quota(max_requests_per_second)?;
314        let per_min_quota =
315            Quota::per_minute(NonZeroU32::new(max_requests_per_minute).unwrap_or_else(|| {
316                NonZeroU32::new(BITMEX_DEFAULT_RATE_LIMIT_PER_MINUTE_AUTHENTICATED)
317                    .expect("non-zero")
318            }));
319
320        Ok(vec![
321            (BITMEX_GLOBAL_RATE_KEY.to_string(), per_sec_quota),
322            (BITMEX_MINUTE_RATE_KEY.to_string(), per_min_quota),
323        ])
324    }
325
326    fn rate_limit_keys() -> Vec<Ustr> {
327        RATE_LIMIT_KEYS.clone()
328    }
329
330    /// Cancel all pending HTTP requests.
331    pub fn cancel_all_requests(&self) {
332        self.cancellation_token.read().cancel();
333    }
334
335    /// Replace the cancellation token so new requests can proceed.
336    pub fn reset_cancellation_token(&self) {
337        *self.cancellation_token.write() = CancellationToken::new();
338    }
339
340    /// Get a clone of the cancellation token for this client.
341    pub fn cancellation_token(&self) -> CancellationToken {
342        self.cancellation_token.read().clone()
343    }
344
345    fn sign_request(
346        &self,
347        method: &Method,
348        endpoint: &str,
349        body: Option<&[u8]>,
350    ) -> Result<HashMap<String, String>, BitmexHttpError> {
351        let credential = self
352            .credential
353            .as_ref()
354            .ok_or(BitmexHttpError::MissingCredentials)?;
355
356        let expires = Timestamp::now().as_second() + (self.recv_window_ms / 1000) as i64;
357        let body_str = body.and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("");
358
359        let full_path = if endpoint.starts_with("/api/v1") {
360            endpoint.to_string()
361        } else {
362            format!("/api/v1{endpoint}")
363        };
364
365        let signature = credential.sign(method.as_str(), &full_path, expires, body_str);
366
367        let mut headers = HashMap::new();
368        headers.insert("api-expires".to_string(), expires.to_string());
369        headers.insert("api-key".to_string(), credential.api_key().to_string());
370        headers.insert("api-signature".to_string(), signature);
371
372        // Add Content-Type header for form-encoded body
373        if body.is_some()
374            && (*method == Method::POST || *method == Method::PUT || *method == Method::DELETE)
375        {
376            headers.insert(
377                "Content-Type".to_string(),
378                "application/x-www-form-urlencoded".to_string(),
379            );
380        }
381
382        Ok(headers)
383    }
384
385    async fn send_request<T: DeserializeOwned, P: Serialize>(
386        &self,
387        method: Method,
388        endpoint: &str,
389        params: Option<&P>,
390        body: Option<Vec<u8>>,
391        authenticate: bool,
392    ) -> Result<T, BitmexHttpError> {
393        let endpoint = endpoint.to_string();
394        let method_clone = method.clone();
395        let body_clone = body.clone();
396
397        // Serialize params before closure to avoid reference lifetime issues
398        // Query params are used with GET and DELETE methods
399        let params_str = if method == Method::GET || method == Method::DELETE {
400            params
401                .map(serde_urlencoded::to_string)
402                .transpose()
403                .map_err(|e| {
404                    BitmexHttpError::JsonError(format!("Failed to serialize params: {e}"))
405                })?
406        } else {
407            None
408        };
409
410        let full_endpoint = match params_str {
411            Some(ref query) if !query.is_empty() => format!("{endpoint}?{query}"),
412            _ => endpoint.clone(),
413        };
414
415        let url = format!("{}{}", self.base_url, full_endpoint);
416
417        let operation = || {
418            let url = url.clone();
419            let method = method_clone.clone();
420            let body = body_clone.clone();
421            let full_endpoint = full_endpoint.clone();
422
423            async move {
424                let headers = if authenticate {
425                    Some(self.sign_request(&method, &full_endpoint, body.as_deref())?)
426                } else {
427                    None
428                };
429
430                let rate_keys = Self::rate_limit_keys();
431                let resp = self
432                    .client
433                    .request_with_ustr_keys(method, url, None, headers, body, None, Some(rate_keys))
434                    .await?;
435
436                if resp.status.is_success() {
437                    serde_json::from_slice(&resp.body).map_err(Into::into)
438                } else if let Ok(error_resp) =
439                    serde_json::from_slice::<BitmexErrorResponse>(&resp.body)
440                {
441                    Err(error_resp.into())
442                } else {
443                    Err(BitmexHttpError::UnexpectedStatus {
444                        status: StatusCode::from_u16(resp.status.as_u16())
445                            .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
446                        body: String::from_utf8_lossy(&resp.body).to_string(),
447                    })
448                }
449            }
450        };
451
452        // Retry strategy based on BitMEX error responses and HTTP status codes:
453        //
454        // 1. Network errors: always retry (transient connection issues).
455        // 2. HTTP 5xx/429: server errors and rate limiting should be retried.
456        // 3. BitMEX JSON errors with specific handling:
457        //    - "RateLimitError": explicit rate limit error from BitMEX.
458        //    - "HTTPError": generic error name used by BitMEX for various issues
459        //      Only retry if message contains "rate limit" to avoid retrying
460        //      non-transient errors like authentication failures, validation errors,
461        //      insufficient balance, etc. which also return as "HTTPError".
462        //
463        // Note: BitMEX returns many permanent errors as "HTTPError" (e.g., "Invalid orderQty",
464        // "Account has insufficient Available Balance", "Invalid API Key") which should NOT
465        // be retried. We only retry when the message explicitly mentions rate limiting.
466        //
467        // See tests in tests/integration/http.rs for retry behavior validation.
468        let should_retry = |error: &BitmexHttpError| -> bool {
469            match error {
470                BitmexHttpError::NetworkError(_) => true,
471                BitmexHttpError::UnexpectedStatus { status, .. } => {
472                    status.as_u16() >= 500 || status.as_u16() == 429
473                }
474                BitmexHttpError::BitmexError {
475                    error_name,
476                    message,
477                } => {
478                    error_name == "RateLimitError"
479                        || (error_name == "HTTPError"
480                            && message.to_lowercase().contains("rate limit"))
481                }
482                _ => false,
483            }
484        };
485
486        let create_error = |error: RetryError| -> BitmexHttpError {
487            match error {
488                RetryError::Canceled => {
489                    BitmexHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
490                }
491                error => BitmexHttpError::NetworkError(error.to_string()),
492            }
493        };
494
495        let cancel_token = self.cancellation_token();
496
497        self.retry_manager
498            .invocation(endpoint.as_str(), operation, should_retry, create_error)
499            .cancellation_token(&cancel_token)
500            .execute()
501            .await
502    }
503
504    /// Get all instruments.
505    ///
506    /// Instruments that cannot be deserialized (e.g. unknown fields for new BitMEX
507    /// instrument types) are skipped with a warning rather than failing the whole
508    /// response.
509    ///
510    /// # Errors
511    ///
512    /// Returns an error if the HTTP request fails or the response is not a JSON array.
513    pub async fn get_instruments(
514        &self,
515        active_only: bool,
516    ) -> Result<Vec<BitmexInstrument>, BitmexHttpError> {
517        let path = if active_only {
518            "/instrument/active"
519        } else {
520            "/instrument"
521        };
522        let raw: Vec<serde_json::Value> = self
523            .send_request::<_, ()>(Method::GET, path, None, None, false)
524            .await?;
525
526        let raw_len = raw.len();
527        let mut instruments = Vec::with_capacity(raw_len);
528
529        for value in raw {
530            match serde_json::from_value::<BitmexInstrument>(value) {
531                Ok(inst) => instruments.push(inst),
532                Err(e) => {
533                    log::warn!("Skipping instrument that could not be deserialized: {e}");
534                }
535            }
536        }
537
538        if raw_len > 0 && instruments.is_empty() {
539            return Err(BitmexHttpError::JsonError(format!(
540                "All {raw_len} instrument(s) failed to deserialize; venue schema may have changed"
541            )));
542        }
543
544        Ok(instruments)
545    }
546
547    /// Requests the current server time from BitMEX.
548    ///
549    /// Retrieves the BitMEX API info including the system time in Unix timestamp (milliseconds).
550    /// This is useful for synchronizing local clocks with the exchange server and logging time drift.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error if the HTTP request fails or if the response body
555    /// cannot be parsed into [`BitmexApiInfo`].
556    pub async fn get_server_time(&self) -> Result<u64, BitmexHttpError> {
557        let response: BitmexApiInfo = self
558            .send_request::<_, ()>(Method::GET, "", None, None, false)
559            .await?;
560        Ok(response.timestamp)
561    }
562
563    /// Get the instrument definition for the specified symbol.
564    ///
565    /// BitMEX responds to `/instrument?symbol=...` with an array, even when
566    /// a single symbol is requested. This method returns the first element of
567    /// that array and yields `Ok(None)` when the venue returns an empty list
568    /// (e.g. unknown symbol).
569    ///
570    /// # Errors
571    ///
572    /// Returns an error if the request fails or the payload cannot be deserialized.
573    pub async fn get_instrument(
574        &self,
575        symbol: &str,
576    ) -> Result<Option<BitmexInstrument>, BitmexHttpError> {
577        let path = &format!("/instrument?symbol={symbol}");
578        let instruments: Vec<BitmexInstrument> = self
579            .send_request::<_, ()>(Method::GET, path, None, None, false)
580            .await?;
581
582        Ok(instruments.into_iter().next())
583    }
584
585    /// Get user wallet information.
586    ///
587    /// # Errors
588    ///
589    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
590    pub async fn get_wallet(&self) -> Result<BitmexWallet, BitmexHttpError> {
591        let endpoint = "/user/wallet";
592        self.send_request::<_, ()>(Method::GET, endpoint, None, None, true)
593            .await
594    }
595
596    /// Get user margin information for a specific currency.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
601    pub async fn get_margin(&self, currency: &str) -> Result<BitmexMargin, BitmexHttpError> {
602        let path = format!("/user/margin?currency={currency}");
603        self.send_request::<_, ()>(Method::GET, &path, None, None, true)
604            .await
605    }
606
607    /// Get user margin information for all currencies.
608    ///
609    /// # Errors
610    ///
611    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
612    pub async fn get_all_margins(&self) -> Result<Vec<BitmexMargin>, BitmexHttpError> {
613        self.send_request::<_, ()>(Method::GET, "/user/margin?currency=all", None, None, true)
614            .await
615    }
616
617    /// Get historical trades.
618    ///
619    /// # Errors
620    ///
621    /// Returns an error if the request fails or the API returns an error.
622    pub async fn get_trades(
623        &self,
624        params: GetTradeParams,
625    ) -> Result<Vec<BitmexTrade>, BitmexHttpError> {
626        self.send_request(Method::GET, "/trade", Some(&params), None, false)
627            .await
628    }
629
630    /// Get bucketed (aggregated) trade data.
631    ///
632    /// # Errors
633    ///
634    /// Returns an error if the request fails or the API returns an error.
635    pub async fn get_trade_bucketed(
636        &self,
637        params: GetTradeBucketedParams,
638    ) -> Result<Vec<BitmexTradeBin>, BitmexHttpError> {
639        self.send_request(Method::GET, "/trade/bucketed", Some(&params), None, false)
640            .await
641    }
642
643    /// Get current L2 order book rows.
644    ///
645    /// # Errors
646    ///
647    /// Returns an error if the request fails or the API returns an error.
648    pub async fn get_order_book_l2(
649        &self,
650        params: GetOrderBookL2Params,
651    ) -> Result<Vec<BitmexOrderBookL2>, BitmexHttpError> {
652        self.send_request(Method::GET, "/orderBook/L2", Some(&params), None, false)
653            .await
654    }
655
656    /// Get historical funding rates.
657    ///
658    /// # Errors
659    ///
660    /// Returns an error if the request fails or the API returns an error.
661    pub async fn get_funding(
662        &self,
663        params: GetFundingParams,
664    ) -> Result<Vec<BitmexFunding>, BitmexHttpError> {
665        self.send_request(Method::GET, "/funding", Some(&params), None, false)
666            .await
667    }
668
669    /// Get user orders.
670    ///
671    /// # Errors
672    ///
673    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
674    pub async fn get_orders(
675        &self,
676        params: GetOrderParams,
677    ) -> Result<Vec<BitmexOrder>, BitmexHttpError> {
678        self.send_request(Method::GET, "/order", Some(&params), None, true)
679            .await
680    }
681
682    /// Place a new order.
683    ///
684    /// # Errors
685    ///
686    /// Returns an error if credentials are missing, the request fails, order validation fails, or the API returns an error.
687    pub async fn place_order(&self, params: PostOrderParams) -> Result<Value, BitmexHttpError> {
688        self.place_order_response(params).await
689    }
690
691    async fn place_order_response<T: DeserializeOwned>(
692        &self,
693        params: PostOrderParams,
694    ) -> Result<T, BitmexHttpError> {
695        // BitMEX spec requires form-encoded body for POST /order
696        let body = serde_urlencoded::to_string(&params)
697            .map_err(|e| {
698                BitmexHttpError::ValidationError(format!("Failed to serialize parameters: {e}"))
699            })?
700            .into_bytes();
701        let path = "/order";
702        self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
703            .await
704    }
705
706    /// Cancel user orders.
707    ///
708    /// # Errors
709    ///
710    /// Returns an error if credentials are missing, the request fails, the order doesn't exist, or the API returns an error.
711    pub async fn cancel_orders(&self, params: DeleteOrderParams) -> Result<Value, BitmexHttpError> {
712        self.cancel_orders_response(params).await
713    }
714
715    async fn cancel_orders_response<T: DeserializeOwned>(
716        &self,
717        params: DeleteOrderParams,
718    ) -> Result<T, BitmexHttpError> {
719        // BitMEX spec requires form-encoded body for DELETE /order
720        let body = serde_urlencoded::to_string(&params)
721            .map_err(|e| {
722                BitmexHttpError::ValidationError(format!("Failed to serialize parameters: {e}"))
723            })?
724            .into_bytes();
725        let path = "/order";
726        self.send_request::<_, ()>(Method::DELETE, path, None, Some(body), true)
727            .await
728    }
729
730    /// Amend an existing order.
731    ///
732    /// # Errors
733    ///
734    /// Returns an error if credentials are missing, the request fails, the order doesn't exist, or the API returns an error.
735    pub async fn amend_order(&self, params: PutOrderParams) -> Result<Value, BitmexHttpError> {
736        self.amend_order_response(params).await
737    }
738
739    async fn amend_order_response<T: DeserializeOwned>(
740        &self,
741        params: PutOrderParams,
742    ) -> Result<T, BitmexHttpError> {
743        // BitMEX spec requires form-encoded body for PUT /order
744        let body = serde_urlencoded::to_string(&params)
745            .map_err(|e| {
746                BitmexHttpError::ValidationError(format!("Failed to serialize parameters: {e}"))
747            })?
748            .into_bytes();
749        let path = "/order";
750        self.send_request::<_, ()>(Method::PUT, path, None, Some(body), true)
751            .await
752    }
753
754    /// Cancel all orders.
755    ///
756    /// # Errors
757    ///
758    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
759    ///
760    /// # References
761    ///
762    /// <https://www.bitmex.com/api/explorer/#!/Order/Order_cancelAll>
763    pub async fn cancel_all_orders(
764        &self,
765        params: DeleteAllOrdersParams,
766    ) -> Result<Value, BitmexHttpError> {
767        self.cancel_all_orders_response(params).await
768    }
769
770    async fn cancel_all_orders_response<T: DeserializeOwned>(
771        &self,
772        params: DeleteAllOrdersParams,
773    ) -> Result<T, BitmexHttpError> {
774        self.send_request(Method::DELETE, "/order/all", Some(&params), None, true)
775            .await
776    }
777
778    /// Set a dead man's switch (cancel all orders after timeout).
779    ///
780    /// Calling with `timeout=0` disarms the switch.
781    ///
782    /// # Errors
783    ///
784    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
785    ///
786    /// # References
787    ///
788    /// <https://www.bitmex.com/api/explorer/#!/Order/Order_cancelAllAfter>
789    pub async fn cancel_all_after(
790        &self,
791        params: PostCancelAllAfterParams,
792    ) -> Result<Value, BitmexHttpError> {
793        let body = serde_urlencoded::to_string(&params)
794            .map_err(|e| {
795                BitmexHttpError::ValidationError(format!("Failed to serialize parameters: {e}"))
796            })?
797            .into_bytes();
798        self.send_request::<_, ()>(
799            Method::POST,
800            "/order/cancelAllAfter",
801            None,
802            Some(body),
803            true,
804        )
805        .await
806    }
807
808    /// Get user executions.
809    ///
810    /// # Errors
811    ///
812    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
813    pub async fn get_executions(
814        &self,
815        params: GetExecutionParams,
816    ) -> Result<Vec<BitmexExecution>, BitmexHttpError> {
817        let query = serde_urlencoded::to_string(&params).map_err(|e| {
818            BitmexHttpError::ValidationError(format!("Failed to serialize parameters: {e}"))
819        })?;
820        let path = format!("/execution/tradeHistory?{query}");
821        self.send_request::<_, ()>(Method::GET, &path, None, None, true)
822            .await
823    }
824
825    /// Get user positions.
826    ///
827    /// # Errors
828    ///
829    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
830    pub async fn get_positions(
831        &self,
832        params: GetPositionParams,
833    ) -> Result<Vec<BitmexPosition>, BitmexHttpError> {
834        self.send_request(Method::GET, "/position", Some(&params), None, true)
835            .await
836    }
837
838    /// Update position leverage.
839    ///
840    /// # Errors
841    ///
842    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
843    pub async fn update_position_leverage(
844        &self,
845        params: PostPositionLeverageParams,
846    ) -> Result<BitmexPosition, BitmexHttpError> {
847        // BitMEX spec requires form-encoded body for POST endpoints
848        let body = serde_urlencoded::to_string(&params)
849            .map_err(|e| {
850                BitmexHttpError::ValidationError(format!("Failed to serialize parameters: {e}"))
851            })?
852            .into_bytes();
853        let path = "/position/leverage";
854        self.send_request::<_, ()>(Method::POST, path, None, Some(body), true)
855            .await
856    }
857}
858
859/// Provides a HTTP client for connecting to the [BitMEX](https://www.bitmex.com) REST API.
860///
861/// This is the high-level client that wraps the inner client and provides
862/// Nautilus-specific functionality for trading operations.
863#[derive(Debug)]
864#[cfg_attr(
865    feature = "python",
866    pyo3::pyclass(module = "nautilus_trader.adapters.bitmex", from_py_object)
867)]
868#[cfg_attr(
869    feature = "python",
870    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.bitmex")
871)]
872pub struct BitmexHttpClient {
873    pub(crate) instruments_cache: Arc<AtomicMap<Ustr, InstrumentAny>>,
874    pub(crate) order_type_cache: Arc<DashMap<ClientOrderId, OrderType>>,
875    clock: &'static AtomicTime,
876    inner: Arc<BitmexRawHttpClient>,
877    cache_initialized: AtomicBool,
878}
879
880impl Clone for BitmexHttpClient {
881    fn clone(&self) -> Self {
882        let cache_initialized = AtomicBool::new(false);
883
884        let is_initialized = self.cache_initialized.load(Ordering::Acquire);
885        if is_initialized {
886            cache_initialized.store(true, Ordering::Release);
887        }
888
889        Self {
890            inner: self.inner.clone(),
891            instruments_cache: self.instruments_cache.clone(),
892            order_type_cache: self.order_type_cache.clone(),
893            cache_initialized,
894            clock: self.clock,
895        }
896    }
897}
898
899impl Default for BitmexHttpClient {
900    fn default() -> Self {
901        Self::new(
902            None,
903            None,
904            None,
905            BitmexEnvironment::Mainnet,
906            60,
907            3,
908            1_000,
909            10_000,
910            10_000,
911            BITMEX_DEFAULT_RATE_LIMIT_PER_SECOND,
912            BITMEX_DEFAULT_RATE_LIMIT_PER_MINUTE_UNAUTHENTICATED,
913            None,
914        )
915        .expect("Failed to create default BitmexHttpClient")
916    }
917}
918
919impl BitmexHttpClient {
920    /// Creates a new [`BitmexHttpClient`] instance.
921    ///
922    /// # Errors
923    ///
924    /// Returns an error if the HTTP client cannot be created.
925    #[expect(clippy::too_many_arguments)]
926    pub fn new(
927        base_url: Option<String>,
928        api_key: Option<String>,
929        api_secret: Option<String>,
930        environment: BitmexEnvironment,
931        timeout_secs: u64,
932        max_retries: u32,
933        retry_delay_ms: u64,
934        retry_delay_max_ms: u64,
935        recv_window_ms: u64,
936        max_requests_per_second: u32,
937        max_requests_per_minute: u32,
938        proxy_url: Option<String>,
939    ) -> Result<Self, BitmexHttpError> {
940        // Determine the base URL
941        let url = base_url.unwrap_or_else(|| match environment {
942            BitmexEnvironment::Testnet => BITMEX_HTTP_TESTNET_URL.to_string(),
943            BitmexEnvironment::Mainnet => BITMEX_HTTP_URL.to_string(),
944        });
945
946        let (key_var, secret_var) = credential_env_vars(environment);
947        let api_key = get_or_env_var_opt(api_key, key_var);
948        let api_secret = get_or_env_var_opt(api_secret, secret_var);
949
950        let inner = match (api_key, api_secret) {
951            (Some(key), Some(secret)) => BitmexRawHttpClient::with_credentials(
952                key,
953                secret,
954                url,
955                timeout_secs,
956                max_retries,
957                retry_delay_ms,
958                retry_delay_max_ms,
959                recv_window_ms,
960                max_requests_per_second,
961                max_requests_per_minute,
962                proxy_url,
963            )?,
964            (Some(_), None) | (None, Some(_)) => {
965                return Err(BitmexHttpError::ValidationError(
966                    "Both api_key and api_secret must be provided, or neither".to_string(),
967                ));
968            }
969            (None, None) => BitmexRawHttpClient::new(
970                Some(url),
971                timeout_secs,
972                max_retries,
973                retry_delay_ms,
974                retry_delay_max_ms,
975                recv_window_ms,
976                max_requests_per_second,
977                max_requests_per_minute,
978                proxy_url,
979            )?,
980        };
981
982        Ok(Self {
983            inner: Arc::new(inner),
984            instruments_cache: Arc::new(AtomicMap::new()),
985            order_type_cache: Arc::new(DashMap::new()),
986            cache_initialized: AtomicBool::new(false),
987            clock: get_atomic_clock_realtime(),
988        })
989    }
990
991    /// Creates a new [`BitmexHttpClient`] instance using environment variables and
992    /// the default BitMEX HTTP base URL.
993    ///
994    /// # Errors
995    ///
996    /// Returns an error if required environment variables are not set or invalid.
997    pub fn from_env() -> anyhow::Result<Self> {
998        Self::with_credentials(
999            None, None, None, 60, 3, 1_000, 10_000, 10_000, 10, 120, None,
1000        )
1001        .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))
1002    }
1003
1004    /// Creates a new [`BitmexHttpClient`] configured with credentials
1005    /// for authenticated requests.
1006    ///
1007    /// If `api_key` or `api_secret` are `None`, they will be sourced from the
1008    /// `BITMEX_API_KEY` and `BITMEX_API_SECRET` environment variables.
1009    ///
1010    /// # Errors
1011    ///
1012    /// Returns an error if one credential is provided without the other.
1013    #[expect(clippy::too_many_arguments)]
1014    pub fn with_credentials(
1015        api_key: Option<String>,
1016        api_secret: Option<String>,
1017        base_url: Option<String>,
1018        timeout_secs: u64,
1019        max_retries: u32,
1020        retry_delay_ms: u64,
1021        retry_delay_max_ms: u64,
1022        recv_window_ms: u64,
1023        max_requests_per_second: u32,
1024        max_requests_per_minute: u32,
1025        proxy_url: Option<String>,
1026    ) -> anyhow::Result<Self> {
1027        // Determine environment from URL to select correct environment variables
1028        let environment = if base_url.as_ref().is_some_and(|url| url.contains("testnet")) {
1029            BitmexEnvironment::Testnet
1030        } else {
1031            BitmexEnvironment::Mainnet
1032        };
1033
1034        let (key_var, secret_var) = credential_env_vars(environment);
1035
1036        let api_key = get_or_env_var_opt(api_key, key_var);
1037        let api_secret = get_or_env_var_opt(api_secret, secret_var);
1038
1039        // If we're trying to create an authenticated client, we need both key and secret
1040        if api_key.is_some() && api_secret.is_none() {
1041            anyhow::bail!("{secret_var} is required when {key_var} is provided");
1042        }
1043
1044        if api_key.is_none() && api_secret.is_some() {
1045            anyhow::bail!("{key_var} is required when {secret_var} is provided");
1046        }
1047
1048        Self::new(
1049            base_url,
1050            api_key,
1051            api_secret,
1052            environment,
1053            timeout_secs,
1054            max_retries,
1055            retry_delay_ms,
1056            retry_delay_max_ms,
1057            recv_window_ms,
1058            max_requests_per_second,
1059            max_requests_per_minute,
1060            proxy_url,
1061        )
1062        .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))
1063    }
1064
1065    /// Returns the base url being used by the client.
1066    #[must_use]
1067    pub fn base_url(&self) -> &str {
1068        self.inner.base_url.as_str()
1069    }
1070
1071    /// Returns the public API key being used by the client.
1072    #[must_use]
1073    pub fn api_key(&self) -> Option<&str> {
1074        self.inner.credential.as_ref().map(|c| c.api_key())
1075    }
1076
1077    /// Returns a masked version of the API key for logging purposes.
1078    #[must_use]
1079    pub fn api_key_masked(&self) -> Option<String> {
1080        self.inner.credential.as_ref().map(|c| c.api_key_masked())
1081    }
1082
1083    /// Requests the current server time from BitMEX.
1084    ///
1085    /// Returns the BitMEX system time as a Unix timestamp in milliseconds.
1086    ///
1087    /// # Errors
1088    ///
1089    /// Returns an error if the HTTP request fails or if the response cannot be parsed.
1090    pub async fn get_server_time(&self) -> Result<u64, BitmexHttpError> {
1091        self.inner.get_server_time().await
1092    }
1093
1094    /// Sets the dead man's switch (cancel all orders after timeout).
1095    ///
1096    /// Calling with `timeout_ms=0` disarms the switch.
1097    ///
1098    /// # Errors
1099    ///
1100    /// Returns an error if the HTTP request fails.
1101    pub async fn cancel_all_after(&self, timeout_ms: u64) -> anyhow::Result<()> {
1102        let params = PostCancelAllAfterParams {
1103            timeout: timeout_ms,
1104        };
1105        self.inner.cancel_all_after(params).await?;
1106        Ok(())
1107    }
1108
1109    /// Generates a timestamp for initialization.
1110    fn generate_ts_init(&self) -> UnixNanos {
1111        self.clock.get_time_ns()
1112    }
1113
1114    /// Check if the order has a contingency type that requires linking.
1115    fn is_contingent_order(contingency_type: Option<ContingencyType>) -> bool {
1116        contingency_type.is_some()
1117    }
1118
1119    /// Check if the order is a parent in contingency relationships.
1120    fn is_parent_contingency(contingency_type: Option<ContingencyType>) -> bool {
1121        matches!(
1122            contingency_type,
1123            Some(ContingencyType::Oco | ContingencyType::Oto)
1124        )
1125    }
1126
1127    /// Populate missing `linked_order_ids` for contingency orders by grouping on `order_list_id`.
1128    fn populate_linked_order_ids(reports: &mut [OrderStatusReport]) {
1129        let mut order_list_groups: HashMap<OrderListId, Vec<ClientOrderId>> = HashMap::new();
1130        let mut order_list_parents: HashMap<OrderListId, ClientOrderId> = HashMap::new();
1131        let mut prefix_groups: HashMap<String, Vec<ClientOrderId>> = HashMap::new();
1132        let mut prefix_parents: HashMap<String, ClientOrderId> = HashMap::new();
1133
1134        for report in reports.iter() {
1135            let Some(client_order_id) = report.client_order_id else {
1136                continue;
1137            };
1138
1139            if let Some(order_list_id) = report.order_list_id {
1140                order_list_groups
1141                    .entry(order_list_id)
1142                    .or_default()
1143                    .push(client_order_id);
1144
1145                if Self::is_parent_contingency(report.contingency_type) {
1146                    order_list_parents
1147                        .entry(order_list_id)
1148                        .or_insert(client_order_id);
1149                }
1150            }
1151
1152            if let Some((base, _)) = client_order_id.as_str().rsplit_once('-')
1153                && Self::is_contingent_order(report.contingency_type)
1154            {
1155                prefix_groups
1156                    .entry(base.to_owned())
1157                    .or_default()
1158                    .push(client_order_id);
1159
1160                if Self::is_parent_contingency(report.contingency_type) {
1161                    prefix_parents
1162                        .entry(base.to_owned())
1163                        .or_insert(client_order_id);
1164                }
1165            }
1166        }
1167
1168        for report in reports.iter_mut() {
1169            let Some(client_order_id) = report.client_order_id else {
1170                continue;
1171            };
1172
1173            if report.linked_order_ids.is_some() {
1174                continue;
1175            }
1176
1177            // Only process contingent orders
1178            if !Self::is_contingent_order(report.contingency_type) {
1179                continue;
1180            }
1181
1182            if let Some(order_list_id) = report.order_list_id
1183                && let Some(group) = order_list_groups.get(&order_list_id)
1184            {
1185                let mut linked: Vec<ClientOrderId> = group
1186                    .iter()
1187                    .copied()
1188                    .filter(|candidate| candidate != &client_order_id)
1189                    .collect();
1190
1191                if !linked.is_empty() {
1192                    if let Some(parent_id) = order_list_parents.get(&order_list_id) {
1193                        if client_order_id == *parent_id {
1194                            report.parent_order_id = None;
1195                        } else {
1196                            linked.sort_by_key(|candidate| i32::from(candidate != parent_id));
1197                            report.parent_order_id = Some(*parent_id);
1198                        }
1199                    } else {
1200                        report.parent_order_id = None;
1201                    }
1202
1203                    log::trace!(
1204                        "BitMEX linked ids sourced from order list id: client_order_id={:?}, order_list_id={:?}, contingency_type={:?}, linked_order_ids={:?}",
1205                        client_order_id,
1206                        order_list_id,
1207                        report.contingency_type,
1208                        linked,
1209                    );
1210                    report.linked_order_ids = Some(linked);
1211                    continue;
1212                }
1213
1214                log::trace!(
1215                    "BitMEX order list id group had no peers: client_order_id={:?}, order_list_id={:?}, contingency_type={:?}, order_list_group={:?}",
1216                    client_order_id,
1217                    order_list_id,
1218                    report.contingency_type,
1219                    group,
1220                );
1221                report.parent_order_id = None;
1222            } else if report.order_list_id.is_none() {
1223                report.parent_order_id = None;
1224            }
1225
1226            if let Some((base, _)) = client_order_id.as_str().rsplit_once('-')
1227                && let Some(group) = prefix_groups.get(base)
1228            {
1229                let mut linked: Vec<ClientOrderId> = group
1230                    .iter()
1231                    .copied()
1232                    .filter(|candidate| candidate != &client_order_id)
1233                    .collect();
1234
1235                if !linked.is_empty() {
1236                    if let Some(parent_id) = prefix_parents.get(base) {
1237                        if client_order_id == *parent_id {
1238                            report.parent_order_id = None;
1239                        } else {
1240                            linked.sort_by_key(|candidate| i32::from(candidate != parent_id));
1241                            report.parent_order_id = Some(*parent_id);
1242                        }
1243                    } else {
1244                        report.parent_order_id = None;
1245                    }
1246
1247                    log::trace!(
1248                        "BitMEX linked ids constructed from client order id prefix: client_order_id={:?}, contingency_type={:?}, base={}, linked_order_ids={:?}",
1249                        client_order_id,
1250                        report.contingency_type,
1251                        base,
1252                        linked,
1253                    );
1254                    report.linked_order_ids = Some(linked);
1255                    continue;
1256                }
1257
1258                log::trace!(
1259                    "BitMEX client order id prefix group had no peers: client_order_id={:?}, contingency_type={:?}, base={}, prefix_group={:?}",
1260                    client_order_id,
1261                    report.contingency_type,
1262                    base,
1263                    group,
1264                );
1265                report.parent_order_id = None;
1266            } else if client_order_id.as_str().contains('-') {
1267                report.parent_order_id = None;
1268            }
1269
1270            if report.contingency_type == Some(ContingencyType::Oto) {
1271                log::debug!(
1272                    "BitMEX OTO order has no linked venue peers; reconciling as standalone: client_order_id={:?}, order_list_id={:?}",
1273                    report.client_order_id,
1274                    report.order_list_id,
1275                );
1276                report.contingency_type = None;
1277                report.parent_order_id = None;
1278            } else if Self::is_contingent_order(report.contingency_type) {
1279                log::warn!(
1280                    "BitMEX order status report missing linked ids after grouping: client_order_id={:?}, order_list_id={:?}, contingency_type={:?}",
1281                    report.client_order_id,
1282                    report.order_list_id,
1283                    report.contingency_type,
1284                );
1285                report.contingency_type = None;
1286                report.parent_order_id = None;
1287            }
1288
1289            report.linked_order_ids = None;
1290        }
1291    }
1292
1293    /// Cancel all pending HTTP requests.
1294    pub fn cancel_all_requests(&self) {
1295        self.inner.cancel_all_requests();
1296    }
1297
1298    /// Replace the cancellation token so new requests can proceed.
1299    pub fn reset_cancellation_token(&self) {
1300        self.inner.reset_cancellation_token();
1301    }
1302
1303    /// Get a clone of the cancellation token for this client.
1304    pub fn cancellation_token(&self) -> CancellationToken {
1305        self.inner.cancellation_token()
1306    }
1307
1308    /// Caches a single instrument.
1309    ///
1310    /// Any existing instrument with the same symbol will be replaced.
1311    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1312        self.instruments_cache
1313            .insert(instrument.raw_symbol().inner(), instrument);
1314        self.cache_initialized.store(true, Ordering::Release);
1315    }
1316
1317    /// Caches multiple instruments.
1318    ///
1319    /// Any existing instruments with the same symbols will be replaced.
1320    pub fn cache_instruments(&self, instruments: &[InstrumentAny]) {
1321        self.instruments_cache.rcu(|m| {
1322            for inst in instruments {
1323                m.insert(inst.raw_symbol().inner(), inst.clone());
1324            }
1325        });
1326        self.cache_initialized.store(true, Ordering::Release);
1327    }
1328
1329    /// Gets an instrument from the cache by symbol.
1330    pub fn get_instrument(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1331        self.instruments_cache.get_cloned(symbol)
1332    }
1333
1334    /// Request a single instrument and parse it into a Nautilus type.
1335    ///
1336    /// # Errors
1337    ///
1338    /// Returns `Ok(Some(..))` when the venue returns a definition that parses
1339    /// successfully, `Ok(None)` when the instrument is unknown, unsupported, or the payload
1340    /// cannot be converted into a Nautilus `Instrument`.
1341    pub async fn request_instrument(
1342        &self,
1343        instrument_id: InstrumentId,
1344    ) -> anyhow::Result<Option<InstrumentAny>> {
1345        let response = self
1346            .inner
1347            .get_instrument(instrument_id.symbol.as_str())
1348            .await?;
1349
1350        let instrument = match response {
1351            Some(instrument) => instrument,
1352            None => return Ok(None),
1353        };
1354
1355        let ts_init = self.generate_ts_init();
1356
1357        match parse_instrument_any(&instrument, ts_init) {
1358            InstrumentParseResult::Ok(inst) => Ok(Some(*inst)),
1359            InstrumentParseResult::Unsupported {
1360                symbol,
1361                instrument_type,
1362            } => {
1363                log::debug!(
1364                    "Instrument {symbol} has unsupported type {instrument_type:?}, returning None"
1365                );
1366                Ok(None)
1367            }
1368            InstrumentParseResult::Inactive { symbol, state } => {
1369                log::debug!("Instrument {symbol} is inactive (state={state}), returning None");
1370                Ok(None)
1371            }
1372            InstrumentParseResult::Failed {
1373                symbol,
1374                instrument_type,
1375                error,
1376            } => {
1377                log::error!(
1378                    "Failed to parse instrument {symbol} (type={instrument_type:?}): {error}"
1379                );
1380                Ok(None)
1381            }
1382        }
1383    }
1384
1385    /// Request all available instruments and parse them into Nautilus types.
1386    ///
1387    /// # Errors
1388    ///
1389    /// Returns an error if the HTTP request fails or parsing fails.
1390    pub async fn request_instruments(
1391        &self,
1392        active_only: bool,
1393    ) -> anyhow::Result<Vec<InstrumentAny>> {
1394        let instruments = self.inner.get_instruments(active_only).await?;
1395        let ts_init = self.generate_ts_init();
1396
1397        let mut parsed_instruments = Vec::new();
1398        let mut skipped_count = 0;
1399        let mut inactive_count = 0;
1400        let mut failed_count = 0;
1401        let total_count = instruments.len();
1402
1403        for inst in instruments {
1404            match parse_instrument_any(&inst, ts_init) {
1405                InstrumentParseResult::Ok(instrument_any) => {
1406                    parsed_instruments.push(*instrument_any);
1407                }
1408                InstrumentParseResult::Unsupported {
1409                    symbol,
1410                    instrument_type,
1411                } => {
1412                    skipped_count += 1;
1413                    log::debug!(
1414                        "Skipping unsupported instrument type: symbol={symbol}, type={instrument_type:?}"
1415                    );
1416                }
1417                InstrumentParseResult::Inactive { symbol, state } => {
1418                    inactive_count += 1;
1419                    log::debug!("Skipping inactive instrument: symbol={symbol}, state={state}");
1420                }
1421                InstrumentParseResult::Failed {
1422                    symbol,
1423                    instrument_type,
1424                    error,
1425                } => {
1426                    failed_count += 1;
1427                    log::error!(
1428                        "Failed to parse instrument: symbol={symbol}, type={instrument_type:?}, error={error}"
1429                    );
1430                }
1431            }
1432        }
1433
1434        if skipped_count > 0 {
1435            log::debug!(
1436                "Skipped {skipped_count} unsupported instrument type(s) out of {total_count} total"
1437            );
1438        }
1439
1440        if inactive_count > 0 {
1441            log::debug!(
1442                "Skipped {inactive_count} inactive instrument(s) out of {total_count} total"
1443            );
1444        }
1445
1446        if failed_count > 0 {
1447            log::error!(
1448                "Instrument parse failures: {failed_count} failed out of {total_count} total ({} successfully parsed)",
1449                parsed_instruments.len()
1450            );
1451        }
1452
1453        Ok(parsed_instruments)
1454    }
1455
1456    /// Get user wallet information.
1457    ///
1458    /// # Errors
1459    ///
1460    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
1461    pub async fn get_wallet(&self) -> Result<BitmexWallet, BitmexHttpError> {
1462        let inner = self.inner.clone();
1463        inner.get_wallet().await
1464    }
1465
1466    /// Get user orders.
1467    ///
1468    /// # Errors
1469    ///
1470    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
1471    pub async fn get_orders(
1472        &self,
1473        params: GetOrderParams,
1474    ) -> Result<Vec<BitmexOrder>, BitmexHttpError> {
1475        let inner = self.inner.clone();
1476        inner.get_orders(params).await
1477    }
1478
1479    /// Get instrument from the instruments cache (if found).
1480    ///
1481    /// # Errors
1482    ///
1483    /// Returns an error if the instrument is not found in the cache.
1484    fn instrument_from_cache(&self, symbol: Ustr) -> anyhow::Result<InstrumentAny> {
1485        self.get_instrument(&symbol).ok_or_else(|| {
1486            anyhow::anyhow!(
1487                "Instrument {symbol} not found in cache, ensure instruments loaded first"
1488            )
1489        })
1490    }
1491
1492    /// Returns the cached price precision for the given symbol.
1493    ///
1494    /// # Errors
1495    ///
1496    /// Returns an error if the instrument was never cached (for example, if
1497    /// instruments were not loaded prior to use).
1498    pub fn get_price_precision(&self, symbol: Ustr) -> anyhow::Result<u8> {
1499        self.instrument_from_cache(symbol)
1500            .map(|instrument| instrument.price_precision())
1501    }
1502
1503    /// Get user margin information for a specific currency.
1504    ///
1505    /// # Errors
1506    ///
1507    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
1508    pub async fn get_margin(&self, currency: &str) -> anyhow::Result<BitmexMargin> {
1509        self.inner
1510            .get_margin(currency)
1511            .await
1512            .map_err(|e| anyhow::anyhow!(e))
1513    }
1514
1515    /// Get user margin information for all currencies.
1516    ///
1517    /// # Errors
1518    ///
1519    /// Returns an error if credentials are missing, the request fails, or the API returns an error.
1520    pub async fn get_all_margins(&self) -> anyhow::Result<Vec<BitmexMargin>> {
1521        self.inner
1522            .get_all_margins()
1523            .await
1524            .map_err(|e| anyhow::anyhow!(e))
1525    }
1526
1527    /// Request account state for the authenticated BitMEX account.
1528    ///
1529    /// # Errors
1530    ///
1531    /// Returns an error if the HTTP request fails or no account state is returned.
1532    pub async fn request_account_state(
1533        &self,
1534        fallback_account_id: AccountId,
1535    ) -> anyhow::Result<AccountState> {
1536        let margins = self
1537            .inner
1538            .get_all_margins()
1539            .await
1540            .map_err(|e| anyhow::anyhow!(e))?;
1541        let account_id = account_id_from_margins(&margins)?.unwrap_or(fallback_account_id);
1542
1543        let ts_init =
1544            UnixNanos::from(u64::try_from(Timestamp::now().as_nanosecond()).unwrap_or_default());
1545
1546        let mut balances = Vec::with_capacity(margins.len());
1547        let mut margins_vec = Vec::new();
1548        let mut latest_timestamp: Option<Timestamp> = None;
1549
1550        for margin in margins {
1551            if let Some(ts) = margin.timestamp {
1552                latest_timestamp = Some(latest_timestamp.map_or(ts, |prev| prev.max(ts)));
1553            }
1554
1555            let margin_msg = BitmexMarginMsg {
1556                account: margin.account,
1557                currency: margin.currency,
1558                risk_limit: margin.risk_limit,
1559                amount: margin.amount,
1560                prev_realised_pnl: margin.prev_realised_pnl,
1561                gross_comm: margin.gross_comm,
1562                gross_open_cost: margin.gross_open_cost,
1563                gross_open_premium: margin.gross_open_premium,
1564                gross_exec_cost: margin.gross_exec_cost,
1565                gross_mark_value: margin.gross_mark_value,
1566                risk_value: margin.risk_value,
1567                init_margin: margin.init_margin,
1568                maint_margin: margin.maint_margin,
1569                target_excess_margin: margin.target_excess_margin,
1570                realised_pnl: margin.realised_pnl,
1571                unrealised_pnl: margin.unrealised_pnl,
1572                wallet_balance: margin.wallet_balance,
1573                margin_balance: margin.margin_balance,
1574                margin_leverage: margin.margin_leverage,
1575                margin_used_pcnt: margin.margin_used_pcnt,
1576                excess_margin: margin.excess_margin,
1577                available_margin: margin.available_margin,
1578                withdrawable_margin: margin.withdrawable_margin,
1579                maker_fee_discount: None,
1580                taker_fee_discount: None,
1581                timestamp: margin.timestamp.unwrap_or_else(Timestamp::now),
1582                foreign_margin_balance: None,
1583                foreign_requirement: None,
1584            };
1585
1586            let balance = parse_account_balance(&margin_msg);
1587
1588            let divisor = bitmex_currency_divisor(margin_msg.currency.as_str());
1589            let initial_dec = Decimal::from(margin_msg.init_margin.unwrap_or(0).max(0)) / divisor;
1590            let maintenance_dec =
1591                Decimal::from(margin_msg.maint_margin.unwrap_or(0).max(0)) / divisor;
1592
1593            if !initial_dec.is_zero() || !maintenance_dec.is_zero() {
1594                let currency = balance.total.currency;
1595                // BitMEX reports cross-margin aggregates per collateral currency.
1596                margins_vec.push(MarginBalance::new(
1597                    Money::from_decimal(initial_dec, currency)
1598                        .unwrap_or_else(|_| Money::zero(currency)),
1599                    Money::from_decimal(maintenance_dec, currency)
1600                        .unwrap_or_else(|_| Money::zero(currency)),
1601                    None,
1602                ));
1603            }
1604
1605            balances.push(balance);
1606        }
1607
1608        if balances.is_empty() {
1609            anyhow::bail!("No margin data returned from BitMEX");
1610        }
1611
1612        let account_type = AccountType::Margin;
1613        let is_reported = true;
1614        let event_id = UUID4::new();
1615
1616        // Use server timestamp if available, otherwise fall back to local time
1617        let ts_event = latest_timestamp.map_or(ts_init, |ts| {
1618            UnixNanos::from(u64::try_from(ts.as_nanosecond()).unwrap_or_default())
1619        });
1620
1621        Ok(AccountState::new(
1622            account_id,
1623            account_type,
1624            balances,
1625            margins_vec,
1626            is_reported,
1627            event_id,
1628            ts_event,
1629            ts_init,
1630            None,
1631        ))
1632    }
1633
1634    /// Submit a new order.
1635    ///
1636    /// # Errors
1637    ///
1638    /// Returns an error if credentials are missing, the request fails, order validation fails,
1639    /// the order is rejected, or the API returns an error.
1640    #[expect(clippy::too_many_arguments)]
1641    pub async fn submit_order(
1642        &self,
1643        instrument_id: InstrumentId,
1644        client_order_id: ClientOrderId,
1645        order_side: OrderSide,
1646        order_type: OrderType,
1647        quantity: Quantity,
1648        time_in_force: TimeInForce,
1649        price: Option<Price>,
1650        trigger_price: Option<Price>,
1651        trigger_type: Option<TriggerType>,
1652        trailing_offset: Option<f64>,
1653        trailing_offset_type: Option<TrailingOffsetType>,
1654        display_qty: Option<Quantity>,
1655        post_only: bool,
1656        reduce_only: bool,
1657        order_list_id: Option<OrderListId>,
1658        contingency_type: Option<ContingencyType>,
1659        peg_price_type: Option<BitmexPegPriceType>,
1660        peg_offset_value: Option<f64>,
1661    ) -> anyhow::Result<OrderStatusReport> {
1662        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
1663
1664        let mut params = super::query::PostOrderParamsBuilder::default();
1665        params.text(NAUTILUS_TRADER);
1666        params.symbol(instrument_id.symbol.as_str());
1667        params.cl_ord_id(client_order_id.as_str());
1668
1669        let side = BitmexSide::from(order_side);
1670        params.side(side);
1671
1672        let ord_type = BitmexOrderType::try_from_order_type(order_type)?;
1673        params.ord_type(ord_type);
1674
1675        params.order_qty(quantity_to_u32(&quantity, &instrument));
1676
1677        let tif = BitmexTimeInForce::try_from_time_in_force(time_in_force)?;
1678        params.time_in_force(tif);
1679
1680        if let Some(price) = price {
1681            params.price(price.as_f64());
1682        }
1683
1684        if let Some(trigger_price) = trigger_price {
1685            params.stop_px(trigger_price.as_f64());
1686        }
1687
1688        if let Some(display_qty) = display_qty {
1689            params.display_qty(quantity_to_u32(&display_qty, &instrument));
1690        }
1691
1692        if let Some(order_list_id) = order_list_id {
1693            params.cl_ord_link_id(order_list_id.as_str());
1694        }
1695
1696        let is_trailing_stop = matches!(
1697            order_type,
1698            OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
1699        );
1700
1701        if is_trailing_stop && let Some(offset) = trailing_offset {
1702            if let Some(offset_type) = trailing_offset_type
1703                && offset_type != TrailingOffsetType::Price
1704            {
1705                anyhow::bail!(
1706                    "BitMEX only supports PRICE trailing offset type, was {offset_type:?}"
1707                );
1708            }
1709
1710            params.peg_price_type(BitmexPegPriceType::TrailingStopPeg);
1711
1712            // BitMEX requires negative offset for stop-sell orders
1713            let signed_offset = match order_side {
1714                OrderSide::Sell => -offset.abs(),
1715                OrderSide::Buy => offset.abs(),
1716            };
1717            params.peg_offset_value(signed_offset);
1718        }
1719
1720        // Pegged orders (BBO) via params override
1721        if peg_price_type.is_none() && peg_offset_value.is_some() {
1722            anyhow::bail!("`peg_offset_value` requires `peg_price_type`");
1723        }
1724
1725        if let Some(peg_type) = peg_price_type {
1726            if order_type != OrderType::Limit {
1727                anyhow::bail!(
1728                    "Pegged orders only supported for LIMIT order type, was {order_type:?}"
1729                );
1730            }
1731            params.ord_type(BitmexOrderType::Pegged);
1732            params.peg_price_type(peg_type);
1733
1734            if let Some(offset) = peg_offset_value {
1735                params.peg_offset_value(offset);
1736            }
1737        }
1738
1739        let mut exec_inst = Vec::new();
1740
1741        if post_only {
1742            exec_inst.push(BitmexExecInstruction::ParticipateDoNotInitiate);
1743        }
1744
1745        if reduce_only {
1746            exec_inst.push(BitmexExecInstruction::ReduceOnly);
1747        }
1748
1749        // For trailing stops, trigger_type specifies which price to track (Mark, Last, Index)
1750        if (trigger_price.is_some() || is_trailing_stop)
1751            && let Some(trigger_type) = trigger_type
1752        {
1753            match trigger_type {
1754                TriggerType::LastPrice => exec_inst.push(BitmexExecInstruction::LastPrice),
1755                TriggerType::MarkPrice => exec_inst.push(BitmexExecInstruction::MarkPrice),
1756                TriggerType::IndexPrice => exec_inst.push(BitmexExecInstruction::IndexPrice),
1757                _ => {} // Use BitMEX default (LastPrice) for other trigger types
1758            }
1759        }
1760
1761        if !exec_inst.is_empty() {
1762            params.exec_inst(exec_inst);
1763        }
1764
1765        if let Some(contingency_type) = contingency_type {
1766            let bitmex_contingency = BitmexContingencyType::try_from(contingency_type)?;
1767            params.contingency_type(bitmex_contingency);
1768        }
1769
1770        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1771
1772        let order: BitmexOrder = self.inner.place_order_response(params).await?;
1773
1774        if order.ord_status == Some(BitmexOrderStatus::Rejected) {
1775            let reason = order
1776                .ord_rej_reason
1777                .map_or_else(|| "No reason provided".to_string(), |r| r.to_string());
1778            anyhow::bail!("Order rejected: {reason}");
1779        }
1780
1781        // Cache order type for future lookups (e.g., cancel responses missing ord_type)
1782        self.order_type_cache.insert(client_order_id, order_type);
1783
1784        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
1785        let ts_init = self.generate_ts_init();
1786
1787        parse_order_status_report(&order, &instrument, &self.order_type_cache, ts_init)
1788    }
1789
1790    /// Cancel an order.
1791    ///
1792    /// # Errors
1793    ///
1794    /// Returns an error if:
1795    /// - Credentials are missing.
1796    /// - The request fails.
1797    /// - The order doesn't exist.
1798    /// - The API returns an error.
1799    pub async fn cancel_order(
1800        &self,
1801        instrument_id: InstrumentId,
1802        client_order_id: Option<ClientOrderId>,
1803        venue_order_id: Option<VenueOrderId>,
1804    ) -> anyhow::Result<OrderStatusReport> {
1805        let mut params = super::query::DeleteOrderParamsBuilder::default();
1806        params.text(NAUTILUS_TRADER);
1807
1808        if let Some(venue_order_id) = venue_order_id {
1809            params.order_id(vec![venue_order_id.as_str().to_string()]);
1810        } else if let Some(client_order_id) = client_order_id {
1811            params.cl_ord_id(vec![client_order_id.as_str().to_string()]);
1812        } else {
1813            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
1814        }
1815
1816        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1817
1818        let orders: Vec<BitmexOrder> = self.inner.cancel_orders_response(params).await?;
1819        let order = orders
1820            .into_iter()
1821            .next()
1822            .ok_or_else(|| anyhow::anyhow!("No order returned in cancel response"))?;
1823
1824        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
1825        let ts_init = self.generate_ts_init();
1826
1827        parse_order_status_report(&order, &instrument, &self.order_type_cache, ts_init)
1828    }
1829
1830    /// Cancel multiple orders.
1831    ///
1832    /// # Errors
1833    ///
1834    /// Returns an error if:
1835    /// - Credentials are missing.
1836    /// - The request fails.
1837    /// - The order doesn't exist.
1838    /// - The API returns an error.
1839    pub async fn cancel_orders(
1840        &self,
1841        instrument_id: InstrumentId,
1842        client_order_ids: Option<Vec<ClientOrderId>>,
1843        venue_order_ids: Option<Vec<VenueOrderId>>,
1844    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1845        let mut params = super::query::DeleteOrderParamsBuilder::default();
1846        params.text(NAUTILUS_TRADER);
1847
1848        // BitMEX API requires either client order IDs or venue order IDs, not both
1849        // Prioritize venue order IDs if both are provided
1850        if let Some(venue_order_ids) = venue_order_ids {
1851            if venue_order_ids.is_empty() {
1852                anyhow::bail!("venue_order_ids cannot be empty");
1853            }
1854            params.order_id(
1855                venue_order_ids
1856                    .iter()
1857                    .map(|id| id.to_string())
1858                    .collect::<Vec<_>>(),
1859            );
1860        } else if let Some(client_order_ids) = client_order_ids {
1861            if client_order_ids.is_empty() {
1862                anyhow::bail!("client_order_ids cannot be empty");
1863            }
1864            params.cl_ord_id(
1865                client_order_ids
1866                    .iter()
1867                    .map(|id| id.to_string())
1868                    .collect::<Vec<_>>(),
1869            );
1870        } else {
1871            anyhow::bail!("Either client_order_ids or venue_order_ids must be provided");
1872        }
1873
1874        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1875
1876        let orders: Vec<BitmexOrder> = self.inner.cancel_orders_response(params).await?;
1877
1878        let ts_init = self.generate_ts_init();
1879        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
1880
1881        let mut reports = Vec::new();
1882
1883        for order in orders {
1884            reports.push(parse_order_status_report(
1885                &order,
1886                &instrument,
1887                &self.order_type_cache,
1888                ts_init,
1889            )?);
1890        }
1891
1892        Self::populate_linked_order_ids(&mut reports);
1893
1894        Ok(reports)
1895    }
1896
1897    /// Cancel all orders for an instrument and optionally an order side.
1898    ///
1899    /// # Errors
1900    ///
1901    /// Returns an error if:
1902    /// - Credentials are missing.
1903    /// - The request fails.
1904    /// - The order doesn't exist.
1905    /// - The API returns an error.
1906    pub async fn cancel_all_orders(
1907        &self,
1908        instrument_id: InstrumentId,
1909        order_side: Option<OrderSide>,
1910    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1911        let mut params = DeleteAllOrdersParamsBuilder::default();
1912        params.text(NAUTILUS_TRADER);
1913        params.symbol(instrument_id.symbol.as_str());
1914
1915        if let Some(side) = order_side {
1916            let side = BitmexSide::from(side);
1917            params.filter(serde_json::json!({
1918                "side": side
1919            }));
1920        }
1921
1922        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1923
1924        let orders: Vec<BitmexOrder> = self.inner.cancel_all_orders_response(params).await?;
1925
1926        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
1927        let ts_init = self.generate_ts_init();
1928
1929        let mut reports = Vec::new();
1930
1931        for order in orders {
1932            if is_cancel_all_rejection(&order) {
1933                continue;
1934            }
1935
1936            reports.push(parse_order_status_report(
1937                &order,
1938                &instrument,
1939                &self.order_type_cache,
1940                ts_init,
1941            )?);
1942        }
1943
1944        Self::populate_linked_order_ids(&mut reports);
1945
1946        Ok(reports)
1947    }
1948
1949    /// Modify an existing order.
1950    ///
1951    /// # Errors
1952    ///
1953    /// Returns an error if:
1954    /// - Credentials are missing.
1955    /// - The request fails.
1956    /// - The order doesn't exist.
1957    /// - The order is already closed.
1958    /// - The API returns an error.
1959    pub async fn modify_order(
1960        &self,
1961        instrument_id: InstrumentId,
1962        client_order_id: Option<ClientOrderId>,
1963        venue_order_id: Option<VenueOrderId>,
1964        quantity: Option<Quantity>,
1965        price: Option<Price>,
1966        trigger_price: Option<Price>,
1967    ) -> anyhow::Result<OrderStatusReport> {
1968        let mut params = PutOrderParamsBuilder::default();
1969        params.text(NAUTILUS_TRADER);
1970
1971        // Set order ID - prefer venue_order_id if available
1972        if let Some(venue_order_id) = venue_order_id {
1973            params.order_id(venue_order_id.as_str());
1974        } else if let Some(client_order_id) = client_order_id {
1975            params.orig_cl_ord_id(client_order_id.as_str());
1976        } else {
1977            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
1978        }
1979
1980        if let Some(quantity) = quantity {
1981            let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
1982            params.order_qty(quantity_to_u32(&quantity, &instrument));
1983        }
1984
1985        if let Some(price) = price {
1986            params.price(price.as_f64());
1987        }
1988
1989        if let Some(trigger_price) = trigger_price {
1990            params.stop_px(trigger_price.as_f64());
1991        }
1992
1993        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
1994
1995        let order: BitmexOrder = self.inner.amend_order_response(params).await?;
1996
1997        if order.ord_status == Some(BitmexOrderStatus::Rejected) {
1998            let reason = order
1999                .ord_rej_reason
2000                .map_or_else(|| "No reason provided".to_string(), |r| r.to_string());
2001            anyhow::bail!("Order modification rejected: {reason}");
2002        }
2003
2004        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
2005        let ts_init = self.generate_ts_init();
2006
2007        parse_order_status_report(&order, &instrument, &self.order_type_cache, ts_init)
2008    }
2009
2010    /// Query a single order by client order ID or venue order ID.
2011    ///
2012    /// # Errors
2013    ///
2014    /// Returns an error if:
2015    /// - Credentials are missing.
2016    /// - The request fails.
2017    /// - The API returns an error.
2018    pub async fn query_order(
2019        &self,
2020        instrument_id: InstrumentId,
2021        client_order_id: Option<ClientOrderId>,
2022        venue_order_id: Option<VenueOrderId>,
2023    ) -> anyhow::Result<Option<OrderStatusReport>> {
2024        let mut params = GetOrderParamsBuilder::default();
2025
2026        let filter_json = if let Some(client_order_id) = client_order_id {
2027            serde_json::json!({
2028                "clOrdID": client_order_id.to_string()
2029            })
2030        } else if let Some(venue_order_id) = venue_order_id {
2031            serde_json::json!({
2032                "orderID": venue_order_id.to_string()
2033            })
2034        } else {
2035            anyhow::bail!("Either client_order_id or venue_order_id must be provided");
2036        };
2037
2038        params.filter(filter_json);
2039        params.count(1); // Only need one order
2040
2041        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2042
2043        let response = self.inner.get_orders(params).await?;
2044
2045        if response.is_empty() {
2046            return Ok(None);
2047        }
2048
2049        let order = &response[0];
2050
2051        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
2052        let ts_init = self.generate_ts_init();
2053
2054        let report =
2055            parse_order_status_report(order, &instrument, &self.order_type_cache, ts_init)?;
2056
2057        Ok(Some(report))
2058    }
2059
2060    /// Request a single order status report.
2061    ///
2062    /// # Errors
2063    ///
2064    /// Returns an error if:
2065    /// - Credentials are missing.
2066    /// - The request fails.
2067    /// - The API returns an error.
2068    pub async fn request_order_status_report(
2069        &self,
2070        instrument_id: InstrumentId,
2071        client_order_id: Option<ClientOrderId>,
2072        venue_order_id: Option<VenueOrderId>,
2073    ) -> anyhow::Result<OrderStatusReport> {
2074        if venue_order_id.is_none() && client_order_id.is_none() {
2075            anyhow::bail!("Either venue_order_id or client_order_id must be provided");
2076        }
2077
2078        let mut params = GetOrderParamsBuilder::default();
2079        params.symbol(instrument_id.symbol.as_str());
2080
2081        if let Some(venue_order_id) = venue_order_id {
2082            params.filter(serde_json::json!({
2083                "orderID": venue_order_id.as_str()
2084            }));
2085        } else if let Some(client_order_id) = client_order_id {
2086            params.filter(serde_json::json!({
2087                "clOrdID": client_order_id.as_str()
2088            }));
2089        }
2090
2091        params.count(1i32);
2092        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2093
2094        let response = self.inner.get_orders(params).await?;
2095
2096        let order = response
2097            .into_iter()
2098            .next()
2099            .ok_or_else(|| anyhow::anyhow!("Order not found"))?;
2100
2101        let instrument = self.instrument_from_cache(instrument_id.symbol.inner())?;
2102        let ts_init = self.generate_ts_init();
2103
2104        parse_order_status_report(&order, &instrument, &self.order_type_cache, ts_init)
2105    }
2106
2107    /// Request multiple order status reports.
2108    ///
2109    /// # Errors
2110    ///
2111    /// Returns an error if:
2112    /// - Credentials are missing.
2113    /// - The request fails.
2114    /// - The API returns an error.
2115    pub async fn request_order_status_reports(
2116        &self,
2117        instrument_id: Option<InstrumentId>,
2118        open_only: bool,
2119        start: Option<Timestamp>,
2120        end: Option<Timestamp>,
2121        limit: Option<u32>,
2122    ) -> anyhow::Result<Vec<OrderStatusReport>> {
2123        if let (Some(start), Some(end)) = (start, end) {
2124            anyhow::ensure!(
2125                start < end,
2126                "Invalid time range: start={start:?} end={end:?}",
2127            );
2128        }
2129
2130        let mut params = GetOrderParamsBuilder::default();
2131
2132        if let Some(instrument_id) = &instrument_id {
2133            params.symbol(instrument_id.symbol.as_str());
2134        }
2135
2136        if open_only {
2137            params.filter(serde_json::json!({
2138                "open": true
2139            }));
2140        }
2141
2142        if let Some(start) = start {
2143            params.start_time(start);
2144        }
2145
2146        if let Some(end) = end {
2147            params.end_time(end);
2148        }
2149
2150        if let Some(limit) = limit {
2151            params.count(limit as i32);
2152        } else {
2153            params.count(500); // Default count to avoid empty query
2154        }
2155
2156        params.reverse(true); // Get newest orders first
2157
2158        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2159
2160        let response = self.inner.get_orders(params).await?;
2161
2162        let ts_init = self.generate_ts_init();
2163
2164        let mut reports = Vec::new();
2165
2166        for order in response {
2167            if let Some(start) = start {
2168                match order.timestamp {
2169                    Some(timestamp) if timestamp < start => continue,
2170                    Some(_) => {}
2171                    None => {
2172                        log::debug!("Skipping order report without timestamp for bounded query");
2173                        continue;
2174                    }
2175                }
2176            }
2177
2178            if let Some(end) = end {
2179                match order.timestamp {
2180                    Some(timestamp) if timestamp > end => continue,
2181                    Some(_) => {}
2182                    None => {
2183                        log::debug!("Skipping order report without timestamp for bounded query");
2184                        continue;
2185                    }
2186                }
2187            }
2188
2189            // Skip orders without symbol (can happen with query responses)
2190            let Some(symbol) = order.symbol else {
2191                log::warn!("Order response missing symbol, skipping");
2192                continue;
2193            };
2194
2195            let Ok(instrument) = self.instrument_from_cache(symbol) else {
2196                log::debug!("Skipping order report for instrument not in cache: symbol={symbol}");
2197                continue;
2198            };
2199
2200            match parse_order_status_report(&order, &instrument, &self.order_type_cache, ts_init) {
2201                Ok(report) => reports.push(report),
2202                Err(e) => log::error!("Failed to parse order status report: {e}"),
2203            }
2204        }
2205
2206        Self::populate_linked_order_ids(&mut reports);
2207
2208        Ok(reports)
2209    }
2210
2211    /// Request trades for the given instrument.
2212    ///
2213    /// # Errors
2214    ///
2215    /// Returns an error if the HTTP request fails or parsing fails.
2216    pub async fn request_trades(
2217        &self,
2218        instrument_id: InstrumentId,
2219        start: Option<Timestamp>,
2220        end: Option<Timestamp>,
2221        limit: Option<u32>,
2222    ) -> anyhow::Result<Vec<TradeTick>> {
2223        let mut params = GetTradeParamsBuilder::default();
2224        params.symbol(instrument_id.symbol.as_str());
2225
2226        if let Some(start) = start {
2227            params.start_time(start);
2228        }
2229
2230        if let Some(end) = end {
2231            params.end_time(end);
2232        }
2233
2234        if let (Some(start), Some(end)) = (start, end) {
2235            anyhow::ensure!(
2236                start < end,
2237                "Invalid time range: start={start:?} end={end:?}",
2238            );
2239        }
2240
2241        if let Some(limit) = limit {
2242            let clamped_limit = limit.min(1000);
2243            if limit > 1000 {
2244                log::warn!(
2245                    "BitMEX trade request limit exceeds venue maximum; clamping: limit={limit}, clamped_limit={clamped_limit}",
2246                );
2247            }
2248            params.count(i32::try_from(clamped_limit).unwrap_or(1000));
2249        }
2250        params.reverse(false);
2251        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2252
2253        let response = self.inner.get_trades(params).await?;
2254
2255        let ts_init = self.generate_ts_init();
2256
2257        let mut parsed_trades = Vec::new();
2258
2259        for trade in response {
2260            if let Some(start) = start
2261                && trade.timestamp < start
2262            {
2263                continue;
2264            }
2265
2266            if let Some(end) = end
2267                && trade.timestamp > end
2268            {
2269                continue;
2270            }
2271
2272            let Some(instrument) = self.get_instrument(&trade.symbol) else {
2273                log::error!(
2274                    "Instrument {} not found in cache, skipping trade",
2275                    trade.symbol
2276                );
2277                continue;
2278            };
2279
2280            match parse_trade(&trade, &instrument, ts_init) {
2281                Ok(trade) => parsed_trades.push(trade),
2282                Err(e) => log::error!("Failed to parse trade: {e}"),
2283            }
2284        }
2285
2286        Ok(parsed_trades)
2287    }
2288
2289    /// Request bars for the given bar type.
2290    ///
2291    /// # Errors
2292    ///
2293    /// Returns an error if the HTTP request fails, parsing fails, or the bar specification is
2294    /// unsupported by BitMEX.
2295    pub async fn request_bars(
2296        &self,
2297        mut bar_type: BarType,
2298        start: Option<Timestamp>,
2299        end: Option<Timestamp>,
2300        limit: Option<u32>,
2301        partial: bool,
2302    ) -> anyhow::Result<Vec<Bar>> {
2303        bar_type = bar_type.standard();
2304
2305        anyhow::ensure!(
2306            bar_type.aggregation_source() == AggregationSource::External,
2307            "Only EXTERNAL aggregation bars are supported"
2308        );
2309        anyhow::ensure!(
2310            bar_type.spec().price_type == PriceType::Last,
2311            "Only LAST price type bars are supported"
2312        );
2313
2314        if let (Some(start), Some(end)) = (start, end) {
2315            anyhow::ensure!(
2316                start < end,
2317                "Invalid time range: start={start:?} end={end:?}"
2318            );
2319        }
2320
2321        let spec = bar_type.spec();
2322        let bin_size = match (spec.aggregation, spec.step.get()) {
2323            (BarAggregation::Minute, 1) => "1m",
2324            (BarAggregation::Minute, 5) => "5m",
2325            (BarAggregation::Hour, 1) => "1h",
2326            (BarAggregation::Day, 1) => "1d",
2327            _ => anyhow::bail!(
2328                "BitMEX does not support {}-{:?}-{:?} bars",
2329                spec.step.get(),
2330                spec.aggregation,
2331                spec.price_type,
2332            ),
2333        };
2334
2335        let instrument_id = bar_type.instrument_id();
2336        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
2337
2338        let mut params = GetTradeBucketedParamsBuilder::default();
2339        params.symbol(instrument_id.symbol.as_str());
2340        params.bin_size(bin_size);
2341
2342        if partial {
2343            params.partial(true);
2344        }
2345
2346        if let Some(start) = start {
2347            params.start_time(start);
2348        }
2349
2350        if let Some(end) = end {
2351            params.end_time(end);
2352        }
2353
2354        if let Some(limit) = limit {
2355            let clamped_limit = limit.min(1000);
2356            if limit > 1000 {
2357                log::warn!(
2358                    "BitMEX bar request limit exceeds venue maximum; clamping: limit={limit}, clamped_limit={clamped_limit}",
2359                );
2360            }
2361            params.count(i32::try_from(clamped_limit).unwrap_or(1000));
2362        }
2363        params.reverse(false);
2364        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2365
2366        let response = self.inner.get_trade_bucketed(params).await?;
2367        let ts_init = self.generate_ts_init();
2368        let mut bars = Vec::new();
2369
2370        for bin in response {
2371            if let Some(start) = start
2372                && bin.timestamp < start
2373            {
2374                continue;
2375            }
2376
2377            if let Some(end) = end
2378                && bin.timestamp > end
2379            {
2380                continue;
2381            }
2382
2383            if bin.symbol != instrument_id.symbol.inner() {
2384                log::warn!(
2385                    "Skipping trade bin for unexpected symbol: symbol={}, expected={}",
2386                    bin.symbol,
2387                    instrument_id.symbol,
2388                );
2389                continue;
2390            }
2391
2392            match parse_trade_bin(&bin, &instrument, &bar_type, ts_init) {
2393                Ok(bar) => bars.push(bar),
2394                Err(e) => log::warn!("Failed to parse trade bin: {e}"),
2395            }
2396        }
2397
2398        Ok(bars)
2399    }
2400
2401    /// Request a current L2 order book snapshot.
2402    ///
2403    /// # Errors
2404    ///
2405    /// Returns an error if the HTTP request fails, the instrument is not cached, or the book
2406    /// rows cannot be parsed.
2407    pub async fn request_book_snapshot(
2408        &self,
2409        instrument_id: InstrumentId,
2410        depth: Option<u32>,
2411    ) -> anyhow::Result<OrderBook> {
2412        let instrument = self.instrument_from_cache_by_id(instrument_id)?;
2413        let mut params = GetOrderBookL2ParamsBuilder::default();
2414        params.symbol(instrument_id.symbol.as_str());
2415
2416        if let Some(depth) = depth {
2417            params.depth(depth);
2418        }
2419
2420        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2421        let response = self.inner.get_order_book_l2(params).await?;
2422        let ts_init = self.generate_ts_init();
2423        let deltas = parse_order_book_l2_snapshot(&response, &instrument, instrument_id, ts_init)?;
2424
2425        let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
2426        book.apply_deltas(&deltas)?;
2427        Ok(book)
2428    }
2429
2430    fn instrument_from_cache_by_id(
2431        &self,
2432        instrument_id: InstrumentId,
2433    ) -> anyhow::Result<InstrumentAny> {
2434        self.get_instrument(&instrument_id.symbol.inner())
2435            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id).into())
2436    }
2437
2438    /// Request historical funding rates for the given instrument.
2439    ///
2440    /// # Errors
2441    ///
2442    /// Returns an error if the HTTP request fails or the time range is invalid.
2443    pub async fn request_funding_rates(
2444        &self,
2445        instrument_id: InstrumentId,
2446        start: Option<Timestamp>,
2447        end: Option<Timestamp>,
2448        limit: Option<u32>,
2449    ) -> anyhow::Result<Vec<FundingRateUpdate>> {
2450        if let (Some(start), Some(end)) = (start, end) {
2451            anyhow::ensure!(
2452                start < end,
2453                "Invalid time range: start={start:?} end={end:?}",
2454            );
2455        }
2456
2457        let total_limit = limit.map(|value| value as usize);
2458        let mut offset = 0_i32;
2459        let mut rates = Vec::new();
2460
2461        loop {
2462            if total_limit.is_some_and(|limit| rates.len() >= limit) {
2463                break;
2464            }
2465
2466            let remaining = total_limit.map_or(BITMEX_MAX_TABLE_COUNT as usize, |limit| {
2467                limit.saturating_sub(rates.len())
2468            });
2469            let page_count = remaining.min(BITMEX_MAX_TABLE_COUNT as usize);
2470
2471            if page_count == 0 {
2472                break;
2473            }
2474
2475            let mut params = GetFundingParamsBuilder::default();
2476            params.symbol(instrument_id.symbol.as_str());
2477            params.count(i32::try_from(page_count).unwrap_or(BITMEX_MAX_TABLE_COUNT as i32));
2478            params.start(offset);
2479            params.reverse(false);
2480
2481            if let Some(start) = start {
2482                params.start_time(start);
2483            }
2484
2485            if let Some(end) = end {
2486                params.end_time(end);
2487            }
2488
2489            let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2490            let response = self.inner.get_funding(params).await?;
2491            let response_len = response.len();
2492
2493            if response.is_empty() {
2494                break;
2495            }
2496
2497            for raw in response {
2498                if raw.symbol != instrument_id.symbol.inner() {
2499                    log::warn!(
2500                        "Skipping funding rate for unexpected symbol: symbol={}, expected={}",
2501                        raw.symbol,
2502                        instrument_id.symbol,
2503                    );
2504                    continue;
2505                }
2506
2507                if let Some(start) = start
2508                    && raw.timestamp < start
2509                {
2510                    continue;
2511                }
2512
2513                if let Some(end) = end
2514                    && raw.timestamp > end
2515                {
2516                    continue;
2517                }
2518
2519                let Some(rate) = parse_funding_rate_update(&raw, instrument_id) else {
2520                    continue;
2521                };
2522
2523                rates.push(rate);
2524
2525                if total_limit.is_some_and(|limit| rates.len() >= limit) {
2526                    break;
2527                }
2528            }
2529
2530            if response_len < page_count {
2531                break;
2532            }
2533
2534            offset += i32::try_from(response_len).unwrap_or(BITMEX_MAX_TABLE_COUNT as i32);
2535        }
2536
2537        Ok(rates)
2538    }
2539
2540    /// Request fill reports for the given instrument.
2541    ///
2542    /// # Errors
2543    ///
2544    /// Returns an error if the HTTP request fails or parsing fails.
2545    pub async fn request_fill_reports(
2546        &self,
2547        instrument_id: Option<InstrumentId>,
2548        start: Option<Timestamp>,
2549        end: Option<Timestamp>,
2550        limit: Option<u32>,
2551    ) -> anyhow::Result<Vec<FillReport>> {
2552        if let (Some(start), Some(end)) = (start, end) {
2553            anyhow::ensure!(
2554                start < end,
2555                "Invalid time range: start={start:?} end={end:?}",
2556            );
2557        }
2558
2559        let mut params = GetExecutionParamsBuilder::default();
2560
2561        if let Some(instrument_id) = instrument_id {
2562            params.symbol(instrument_id.symbol.as_str());
2563        }
2564
2565        if let Some(start) = start {
2566            params.start_time(start);
2567        }
2568
2569        if let Some(end) = end {
2570            params.end_time(end);
2571        }
2572
2573        if let Some(limit) = limit {
2574            params.count(limit as i32);
2575        } else {
2576            params.count(500); // Default count
2577        }
2578        params.reverse(true); // Get newest fills first
2579
2580        let params = params.build().map_err(|e| anyhow::anyhow!(e))?;
2581
2582        let response = self.inner.get_executions(params).await?;
2583
2584        let ts_init = self.generate_ts_init();
2585
2586        let mut reports = Vec::new();
2587
2588        for exec in response {
2589            if let Some(start) = start {
2590                match exec.transact_time {
2591                    Some(timestamp) if timestamp < start => continue,
2592                    Some(_) => {}
2593                    None => {
2594                        log::debug!("Skipping fill report without transact_time for bounded query");
2595                        continue;
2596                    }
2597                }
2598            }
2599
2600            if let Some(end) = end {
2601                match exec.transact_time {
2602                    Some(timestamp) if timestamp > end => continue,
2603                    Some(_) => {}
2604                    None => {
2605                        log::debug!("Skipping fill report without transact_time for bounded query");
2606                        continue;
2607                    }
2608                }
2609            }
2610
2611            // Skip executions without symbol (e.g., CancelReject)
2612            let Some(symbol) = exec.symbol else {
2613                log::debug!("Skipping execution without symbol: {:?}", exec.exec_type);
2614                continue;
2615            };
2616            let symbol_str = symbol.to_string();
2617
2618            let instrument = match self.instrument_from_cache(symbol) {
2619                Ok(instrument) => instrument,
2620                Err(e) => {
2621                    log::error!(
2622                        "Instrument not found in cache for execution parsing: symbol={symbol_str}, {e}"
2623                    );
2624                    continue;
2625                }
2626            };
2627
2628            match parse_fill_report(&exec, &instrument, ts_init) {
2629                Ok(report) => reports.push(report),
2630                Err(e) => {
2631                    // Log at debug level for expected skip cases
2632                    let error_msg = e.to_string();
2633                    if error_msg.starts_with("Skipping non-trade execution")
2634                        || error_msg.starts_with("Skipping execution without order_id")
2635                    {
2636                        log::debug!("{e}");
2637                    } else {
2638                        log::error!("Failed to parse fill report: {e}");
2639                    }
2640                }
2641            }
2642        }
2643
2644        Ok(reports)
2645    }
2646
2647    /// Request position reports.
2648    ///
2649    /// # Errors
2650    ///
2651    /// Returns an error if the HTTP request fails or parsing fails.
2652    pub async fn request_position_status_reports(
2653        &self,
2654    ) -> anyhow::Result<Vec<PositionStatusReport>> {
2655        let params = GetPositionParamsBuilder::default()
2656            .count(500) // Default count
2657            .build()
2658            .map_err(|e| anyhow::anyhow!(e))?;
2659
2660        let response = self.inner.get_positions(params).await?;
2661
2662        let ts_init = self.generate_ts_init();
2663
2664        let mut reports = Vec::new();
2665
2666        for pos in response {
2667            let symbol = pos.symbol;
2668            let instrument = match self.instrument_from_cache(symbol) {
2669                Ok(instrument) => instrument,
2670                Err(e) => {
2671                    log::error!(
2672                        "Instrument not found in cache for position parsing: symbol={}, {e}",
2673                        pos.symbol.as_str(),
2674                    );
2675                    continue;
2676                }
2677            };
2678
2679            match parse_position_report(&pos, &instrument, ts_init) {
2680                Ok(report) => reports.push(report),
2681                Err(e) => log::error!("Failed to parse position report: {e}"),
2682            }
2683        }
2684
2685        Ok(reports)
2686    }
2687
2688    /// Update position leverage.
2689    ///
2690    /// # Errors
2691    ///
2692    /// - Credentials are missing.
2693    /// - The request fails.
2694    /// - The API returns an error.
2695    pub async fn update_position_leverage(
2696        &self,
2697        symbol: &str,
2698        leverage: f64,
2699    ) -> anyhow::Result<PositionStatusReport> {
2700        let params = PostPositionLeverageParams {
2701            symbol: symbol.to_string(),
2702            leverage,
2703            target_account_id: None,
2704        };
2705
2706        let response = self.inner.update_position_leverage(params).await?;
2707
2708        let instrument = self.instrument_from_cache(Ustr::from(symbol))?;
2709        let ts_init = self.generate_ts_init();
2710
2711        parse_position_report(&response, &instrument, ts_init)
2712    }
2713}
2714
2715fn is_cancel_all_rejection(order: &BitmexOrder) -> bool {
2716    order.ord_status == Some(BitmexOrderStatus::Rejected)
2717        && order.ord_rej_reason.as_deref() == Some("Invalid orderID")
2718        && order.cl_ord_id.is_none()
2719        && order.order_qty.is_none()
2720        && order.leaves_qty.is_none()
2721        && order.cum_qty.is_none()
2722}
2723
2724fn parse_order_book_l2_snapshot(
2725    rows: &[BitmexOrderBookL2],
2726    instrument: &InstrumentAny,
2727    instrument_id: InstrumentId,
2728    ts_init: UnixNanos,
2729) -> anyhow::Result<OrderBookDeltas> {
2730    let price_precision = instrument.price_precision();
2731    let mut deltas = Vec::with_capacity(rows.len() + 1);
2732    deltas.push(OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init));
2733
2734    for row in rows {
2735        if row.symbol != instrument_id.symbol.inner() {
2736            log::warn!(
2737                "Skipping BitMEX order book row for unexpected symbol: symbol={}, expected={}",
2738                row.symbol,
2739                instrument_id.symbol,
2740            );
2741            continue;
2742        }
2743
2744        let Some(price_value) = row.price else {
2745            log::warn!(
2746                "Skipping BitMEX order book row without price: symbol={}, id={}",
2747                row.symbol,
2748                row.id,
2749            );
2750            continue;
2751        };
2752
2753        let Some(size_value) = row.size else {
2754            log::warn!(
2755                "Skipping BitMEX order book row without size: symbol={}, id={}",
2756                row.symbol,
2757                row.id,
2758            );
2759            continue;
2760        };
2761
2762        let Ok(size) = u64::try_from(size_value) else {
2763            log::warn!(
2764                "Skipping BitMEX order book row with negative size: symbol={}, id={}, size={}",
2765                row.symbol,
2766                row.id,
2767                size_value,
2768            );
2769            continue;
2770        };
2771
2772        let Ok(order_id) = u64::try_from(row.id) else {
2773            log::warn!(
2774                "Skipping BitMEX order book row with negative id: symbol={}, id={}",
2775                row.symbol,
2776                row.id,
2777            );
2778            continue;
2779        };
2780
2781        let order = BookOrder::new(
2782            OrderSide::from(row.side),
2783            Price::new(price_value, price_precision),
2784            parse_contracts_quantity(size, instrument),
2785            order_id,
2786        );
2787        let delta = OrderBookDelta::new(
2788            instrument_id,
2789            BookAction::Add,
2790            order,
2791            RecordFlag::F_SNAPSHOT as u8,
2792            0,
2793            ts_init,
2794            ts_init,
2795        );
2796        deltas.push(delta);
2797    }
2798
2799    if let Some(last) = deltas.last_mut() {
2800        last.flags |= RecordFlag::F_LAST as u8;
2801    }
2802
2803    OrderBookDeltas::new_checked(instrument_id, deltas)
2804}
2805
2806fn parse_funding_rate_update(
2807    raw: &BitmexFunding,
2808    instrument_id: InstrumentId,
2809) -> Option<FundingRateUpdate> {
2810    let Some(rate) = raw.funding_rate else {
2811        log::warn!(
2812            "Skipping BitMEX funding rate without funding_rate: symbol={}, timestamp={}",
2813            raw.symbol,
2814            raw.timestamp,
2815        );
2816        return None;
2817    };
2818
2819    let interval = raw.funding_interval.map(|interval| {
2820        let interval = Offset::UTC.to_datetime(interval);
2821        let hours = u16::try_from(interval.hour()).expect("civil hour is non-negative");
2822        let minutes = u16::try_from(interval.minute()).expect("civil minute is non-negative");
2823        hours * 60 + minutes
2824    });
2825    let ts_event = UnixNanos::from(raw.timestamp);
2826
2827    Some(FundingRateUpdate::new(
2828        instrument_id,
2829        rate,
2830        interval,
2831        None,
2832        ts_event,
2833        ts_event,
2834    ))
2835}
2836
2837fn account_id_from_margins(margins: &[BitmexMargin]) -> anyhow::Result<Option<AccountId>> {
2838    let Some(first) = margins.first() else {
2839        return Ok(None);
2840    };
2841
2842    let account = first.account;
2843    if let Some(mismatch) = margins.iter().find(|margin| margin.account != account) {
2844        anyhow::bail!(
2845            "BitMEX returned inconsistent margin account IDs: {account} and {}",
2846            mismatch.account
2847        );
2848    }
2849
2850    Ok(Some(bitmex_account_id(account)))
2851}
2852
2853#[cfg(test)]
2854mod tests {
2855    use nautilus_core::UUID4;
2856    use nautilus_model::enums::OrderStatus;
2857    use nautilus_testkit::http::assert_http_redirect_rejected;
2858    use rstest::rstest;
2859    use serde_json::json;
2860
2861    use super::*;
2862
2863    fn margin_with_account(account: i64) -> BitmexMargin {
2864        BitmexMargin {
2865            account,
2866            currency: Ustr::from("XBt"),
2867            risk_limit: None,
2868            prev_state: None,
2869            state: None,
2870            action: None,
2871            amount: None,
2872            pending_credit: None,
2873            pending_debit: None,
2874            confirmed_debit: None,
2875            prev_realised_pnl: None,
2876            prev_unrealised_pnl: None,
2877            gross_comm: None,
2878            gross_open_cost: None,
2879            gross_open_premium: None,
2880            gross_exec_cost: None,
2881            gross_mark_value: None,
2882            risk_value: None,
2883            taxable_margin: None,
2884            init_margin: None,
2885            maint_margin: None,
2886            session_margin: None,
2887            target_excess_margin: None,
2888            var_margin: None,
2889            realised_pnl: None,
2890            unrealised_pnl: None,
2891            indicative_tax: None,
2892            unrealised_profit: None,
2893            synthetic_margin: None,
2894            wallet_balance: None,
2895            margin_balance: None,
2896            margin_balance_pcnt: None,
2897            margin_leverage: None,
2898            margin_used_pcnt: None,
2899            excess_margin: None,
2900            excess_margin_pcnt: None,
2901            available_margin: None,
2902            withdrawable_margin: None,
2903            timestamp: None,
2904            gross_last_value: None,
2905            commission: None,
2906        }
2907    }
2908
2909    fn build_report(
2910        client_order_id: &str,
2911        venue_order_id: &str,
2912        contingency_type: Option<ContingencyType>,
2913        order_list_id: Option<&str>,
2914    ) -> OrderStatusReport {
2915        let mut report = OrderStatusReport::new(
2916            AccountId::from("BITMEX-1"),
2917            InstrumentId::from("XBTUSD.BITMEX"),
2918            Some(ClientOrderId::from(client_order_id)),
2919            VenueOrderId::from(venue_order_id),
2920            OrderSide::Buy.into(),
2921            OrderType::Limit,
2922            TimeInForce::Gtc,
2923            OrderStatus::Accepted,
2924            Quantity::new(100.0, 0),
2925            Quantity::default(),
2926            UnixNanos::from(1_u64),
2927            UnixNanos::from(1_u64),
2928            UnixNanos::from(1_u64),
2929            Some(UUID4::new()),
2930        );
2931
2932        if let Some(id) = order_list_id {
2933            report = report.with_order_list_id(OrderListId::from(id));
2934        }
2935
2936        report.contingency_type = contingency_type;
2937        report
2938    }
2939
2940    #[tokio::test]
2941    async fn test_authenticated_client_rejects_redirects() {
2942        let client = BitmexRawHttpClient::with_credentials(
2943            "key".into(),
2944            "secret".into(),
2945            "http://localhost".into(),
2946            3,
2947            0,
2948            1,
2949            1,
2950            10000,
2951            10,
2952            120,
2953            None,
2954        )
2955        .unwrap()
2956        .client;
2957        assert_http_redirect_rejected(|url| async move {
2958            client
2959                .get(url, None, None, Some(3), None)
2960                .await
2961                .unwrap()
2962                .status
2963                .as_u16()
2964        })
2965        .await;
2966    }
2967
2968    #[rstest]
2969    fn test_account_id_from_margins_uses_bitmex_account_number() {
2970        let margins = vec![margin_with_account(319111), margin_with_account(319111)];
2971
2972        let account_id = account_id_from_margins(&margins).unwrap().unwrap();
2973
2974        assert_eq!(account_id, AccountId::from("BITMEX-319111"));
2975    }
2976
2977    #[rstest]
2978    fn test_account_id_from_margins_empty_returns_none() {
2979        let margins = [];
2980
2981        let account_id = account_id_from_margins(&margins).unwrap();
2982
2983        assert_eq!(account_id, None);
2984    }
2985
2986    #[rstest]
2987    fn test_account_id_from_margins_rejects_inconsistent_accounts() {
2988        let margins = vec![margin_with_account(319111), margin_with_account(319112)];
2989
2990        let err = account_id_from_margins(&margins).unwrap_err();
2991
2992        assert!(err.to_string().contains("inconsistent margin account IDs"));
2993    }
2994
2995    #[rstest]
2996    fn test_cancel_all_rejection_requires_unavailable_order_shape() {
2997        let unavailable: BitmexOrder = serde_json::from_str(include_str!(
2998            "../../test_data/http_cancel_all_close_race.json"
2999        ))
3000        .unwrap();
3001        let mut with_client_id = unavailable.clone();
3002        with_client_id.cl_ord_id = Some(Ustr::from("tracked-rejection"));
3003        let mut with_order_qty = unavailable.clone();
3004        with_order_qty.order_qty = Some(100);
3005        let mut with_leaves_qty = unavailable.clone();
3006        with_leaves_qty.leaves_qty = Some(0);
3007        let mut with_cum_qty = unavailable.clone();
3008        with_cum_qty.cum_qty = Some(0);
3009        let mut with_other_reason = unavailable.clone();
3010        with_other_reason.ord_rej_reason = Some(Ustr::from("Insufficient margin"));
3011
3012        let unavailable_result = is_cancel_all_rejection(&unavailable);
3013        let preserved = [
3014            ("client order ID", with_client_id),
3015            ("order quantity", with_order_qty),
3016            ("leaves quantity", with_leaves_qty),
3017            ("cumulative quantity", with_cum_qty),
3018            ("different rejection reason", with_other_reason),
3019        ];
3020
3021        assert!(unavailable_result);
3022        for (case, order) in preserved {
3023            assert!(!is_cancel_all_rejection(&order), "preserved {case}");
3024        }
3025    }
3026
3027    #[rstest]
3028    fn test_sign_request_generates_correct_headers() {
3029        let client = BitmexRawHttpClient::with_credentials(
3030            "test_api_key".to_string(),
3031            "test_api_secret".to_string(),
3032            "http://localhost:8080".to_string(),
3033            60,
3034            3,
3035            1_000,
3036            10_000,
3037            10_000,
3038            10,
3039            120,
3040            None,
3041        )
3042        .expect("Failed to create test client");
3043
3044        let headers = client
3045            .sign_request(&Method::GET, "/api/v1/order", None)
3046            .unwrap();
3047
3048        assert!(headers.contains_key("api-key"));
3049        assert!(headers.contains_key("api-signature"));
3050        assert!(headers.contains_key("api-expires"));
3051        assert_eq!(headers.get("api-key").unwrap(), "test_api_key");
3052    }
3053
3054    #[rstest]
3055    fn test_sign_request_with_body() {
3056        let client = BitmexRawHttpClient::with_credentials(
3057            "test_api_key".to_string(),
3058            "test_api_secret".to_string(),
3059            "http://localhost:8080".to_string(),
3060            60,
3061            3,
3062            1_000,
3063            10_000,
3064            10_000,
3065            10,
3066            120,
3067            None,
3068        )
3069        .expect("Failed to create test client");
3070
3071        let body = json!({"symbol": "XBTUSD", "orderQty": 100});
3072        let body_bytes = serde_json::to_vec(&body).unwrap();
3073
3074        let headers_without_body = client
3075            .sign_request(&Method::POST, "/api/v1/order", None)
3076            .unwrap();
3077        let headers_with_body = client
3078            .sign_request(&Method::POST, "/api/v1/order", Some(&body_bytes))
3079            .unwrap();
3080
3081        // Signatures should be different when body is included
3082        assert_ne!(
3083            headers_without_body.get("api-signature").unwrap(),
3084            headers_with_body.get("api-signature").unwrap()
3085        );
3086    }
3087
3088    #[rstest]
3089    fn test_sign_request_uses_custom_recv_window() {
3090        let client_default = BitmexRawHttpClient::with_credentials(
3091            "test_api_key".to_string(),
3092            "test_api_secret".to_string(),
3093            "http://localhost:8080".to_string(),
3094            60,
3095            3,
3096            1_000,
3097            10_000,
3098            10_000, // default recv_window_ms (10000ms = 10s)
3099            10,
3100            120,
3101            None,
3102        )
3103        .expect("Failed to create test client");
3104
3105        let client_custom = BitmexRawHttpClient::with_credentials(
3106            "test_api_key".to_string(),
3107            "test_api_secret".to_string(),
3108            "http://localhost:8080".to_string(),
3109            60,
3110            3,
3111            1_000,
3112            10_000,
3113            30_000, // 30 seconds
3114            10,
3115            120,
3116            None,
3117        )
3118        .expect("Failed to create test client");
3119
3120        let headers_default = client_default
3121            .sign_request(&Method::GET, "/api/v1/order", None)
3122            .unwrap();
3123        let headers_custom = client_custom
3124            .sign_request(&Method::GET, "/api/v1/order", None)
3125            .unwrap();
3126
3127        // Parse expires timestamps
3128        let expires_default: i64 = headers_default.get("api-expires").unwrap().parse().unwrap();
3129        let expires_custom: i64 = headers_custom.get("api-expires").unwrap().parse().unwrap();
3130
3131        // Verify both are valid future timestamps
3132        let now = Timestamp::now().as_second();
3133        assert!(expires_default > now);
3134        assert!(expires_custom > now);
3135
3136        // Custom window should be greater than default
3137        assert!(expires_custom > expires_default);
3138
3139        // The difference should be approximately 20 seconds (30s - 10s)
3140        // Allow wider tolerance for delays between calls on slow CI runners
3141        let diff = expires_custom - expires_default;
3142        assert!((18..=25).contains(&diff));
3143    }
3144
3145    #[rstest]
3146    fn test_populate_linked_order_ids_from_order_list() {
3147        let base = "O-20250922-002219-001-000";
3148        let entry = format!("{base}-1");
3149        let stop = format!("{base}-2");
3150        let take = format!("{base}-3");
3151
3152        let mut reports = vec![
3153            build_report(&entry, "V-1", Some(ContingencyType::Oto), Some("OL-1")),
3154            build_report(&stop, "V-2", Some(ContingencyType::Ouo), Some("OL-1")),
3155            build_report(&take, "V-3", Some(ContingencyType::Ouo), Some("OL-1")),
3156        ];
3157
3158        BitmexHttpClient::populate_linked_order_ids(&mut reports);
3159
3160        assert_eq!(
3161            reports[0].linked_order_ids,
3162            Some(vec![
3163                ClientOrderId::from(stop.as_str()),
3164                ClientOrderId::from(take.as_str()),
3165            ]),
3166        );
3167        assert_eq!(
3168            reports[1].linked_order_ids,
3169            Some(vec![
3170                ClientOrderId::from(entry.as_str()),
3171                ClientOrderId::from(take.as_str()),
3172            ]),
3173        );
3174        assert_eq!(
3175            reports[2].linked_order_ids,
3176            Some(vec![
3177                ClientOrderId::from(entry.as_str()),
3178                ClientOrderId::from(stop.as_str()),
3179            ]),
3180        );
3181    }
3182
3183    #[rstest]
3184    fn test_populate_linked_order_ids_from_id_prefix() {
3185        let base = "O-20250922-002220-001-000";
3186        let entry = format!("{base}-1");
3187        let stop = format!("{base}-2");
3188        let take = format!("{base}-3");
3189
3190        let mut reports = vec![
3191            build_report(&entry, "V-1", Some(ContingencyType::Oto), None),
3192            build_report(&stop, "V-2", Some(ContingencyType::Ouo), None),
3193            build_report(&take, "V-3", Some(ContingencyType::Ouo), None),
3194        ];
3195
3196        BitmexHttpClient::populate_linked_order_ids(&mut reports);
3197
3198        assert_eq!(
3199            reports[0].linked_order_ids,
3200            Some(vec![
3201                ClientOrderId::from(stop.as_str()),
3202                ClientOrderId::from(take.as_str()),
3203            ]),
3204        );
3205        assert_eq!(
3206            reports[1].linked_order_ids,
3207            Some(vec![
3208                ClientOrderId::from(entry.as_str()),
3209                ClientOrderId::from(take.as_str()),
3210            ]),
3211        );
3212        assert_eq!(
3213            reports[2].linked_order_ids,
3214            Some(vec![
3215                ClientOrderId::from(entry.as_str()),
3216                ClientOrderId::from(stop.as_str()),
3217            ]),
3218        );
3219    }
3220
3221    #[rstest]
3222    fn test_populate_linked_order_ids_respects_non_contingent_orders() {
3223        let base = "O-20250922-002221-001-000";
3224        let entry = format!("{base}-1");
3225        let passive = format!("{base}-2");
3226
3227        let mut reports = vec![
3228            build_report(&entry, "V-1", None, None),
3229            build_report(&passive, "V-2", Some(ContingencyType::Ouo), None),
3230        ];
3231
3232        BitmexHttpClient::populate_linked_order_ids(&mut reports);
3233
3234        // Non-contingent orders should not be linked
3235        assert!(reports[0].linked_order_ids.is_none());
3236
3237        // A contingent order with no other contingent peers should have contingency reset
3238        assert!(reports[1].linked_order_ids.is_none());
3239        assert_eq!(reports[1].contingency_type, None);
3240    }
3241
3242    #[rstest]
3243    fn test_populate_linked_order_ids_treats_orphaned_oto_as_standalone() {
3244        let mut reports = vec![build_report(
3245            "O-20250922-002222-001-000-1",
3246            "V-1",
3247            Some(ContingencyType::Oto),
3248            Some("OL-1"),
3249        )];
3250
3251        BitmexHttpClient::populate_linked_order_ids(&mut reports);
3252
3253        assert!(reports[0].linked_order_ids.is_none());
3254        assert_eq!(reports[0].contingency_type, None);
3255        assert_eq!(reports[0].parent_order_id, None);
3256    }
3257}