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