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