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