1use std::num::NonZeroU32;
19
20use nautilus_common::config::{ConfigResult, check_unique_values};
21#[cfg(test)]
22use nautilus_core::string::secret::REDACTED;
23use nautilus_core::string::secret::SecretString;
24use nautilus_model::identifiers::AccountId;
25use nautilus_network::{ratelimiter::quota::Quota, websocket::TransportBackend};
26use serde::{Deserialize, Serialize};
27
28use crate::{
29 common::{consts::DYDX_CHAIN_ID, enums::DydxNetwork, urls},
30 grpc::types::ChainId,
31};
32
33#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
39#[serde(deny_unknown_fields)]
40pub struct DydxAdapterConfig {
41 #[serde(default)]
43 #[builder(default)]
44 pub network: DydxNetwork,
45 #[builder(default = urls::http_base_url(DydxNetwork::Mainnet).to_string())]
47 pub base_url: String,
48 #[builder(default = urls::ws_url(DydxNetwork::Mainnet).to_string())]
50 pub ws_url: String,
51 #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet)[0].to_string())]
56 pub grpc_url: String,
57 #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet).iter().map(|&s| s.to_string()).collect())]
63 pub grpc_urls: Vec<String>,
64 #[builder(default = DYDX_CHAIN_ID.to_string())]
66 pub chain_id: String,
67 #[builder(default = 30)]
69 pub timeout_secs: u64,
70 #[serde(default)]
78 pub wallet_address: Option<String>,
79 #[serde(default)]
81 #[builder(default)]
82 pub subaccount: u32,
83 #[serde(default)]
91 pub private_key: Option<SecretString>,
92 #[serde(default)]
101 #[builder(default)]
102 pub authenticator_ids: Vec<u64>,
103 #[serde(default = "default_max_retries")]
105 #[builder(default = 3)]
106 pub max_retries: u32,
107 #[serde(default = "default_retry_delay_initial_ms")]
109 #[builder(default = 1000)]
110 pub retry_delay_initial_ms: u64,
111 #[serde(default = "default_retry_delay_max_ms")]
113 #[builder(default = 10000)]
114 pub retry_delay_max_ms: u64,
115 #[serde(default = "default_grpc_rate_limit_per_second")]
126 pub grpc_rate_limit_per_second: Option<u32>,
127 #[serde(default)]
129 pub proxy_url: Option<SecretString>,
130 #[serde(default)]
132 #[builder(default)]
133 pub transport_backend: TransportBackend,
134}
135
136fn default_max_retries() -> u32 {
137 3
138}
139
140fn default_retry_delay_initial_ms() -> u64 {
141 1000
142}
143
144fn default_retry_delay_max_ms() -> u64 {
145 10000
146}
147
148#[expect(
149 clippy::unnecessary_wraps,
150 reason = "serde default must match field type Option<u32>"
151)]
152fn default_grpc_rate_limit_per_second() -> Option<u32> {
153 Some(4)
154}
155
156fn default_data_http_timeout_secs() -> u64 {
157 60
158}
159
160fn default_data_max_retries() -> u64 {
161 3
162}
163
164fn default_data_retry_delay_initial_ms() -> u64 {
165 100
166}
167
168fn default_data_retry_delay_max_ms() -> u64 {
169 5000
170}
171
172fn default_max_ws_connections() -> usize {
173 8
174}
175
176fn default_per_channel_subscription_limit() -> usize {
177 32
178}
179
180impl DydxAdapterConfig {
181 pub fn validate(&self) -> ConfigResult<()> {
187 check_unique_values("grpc_urls", &self.grpc_urls)?;
188 check_unique_values("authenticator_ids", &self.authenticator_ids)?;
189 Ok(())
190 }
191
192 #[must_use]
198 pub fn for_network(network: DydxNetwork) -> Self {
199 let chain_id = match network {
200 DydxNetwork::Mainnet => crate::common::consts::DYDX_CHAIN_ID,
201 DydxNetwork::Testnet => crate::common::consts::DYDX_TESTNET_CHAIN_ID,
202 };
203 Self {
204 network,
205 base_url: urls::http_base_url(network).to_string(),
206 ws_url: urls::ws_url(network).to_string(),
207 grpc_url: urls::grpc_urls(network)[0].to_string(),
208 grpc_urls: urls::grpc_urls(network)
209 .iter()
210 .map(|&s| s.to_string())
211 .collect(),
212 chain_id: chain_id.to_string(),
213 ..Self::default()
214 }
215 }
216
217 #[must_use]
222 pub fn get_grpc_urls(&self) -> Vec<String> {
223 if self.grpc_urls.is_empty() {
224 vec![self.grpc_url.clone()]
225 } else {
226 self.grpc_urls.clone()
227 }
228 }
229
230 #[must_use]
234 pub const fn get_chain_id(&self) -> ChainId {
235 self.network.chain_id()
236 }
237
238 #[must_use]
240 pub const fn is_testnet(&self) -> bool {
241 matches!(self.network, DydxNetwork::Testnet)
242 }
243
244 #[must_use]
246 pub fn grpc_quota(&self) -> Option<Quota> {
247 self.grpc_rate_limit_per_second
248 .and_then(NonZeroU32::new)
249 .and_then(Quota::per_second)
250 }
251}
252
253impl Default for DydxAdapterConfig {
254 fn default() -> Self {
255 Self {
256 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
257 ..Self::builder().build()
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
264#[serde(default, deny_unknown_fields)]
265#[cfg_attr(
266 feature = "python",
267 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
268)]
269#[cfg_attr(
270 feature = "python",
271 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
272)]
273pub struct DydxDataClientConfig {
274 #[serde(default)]
276 #[builder(default)]
277 pub network: DydxNetwork,
278 pub base_url_http: Option<String>,
280 pub base_url_ws: Option<String>,
282 pub proxy_url: Option<SecretString>,
284 #[serde(default = "default_data_http_timeout_secs")]
286 #[builder(default = 60)]
287 pub http_timeout_secs: u64,
288 #[serde(default = "default_data_max_retries")]
290 #[builder(default = 3)]
291 pub max_retries: u64,
292 #[serde(default = "default_data_retry_delay_initial_ms")]
294 #[builder(default = 100)]
295 pub retry_delay_initial_ms: u64,
296 #[serde(default = "default_data_retry_delay_max_ms")]
298 #[builder(default = 5000)]
299 pub retry_delay_max_ms: u64,
300 #[serde(default)]
302 #[builder(default)]
303 pub transport_backend: TransportBackend,
304 #[serde(default = "default_max_ws_connections")]
310 #[builder(default = default_max_ws_connections())]
311 pub max_ws_connections: usize,
312 #[serde(default = "default_per_channel_subscription_limit")]
318 #[builder(default = default_per_channel_subscription_limit())]
319 pub per_channel_subscription_limit: usize,
320}
321
322#[cfg(feature = "python")]
323nautilus_core::impl_pyo3_config_getters!(DydxDataClientConfig {
324 network: DydxNetwork,
325});
326
327impl DydxDataClientConfig {
328 #[must_use]
330 pub const fn is_testnet(&self) -> bool {
331 matches!(self.network, DydxNetwork::Testnet)
332 }
333}
334
335impl Default for DydxDataClientConfig {
336 fn default() -> Self {
337 Self::builder().build()
338 }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
343#[serde(default, deny_unknown_fields)]
344#[cfg_attr(
345 feature = "python",
346 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
347)]
348#[cfg_attr(
349 feature = "python",
350 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
351)]
352pub struct DydxExecutionClientConfig {
353 #[builder(default = AccountId::from("DYDX-001"))]
355 pub account_id: AccountId,
356 #[serde(default)]
358 #[builder(default)]
359 pub network: DydxNetwork,
360 pub grpc_endpoint: Option<String>,
362 #[serde(default)]
364 #[builder(default)]
365 pub grpc_urls: Vec<String>,
366 pub http_endpoint: Option<String>,
368 pub ws_endpoint: Option<String>,
370 pub proxy_url: Option<SecretString>,
372 pub private_key: Option<SecretString>,
378 pub wallet_address: Option<String>,
384 #[serde(default)]
386 #[builder(default)]
387 pub subaccount_number: u32,
388 #[serde(default)]
390 #[builder(default)]
391 pub authenticator_ids: Vec<u64>,
392 pub http_timeout_secs: Option<u64>,
394 pub max_retries: Option<u32>,
396 pub retry_delay_initial_ms: Option<u64>,
398 pub retry_delay_max_ms: Option<u64>,
400 #[serde(default = "default_grpc_rate_limit_per_second")]
403 pub grpc_rate_limit_per_second: Option<u32>,
404 #[serde(default)]
406 #[builder(default)]
407 pub transport_backend: TransportBackend,
408}
409
410#[cfg(feature = "python")]
411nautilus_core::impl_pyo3_config_getters!(DydxExecutionClientConfig {
412 account_id: AccountId,
413 network: DydxNetwork,
414 wallet_address: Option<String>,
415 subaccount_number: u32,
416});
417
418impl Default for DydxExecutionClientConfig {
419 fn default() -> Self {
420 Self {
421 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
422 ..Self::builder().build()
423 }
424 }
425}
426
427impl DydxExecutionClientConfig {
428 pub fn validate(&self) -> ConfigResult<()> {
434 check_unique_values("grpc_urls", &self.grpc_urls)?;
435 check_unique_values("authenticator_ids", &self.authenticator_ids)?;
436 Ok(())
437 }
438
439 #[must_use]
444 pub fn get_grpc_urls(&self) -> Vec<String> {
445 if !self.grpc_urls.is_empty() {
446 return self.grpc_urls.clone();
447 }
448
449 if let Some(ref endpoint) = self.grpc_endpoint {
450 return vec![endpoint.clone()];
451 }
452 urls::grpc_urls(self.network)
453 .iter()
454 .map(|&s| s.to_string())
455 .collect()
456 }
457
458 #[must_use]
460 pub fn get_ws_url(&self) -> String {
461 self.ws_endpoint
462 .clone()
463 .unwrap_or_else(|| urls::ws_url(self.network).to_string())
464 }
465
466 #[must_use]
468 pub fn get_http_url(&self) -> String {
469 self.http_endpoint
470 .clone()
471 .unwrap_or_else(|| urls::http_base_url(self.network).to_string())
472 }
473
474 #[must_use]
476 pub const fn get_chain_id(&self) -> ChainId {
477 self.network.chain_id()
478 }
479
480 #[must_use]
482 pub const fn is_testnet(&self) -> bool {
483 matches!(self.network, DydxNetwork::Testnet)
484 }
485
486 #[must_use]
488 pub fn grpc_quota(&self) -> Option<Quota> {
489 self.grpc_rate_limit_per_second
490 .and_then(NonZeroU32::new)
491 .and_then(Quota::per_second)
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use std::{cell::RefCell, rc::Rc};
498
499 use nautilus_common::{
500 cache::Cache, clock::VirtualClock, config::ConfigError, factories::ExecutionClientFactory,
501 };
502 use nautilus_model::identifiers::TraderId;
503 use rstest::rstest;
504
505 use super::*;
506
507 #[rstest]
508 fn test_config_debug_redacts_credentials() {
509 let adapter = DydxAdapterConfig {
510 private_key: Some("adapter-private-key".into()),
511 proxy_url: Some("http://user:adapter-proxy@localhost".into()),
512 ..Default::default()
513 };
514 let data = DydxDataClientConfig {
515 proxy_url: Some("http://user:data-proxy@localhost".into()),
516 ..Default::default()
517 };
518 let execution = DydxExecutionClientConfig {
519 private_key: Some("exec-private-key".into()),
520 proxy_url: Some("http://user:exec-proxy@localhost".into()),
521 ..Default::default()
522 };
523
524 let formatted = format!("{adapter:?} {data:?} {execution:?}");
525
526 assert_eq!(formatted.matches(REDACTED).count(), 5);
527
528 for secret in [
529 "adapter-private-key",
530 "adapter-proxy",
531 "data-proxy",
532 "exec-private-key",
533 "exec-proxy",
534 ] {
535 assert!(!formatted.contains(secret));
536 }
537 }
538
539 #[rstest]
540 fn test_config_get_chain_id_mainnet() {
541 let config = DydxAdapterConfig {
542 network: DydxNetwork::Mainnet,
543 ..Default::default()
544 };
545 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
546 }
547
548 #[rstest]
549 fn test_config_get_chain_id_testnet() {
550 let config = DydxAdapterConfig {
551 network: DydxNetwork::Testnet,
552 ..Default::default()
553 };
554 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
555 }
556
557 #[rstest]
558 fn test_config_is_testnet() {
559 let mainnet_config = DydxAdapterConfig {
560 network: DydxNetwork::Mainnet,
561 ..Default::default()
562 };
563 assert!(!mainnet_config.is_testnet());
564
565 let testnet_config = DydxAdapterConfig {
566 network: DydxNetwork::Testnet,
567 ..Default::default()
568 };
569 assert!(testnet_config.is_testnet());
570 }
571
572 #[rstest]
573 fn test_config_default_uses_mainnet() {
574 let config = DydxAdapterConfig::default();
575 assert_eq!(config.network, DydxNetwork::Mainnet);
576 assert!(!config.is_testnet());
577 }
578
579 #[rstest]
580 fn test_config_serde_backwards_compat() {
581 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}"#;
583
584 let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
585 assert!(config.is_ok());
586 let config = config.unwrap();
587 assert_eq!(config.network, DydxNetwork::Mainnet);
589 }
590
591 #[rstest]
592 fn test_config_get_grpc_urls_fallback() {
593 let config = DydxAdapterConfig {
594 grpc_url: "https://primary.example.com".to_string(),
595 grpc_urls: vec![],
596 ..Default::default()
597 };
598
599 let urls = config.get_grpc_urls();
600 assert_eq!(urls.len(), 1);
601 assert_eq!(urls[0], "https://primary.example.com");
602 }
603
604 #[rstest]
605 fn test_config_get_grpc_urls_multiple() {
606 let config = DydxAdapterConfig {
607 grpc_url: "https://primary.example.com".to_string(),
608 grpc_urls: vec![
609 "https://fallback1.example.com".to_string(),
610 "https://fallback2.example.com".to_string(),
611 ],
612 ..Default::default()
613 };
614
615 let urls = config.get_grpc_urls();
616 assert_eq!(urls.len(), 2);
617 assert_eq!(urls[0], "https://fallback1.example.com");
618 assert_eq!(urls[1], "https://fallback2.example.com");
619 }
620
621 #[rstest]
622 fn test_for_network_mainnet_resolves_urls_and_chain_id() {
623 let config = DydxAdapterConfig::for_network(DydxNetwork::Mainnet);
624
625 assert_eq!(config.network, DydxNetwork::Mainnet);
626 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Mainnet));
627 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Mainnet));
628 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Mainnet)[0]);
629 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Mainnet)
630 .iter()
631 .map(|s| (*s).to_string())
632 .collect();
633 assert_eq!(config.grpc_urls, expected_grpc);
634 assert_eq!(config.chain_id, crate::common::consts::DYDX_CHAIN_ID);
635 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
636 }
637
638 #[rstest]
639 fn test_for_network_testnet_resolves_urls_and_chain_id() {
640 let config = DydxAdapterConfig::for_network(DydxNetwork::Testnet);
641
642 assert_eq!(config.network, DydxNetwork::Testnet);
643 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Testnet));
644 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Testnet));
645 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Testnet)[0]);
646 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Testnet)
647 .iter()
648 .map(|s| (*s).to_string())
649 .collect();
650 assert_eq!(config.grpc_urls, expected_grpc);
651 assert_eq!(
652 config.chain_id,
653 crate::common::consts::DYDX_TESTNET_CHAIN_ID,
654 );
655 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
656 }
657
658 #[rstest]
659 #[case(DydxNetwork::Mainnet)]
660 #[case(DydxNetwork::Testnet)]
661 fn test_for_network_preserves_grpc_rate_limit_default(#[case] network: DydxNetwork) {
662 let config = DydxAdapterConfig::for_network(network);
666 assert_eq!(config.grpc_rate_limit_per_second, Some(4));
667 assert!(config.grpc_quota().is_some());
668 }
669
670 #[rstest]
671 fn test_adapter_config_toml_requires_url_fields() {
672 let err = toml::from_str::<DydxAdapterConfig>(r#"network = "testnet""#).unwrap_err();
677 let message = err.to_string();
678 assert!(message.contains("missing field"), "{message}");
679 }
680
681 #[rstest]
682 fn test_exec_config_toml_empty_uses_defaults() {
683 let config: DydxExecutionClientConfig = toml::from_str("").unwrap();
684 let expected = DydxExecutionClientConfig::default();
685 assert_eq!(config.account_id, expected.account_id);
686 assert_eq!(config.network, expected.network);
687 assert_eq!(config.subaccount_number, expected.subaccount_number);
688 assert_eq!(
689 config.grpc_rate_limit_per_second,
690 expected.grpc_rate_limit_per_second,
691 );
692 }
693
694 #[rstest]
695 #[case("grpc_urls", serde_json::json!(["https://one.example", "https://two.example"]))]
696 #[case("authenticator_ids", serde_json::json!([1, 2]))]
697 fn test_adapter_selection_uniqueness(
698 #[case] field: &str,
699 #[case] mut values: serde_json::Value,
700 #[values(false, true)] duplicate: bool,
701 ) {
702 if duplicate {
703 let entries = values.as_array_mut().unwrap();
704 entries.push(entries[0].clone());
705 }
706
707 let mut input = serde_json::to_value(DydxAdapterConfig::default()).unwrap();
708 input[field] = values;
709 let config: DydxAdapterConfig = serde_json::from_value(input).unwrap();
710 let original = serde_json::to_value(&config).unwrap();
711
712 let expected = if duplicate {
713 Err(ConfigError::duplicate(field, None))
714 } else {
715 Ok(())
716 };
717
718 let result = config.validate();
719
720 assert_eq!(result, expected);
721 assert_eq!(serde_json::to_value(&config).unwrap(), original);
722 }
723
724 #[rstest]
725 #[case("grpc_urls", serde_json::json!(["https://one.example", "https://two.example"]))]
726 #[case("authenticator_ids", serde_json::json!([1, 2]))]
727 fn test_execution_selection_uniqueness(
728 #[case] field: &str,
729 #[case] mut values: serde_json::Value,
730 #[values(false, true)] duplicate: bool,
731 ) {
732 if duplicate {
733 let entries = values.as_array_mut().unwrap();
734 entries.push(entries[0].clone());
735 }
736
737 let mut input = serde_json::to_value(DydxExecutionClientConfig::default()).unwrap();
738 input[field] = values;
739 let config: DydxExecutionClientConfig = serde_json::from_value(input).unwrap();
740 let original = serde_json::to_value(&config).unwrap();
741
742 let expected = if duplicate {
743 Err(ConfigError::duplicate(field, None))
744 } else {
745 Ok(())
746 };
747
748 let result = config.validate();
749
750 assert_eq!(result, expected);
751 assert_eq!(serde_json::to_value(&config).unwrap(), original);
752 }
753
754 #[rstest]
755 fn test_factory_rejects_duplicate_selections_before_setup() {
756 let config = DydxExecutionClientConfig {
757 authenticator_ids: vec![1, 2, 1],
758 ..Default::default()
759 };
760
761 let cache = Rc::new(RefCell::new(Cache::default()));
762 let clock = Rc::new(RefCell::new(VirtualClock::new()));
763 let error = crate::factories::DydxExecutionClientFactory::new()
764 .create(
765 TraderId::from("TESTER-001"),
766 "DYDX",
767 &config,
768 cache.into(),
769 clock,
770 )
771 .err()
772 .expect("duplicate config must fail before credentials or client setup");
773
774 assert_eq!(
775 error.downcast_ref::<ConfigError>(),
776 Some(&ConfigError::duplicate("authenticator_ids", None))
777 );
778 }
779}