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