Skip to main content

nautilus_bitmex/http/
client.rs

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