1use 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
104const DYDX_MAX_BARS_PER_REQUEST: u32 = 1_000;
106
107const 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
132pub 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
143static 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#[derive(Debug, Serialize, Deserialize)]
170pub struct DydxResponse<T> {
171 pub data: T,
173}
174
175pub 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 pub fn cancel_all_requests(&self) {
211 self.cancellation_token.cancel();
212 }
213
214 pub fn cancellation_token(&self) -> &CancellationToken {
216 &self.cancellation_token
217 }
218
219 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 #[must_use]
266 pub const fn is_testnet(&self) -> bool {
267 matches!(self.network, DydxNetwork::Testnet)
268 }
269
270 #[must_use]
272 pub fn base_url(&self) -> &str {
273 &self.base_url
274 }
275
276 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn get_time(&self) -> Result<super::models::TimeResponse, DydxHttpError> {
703 self.send_request(Method::GET, "/v4/time", None).await
704 }
705
706 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#[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 pub(crate) inner: Arc<DydxRawHttpClient>,
743 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 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 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 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 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 pub async fn fetch_and_cache_instruments(&self) -> anyhow::Result<()> {
904 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 self.instrument_cache.clear();
935
936 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 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 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 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
992 self.instrument_cache.insert_instruments_only(instruments);
993 }
994
995 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1000 self.instrument_cache.insert_instrument_only(instrument);
1001 }
1002
1003 #[must_use]
1005 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
1006 self.instrument_cache.get(instrument_id)
1007 }
1008
1009 #[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 #[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 #[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 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 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 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 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 (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 _ => {
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 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 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 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 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 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 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 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 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 let Some(s) = start
1315 && oldest_created_at < s
1316 {
1317 let _ = page_before_start;
1318 break;
1319 }
1320
1321 let next_cursor = Some(oldest_height.saturating_sub(1));
1329
1330 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 if page_count < page_limit || remaining == 0 {
1341 break;
1342 }
1343 }
1344
1345 all_trades.reverse();
1347
1348 if let Some(lim) = limit {
1350 all_trades.truncate(lim as usize);
1351 }
1352
1353 Ok(all_trades)
1354 }
1355
1356 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 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 rates.reverse();
1407
1408 log::debug!("Fetched {} funding rates for {instrument_id}", rates.len(),);
1409
1410 Ok(rates)
1411 }
1412
1413 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 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 #[must_use]
1512 pub fn raw_client(&self) -> &Arc<DydxRawHttpClient> {
1513 &self.inner
1514 }
1515
1516 #[must_use]
1518 pub fn is_testnet(&self) -> bool {
1519 self.inner.is_testnet()
1520 }
1521
1522 #[must_use]
1524 pub fn base_url(&self) -> &str {
1525 self.inner.base_url()
1526 }
1527
1528 #[must_use]
1530 pub fn is_cache_initialized(&self) -> bool {
1531 self.instrument_cache.is_initialized()
1532 }
1533
1534 #[must_use]
1536 pub fn cached_instruments_count(&self) -> usize {
1537 self.instrument_cache.len()
1538 }
1539
1540 #[must_use]
1544 pub fn instrument_cache(&self) -> &Arc<InstrumentCache> {
1545 &self.instrument_cache
1546 }
1547
1548 #[must_use]
1552 pub fn all_instruments(&self) -> Vec<InstrumentAny> {
1553 self.instrument_cache.all_instruments()
1554 }
1555
1556 #[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 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 let market = instrument_id.map(|id| {
1585 let symbol = id.symbol.to_string();
1586 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(Arc::ptr_eq(&shared_a, &shared_b));
1832 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 let cloned = client.clone();
1872 assert!(!cloned.is_cache_initialized());
1873
1874 client.instrument_cache.insert_instruments_only(vec![]);
1875
1876 #[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 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 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 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 assert!(result.is_err());
1945 assert!(elapsed < std::time::Duration::from_secs(3));
1946 }
1947}