Skip to main content

nautilus_bitmex/http/
client.rs

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