1use std::num::NonZeroU32;
19
20#[cfg(test)]
21use nautilus_core::string::secret::REDACTED;
22use nautilus_core::string::secret::SecretString;
23use nautilus_model::identifiers::AccountId;
24use nautilus_network::{ratelimiter::quota::Quota, websocket::TransportBackend};
25use serde::{Deserialize, Serialize};
26
27use crate::{
28 common::{consts::DYDX_CHAIN_ID, enums::DydxNetwork, urls},
29 grpc::types::ChainId,
30};
31
32#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
38#[serde(deny_unknown_fields)]
39pub struct DydxAdapterConfig {
40 #[serde(default)]
42 #[builder(default)]
43 pub network: DydxNetwork,
44 #[builder(default = urls::http_base_url(DydxNetwork::Mainnet).to_string())]
46 pub base_url: String,
47 #[builder(default = urls::ws_url(DydxNetwork::Mainnet).to_string())]
49 pub ws_url: String,
50 #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet)[0].to_string())]
55 pub grpc_url: String,
56 #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet).iter().map(|&s| s.to_string()).collect())]
62 pub grpc_urls: Vec<String>,
63 #[builder(default = DYDX_CHAIN_ID.to_string())]
65 pub chain_id: String,
66 #[builder(default = 30)]
68 pub timeout_secs: u64,
69 #[serde(default)]
77 pub wallet_address: Option<String>,
78 #[serde(default)]
80 #[builder(default)]
81 pub subaccount: u32,
82 #[serde(default)]
90 pub private_key: Option<SecretString>,
91 #[serde(default)]
100 #[builder(default)]
101 pub authenticator_ids: Vec<u64>,
102 #[serde(default = "default_max_retries")]
104 #[builder(default = 3)]
105 pub max_retries: u32,
106 #[serde(default = "default_retry_delay_initial_ms")]
108 #[builder(default = 1000)]
109 pub retry_delay_initial_ms: u64,
110 #[serde(default = "default_retry_delay_max_ms")]
112 #[builder(default = 10000)]
113 pub retry_delay_max_ms: u64,
114 #[serde(default = "default_grpc_rate_limit_per_second")]
125 pub grpc_rate_limit_per_second: Option<u32>,
126 #[serde(default)]
128 pub proxy_url: Option<SecretString>,
129 #[serde(default)]
131 #[builder(default)]
132 pub transport_backend: TransportBackend,
133}
134
135fn default_max_retries() -> u32 {
136 3
137}
138
139fn default_retry_delay_initial_ms() -> u64 {
140 1000
141}
142
143fn default_retry_delay_max_ms() -> u64 {
144 10000
145}
146
147#[expect(
148 clippy::unnecessary_wraps,
149 reason = "serde default must match field type Option<u32>"
150)]
151fn default_grpc_rate_limit_per_second() -> Option<u32> {
152 Some(4)
153}
154
155fn default_data_http_timeout_secs() -> u64 {
156 60
157}
158
159fn default_data_max_retries() -> u64 {
160 3
161}
162
163fn default_data_retry_delay_initial_ms() -> u64 {
164 100
165}
166
167fn default_data_retry_delay_max_ms() -> u64 {
168 5000
169}
170
171fn default_max_ws_connections() -> usize {
172 8
173}
174
175fn default_per_channel_subscription_limit() -> usize {
176 32
177}
178
179impl DydxAdapterConfig {
180 #[must_use]
186 pub fn for_network(network: DydxNetwork) -> Self {
187 let chain_id = match network {
188 DydxNetwork::Mainnet => crate::common::consts::DYDX_CHAIN_ID,
189 DydxNetwork::Testnet => crate::common::consts::DYDX_TESTNET_CHAIN_ID,
190 };
191 Self {
192 network,
193 base_url: urls::http_base_url(network).to_string(),
194 ws_url: urls::ws_url(network).to_string(),
195 grpc_url: urls::grpc_urls(network)[0].to_string(),
196 grpc_urls: urls::grpc_urls(network)
197 .iter()
198 .map(|&s| s.to_string())
199 .collect(),
200 chain_id: chain_id.to_string(),
201 ..Self::default()
202 }
203 }
204
205 #[must_use]
210 pub fn get_grpc_urls(&self) -> Vec<String> {
211 if self.grpc_urls.is_empty() {
212 vec![self.grpc_url.clone()]
213 } else {
214 self.grpc_urls.clone()
215 }
216 }
217
218 #[must_use]
222 pub const fn get_chain_id(&self) -> ChainId {
223 self.network.chain_id()
224 }
225
226 #[must_use]
228 pub const fn is_testnet(&self) -> bool {
229 matches!(self.network, DydxNetwork::Testnet)
230 }
231
232 #[must_use]
234 pub fn grpc_quota(&self) -> Option<Quota> {
235 self.grpc_rate_limit_per_second
236 .and_then(NonZeroU32::new)
237 .and_then(Quota::per_second)
238 }
239}
240
241impl Default for DydxAdapterConfig {
242 fn default() -> Self {
243 Self {
244 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
245 ..Self::builder().build()
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
252#[serde(default, deny_unknown_fields)]
253#[cfg_attr(
254 feature = "python",
255 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
256)]
257#[cfg_attr(
258 feature = "python",
259 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
260)]
261pub struct DydxDataClientConfig {
262 #[serde(default)]
264 #[builder(default)]
265 pub network: DydxNetwork,
266 pub base_url_http: Option<String>,
268 pub base_url_ws: Option<String>,
270 pub proxy_url: Option<SecretString>,
272 #[serde(default = "default_data_http_timeout_secs")]
274 #[builder(default = 60)]
275 pub http_timeout_secs: u64,
276 #[serde(default = "default_data_max_retries")]
278 #[builder(default = 3)]
279 pub max_retries: u64,
280 #[serde(default = "default_data_retry_delay_initial_ms")]
282 #[builder(default = 100)]
283 pub retry_delay_initial_ms: u64,
284 #[serde(default = "default_data_retry_delay_max_ms")]
286 #[builder(default = 5000)]
287 pub retry_delay_max_ms: u64,
288 #[serde(default)]
290 #[builder(default)]
291 pub transport_backend: TransportBackend,
292 #[serde(default = "default_max_ws_connections")]
298 #[builder(default = default_max_ws_connections())]
299 pub max_ws_connections: usize,
300 #[serde(default = "default_per_channel_subscription_limit")]
306 #[builder(default = default_per_channel_subscription_limit())]
307 pub per_channel_subscription_limit: usize,
308}
309
310#[cfg(feature = "python")]
311nautilus_core::impl_pyo3_config_getters!(DydxDataClientConfig {
312 network: DydxNetwork,
313});
314
315impl DydxDataClientConfig {
316 #[must_use]
318 pub const fn is_testnet(&self) -> bool {
319 matches!(self.network, DydxNetwork::Testnet)
320 }
321}
322
323impl Default for DydxDataClientConfig {
324 fn default() -> Self {
325 Self::builder().build()
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
331#[serde(default, deny_unknown_fields)]
332#[cfg_attr(
333 feature = "python",
334 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
335)]
336#[cfg_attr(
337 feature = "python",
338 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
339)]
340pub struct DydxExecutionClientConfig {
341 #[builder(default = AccountId::from("DYDX-001"))]
343 pub account_id: AccountId,
344 #[serde(default)]
346 #[builder(default)]
347 pub network: DydxNetwork,
348 pub grpc_endpoint: Option<String>,
350 #[serde(default)]
352 #[builder(default)]
353 pub grpc_urls: Vec<String>,
354 pub http_endpoint: Option<String>,
356 pub ws_endpoint: Option<String>,
358 pub proxy_url: Option<SecretString>,
360 pub private_key: Option<SecretString>,
366 pub wallet_address: Option<String>,
372 #[serde(default)]
374 #[builder(default)]
375 pub subaccount_number: u32,
376 #[serde(default)]
378 #[builder(default)]
379 pub authenticator_ids: Vec<u64>,
380 pub http_timeout_secs: Option<u64>,
382 pub max_retries: Option<u32>,
384 pub retry_delay_initial_ms: Option<u64>,
386 pub retry_delay_max_ms: Option<u64>,
388 #[serde(default = "default_grpc_rate_limit_per_second")]
391 pub grpc_rate_limit_per_second: Option<u32>,
392 #[serde(default)]
394 #[builder(default)]
395 pub transport_backend: TransportBackend,
396}
397
398#[cfg(feature = "python")]
399nautilus_core::impl_pyo3_config_getters!(DydxExecutionClientConfig {
400 account_id: AccountId,
401 network: DydxNetwork,
402 wallet_address: Option<String>,
403 subaccount_number: u32,
404});
405
406impl Default for DydxExecutionClientConfig {
407 fn default() -> Self {
408 Self {
409 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
410 ..Self::builder().build()
411 }
412 }
413}
414
415impl DydxExecutionClientConfig {
416 #[must_use]
421 pub fn get_grpc_urls(&self) -> Vec<String> {
422 if !self.grpc_urls.is_empty() {
423 return self.grpc_urls.clone();
424 }
425
426 if let Some(ref endpoint) = self.grpc_endpoint {
427 return vec![endpoint.clone()];
428 }
429 urls::grpc_urls(self.network)
430 .iter()
431 .map(|&s| s.to_string())
432 .collect()
433 }
434
435 #[must_use]
437 pub fn get_ws_url(&self) -> String {
438 self.ws_endpoint
439 .clone()
440 .unwrap_or_else(|| urls::ws_url(self.network).to_string())
441 }
442
443 #[must_use]
445 pub fn get_http_url(&self) -> String {
446 self.http_endpoint
447 .clone()
448 .unwrap_or_else(|| urls::http_base_url(self.network).to_string())
449 }
450
451 #[must_use]
453 pub const fn get_chain_id(&self) -> ChainId {
454 self.network.chain_id()
455 }
456
457 #[must_use]
459 pub const fn is_testnet(&self) -> bool {
460 matches!(self.network, DydxNetwork::Testnet)
461 }
462
463 #[must_use]
465 pub fn grpc_quota(&self) -> Option<Quota> {
466 self.grpc_rate_limit_per_second
467 .and_then(NonZeroU32::new)
468 .and_then(Quota::per_second)
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use rstest::rstest;
475
476 use super::*;
477
478 #[rstest]
479 fn test_config_debug_redacts_credentials() {
480 let adapter = DydxAdapterConfig {
481 private_key: Some("adapter-private-key".into()),
482 proxy_url: Some("http://user:adapter-proxy@localhost".into()),
483 ..Default::default()
484 };
485 let data = DydxDataClientConfig {
486 proxy_url: Some("http://user:data-proxy@localhost".into()),
487 ..Default::default()
488 };
489 let execution = DydxExecutionClientConfig {
490 private_key: Some("exec-private-key".into()),
491 proxy_url: Some("http://user:exec-proxy@localhost".into()),
492 ..Default::default()
493 };
494
495 let formatted = format!("{adapter:?} {data:?} {execution:?}");
496
497 assert_eq!(formatted.matches(REDACTED).count(), 5);
498
499 for secret in [
500 "adapter-private-key",
501 "adapter-proxy",
502 "data-proxy",
503 "exec-private-key",
504 "exec-proxy",
505 ] {
506 assert!(!formatted.contains(secret));
507 }
508 }
509
510 #[rstest]
511 fn test_config_get_chain_id_mainnet() {
512 let config = DydxAdapterConfig {
513 network: DydxNetwork::Mainnet,
514 ..Default::default()
515 };
516 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
517 }
518
519 #[rstest]
520 fn test_config_get_chain_id_testnet() {
521 let config = DydxAdapterConfig {
522 network: DydxNetwork::Testnet,
523 ..Default::default()
524 };
525 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
526 }
527
528 #[rstest]
529 fn test_config_is_testnet() {
530 let mainnet_config = DydxAdapterConfig {
531 network: DydxNetwork::Mainnet,
532 ..Default::default()
533 };
534 assert!(!mainnet_config.is_testnet());
535
536 let testnet_config = DydxAdapterConfig {
537 network: DydxNetwork::Testnet,
538 ..Default::default()
539 };
540 assert!(testnet_config.is_testnet());
541 }
542
543 #[rstest]
544 fn test_config_default_uses_mainnet() {
545 let config = DydxAdapterConfig::default();
546 assert_eq!(config.network, DydxNetwork::Mainnet);
547 assert!(!config.is_testnet());
548 }
549
550 #[rstest]
551 fn test_config_serde_backwards_compat() {
552 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}"#;
554
555 let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
556 assert!(config.is_ok());
557 let config = config.unwrap();
558 assert_eq!(config.network, DydxNetwork::Mainnet);
560 }
561
562 #[rstest]
563 fn test_config_get_grpc_urls_fallback() {
564 let config = DydxAdapterConfig {
565 grpc_url: "https://primary.example.com".to_string(),
566 grpc_urls: vec![],
567 ..Default::default()
568 };
569
570 let urls = config.get_grpc_urls();
571 assert_eq!(urls.len(), 1);
572 assert_eq!(urls[0], "https://primary.example.com");
573 }
574
575 #[rstest]
576 fn test_config_get_grpc_urls_multiple() {
577 let config = DydxAdapterConfig {
578 grpc_url: "https://primary.example.com".to_string(),
579 grpc_urls: vec![
580 "https://fallback1.example.com".to_string(),
581 "https://fallback2.example.com".to_string(),
582 ],
583 ..Default::default()
584 };
585
586 let urls = config.get_grpc_urls();
587 assert_eq!(urls.len(), 2);
588 assert_eq!(urls[0], "https://fallback1.example.com");
589 assert_eq!(urls[1], "https://fallback2.example.com");
590 }
591
592 #[rstest]
593 fn test_for_network_mainnet_resolves_urls_and_chain_id() {
594 let config = DydxAdapterConfig::for_network(DydxNetwork::Mainnet);
595
596 assert_eq!(config.network, DydxNetwork::Mainnet);
597 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Mainnet));
598 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Mainnet));
599 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Mainnet)[0]);
600 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Mainnet)
601 .iter()
602 .map(|s| (*s).to_string())
603 .collect();
604 assert_eq!(config.grpc_urls, expected_grpc);
605 assert_eq!(config.chain_id, crate::common::consts::DYDX_CHAIN_ID);
606 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
607 }
608
609 #[rstest]
610 fn test_for_network_testnet_resolves_urls_and_chain_id() {
611 let config = DydxAdapterConfig::for_network(DydxNetwork::Testnet);
612
613 assert_eq!(config.network, DydxNetwork::Testnet);
614 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Testnet));
615 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Testnet));
616 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Testnet)[0]);
617 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Testnet)
618 .iter()
619 .map(|s| (*s).to_string())
620 .collect();
621 assert_eq!(config.grpc_urls, expected_grpc);
622 assert_eq!(
623 config.chain_id,
624 crate::common::consts::DYDX_TESTNET_CHAIN_ID,
625 );
626 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
627 }
628
629 #[rstest]
630 #[case(DydxNetwork::Mainnet)]
631 #[case(DydxNetwork::Testnet)]
632 fn test_for_network_preserves_grpc_rate_limit_default(#[case] network: DydxNetwork) {
633 let config = DydxAdapterConfig::for_network(network);
637 assert_eq!(config.grpc_rate_limit_per_second, Some(4));
638 assert!(config.grpc_quota().is_some());
639 }
640
641 #[rstest]
642 fn test_adapter_config_toml_requires_url_fields() {
643 let err = toml::from_str::<DydxAdapterConfig>(r#"network = "testnet""#).unwrap_err();
648 let message = err.to_string();
649 assert!(message.contains("missing field"), "{message}");
650 }
651
652 #[rstest]
653 fn test_exec_config_toml_empty_uses_defaults() {
654 let config: DydxExecutionClientConfig = toml::from_str("").unwrap();
655 let expected = DydxExecutionClientConfig::default();
656 assert_eq!(config.account_id, expected.account_id);
657 assert_eq!(config.network, expected.network);
658 assert_eq!(config.subaccount_number, expected.subaccount_number);
659 assert_eq!(
660 config.grpc_rate_limit_per_second,
661 expected.grpc_rate_limit_per_second,
662 );
663 }
664}