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