Skip to main content

nautilus_dydx/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 an ergonomic wrapper around the **dYdX v4 Indexer REST API**:
17//! <https://docs.dydx.xyz/api_integration-indexer/indexer_api>.
18//!
19//! This module exports two complementary HTTP clients following the standardized
20//! two-layer architecture pattern established in OKX, Bybit, and BitMEX adapters:
21//!
22//! - [`DydxRawHttpClient`]: Low-level HTTP methods matching dYdX Indexer API endpoints.
23//! - [`DydxHttpClient`]: High-level methods using Nautilus domain types with instrument caching.
24//!
25//! ## Two-Layer Architecture
26//!
27//! The raw client handles HTTP communication, rate limiting, retries, and basic response parsing.
28//! The domain client wraps the raw client in an `Arc`, maintains an instrument cache using `DashMap`,
29//! and provides high-level methods that work with Nautilus domain types.
30//!
31//! ## Responsibilities
32//!
33//! - Rate-limiting based on the public dYdX specification.
34//! - Zero-copy deserialization of large JSON payloads into domain models.
35//! - Conversion of raw exchange errors into the rich [`DydxHttpError`] enum.
36//! - Instrument caching with standard methods: `cache_instruments()`, `cache_instrument()`, `get_instrument()`.
37//!
38//! # Important Note
39//!
40//! The dYdX v4 Indexer REST API does **NOT** require authentication or request signing.
41//! All endpoints are publicly accessible using only wallet addresses and subaccount numbers
42//! as query parameters. Order submission and trading operations use gRPC with blockchain
43//! transaction signing, not REST API.
44//!
45//! # Official Documentation
46//!
47//! | Endpoint          | Reference                                                                 |
48//! |-------------------|---------------------------------------------------------------------------|
49//! | Market data       | <https://docs.dydx.xyz/api_integration-indexer/indexer_api#markets>  |
50//! | Account data      | <https://docs.dydx.xyz/api_integration-indexer/indexer_api#accounts> |
51//! | Utility endpoints | <https://docs.dydx.xyz/api_integration-indexer/indexer_api#utility>  |
52
53use std::{
54    collections::HashMap,
55    fmt::Debug,
56    num::NonZeroU32,
57    sync::{Arc, LazyLock},
58};
59
60use ahash::AHashMap;
61use jiff::{Timestamp, tz::Offset};
62use nautilus_common::cache::InstrumentLookupError;
63use nautilus_core::{
64    UnixNanos,
65    consts::NAUTILUS_USER_AGENT,
66    string::urlencoding,
67    time::{AtomicTime, get_atomic_clock_realtime},
68};
69use nautilus_model::{
70    data::{
71        Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
72    },
73    enums::{
74        AggregationSource, BarAggregation, BookAction, OrderSide as NautilusOrderSide, PriceType,
75        RecordFlag,
76    },
77    events::AccountState,
78    identifiers::{AccountId, InstrumentId},
79    instruments::{Instrument, InstrumentAny},
80    reports::{FillReport, OrderStatusReport, PositionStatusReport},
81    types::{Price, Quantity},
82};
83use nautilus_network::{
84    http::{HttpClient, Method, USER_AGENT},
85    ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota},
86    retry::{RetryConfig, RetryError, RetryManager},
87};
88use parking_lot::Mutex;
89use rust_decimal::Decimal;
90use serde::{Deserialize, Serialize, de::DeserializeOwned};
91use tokio_util::sync::CancellationToken;
92use ustr::Ustr;
93
94use super::error::DydxHttpError;
95use crate::{
96    common::{
97        consts::{DYDX_HTTP_URL, DYDX_TESTNET_HTTP_URL},
98        enums::{DydxCandleResolution, DydxNetwork},
99        instrument_cache::InstrumentCache,
100        parse::extract_raw_symbol,
101    },
102    http::parse::{parse_account_state_from_http, parse_instrument_any},
103};
104
105/// Maximum number of candles returned per dYdX API request.
106const DYDX_MAX_BARS_PER_REQUEST: u32 = 1_000;
107
108/// Perpetual markets endpoint (shared between `get_markets` and `get_market`).
109const ENDPOINT_PERPETUAL_MARKETS: &str = "/v4/perpetualMarkets";
110
111const QUERY_MARKET_TYPE_PERPETUAL: &str = "marketType=PERPETUAL";
112const DYDX_INDEXER_REPORT_LIMIT: u32 = 1_000;
113
114fn bar_type_to_resolution(bar_type: &BarType) -> anyhow::Result<DydxCandleResolution> {
115    if bar_type.aggregation_source() != AggregationSource::External {
116        anyhow::bail!(
117            "dYdX only supports EXTERNAL aggregation, was {:?}",
118            bar_type.aggregation_source()
119        );
120    }
121
122    let spec = bar_type.spec();
123    if spec.price_type != PriceType::Last {
124        anyhow::bail!(
125            "dYdX only supports LAST price type, was {:?}",
126            spec.price_type
127        );
128    }
129
130    DydxCandleResolution::from_bar_spec(&spec)
131}
132
133/// Default dYdX Indexer REST API rate limit.
134///
135/// The dYdX Indexer API rate limit is 100 requests per 10 seconds per IP.
136/// We use 9 req/s (vs the exact 10) to avoid edge-case 429s from
137/// GCRA vs server sliding-window misalignment at the boundary.
138pub static DYDX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
139    Quota::per_second(NonZeroU32::new(9).expect("non-zero")).expect("valid constant")
140});
141
142type DydxRestRateLimiter = Arc<RateLimiter<Ustr, MonotonicClock>>;
143
144// Process-global registry of dYdX Indexer REST rate limiters, keyed by resolved base URL.
145// Clients on the same URL (a network's data and execution clients) share one 9 req/s bucket
146// matching the Indexer per-IP limit; distinct URLs (testnet, or a mock server in tests) stay
147// isolated so unrelated traffic never contends for the same tokens.
148static DYDX_REST_RATE_LIMITERS: LazyLock<Mutex<AHashMap<String, DydxRestRateLimiter>>> =
149    LazyLock::new(|| Mutex::new(AHashMap::new()));
150
151static DYDX_RATE_LIMIT_KEY: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("dydx:rest"));
152
153fn rate_limit_keys() -> Vec<Ustr> {
154    vec![*DYDX_RATE_LIMIT_KEY]
155}
156
157fn rest_rate_limiter(base_url: &str) -> DydxRestRateLimiter {
158    DYDX_REST_RATE_LIMITERS
159        .lock()
160        .entry(base_url.to_string())
161        .or_insert_with(|| Arc::new(RateLimiter::new_with_quota(Some(*DYDX_REST_QUOTA), vec![])))
162        .clone()
163}
164
165/// Represents a dYdX HTTP response wrapper.
166///
167/// Most dYdX Indexer API endpoints return data directly without a wrapper,
168/// but some endpoints may use this structure for consistency.
169#[derive(Debug, Serialize, Deserialize)]
170pub struct DydxResponse<T> {
171    /// The typed data returned by the dYdX endpoint.
172    pub data: T,
173}
174
175/// Provides a raw HTTP client for interacting with the [dYdX v4](https://dydx.exchange) Indexer REST API.
176///
177/// This client wraps the underlying [`HttpClient`] to handle functionality
178/// specific to dYdX Indexer API, such as rate-limiting, forming request URLs,
179/// and deserializing responses into dYdX specific data models.
180///
181/// **Note**: Unlike traditional centralized exchanges, the dYdX v4 Indexer REST API
182/// does NOT require authentication, API keys, or request signing. All endpoints are
183/// publicly accessible.
184pub struct DydxRawHttpClient {
185    base_url: String,
186    client: HttpClient,
187    retry_manager: RetryManager<DydxHttpError>,
188    cancellation_token: CancellationToken,
189    network: DydxNetwork,
190}
191
192impl Default for DydxRawHttpClient {
193    fn default() -> Self {
194        Self::new(None, 60, None, DydxNetwork::Mainnet, None)
195            .expect("Failed to create default DydxRawHttpClient")
196    }
197}
198
199impl Debug for DydxRawHttpClient {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        f.debug_struct(stringify!(DydxRawHttpClient))
202            .field("base_url", &self.base_url)
203            .field("network", &self.network)
204            .finish_non_exhaustive()
205    }
206}
207
208impl DydxRawHttpClient {
209    /// Cancels all pending HTTP requests.
210    pub fn cancel_all_requests(&self) {
211        self.cancellation_token.cancel();
212    }
213
214    /// Returns the cancellation token for this client.
215    pub fn cancellation_token(&self) -> &CancellationToken {
216        &self.cancellation_token
217    }
218
219    /// Creates a new [`DydxRawHttpClient`] using the default dYdX Indexer HTTP URL,
220    /// optionally overridden with a custom base URL.
221    ///
222    /// **Note**: No credentials are required as the dYdX Indexer API is publicly accessible.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if the retry manager cannot be created.
227    pub fn new(
228        base_url: Option<String>,
229        timeout_secs: u64,
230        proxy_url: Option<String>,
231        network: DydxNetwork,
232        retry_config: Option<RetryConfig>,
233    ) -> anyhow::Result<Self> {
234        let base_url = match network {
235            DydxNetwork::Testnet => base_url.unwrap_or_else(|| DYDX_TESTNET_HTTP_URL.to_string()),
236            DydxNetwork::Mainnet => base_url.unwrap_or_else(|| DYDX_HTTP_URL.to_string()),
237        };
238
239        let retry_manager = RetryManager::new(retry_config.unwrap_or_default());
240
241        let mut headers = HashMap::new();
242        headers.insert(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string());
243
244        let client = HttpClient::builder()
245            .headers(headers)
246            .timeout_secs(timeout_secs)
247            .maybe_proxy_url(proxy_url)
248            .rate_limiters(vec![rest_rate_limiter(&base_url)])
249            .build()
250            .map_err(|e| {
251                DydxHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
252            })?;
253
254        Ok(Self {
255            base_url,
256            client,
257            retry_manager,
258            cancellation_token: CancellationToken::new(),
259            network,
260        })
261    }
262
263    /// Returns `true` if this client is configured for testnet.
264    #[must_use]
265    pub const fn is_testnet(&self) -> bool {
266        matches!(self.network, DydxNetwork::Testnet)
267    }
268
269    /// Returns the base URL used by this client.
270    #[must_use]
271    pub fn base_url(&self) -> &str {
272        &self.base_url
273    }
274
275    /// Sends a request to a dYdX Indexer API endpoint.
276    ///
277    /// **Note**: dYdX Indexer API does not require authentication headers.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if:
282    /// - The HTTP request fails.
283    /// - The response has a non-success HTTP status code.
284    /// - The response body cannot be deserialized to type `T`.
285    /// - The request is canceled.
286    pub async fn send_request<T>(
287        &self,
288        method: Method,
289        endpoint: &str,
290        query_params: Option<&str>,
291    ) -> Result<T, DydxHttpError>
292    where
293        T: DeserializeOwned,
294    {
295        let url = if let Some(params) = query_params {
296            format!("{}{endpoint}?{params}", self.base_url)
297        } else {
298            format!("{}{endpoint}", self.base_url)
299        };
300
301        let operation = || async {
302            let request = self
303                .client
304                .request_with_ustr_keys(
305                    method.clone(),
306                    url.clone(),
307                    None,
308                    None,
309                    None,
310                    None,
311                    Some(rate_limit_keys()),
312                )
313                .await
314                .map_err(|e| DydxHttpError::HttpClientError(e.to_string()))?;
315
316            if !request.status.is_success() {
317                return Err(DydxHttpError::HttpStatus {
318                    status: request.status.as_u16(),
319                    message: String::from_utf8_lossy(&request.body).to_string(),
320                });
321            }
322
323            Ok(request)
324        };
325
326        // Retry strategy for dYdX Indexer API:
327        // 1. Network errors: always retry (transient connection issues)
328        // 2. HTTP 429/5xx: rate limiting and server errors should be retried
329        // 3. Client errors (4xx except 429): should NOT be retried
330        let should_retry = |error: &DydxHttpError| -> bool {
331            match error {
332                DydxHttpError::HttpClientError(_) => true,
333                DydxHttpError::HttpStatus { status, .. } => *status == 429 || *status >= 500,
334                _ => false,
335            }
336        };
337
338        let response = self
339            .retry_manager
340            .execute_with_retry_with_cancel(
341                endpoint,
342                operation,
343                should_retry,
344                create_retry_error,
345                &self.cancellation_token,
346            )
347            .await?;
348
349        serde_json::from_slice(&response.body).map_err(|e| DydxHttpError::Deserialization {
350            error: e.to_string(),
351            body: String::from_utf8_lossy(&response.body).to_string(),
352        })
353    }
354
355    /// Sends a POST request to a dYdX Indexer API endpoint.
356    ///
357    /// Note: Most dYdX Indexer endpoints are GET-based. POST is rarely used.
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if:
362    /// - The request body cannot be serialized to JSON.
363    /// - The HTTP request fails.
364    /// - The response has a non-success HTTP status code.
365    /// - The response body cannot be deserialized to type `T`.
366    /// - The request is canceled.
367    pub async fn send_post_request<T, B>(
368        &self,
369        endpoint: &str,
370        body: &B,
371    ) -> Result<T, DydxHttpError>
372    where
373        T: DeserializeOwned,
374        B: Serialize,
375    {
376        let url = format!("{}{endpoint}", self.base_url);
377
378        let body_bytes = serde_json::to_vec(body).map_err(|e| DydxHttpError::Serialization {
379            error: e.to_string(),
380        })?;
381
382        let operation = || async {
383            let request = self
384                .client
385                .request_with_ustr_keys(
386                    Method::POST,
387                    url.clone(),
388                    None,
389                    None,
390                    Some(body_bytes.clone()),
391                    None,
392                    Some(rate_limit_keys()),
393                )
394                .await
395                .map_err(|e| DydxHttpError::HttpClientError(e.to_string()))?;
396
397            if !request.status.is_success() {
398                return Err(DydxHttpError::HttpStatus {
399                    status: request.status.as_u16(),
400                    message: String::from_utf8_lossy(&request.body).to_string(),
401                });
402            }
403
404            Ok(request)
405        };
406
407        // Retry strategy (same as GET requests)
408        let should_retry = |error: &DydxHttpError| -> bool {
409            match error {
410                DydxHttpError::HttpClientError(_) => true,
411                DydxHttpError::HttpStatus { status, .. } => *status == 429 || *status >= 500,
412                _ => false,
413            }
414        };
415
416        let response = self
417            .retry_manager
418            .execute_with_retry_with_cancel(
419                endpoint,
420                operation,
421                should_retry,
422                create_retry_error,
423                &self.cancellation_token,
424            )
425            .await?;
426
427        serde_json::from_slice(&response.body).map_err(|e| DydxHttpError::Deserialization {
428            error: e.to_string(),
429            body: String::from_utf8_lossy(&response.body).to_string(),
430        })
431    }
432
433    /// Fetch all perpetual markets from dYdX.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the HTTP request fails or response parsing fails.
438    pub async fn get_markets(&self) -> Result<super::models::MarketsResponse, DydxHttpError> {
439        self.send_request(Method::GET, ENDPOINT_PERPETUAL_MARKETS, None)
440            .await
441    }
442
443    /// Fetch a single perpetual market by ticker.
444    ///
445    /// Uses the `market` query parameter for efficient single-market fetch.
446    ///
447    /// # Errors
448    ///
449    /// Returns an error if the HTTP request fails or response parsing fails.
450    pub async fn get_market(
451        &self,
452        ticker: &str,
453    ) -> Result<super::models::MarketsResponse, DydxHttpError> {
454        let query = format!("ticker={ticker}");
455        self.send_request(Method::GET, ENDPOINT_PERPETUAL_MARKETS, Some(&query))
456            .await
457    }
458
459    /// Fetch orderbook for a specific market.
460    ///
461    /// # Errors
462    ///
463    /// Returns an error if the HTTP request fails or response parsing fails.
464    pub async fn get_orderbook(
465        &self,
466        ticker: &str,
467    ) -> Result<super::models::OrderbookResponse, DydxHttpError> {
468        let endpoint = format!("/v4/orderbooks/perpetualMarket/{ticker}");
469        self.send_request(Method::GET, &endpoint, None).await
470    }
471
472    /// Fetch recent trades for a market.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if the HTTP request fails or response parsing fails.
477    pub async fn get_trades(
478        &self,
479        ticker: &str,
480        limit: Option<u32>,
481        starting_before_or_at_height: Option<u64>,
482    ) -> Result<super::models::TradesResponse, DydxHttpError> {
483        let endpoint = format!("/v4/trades/perpetualMarket/{ticker}");
484        let mut query_parts = Vec::new();
485
486        if let Some(l) = limit {
487            query_parts.push(format!("limit={l}"));
488        }
489
490        if let Some(height) = starting_before_or_at_height {
491            query_parts.push(format!("createdBeforeOrAtHeight={height}"));
492        }
493        let query = if query_parts.is_empty() {
494            None
495        } else {
496            Some(query_parts.join("&"))
497        };
498        self.send_request(Method::GET, &endpoint, query.as_deref())
499            .await
500    }
501
502    /// Fetch candles/klines for a market.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the HTTP request fails or response parsing fails.
507    pub async fn get_candles(
508        &self,
509        ticker: &str,
510        resolution: DydxCandleResolution,
511        limit: Option<u32>,
512        from_iso: Option<Timestamp>,
513        to_iso: Option<Timestamp>,
514    ) -> Result<super::models::CandlesResponse, DydxHttpError> {
515        let endpoint = format!("/v4/candles/perpetualMarkets/{ticker}");
516        let mut query_parts = vec![format!("resolution={resolution}")];
517
518        if let Some(l) = limit {
519            query_parts.push(format!("limit={l}"));
520        }
521
522        if let Some(from) = from_iso {
523            let from_str = from.display_with_offset(Offset::UTC).to_string();
524            query_parts.push(format!("fromISO={}", urlencoding::encode(&from_str)));
525        }
526
527        if let Some(to) = to_iso {
528            let to_str = to.display_with_offset(Offset::UTC).to_string();
529            query_parts.push(format!("toISO={}", urlencoding::encode(&to_str)));
530        }
531        let query = query_parts.join("&");
532        self.send_request(Method::GET, &endpoint, Some(&query))
533            .await
534    }
535
536    /// Fetch subaccount information.
537    ///
538    /// # Errors
539    ///
540    /// Returns an error if the HTTP request fails or response parsing fails.
541    pub async fn get_subaccount(
542        &self,
543        address: &str,
544        subaccount_number: u32,
545    ) -> Result<super::models::SubaccountResponse, DydxHttpError> {
546        let endpoint = format!("/v4/addresses/{address}/subaccountNumber/{subaccount_number}");
547        self.send_request(Method::GET, &endpoint, None).await
548    }
549
550    /// Fetch fills for a subaccount.
551    ///
552    /// # Errors
553    ///
554    /// Returns an error if the HTTP request fails or response parsing fails.
555    pub async fn get_fills(
556        &self,
557        address: &str,
558        subaccount_number: u32,
559        market: Option<&str>,
560        limit: Option<u32>,
561    ) -> Result<super::models::FillsResponse, DydxHttpError> {
562        let endpoint = "/v4/fills";
563        let mut query_parts = vec![
564            format!("address={address}"),
565            format!("subaccountNumber={subaccount_number}"),
566        ];
567
568        if let Some(m) = market {
569            query_parts.push(format!("market={m}"));
570            query_parts.push(QUERY_MARKET_TYPE_PERPETUAL.to_string());
571        }
572
573        if let Some(l) = limit {
574            query_parts.push(format!("limit={l}"));
575        }
576        let query = query_parts.join("&");
577        self.send_request(Method::GET, endpoint, Some(&query)).await
578    }
579
580    /// Fetch orders for a subaccount.
581    ///
582    /// # Errors
583    ///
584    /// Returns an error if the HTTP request fails or response parsing fails.
585    pub async fn get_orders(
586        &self,
587        address: &str,
588        subaccount_number: u32,
589        market: Option<&str>,
590        limit: Option<u32>,
591    ) -> Result<super::models::OrdersResponse, DydxHttpError> {
592        let endpoint = "/v4/orders";
593        let mut query_parts = vec![
594            format!("address={address}"),
595            format!("subaccountNumber={subaccount_number}"),
596        ];
597
598        if let Some(m) = market {
599            query_parts.push(format!("market={m}"));
600            query_parts.push(QUERY_MARKET_TYPE_PERPETUAL.to_string());
601        }
602
603        if let Some(l) = limit {
604            query_parts.push(format!("limit={l}"));
605        }
606        let query = query_parts.join("&");
607        self.send_request(Method::GET, endpoint, Some(&query)).await
608    }
609
610    /// Fetch transfers for a subaccount.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if the HTTP request fails or response parsing fails.
615    pub async fn get_transfers(
616        &self,
617        address: &str,
618        subaccount_number: u32,
619        limit: Option<u32>,
620    ) -> Result<super::models::TransfersResponse, DydxHttpError> {
621        let endpoint = "/v4/transfers";
622        let mut query_parts = vec![
623            format!("address={address}"),
624            format!("subaccountNumber={subaccount_number}"),
625        ];
626
627        if let Some(l) = limit {
628            query_parts.push(format!("limit={l}"));
629        }
630        let query = query_parts.join("&");
631        self.send_request(Method::GET, endpoint, Some(&query)).await
632    }
633
634    /// Fetch historical funding rates for a market.
635    ///
636    /// # Errors
637    ///
638    /// Returns an error if the HTTP request fails or response parsing fails.
639    pub async fn get_historical_funding(
640        &self,
641        ticker: &str,
642        limit: Option<u32>,
643        effective_before_or_at_height: Option<u64>,
644        effective_before_or_at: Option<Timestamp>,
645    ) -> Result<super::models::HistoricalFundingResponse, DydxHttpError> {
646        let endpoint = format!("/v4/historicalFunding/{ticker}");
647        let mut query_parts = Vec::new();
648
649        if let Some(l) = limit {
650            query_parts.push(format!("limit={l}"));
651        }
652
653        if let Some(height) = effective_before_or_at_height {
654            query_parts.push(format!("effectiveBeforeOrAtHeight={height}"));
655        }
656
657        if let Some(before) = effective_before_or_at {
658            let before_str = before.display_with_offset(Offset::UTC).to_string();
659            query_parts.push(format!(
660                "effectiveBeforeOrAt={}",
661                urlencoding::encode(&before_str)
662            ));
663        }
664
665        let query = if query_parts.is_empty() {
666            None
667        } else {
668            Some(query_parts.join("&"))
669        };
670        self.send_request(Method::GET, &endpoint, query.as_deref())
671            .await
672    }
673
674    /// Returns the current server time.
675    ///
676    /// # Errors
677    ///
678    /// Returns an error if the HTTP request fails or response parsing fails.
679    pub async fn get_time(&self) -> Result<super::models::TimeResponse, DydxHttpError> {
680        self.send_request(Method::GET, "/v4/time", None).await
681    }
682
683    /// Returns the current blockchain height.
684    ///
685    /// # Errors
686    ///
687    /// Returns an error if the HTTP request fails or response parsing fails.
688    pub async fn get_height(&self) -> Result<super::models::HeightResponse, DydxHttpError> {
689        self.send_request(Method::GET, "/v4/height", None).await
690    }
691}
692
693/// Provides a higher-level HTTP client for the [dYdX v4](https://dydx.exchange) Indexer REST API.
694///
695/// This client wraps the underlying `DydxRawHttpClient` to handle conversions
696/// into the Nautilus domain model, following the two-layer pattern established
697/// in OKX, Bybit, and BitMEX adapters.
698///
699/// **Architecture:**
700/// - **Raw client** (`DydxRawHttpClient`): Low-level HTTP methods matching dYdX Indexer API endpoints.
701/// - **Domain client** (`DydxHttpClient`): High-level methods using Nautilus domain types.
702///
703/// The domain client:
704/// - Wraps the raw client in an `Arc` for efficient cloning (required for Python bindings).
705/// - Maintains an instrument cache using `DashMap` for thread-safe concurrent access.
706/// - Provides standard cache methods: `cache_instruments()`, `cache_instrument()`, `get_instrument()`.
707/// - Tracks cache initialization state for optimizations.
708#[derive(Debug)]
709#[cfg_attr(
710    feature = "python",
711    pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
712)]
713#[cfg_attr(
714    feature = "python",
715    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
716)]
717pub struct DydxHttpClient {
718    /// Raw HTTP client wrapped in Arc for efficient cloning.
719    pub(crate) inner: Arc<DydxRawHttpClient>,
720    /// Shared instrument cache with multiple lookup indices.
721    ///
722    /// This cache is shared across HTTP client, WebSocket client, and execution client.
723    /// It provides O(1) lookups by symbol, market ticker, or clob_pair_id.
724    pub(crate) instrument_cache: Arc<InstrumentCache>,
725    clock: &'static AtomicTime,
726}
727
728impl Clone for DydxHttpClient {
729    fn clone(&self) -> Self {
730        Self {
731            inner: self.inner.clone(),
732            instrument_cache: Arc::clone(&self.instrument_cache),
733            clock: self.clock,
734        }
735    }
736}
737
738impl Default for DydxHttpClient {
739    fn default() -> Self {
740        Self::new(None, 60, None, DydxNetwork::Mainnet, None)
741            .expect("Failed to create default DydxHttpClient")
742    }
743}
744
745fn create_retry_error(error: RetryError) -> DydxHttpError {
746    match error {
747        RetryError::Canceled => {
748            DydxHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
749        }
750        error @ RetryError::OperationTimeout { .. } => {
751            DydxHttpError::HttpClientError(error.to_string())
752        }
753        error => DydxHttpError::ValidationError(error.to_string()),
754    }
755}
756
757impl DydxHttpClient {
758    /// Creates a new [`DydxHttpClient`] using the default dYdX Indexer HTTP URL,
759    /// optionally overridden with a custom base URL.
760    ///
761    /// This constructor creates its own internal instrument cache. For shared caching
762    /// across multiple clients, use [`new_with_cache`](Self::new_with_cache) instead.
763    ///
764    /// **Note**: No credentials are required as the dYdX Indexer API is publicly accessible.
765    /// Order submission and trading operations use gRPC with blockchain transaction signing.
766    ///
767    /// # Errors
768    ///
769    /// Returns an error if the underlying HTTP client or retry manager cannot be created.
770    pub fn new(
771        base_url: Option<String>,
772        timeout_secs: u64,
773        proxy_url: Option<String>,
774        network: DydxNetwork,
775        retry_config: Option<RetryConfig>,
776    ) -> anyhow::Result<Self> {
777        Self::new_with_cache(
778            base_url,
779            timeout_secs,
780            proxy_url,
781            network,
782            retry_config,
783            Arc::new(InstrumentCache::new()),
784        )
785    }
786
787    /// Creates a new [`DydxHttpClient`] with a shared instrument cache.
788    ///
789    /// Use this constructor when sharing instrument data between HTTP client,
790    /// WebSocket client, and execution client.
791    ///
792    /// # Arguments
793    ///
794    /// * `instrument_cache` - Shared instrument cache for lookups by symbol, ticker, or clob_pair_id
795    ///
796    /// # Errors
797    ///
798    /// Returns an error if the underlying HTTP client or retry manager cannot be created.
799    pub fn new_with_cache(
800        base_url: Option<String>,
801        timeout_secs: u64,
802        proxy_url: Option<String>,
803        network: DydxNetwork,
804        retry_config: Option<RetryConfig>,
805        instrument_cache: Arc<InstrumentCache>,
806    ) -> anyhow::Result<Self> {
807        Ok(Self {
808            inner: Arc::new(DydxRawHttpClient::new(
809                base_url,
810                timeout_secs,
811                proxy_url,
812                network,
813                retry_config,
814            )?),
815            instrument_cache,
816            clock: get_atomic_clock_realtime(),
817        })
818    }
819
820    /// Requests instruments from the dYdX Indexer API and returns Nautilus domain types.
821    ///
822    /// This method does NOT automatically cache results. Use `fetch_and_cache_instruments()`
823    /// for automatic caching, or call `cache_instruments()` manually with the results.
824    ///
825    /// # Errors
826    ///
827    /// Returns an error if the HTTP request or parsing fails.
828    /// Individual instrument parsing errors are logged as warnings.
829    pub async fn request_instruments(
830        &self,
831        symbol: Option<String>,
832        maker_fee: Option<Decimal>,
833        taker_fee: Option<Decimal>,
834    ) -> anyhow::Result<Vec<InstrumentAny>> {
835        let markets_response = self.inner.get_markets().await?;
836        let ts_init = self.generate_ts_init();
837
838        let mut instruments = Vec::new();
839        let mut skipped_inactive = 0;
840
841        for (ticker, market) in markets_response.markets {
842            // Filter by symbol if specified
843            if let Some(ref sym) = symbol
844                && ticker != *sym
845            {
846                continue;
847            }
848
849            if !super::parse::is_market_active(&market.status) {
850                log::debug!(
851                    "Skipping inactive market {ticker} (status: {:?})",
852                    market.status
853                );
854                skipped_inactive += 1;
855                continue;
856            }
857
858            match super::parse::parse_instrument_any(&market, maker_fee, taker_fee, ts_init) {
859                Ok(instrument) => {
860                    instruments.push(instrument);
861                }
862                Err(e) => {
863                    log::error!("Failed to parse instrument {ticker}: {e}");
864                }
865            }
866        }
867
868        if skipped_inactive > 0 {
869            log::debug!(
870                "Parsed {} instruments, skipped {} inactive",
871                instruments.len(),
872                skipped_inactive
873            );
874        } else {
875            log::debug!("Parsed {} instruments", instruments.len());
876        }
877
878        Ok(instruments)
879    }
880
881    /// Fetches instruments from the API and caches them.
882    ///
883    /// This is a convenience method that fetches instruments and populates both
884    /// the symbol-based and CLOB pair ID-based caches.
885    ///
886    /// On success, existing caches are cleared and repopulated atomically.
887    /// On failure, existing caches are preserved (no partial updates).
888    ///
889    /// # Errors
890    ///
891    /// Returns an error if the HTTP request fails.
892    pub async fn fetch_and_cache_instruments(&self) -> anyhow::Result<()> {
893        // Fetch first - preserve existing cache on network failure
894        let markets_response = self.inner.get_markets().await?;
895        let ts_init = self.generate_ts_init();
896
897        let mut parsed_instruments = Vec::new();
898        let mut parsed_markets = Vec::new();
899        let mut skipped_inactive = 0;
900
901        for (ticker, market) in markets_response.markets {
902            if !super::parse::is_market_active(&market.status) {
903                log::debug!(
904                    "Skipping inactive market {ticker} (status: {:?})",
905                    market.status
906                );
907                skipped_inactive += 1;
908                continue;
909            }
910
911            match super::parse::parse_instrument_any(&market, None, None, ts_init) {
912                Ok(instrument) => {
913                    parsed_instruments.push(instrument);
914                    parsed_markets.push(market);
915                }
916                Err(e) => {
917                    log::error!("Failed to parse instrument {ticker}: {e}");
918                }
919            }
920        }
921
922        // Only clear and repopulate cache after successful fetch and parse
923        self.instrument_cache.clear();
924
925        // Zip instruments with their market data for bulk insert
926        let items: Vec<_> = parsed_instruments.into_iter().zip(parsed_markets).collect();
927
928        if !items.is_empty() {
929            self.instrument_cache.insert_many(items.clone());
930        }
931
932        let count = items.len();
933
934        if skipped_inactive > 0 {
935            log::debug!("Cached {count} instruments, skipped {skipped_inactive} inactive");
936        } else {
937            log::debug!("Cached {count} instruments");
938        }
939
940        Ok(())
941    }
942
943    /// Fetches a single instrument by ticker and caches it.
944    ///
945    /// # Errors
946    ///
947    /// Returns an error if the HTTP request fails.
948    pub async fn fetch_and_cache_single_instrument(
949        &self,
950        ticker: &str,
951    ) -> anyhow::Result<Option<InstrumentAny>> {
952        let markets_response = self.inner.get_market(ticker).await?;
953        let ts_init = self.generate_ts_init();
954
955        // The API returns all markets if ticker not found, so check specifically
956        if let Some(market) = markets_response.markets.get(ticker) {
957            if !super::parse::is_market_active(&market.status) {
958                log::debug!(
959                    "Skipping inactive market {ticker} (status: {:?})",
960                    market.status
961                );
962                return Ok(None);
963            }
964
965            let instrument = parse_instrument_any(market, None, None, ts_init)?;
966            self.instrument_cache
967                .insert(instrument.clone(), market.clone());
968
969            log::debug!("Fetched and cached new instrument: {ticker}");
970            return Ok(Some(instrument));
971        }
972
973        Ok(None)
974    }
975
976    /// Caches multiple instruments (symbol lookup only).
977    ///
978    /// Use `fetch_and_cache_instruments()` for full caching with market params.
979    /// Any existing instruments with the same symbols will be replaced.
980    pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
981        self.instrument_cache.insert_instruments_only(instruments);
982    }
983
984    /// Caches a single instrument (symbol lookup only).
985    ///
986    /// Use `fetch_and_cache_instruments()` for full caching with market params.
987    /// Any existing instrument with the same symbol will be replaced.
988    pub fn cache_instrument(&self, instrument: InstrumentAny) {
989        self.instrument_cache.insert_instrument_only(instrument);
990    }
991
992    /// Gets an instrument from the cache by InstrumentId.
993    #[must_use]
994    pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
995        self.instrument_cache.get(instrument_id)
996    }
997
998    /// Gets an instrument by CLOB pair ID.
999    ///
1000    /// Only works for instruments cached via `fetch_and_cache_instruments()`.
1001    #[must_use]
1002    pub fn get_instrument_by_clob_id(&self, clob_pair_id: u32) -> Option<InstrumentAny> {
1003        self.instrument_cache.get_by_clob_id(clob_pair_id)
1004    }
1005
1006    /// Gets an instrument by market ticker (e.g., "BTC-USD").
1007    ///
1008    /// Only works for instruments cached via `fetch_and_cache_instruments()`.
1009    #[must_use]
1010    pub fn get_instrument_by_market(&self, ticker: &str) -> Option<InstrumentAny> {
1011        self.instrument_cache.get_by_market(ticker)
1012    }
1013
1014    /// Gets market parameters for order submission from the cached market data.
1015    ///
1016    /// Returns the quantization parameters needed by OrderBuilder to construct
1017    /// properly formatted orders for the dYdX v4 protocol.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Returns None if the instrument is not found in the market params cache.
1022    #[must_use]
1023    pub fn get_market_params(
1024        &self,
1025        instrument_id: &InstrumentId,
1026    ) -> Option<super::models::PerpetualMarket> {
1027        self.instrument_cache.get_market_params(instrument_id)
1028    }
1029
1030    /// Requests historical trades for a symbol.
1031    ///
1032    /// Fetches trade data from the dYdX Indexer API's `/v4/trades/perpetualMarket/:ticker` endpoint.
1033    /// Results are ordered by creation time descending (newest first).
1034    ///
1035    /// # Errors
1036    ///
1037    /// Returns an error if the HTTP request fails or response cannot be parsed.
1038    pub async fn request_trades(
1039        &self,
1040        symbol: &str,
1041        limit: Option<u32>,
1042        starting_before_or_at_height: Option<u64>,
1043    ) -> anyhow::Result<super::models::TradesResponse> {
1044        self.inner
1045            .get_trades(symbol, limit, starting_before_or_at_height)
1046            .await
1047            .map_err(Into::into)
1048    }
1049
1050    /// Requests historical candles for a symbol.
1051    ///
1052    /// Fetches candle data from the dYdX Indexer API's `/v4/candles/perpetualMarkets/:ticker` endpoint.
1053    /// Results are ordered by start time ascending (oldest first).
1054    ///
1055    /// # Errors
1056    ///
1057    /// Returns an error if the HTTP request fails or response cannot be parsed.
1058    pub async fn request_candles(
1059        &self,
1060        symbol: &str,
1061        resolution: DydxCandleResolution,
1062        limit: Option<u32>,
1063        from_iso: Option<Timestamp>,
1064        to_iso: Option<Timestamp>,
1065    ) -> anyhow::Result<super::models::CandlesResponse> {
1066        self.inner
1067            .get_candles(symbol, resolution, limit, from_iso, to_iso)
1068            .await
1069            .map_err(Into::into)
1070    }
1071
1072    /// Requests historical bars for an instrument with optional pagination.
1073    ///
1074    /// Fetches candle data from the dYdX Indexer API and converts to Nautilus
1075    /// `Bar` objects. Supports time-chunked pagination for large date ranges.
1076    ///
1077    /// The resolution is derived internally from `bar_type` (no need to pass
1078    /// `DydxCandleResolution`). Incomplete bars (where `ts_event >= now`) are
1079    /// filtered out.
1080    ///
1081    /// Results are returned in chronological order (oldest first).
1082    ///
1083    /// # Errors
1084    ///
1085    /// Returns an error if:
1086    /// - The bar type uses unsupported aggregation/price type.
1087    /// - The HTTP request fails or response cannot be parsed.
1088    /// - The instrument is not found in the cache.
1089    pub async fn request_bars(
1090        &self,
1091        bar_type: BarType,
1092        start: Option<Timestamp>,
1093        end: Option<Timestamp>,
1094        limit: Option<u32>,
1095        timestamp_on_close: bool,
1096    ) -> anyhow::Result<Vec<Bar>> {
1097        let resolution = bar_type_to_resolution(&bar_type)?;
1098        let instrument_id = bar_type.instrument_id();
1099
1100        let instrument = self
1101            .get_instrument(&instrument_id)
1102            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1103
1104        let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1105        let price_precision = instrument.price_precision();
1106        let size_precision = instrument.size_precision();
1107        let ts_init = self.generate_ts_init();
1108
1109        let mut all_bars: Vec<Bar> = Vec::new();
1110
1111        // Determine bar duration in seconds for pagination chunking
1112        let spec = bar_type.spec();
1113        let bar_secs: i64 = match spec.aggregation {
1114            BarAggregation::Minute => spec.step.get() as i64 * 60,
1115            BarAggregation::Hour => spec.step.get() as i64 * 3_600,
1116            BarAggregation::Day => spec.step.get() as i64 * 86_400,
1117            _ => anyhow::bail!("Unsupported aggregation: {:?}", spec.aggregation),
1118        };
1119
1120        match (start, end) {
1121            // Time-chunked pagination for date ranges
1122            (Some(range_start), Some(range_end)) if range_end > range_start => {
1123                let overall_limit = limit.unwrap_or(u32::MAX);
1124                let mut remaining = overall_limit;
1125                let bars_per_call = DYDX_MAX_BARS_PER_REQUEST.min(remaining);
1126                let chunk_duration =
1127                    jiff::SignedDuration::from_secs(bar_secs * bars_per_call as i64);
1128                let mut chunk_start = range_start;
1129
1130                while chunk_start < range_end && remaining > 0 {
1131                    let chunk_end = (chunk_start + chunk_duration).min(range_end);
1132                    let per_call_limit = remaining.min(DYDX_MAX_BARS_PER_REQUEST);
1133
1134                    let response = self
1135                        .inner
1136                        .get_candles(
1137                            ticker,
1138                            resolution,
1139                            Some(per_call_limit),
1140                            Some(chunk_start),
1141                            Some(chunk_end),
1142                        )
1143                        .await?;
1144
1145                    let count = response.candles.len() as u32;
1146                    if count == 0 {
1147                        break;
1148                    }
1149
1150                    for candle in &response.candles {
1151                        match super::parse::parse_bar(
1152                            candle,
1153                            bar_type,
1154                            price_precision,
1155                            size_precision,
1156                            timestamp_on_close,
1157                            ts_init,
1158                        ) {
1159                            Ok(bar) => all_bars.push(bar),
1160                            Err(e) => log::warn!("Failed to parse candle for {instrument_id}: {e}"),
1161                        }
1162                    }
1163
1164                    if remaining <= count {
1165                        break;
1166                    }
1167                    remaining -= count;
1168                    chunk_start += chunk_duration;
1169                }
1170            }
1171            // Single request (no date range or invalid range)
1172            _ => {
1173                let req_limit = limit.unwrap_or(DYDX_MAX_BARS_PER_REQUEST);
1174                let response = self
1175                    .inner
1176                    .get_candles(ticker, resolution, Some(req_limit), None, None)
1177                    .await?;
1178
1179                for candle in &response.candles {
1180                    match super::parse::parse_bar(
1181                        candle,
1182                        bar_type,
1183                        price_precision,
1184                        size_precision,
1185                        timestamp_on_close,
1186                        ts_init,
1187                    ) {
1188                        Ok(bar) => all_bars.push(bar),
1189                        Err(e) => log::warn!("Failed to parse candle for {instrument_id}: {e}"),
1190                    }
1191                }
1192            }
1193        }
1194
1195        // Filter incomplete bars (ts_event >= current time)
1196        let current_time_ns = self.generate_ts_init();
1197        all_bars.retain(|bar| bar.ts_event < current_time_ns);
1198
1199        Ok(all_bars)
1200    }
1201
1202    /// Requests historical trade ticks for an instrument with optional pagination.
1203    ///
1204    /// Fetches trade data from the dYdX Indexer API and converts them to Nautilus
1205    /// `TradeTick` objects. Supports cursor-based pagination using block height
1206    /// and client-side time filtering (the dYdX API has no timestamp filter).
1207    ///
1208    /// Results are returned in chronological order (oldest first).
1209    ///
1210    /// # Errors
1211    ///
1212    /// Returns an error if the HTTP request fails, response cannot be parsed,
1213    /// or the instrument is not found in the cache.
1214    ///
1215    /// # Panics
1216    ///
1217    /// This function will panic if the API returns a non-empty trades response
1218    /// but `last()` on the trades vector returns `None` (should never happen).
1219    pub async fn request_trade_ticks(
1220        &self,
1221        instrument_id: InstrumentId,
1222        start: Option<Timestamp>,
1223        end: Option<Timestamp>,
1224        limit: Option<u32>,
1225    ) -> anyhow::Result<Vec<TradeTick>> {
1226        const DYDX_MAX_TRADES_PER_REQUEST: u32 = 1_000;
1227
1228        // Validation
1229        if let (Some(s), Some(e)) = (start, end) {
1230            anyhow::ensure!(s < e, "start ({s}) must be before end ({e})");
1231        }
1232
1233        let instrument = self
1234            .get_instrument(&instrument_id)
1235            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1236
1237        let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1238        let price_precision = instrument.price_precision();
1239        let size_precision = instrument.size_precision();
1240        let ts_init = self.generate_ts_init();
1241
1242        // We always start pagination from the chain head (cursor = None). An earlier
1243        // version used `DEFAULT_BLOCK_TIME_SECS` with `get_height()` to skip directly
1244        // to an estimated target block, but any hardcoded block-time estimate that
1245        // underestimates the true average lands the cursor BEFORE the real `end`
1246        // block and silently drops the trades in the skipped window. Walking back
1247        // from head costs a few extra round-trips for stale `end` times but is
1248        // always correct. Per-call trades above `end` are filtered inside the loop.
1249        let overall_limit = limit.unwrap_or(u32::MAX);
1250        let mut remaining = overall_limit;
1251        let mut cursor_height: Option<u64> = None;
1252        let mut all_trades = Vec::new();
1253        // Global trade-id dedup across pages. Using a set prevents non-adjacent duplicates
1254        // from slipping past the legacy Vec::dedup_by adjacency check.
1255        let mut seen_trade_ids: ahash::AHashSet<String> = ahash::AHashSet::new();
1256
1257        loop {
1258            let page_limit = remaining.min(DYDX_MAX_TRADES_PER_REQUEST);
1259            let response = self
1260                .inner
1261                .get_trades(ticker, Some(page_limit), cursor_height)
1262                .await?;
1263
1264            let page_count = response.trades.len() as u32;
1265            if page_count == 0 {
1266                break;
1267            }
1268
1269            // Trades come newest-first; oldest is last
1270            let oldest_trade = response.trades.last().unwrap();
1271            let oldest_height = oldest_trade.created_at_height;
1272            let oldest_created_at = oldest_trade.created_at;
1273
1274            // Count how many unique (unseen) trades this page contributed
1275            let mut new_trades_this_page: usize = 0;
1276            let mut page_before_start = false;
1277
1278            for trade in &response.trades {
1279                if !seen_trade_ids.insert(trade.id.clone()) {
1280                    // Already emitted; skip
1281                    continue;
1282                }
1283
1284                if start.is_some_and(|s| trade.created_at < s) {
1285                    page_before_start = true;
1286                    continue;
1287                }
1288
1289                if end.is_some_and(|e| trade.created_at > e) {
1290                    continue;
1291                }
1292
1293                all_trades.push(super::parse::parse_trade_tick(
1294                    trade,
1295                    instrument_id,
1296                    price_precision,
1297                    size_precision,
1298                    ts_init,
1299                )?);
1300                new_trades_this_page += 1;
1301            }
1302
1303            // If the oldest trade is before the start boundary we're done
1304            if let Some(s) = start
1305                && oldest_created_at < s
1306            {
1307                let _ = page_before_start;
1308                break;
1309            }
1310
1311            // Advance the cursor by one block. `createdBeforeOrAtHeight` is an inclusive
1312            // upper bound, and the endpoint has no `after`/offset cursor, so keeping the
1313            // same height would re-request the same page. Any same-block trades that
1314            // overflowed the previous page are lost here; the dYdX venue tops out well
1315            // below `DYDX_MAX_TRADES_PER_REQUEST` trades per block in practice. The
1316            // `saturating_sub(1)` bottoms out at 0, which the `page_count == 0` guard at
1317            // the top of the loop handles.
1318            let next_cursor = Some(oldest_height.saturating_sub(1));
1319
1320            // Terminal guard: if we're already at block 0 and this page produced nothing
1321            // new, there is nowhere further back to paginate.
1322            if oldest_height == 0 && new_trades_this_page == 0 {
1323                break;
1324            }
1325            cursor_height = next_cursor;
1326
1327            remaining = remaining.saturating_sub(new_trades_this_page as u32);
1328
1329            // Break on partial page (no more data) or limit reached
1330            if page_count < page_limit || remaining == 0 {
1331                break;
1332            }
1333        }
1334
1335        // Reverse to chronological order (oldest first)
1336        all_trades.reverse();
1337
1338        // Truncate to requested limit
1339        if let Some(lim) = limit {
1340            all_trades.truncate(lim as usize);
1341        }
1342
1343        Ok(all_trades)
1344    }
1345
1346    /// Requests historical funding rates for an instrument.
1347    ///
1348    /// Fetches funding rate data from the dYdX Indexer API's
1349    /// `/v4/historicalFunding/:ticker` endpoint and converts them to Nautilus
1350    /// `FundingRateUpdate` objects.
1351    ///
1352    /// Results are returned in chronological order (oldest first).
1353    ///
1354    /// # Errors
1355    ///
1356    /// Returns an error if the HTTP request fails or response cannot be parsed.
1357    pub async fn request_funding_rates(
1358        &self,
1359        instrument_id: InstrumentId,
1360        start: Option<Timestamp>,
1361        end: Option<Timestamp>,
1362        limit: Option<u32>,
1363    ) -> anyhow::Result<Vec<FundingRateUpdate>> {
1364        let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1365        let ts_init = self.generate_ts_init();
1366
1367        let response = self
1368            .inner
1369            .get_historical_funding(ticker, limit, None, end)
1370            .await?;
1371
1372        let mut rates = Vec::with_capacity(response.historical_funding.len());
1373
1374        for entry in &response.historical_funding {
1375            // Filter by start time if specified
1376            if start.is_some_and(|s| entry.effective_at < s) {
1377                continue;
1378            }
1379
1380            let ts_event =
1381                UnixNanos::from(u64::try_from(entry.effective_at.as_nanosecond()).map_err(
1382                    |_| anyhow::anyhow!("Timestamp overflow for {}", entry.effective_at),
1383                )?);
1384
1385            rates.push(FundingRateUpdate::new(
1386                instrument_id,
1387                entry.rate,
1388                Some(60),
1389                None,
1390                ts_event,
1391                ts_init,
1392            ));
1393        }
1394
1395        // dYdX returns newest first; reverse to chronological order
1396        rates.reverse();
1397
1398        log::debug!("Fetched {} funding rates for {instrument_id}", rates.len(),);
1399
1400        Ok(rates)
1401    }
1402
1403    /// Requests an order book snapshot for a symbol.
1404    ///
1405    /// Fetches order book data from the dYdX Indexer API and converts it to Nautilus
1406    /// `OrderBookDeltas`. The snapshot is represented as a sequence of deltas starting
1407    /// with a CLEAR action followed by ADD actions for each level.
1408    ///
1409    /// # Errors
1410    ///
1411    /// Returns an error if the HTTP request fails, response cannot be parsed,
1412    /// or the instrument is not found in the cache.
1413    pub async fn request_orderbook_snapshot(
1414        &self,
1415        instrument_id: InstrumentId,
1416    ) -> anyhow::Result<OrderBookDeltas> {
1417        let instrument = self
1418            .get_instrument(&instrument_id)
1419            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1420
1421        let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1422        let response = self.inner.get_orderbook(ticker).await?;
1423
1424        let ts_init = self.generate_ts_init();
1425        let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
1426
1427        let mut deltas = Vec::with_capacity(1 + response.bids.len() + response.asks.len());
1428
1429        // Empty book snapshot: Clear alone must carry F_SNAPSHOT | F_LAST
1430        if response.bids.is_empty() && response.asks.is_empty() {
1431            let mut clear_delta = OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init);
1432            clear_delta.flags = snapshot_flag | RecordFlag::F_LAST as u8;
1433            deltas.push(clear_delta);
1434            return Ok(OrderBookDeltas::new(instrument_id, deltas));
1435        }
1436
1437        let mut clear_delta = OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init);
1438        clear_delta.flags = snapshot_flag;
1439        deltas.push(clear_delta);
1440
1441        for (i, level) in response.bids.iter().enumerate() {
1442            let is_last = i == response.bids.len() - 1 && response.asks.is_empty();
1443            let flags = if is_last {
1444                snapshot_flag | RecordFlag::F_LAST as u8
1445            } else {
1446                snapshot_flag
1447            };
1448
1449            let order = BookOrder::new(
1450                NautilusOrderSide::Buy,
1451                Price::from_decimal_dp(level.price, instrument.price_precision())?,
1452                Quantity::from_decimal_dp(level.size, instrument.size_precision())?,
1453                0,
1454            );
1455
1456            deltas.push(OrderBookDelta::new(
1457                instrument_id,
1458                BookAction::Add,
1459                order,
1460                flags,
1461                0,
1462                ts_init,
1463                ts_init,
1464            ));
1465        }
1466
1467        for (i, level) in response.asks.iter().enumerate() {
1468            let is_last = i == response.asks.len() - 1;
1469            let flags = if is_last {
1470                snapshot_flag | RecordFlag::F_LAST as u8
1471            } else {
1472                snapshot_flag
1473            };
1474
1475            let order = BookOrder::new(
1476                NautilusOrderSide::Sell,
1477                Price::from_decimal_dp(level.price, instrument.price_precision())?,
1478                Quantity::from_decimal_dp(level.size, instrument.size_precision())?,
1479                0,
1480            );
1481
1482            deltas.push(OrderBookDelta::new(
1483                instrument_id,
1484                BookAction::Add,
1485                order,
1486                flags,
1487                0,
1488                ts_init,
1489                ts_init,
1490            ));
1491        }
1492
1493        Ok(OrderBookDeltas::new(instrument_id, deltas))
1494    }
1495
1496    /// Exposes raw HTTP client for testing and advanced use cases.
1497    ///
1498    /// This provides access to the underlying [`DydxRawHttpClient`] for cases
1499    /// where low-level API access is needed. Most users should use the domain
1500    /// client methods instead.
1501    #[must_use]
1502    pub fn raw_client(&self) -> &Arc<DydxRawHttpClient> {
1503        &self.inner
1504    }
1505
1506    /// Returns `true` if this client is configured for testnet.
1507    #[must_use]
1508    pub fn is_testnet(&self) -> bool {
1509        self.inner.is_testnet()
1510    }
1511
1512    /// Returns the base URL used by this client.
1513    #[must_use]
1514    pub fn base_url(&self) -> &str {
1515        self.inner.base_url()
1516    }
1517
1518    /// Returns `true` if the instrument cache has been initialized.
1519    #[must_use]
1520    pub fn is_cache_initialized(&self) -> bool {
1521        self.instrument_cache.is_initialized()
1522    }
1523
1524    /// Returns the number of instruments currently cached.
1525    #[must_use]
1526    pub fn cached_instruments_count(&self) -> usize {
1527        self.instrument_cache.len()
1528    }
1529
1530    /// Returns a reference to the shared instrument cache.
1531    ///
1532    /// The cache provides lookups by symbol, market ticker, and clob_pair_id.
1533    #[must_use]
1534    pub fn instrument_cache(&self) -> &Arc<InstrumentCache> {
1535        &self.instrument_cache
1536    }
1537
1538    /// Returns all cached instruments.
1539    ///
1540    /// This is a convenience method that collects all instruments into a Vec.
1541    #[must_use]
1542    pub fn all_instruments(&self) -> Vec<InstrumentAny> {
1543        self.instrument_cache.all_instruments()
1544    }
1545
1546    /// Returns all cached instrument IDs.
1547    #[must_use]
1548    pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
1549        self.instrument_cache.all_instrument_ids()
1550    }
1551
1552    fn generate_ts_init(&self) -> UnixNanos {
1553        self.clock.get_time_ns()
1554    }
1555
1556    /// Requests order status reports for a subaccount.
1557    ///
1558    /// Fetches orders from the dYdX Indexer API and converts them to Nautilus
1559    /// `OrderStatusReport` objects.
1560    ///
1561    /// # Errors
1562    ///
1563    /// Returns an error if the HTTP request fails or parsing fails.
1564    pub async fn request_order_status_reports(
1565        &self,
1566        address: &str,
1567        subaccount_number: u32,
1568        account_id: AccountId,
1569        instrument_id: Option<InstrumentId>,
1570    ) -> anyhow::Result<Vec<OrderStatusReport>> {
1571        let ts_init = self.generate_ts_init();
1572
1573        // Convert instrument_id to market filter
1574        let market = instrument_id.map(|id| {
1575            let symbol = id.symbol.to_string();
1576            // Remove -PERP suffix if present to get the dYdX market format (e.g., ETH-USD)
1577            symbol.trim_end_matches("-PERP").to_string()
1578        });
1579
1580        let orders = self
1581            .inner
1582            .get_orders(
1583                address,
1584                subaccount_number,
1585                market.as_deref(),
1586                Some(DYDX_INDEXER_REPORT_LIMIT),
1587            )
1588            .await?;
1589
1590        let mut reports = Vec::new();
1591
1592        for order in orders {
1593            // Get instrument by clob_pair_id
1594            let instrument = match self.get_instrument_by_clob_id(order.clob_pair_id) {
1595                Some(inst) => inst,
1596                None => {
1597                    log::warn!(
1598                        "Skipping order {}: no cached instrument for clob_pair_id {}",
1599                        order.id,
1600                        order.clob_pair_id
1601                    );
1602                    continue;
1603                }
1604            };
1605
1606            // Filter by instrument_id if specified
1607            if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1608                continue;
1609            }
1610
1611            match super::parse::parse_order_status_report(&order, &instrument, account_id, ts_init)
1612            {
1613                Ok(report) => reports.push(report),
1614                Err(e) => {
1615                    log::warn!("Failed to parse order {}: {e}", order.id);
1616                }
1617            }
1618        }
1619
1620        Ok(reports)
1621    }
1622
1623    /// Requests fill reports for a subaccount.
1624    ///
1625    /// Fetches fills from the dYdX Indexer API and converts them to Nautilus
1626    /// `FillReport` objects.
1627    ///
1628    /// # Errors
1629    ///
1630    /// Returns an error if the HTTP request fails or parsing fails.
1631    pub async fn request_fill_reports(
1632        &self,
1633        address: &str,
1634        subaccount_number: u32,
1635        account_id: AccountId,
1636        instrument_id: Option<InstrumentId>,
1637    ) -> anyhow::Result<Vec<FillReport>> {
1638        let ts_init = self.generate_ts_init();
1639
1640        // Convert instrument_id to market filter
1641        let market = instrument_id.map(|id| {
1642            let symbol = id.symbol.to_string();
1643            symbol.trim_end_matches("-PERP").to_string()
1644        });
1645
1646        let fills_response = self
1647            .inner
1648            .get_fills(
1649                address,
1650                subaccount_number,
1651                market.as_deref(),
1652                Some(DYDX_INDEXER_REPORT_LIMIT),
1653            )
1654            .await?;
1655
1656        let mut reports = Vec::new();
1657
1658        for fill in fills_response.fills {
1659            // Get instrument by market ticker (e.g., "BTC-USD")
1660            let instrument = match self.get_instrument_by_market(&fill.market) {
1661                Some(inst) => inst,
1662                None => {
1663                    log::warn!(
1664                        "Skipping fill {}: no cached instrument for market {}",
1665                        fill.id,
1666                        fill.market
1667                    );
1668                    continue;
1669                }
1670            };
1671
1672            // Filter by instrument_id if specified
1673            if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1674                continue;
1675            }
1676
1677            match super::parse::parse_fill_report(&fill, &instrument, account_id, ts_init) {
1678                Ok(report) => reports.push(report),
1679                Err(e) => {
1680                    log::warn!("Failed to parse fill {}: {e}", fill.id);
1681                }
1682            }
1683        }
1684
1685        Ok(reports)
1686    }
1687
1688    /// Requests position status reports for a subaccount.
1689    ///
1690    /// Fetches positions from the dYdX Indexer API and converts them to Nautilus
1691    /// `PositionStatusReport` objects.
1692    ///
1693    /// # Errors
1694    ///
1695    /// Returns an error if the HTTP request fails or parsing fails.
1696    pub async fn request_position_status_reports(
1697        &self,
1698        address: &str,
1699        subaccount_number: u32,
1700        account_id: AccountId,
1701        instrument_id: Option<InstrumentId>,
1702    ) -> anyhow::Result<Vec<PositionStatusReport>> {
1703        let ts_init = self.generate_ts_init();
1704
1705        let subaccount_response = self
1706            .inner
1707            .get_subaccount(address, subaccount_number)
1708            .await?;
1709
1710        let mut reports = Vec::new();
1711
1712        for (market, position) in subaccount_response.subaccount.open_perpetual_positions {
1713            // Get instrument by market ticker (e.g., "BTC-USD")
1714            let instrument = match self.get_instrument_by_market(&market) {
1715                Some(inst) => inst,
1716                None => {
1717                    log::warn!("Skipping position: no cached instrument for market {market}");
1718                    continue;
1719                }
1720            };
1721
1722            // Filter by instrument_id if specified
1723            if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1724                continue;
1725            }
1726
1727            match super::parse::parse_position_status_report(
1728                &position,
1729                &instrument,
1730                account_id,
1731                ts_init,
1732            ) {
1733                Ok(report) => reports.push(report),
1734                Err(e) => {
1735                    log::warn!("Failed to parse position for {market}: {e}");
1736                }
1737            }
1738        }
1739
1740        Ok(reports)
1741    }
1742
1743    /// Requests account state for a subaccount.
1744    ///
1745    /// Fetches the subaccount from the dYdX Indexer API and converts it to a Nautilus
1746    /// `AccountState` with balances and margin calculations.
1747    ///
1748    /// # Errors
1749    ///
1750    /// Returns an error if the HTTP request fails or parsing fails.
1751    pub async fn request_account_state(
1752        &self,
1753        address: &str,
1754        subaccount_number: u32,
1755        account_id: AccountId,
1756    ) -> anyhow::Result<AccountState> {
1757        let ts_init = self.generate_ts_init();
1758        let subaccount_response = self
1759            .inner
1760            .get_subaccount(address, subaccount_number)
1761            .await?;
1762
1763        // Build instruments map from cache
1764        let instruments: HashMap<InstrumentId, InstrumentAny> = self
1765            .instrument_cache
1766            .all_instruments()
1767            .into_iter()
1768            .map(|inst| (inst.id(), inst))
1769            .collect();
1770
1771        // Use current oracle prices from instrument cache (updated via WS)
1772        let oracle_prices = self.instrument_cache.to_oracle_prices_map();
1773
1774        parse_account_state_from_http(
1775            &subaccount_response.subaccount,
1776            account_id,
1777            &instruments,
1778            &oracle_prices,
1779            ts_init,
1780            ts_init,
1781        )
1782    }
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use std::sync::{
1788        Arc,
1789        atomic::{AtomicBool, Ordering},
1790    };
1791
1792    use axum::{Router, routing::get};
1793    use nautilus_common::testing::wait_until_async;
1794    use nautilus_model::identifiers::Symbol;
1795    use rstest::rstest;
1796
1797    use super::*;
1798    use crate::{common::consts::DYDX_VENUE, http::error};
1799
1800    #[tokio::test]
1801    async fn test_raw_client_creation() {
1802        let client = DydxRawHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None);
1803        assert!(client.is_ok());
1804
1805        let client = client.unwrap();
1806        assert!(!client.is_testnet());
1807        assert_eq!(client.base_url(), DYDX_HTTP_URL);
1808    }
1809
1810    #[tokio::test]
1811    async fn test_raw_client_testnet() {
1812        let client = DydxRawHttpClient::new(None, 30, None, DydxNetwork::Testnet, None);
1813        assert!(client.is_ok());
1814
1815        let client = client.unwrap();
1816        assert!(client.is_testnet());
1817        assert_eq!(client.base_url(), DYDX_TESTNET_HTTP_URL);
1818    }
1819
1820    #[rstest]
1821    fn test_rest_rate_limiter_shared_per_base_url() {
1822        let shared_a = rest_rate_limiter(DYDX_HTTP_URL);
1823        let shared_b = rest_rate_limiter(DYDX_HTTP_URL);
1824        let isolated = rest_rate_limiter("http://rate-limiter-test.invalid");
1825
1826        // Same base URL: data and execution clients on a network share one bucket.
1827        assert!(Arc::ptr_eq(&shared_a, &shared_b));
1828        // Distinct base URL: custom endpoints and mock servers stay isolated.
1829        assert!(!Arc::ptr_eq(&shared_a, &isolated));
1830    }
1831
1832    #[tokio::test]
1833    async fn test_domain_client_creation() {
1834        let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None);
1835        assert!(client.is_ok());
1836
1837        let client = client.unwrap();
1838        assert!(!client.is_testnet());
1839        assert_eq!(client.base_url(), DYDX_HTTP_URL);
1840        assert!(!client.is_cache_initialized());
1841        assert_eq!(client.cached_instruments_count(), 0);
1842    }
1843
1844    #[tokio::test]
1845    async fn test_domain_client_testnet() {
1846        let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Testnet, None);
1847        assert!(client.is_ok());
1848
1849        let client = client.unwrap();
1850        assert!(client.is_testnet());
1851        assert_eq!(client.base_url(), DYDX_TESTNET_HTTP_URL);
1852    }
1853
1854    #[tokio::test]
1855    async fn test_domain_client_default() {
1856        let client = DydxHttpClient::default();
1857        assert!(!client.is_testnet());
1858        assert_eq!(client.base_url(), DYDX_HTTP_URL);
1859        assert!(!client.is_cache_initialized());
1860    }
1861
1862    #[tokio::test]
1863    async fn test_domain_client_clone() {
1864        let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None).unwrap();
1865
1866        // Clone before initialization
1867        let cloned = client.clone();
1868        assert!(!cloned.is_cache_initialized());
1869
1870        client.instrument_cache.insert_instruments_only(vec![]);
1871
1872        // Clone after initialization
1873        #[expect(clippy::redundant_clone)]
1874        let cloned_after = client.clone();
1875        assert!(cloned_after.is_cache_initialized());
1876    }
1877
1878    #[rstest]
1879    fn test_domain_client_get_instrument_not_found() {
1880        let client = DydxHttpClient::default();
1881        let instrument_id = InstrumentId::new(Symbol::new("ETH-USD-PERP"), *DYDX_VENUE);
1882        let result = client.get_instrument(&instrument_id);
1883        assert!(result.is_none());
1884    }
1885
1886    #[tokio::test]
1887    async fn test_http_timeout_respects_configuration_and_does_not_block() {
1888        use tokio::net::TcpListener;
1889
1890        let handler_entered = Arc::new(AtomicBool::new(false));
1891        let handler_entered_clone = Arc::clone(&handler_entered);
1892        let router = Router::new()
1893            .route(
1894                "/v4/slow",
1895                get(move || async move {
1896                    handler_entered_clone.store(true, Ordering::SeqCst);
1897                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1898                    "ok"
1899                }),
1900            )
1901            .route("/health", get(|| async { "ok" }));
1902
1903        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1904        let addr = listener.local_addr().unwrap();
1905
1906        tokio::spawn(async move {
1907            axum::serve(listener, router.into_make_service())
1908                .await
1909                .unwrap();
1910        });
1911
1912        let base_url = format!("http://{addr}");
1913
1914        // The measured request must reach the slow route, so establish that the server is
1915        // accepting before starting the clock: binding the listener makes the port
1916        // connectable before the serve task has reached its accept loop.
1917        let ready_url = format!("{base_url}/health");
1918        let probe = HttpClient::builder().build().unwrap();
1919        wait_until_async(
1920            || {
1921                let url = ready_url.clone();
1922                let probe = probe.clone();
1923                async move { probe.get(url, None, None, Some(1), None).await.is_ok() }
1924            },
1925            std::time::Duration::from_secs(5),
1926        )
1927        .await;
1928
1929        // Configure a small operation timeout and no retries so the request
1930        // fails quickly even though the handler sleeps for 5 seconds.
1931        let retry_config = RetryConfig {
1932            max_retries: 0,
1933            initial_delay_ms: 1,
1934            max_delay_ms: 1,
1935            backoff_factor: 1.0,
1936            jitter_ms: 0,
1937            operation_timeout_ms: Some(500),
1938            immediate_first: true,
1939            max_elapsed_ms: Some(1_000),
1940        };
1941
1942        // Keep HTTP client timeout at a typical value; rely on RetryManager
1943        // operation timeout to enforce non-blocking behavior.
1944        let client = DydxRawHttpClient::new(
1945            Some(base_url),
1946            60,
1947            None,
1948            DydxNetwork::Mainnet,
1949            Some(retry_config),
1950        )
1951        .unwrap();
1952
1953        let start = std::time::Instant::now();
1954        let result: Result<serde_json::Value, error::DydxHttpError> =
1955            client.send_request(Method::GET, "/v4/slow", None).await;
1956        let elapsed = start.elapsed();
1957
1958        let expected = RetryError::OperationTimeout { timeout_ms: 500 }.to_string();
1959        assert!(
1960            matches!(
1961                &result,
1962                Err(error::DydxHttpError::HttpClientError(message)) if message == &expected
1963            ),
1964            "Expected operation timeout, received {result:?}"
1965        );
1966        assert!(
1967            handler_entered.load(Ordering::SeqCst),
1968            "Slow route was never entered"
1969        );
1970        assert!(elapsed < std::time::Duration::from_secs(3));
1971    }
1972}