1use std::num::NonZeroU32;
19
20use nautilus_model::identifiers::AccountId;
21use nautilus_network::{ratelimiter::quota::Quota, websocket::TransportBackend};
22use serde::{Deserialize, Serialize};
23
24use crate::{
25 common::{consts::DYDX_CHAIN_ID, enums::DydxNetwork, urls},
26 grpc::types::ChainId,
27};
28
29#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
35#[serde(deny_unknown_fields)]
36pub struct DydxAdapterConfig {
37 #[serde(default)]
39 #[builder(default)]
40 pub network: DydxNetwork,
41 #[builder(default = urls::http_base_url(DydxNetwork::Mainnet).to_string())]
43 pub base_url: String,
44 #[builder(default = urls::ws_url(DydxNetwork::Mainnet).to_string())]
46 pub ws_url: String,
47 #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet)[0].to_string())]
52 pub grpc_url: String,
53 #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet).iter().map(|&s| s.to_string()).collect())]
59 pub grpc_urls: Vec<String>,
60 #[builder(default = DYDX_CHAIN_ID.to_string())]
62 pub chain_id: String,
63 #[builder(default = 30)]
65 pub timeout_secs: u64,
66 #[serde(default)]
74 pub wallet_address: Option<String>,
75 #[serde(default)]
77 #[builder(default)]
78 pub subaccount: u32,
79 #[serde(default)]
87 pub private_key: Option<String>,
88 #[serde(default)]
97 #[builder(default)]
98 pub authenticator_ids: Vec<u64>,
99 #[serde(default = "default_max_retries")]
101 #[builder(default = 3)]
102 pub max_retries: u32,
103 #[serde(default = "default_retry_delay_initial_ms")]
105 #[builder(default = 1000)]
106 pub retry_delay_initial_ms: u64,
107 #[serde(default = "default_retry_delay_max_ms")]
109 #[builder(default = 10000)]
110 pub retry_delay_max_ms: u64,
111 #[serde(default = "default_grpc_rate_limit_per_second")]
122 pub grpc_rate_limit_per_second: Option<u32>,
123 #[serde(default)]
125 pub proxy_url: Option<String>,
126 #[serde(default)]
128 #[builder(default)]
129 pub transport_backend: TransportBackend,
130}
131
132fn default_max_retries() -> u32 {
133 3
134}
135
136fn default_retry_delay_initial_ms() -> u64 {
137 1000
138}
139
140fn default_retry_delay_max_ms() -> u64 {
141 10000
142}
143
144#[expect(
145 clippy::unnecessary_wraps,
146 reason = "serde default must match field type Option<u32>"
147)]
148fn default_grpc_rate_limit_per_second() -> Option<u32> {
149 Some(4)
150}
151
152fn default_data_http_timeout_secs() -> u64 {
153 60
154}
155
156fn default_data_max_retries() -> u64 {
157 3
158}
159
160fn default_data_retry_delay_initial_ms() -> u64 {
161 100
162}
163
164fn default_data_retry_delay_max_ms() -> u64 {
165 5000
166}
167
168fn default_max_ws_connections() -> usize {
169 8
170}
171
172fn default_per_channel_subscription_limit() -> usize {
173 32
174}
175
176impl DydxAdapterConfig {
177 #[must_use]
183 pub fn for_network(network: DydxNetwork) -> Self {
184 let chain_id = match network {
185 DydxNetwork::Mainnet => crate::common::consts::DYDX_CHAIN_ID,
186 DydxNetwork::Testnet => crate::common::consts::DYDX_TESTNET_CHAIN_ID,
187 };
188 Self {
189 network,
190 base_url: urls::http_base_url(network).to_string(),
191 ws_url: urls::ws_url(network).to_string(),
192 grpc_url: urls::grpc_urls(network)[0].to_string(),
193 grpc_urls: urls::grpc_urls(network)
194 .iter()
195 .map(|&s| s.to_string())
196 .collect(),
197 chain_id: chain_id.to_string(),
198 ..Self::default()
199 }
200 }
201
202 #[must_use]
207 pub fn get_grpc_urls(&self) -> Vec<String> {
208 if self.grpc_urls.is_empty() {
209 vec![self.grpc_url.clone()]
210 } else {
211 self.grpc_urls.clone()
212 }
213 }
214
215 #[must_use]
219 pub const fn get_chain_id(&self) -> ChainId {
220 self.network.chain_id()
221 }
222
223 #[must_use]
225 pub const fn is_testnet(&self) -> bool {
226 matches!(self.network, DydxNetwork::Testnet)
227 }
228
229 #[must_use]
231 pub fn grpc_quota(&self) -> Option<Quota> {
232 self.grpc_rate_limit_per_second
233 .and_then(NonZeroU32::new)
234 .and_then(Quota::per_second)
235 }
236}
237
238impl Default for DydxAdapterConfig {
239 fn default() -> Self {
240 Self {
241 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
242 ..Self::builder().build()
243 }
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
249#[serde(default, deny_unknown_fields)]
250#[cfg_attr(
251 feature = "python",
252 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
253)]
254#[cfg_attr(
255 feature = "python",
256 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
257)]
258pub struct DydxDataClientConfig {
259 pub base_url_http: Option<String>,
261 pub base_url_ws: Option<String>,
263 #[serde(default = "default_data_http_timeout_secs")]
265 #[builder(default = 60)]
266 pub http_timeout_secs: u64,
267 #[serde(default = "default_data_max_retries")]
269 #[builder(default = 3)]
270 pub max_retries: u64,
271 #[serde(default = "default_data_retry_delay_initial_ms")]
273 #[builder(default = 100)]
274 pub retry_delay_initial_ms: u64,
275 #[serde(default = "default_data_retry_delay_max_ms")]
277 #[builder(default = 5000)]
278 pub retry_delay_max_ms: u64,
279 #[serde(default)]
281 #[builder(default)]
282 pub network: DydxNetwork,
283 pub proxy_url: Option<String>,
285 #[serde(default)]
287 #[builder(default)]
288 pub transport_backend: TransportBackend,
289 #[serde(default = "default_max_ws_connections")]
295 #[builder(default = default_max_ws_connections())]
296 pub max_ws_connections: usize,
297 #[serde(default = "default_per_channel_subscription_limit")]
303 #[builder(default = default_per_channel_subscription_limit())]
304 pub per_channel_subscription_limit: usize,
305}
306
307#[cfg(feature = "python")]
308nautilus_core::impl_pyo3_config_getters!(DydxDataClientConfig {
309 network: DydxNetwork,
310});
311
312impl DydxDataClientConfig {
313 #[must_use]
315 pub const fn is_testnet(&self) -> bool {
316 matches!(self.network, DydxNetwork::Testnet)
317 }
318}
319
320impl Default for DydxDataClientConfig {
321 fn default() -> Self {
322 Self::builder().build()
323 }
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
328#[serde(default, deny_unknown_fields)]
329#[cfg_attr(
330 feature = "python",
331 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
332)]
333#[cfg_attr(
334 feature = "python",
335 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
336)]
337pub struct DydxExecutionClientConfig {
338 #[builder(default = AccountId::from("DYDX-001"))]
340 pub account_id: AccountId,
341 #[serde(default)]
343 #[builder(default)]
344 pub network: DydxNetwork,
345 pub grpc_endpoint: Option<String>,
347 #[serde(default)]
349 #[builder(default)]
350 pub grpc_urls: Vec<String>,
351 pub ws_endpoint: Option<String>,
353 pub http_endpoint: Option<String>,
355 pub private_key: Option<String>,
361 pub wallet_address: Option<String>,
367 #[serde(default)]
369 #[builder(default)]
370 pub subaccount_number: u32,
371 #[serde(default)]
373 #[builder(default)]
374 pub authenticator_ids: Vec<u64>,
375 pub http_timeout_secs: Option<u64>,
377 pub max_retries: Option<u32>,
379 pub retry_delay_initial_ms: Option<u64>,
381 pub retry_delay_max_ms: Option<u64>,
383 #[serde(default = "default_grpc_rate_limit_per_second")]
386 pub grpc_rate_limit_per_second: Option<u32>,
387 pub proxy_url: Option<String>,
389 #[serde(default)]
391 #[builder(default)]
392 pub transport_backend: TransportBackend,
393}
394
395#[cfg(feature = "python")]
396nautilus_core::impl_pyo3_config_getters!(DydxExecutionClientConfig {
397 account_id: AccountId,
398 network: DydxNetwork,
399 wallet_address: Option<String>,
400 subaccount_number: u32,
401});
402
403impl Default for DydxExecutionClientConfig {
404 fn default() -> Self {
405 Self {
406 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
407 ..Self::builder().build()
408 }
409 }
410}
411
412impl DydxExecutionClientConfig {
413 #[must_use]
418 pub fn get_grpc_urls(&self) -> Vec<String> {
419 if !self.grpc_urls.is_empty() {
420 return self.grpc_urls.clone();
421 }
422
423 if let Some(ref endpoint) = self.grpc_endpoint {
424 return vec![endpoint.clone()];
425 }
426 urls::grpc_urls(self.network)
427 .iter()
428 .map(|&s| s.to_string())
429 .collect()
430 }
431
432 #[must_use]
434 pub fn get_ws_url(&self) -> String {
435 self.ws_endpoint
436 .clone()
437 .unwrap_or_else(|| urls::ws_url(self.network).to_string())
438 }
439
440 #[must_use]
442 pub fn get_http_url(&self) -> String {
443 self.http_endpoint
444 .clone()
445 .unwrap_or_else(|| urls::http_base_url(self.network).to_string())
446 }
447
448 #[must_use]
450 pub const fn get_chain_id(&self) -> ChainId {
451 self.network.chain_id()
452 }
453
454 #[must_use]
456 pub const fn is_testnet(&self) -> bool {
457 matches!(self.network, DydxNetwork::Testnet)
458 }
459
460 #[must_use]
462 pub fn grpc_quota(&self) -> Option<Quota> {
463 self.grpc_rate_limit_per_second
464 .and_then(NonZeroU32::new)
465 .and_then(Quota::per_second)
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use rstest::rstest;
472
473 use super::*;
474
475 #[rstest]
476 fn test_config_get_chain_id_mainnet() {
477 let config = DydxAdapterConfig {
478 network: DydxNetwork::Mainnet,
479 ..Default::default()
480 };
481 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
482 }
483
484 #[rstest]
485 fn test_config_get_chain_id_testnet() {
486 let config = DydxAdapterConfig {
487 network: DydxNetwork::Testnet,
488 ..Default::default()
489 };
490 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
491 }
492
493 #[rstest]
494 fn test_config_is_testnet() {
495 let mainnet_config = DydxAdapterConfig {
496 network: DydxNetwork::Mainnet,
497 ..Default::default()
498 };
499 assert!(!mainnet_config.is_testnet());
500
501 let testnet_config = DydxAdapterConfig {
502 network: DydxNetwork::Testnet,
503 ..Default::default()
504 };
505 assert!(testnet_config.is_testnet());
506 }
507
508 #[rstest]
509 fn test_config_default_uses_mainnet() {
510 let config = DydxAdapterConfig::default();
511 assert_eq!(config.network, DydxNetwork::Mainnet);
512 assert!(!config.is_testnet());
513 }
514
515 #[rstest]
516 fn test_config_serde_backwards_compat() {
517 let json = r#"{"base_url":"https://indexer.dydx.trade","ws_url":"wss://indexer.dydx.trade/v4/ws","grpc_url":"https://dydx-ops-grpc.kingnodes.com:443","grpc_urls":[],"chain_id":"dydx-mainnet-1","timeout_secs":30,"subaccount":0,"max_retries":3,"retry_delay_initial_ms":1000,"retry_delay_max_ms":10000}"#;
519
520 let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
521 assert!(config.is_ok());
522 let config = config.unwrap();
523 assert_eq!(config.network, DydxNetwork::Mainnet);
525 }
526
527 #[rstest]
528 fn test_config_get_grpc_urls_fallback() {
529 let config = DydxAdapterConfig {
530 grpc_url: "https://primary.example.com".to_string(),
531 grpc_urls: vec![],
532 ..Default::default()
533 };
534
535 let urls = config.get_grpc_urls();
536 assert_eq!(urls.len(), 1);
537 assert_eq!(urls[0], "https://primary.example.com");
538 }
539
540 #[rstest]
541 fn test_config_get_grpc_urls_multiple() {
542 let config = DydxAdapterConfig {
543 grpc_url: "https://primary.example.com".to_string(),
544 grpc_urls: vec![
545 "https://fallback1.example.com".to_string(),
546 "https://fallback2.example.com".to_string(),
547 ],
548 ..Default::default()
549 };
550
551 let urls = config.get_grpc_urls();
552 assert_eq!(urls.len(), 2);
553 assert_eq!(urls[0], "https://fallback1.example.com");
554 assert_eq!(urls[1], "https://fallback2.example.com");
555 }
556
557 #[rstest]
558 fn test_for_network_mainnet_resolves_urls_and_chain_id() {
559 let config = DydxAdapterConfig::for_network(DydxNetwork::Mainnet);
560
561 assert_eq!(config.network, DydxNetwork::Mainnet);
562 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Mainnet));
563 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Mainnet));
564 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Mainnet)[0]);
565 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Mainnet)
566 .iter()
567 .map(|s| (*s).to_string())
568 .collect();
569 assert_eq!(config.grpc_urls, expected_grpc);
570 assert_eq!(config.chain_id, crate::common::consts::DYDX_CHAIN_ID);
571 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
572 }
573
574 #[rstest]
575 fn test_for_network_testnet_resolves_urls_and_chain_id() {
576 let config = DydxAdapterConfig::for_network(DydxNetwork::Testnet);
577
578 assert_eq!(config.network, DydxNetwork::Testnet);
579 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Testnet));
580 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Testnet));
581 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Testnet)[0]);
582 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Testnet)
583 .iter()
584 .map(|s| (*s).to_string())
585 .collect();
586 assert_eq!(config.grpc_urls, expected_grpc);
587 assert_eq!(
588 config.chain_id,
589 crate::common::consts::DYDX_TESTNET_CHAIN_ID,
590 );
591 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
592 }
593
594 #[rstest]
595 #[case(DydxNetwork::Mainnet)]
596 #[case(DydxNetwork::Testnet)]
597 fn test_for_network_preserves_grpc_rate_limit_default(#[case] network: DydxNetwork) {
598 let config = DydxAdapterConfig::for_network(network);
602 assert_eq!(config.grpc_rate_limit_per_second, Some(4));
603 assert!(config.grpc_quota().is_some());
604 }
605
606 #[rstest]
607 fn test_adapter_config_toml_requires_url_fields() {
608 let err = toml::from_str::<DydxAdapterConfig>(r#"network = "testnet""#).unwrap_err();
613 let message = err.to_string();
614 assert!(message.contains("missing field"), "{message}");
615 }
616
617 #[rstest]
618 fn test_exec_config_toml_empty_uses_defaults() {
619 let config: DydxExecutionClientConfig = toml::from_str("").unwrap();
620 let expected = DydxExecutionClientConfig::default();
621 assert_eq!(config.account_id, expected.account_id);
622 assert_eq!(config.network, expected.network);
623 assert_eq!(config.subaccount_number, expected.subaccount_number);
624 assert_eq!(
625 config.grpc_rate_limit_per_second,
626 expected.grpc_rate_limit_per_second,
627 );
628 }
629}