1use std::num::NonZeroU32;
19
20use nautilus_model::identifiers::{AccountId, TraderId};
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.core.nautilus_pyo3.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
307impl DydxDataClientConfig {
308 #[must_use]
310 pub const fn is_testnet(&self) -> bool {
311 matches!(self.network, DydxNetwork::Testnet)
312 }
313}
314
315impl Default for DydxDataClientConfig {
316 fn default() -> Self {
317 Self::builder().build()
318 }
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
323#[serde(default, deny_unknown_fields)]
324#[cfg_attr(
325 feature = "python",
326 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.dydx", from_py_object)
327)]
328#[cfg_attr(
329 feature = "python",
330 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
331)]
332pub struct DydxExecClientConfig {
333 #[builder(default = TraderId::from("TRADER-001"))]
335 pub trader_id: TraderId,
336 #[builder(default = AccountId::from("DYDX-001"))]
338 pub account_id: AccountId,
339 #[serde(default)]
341 #[builder(default)]
342 pub network: DydxNetwork,
343 pub grpc_endpoint: Option<String>,
345 #[serde(default)]
347 #[builder(default)]
348 pub grpc_urls: Vec<String>,
349 pub ws_endpoint: Option<String>,
351 pub http_endpoint: Option<String>,
353 pub private_key: Option<String>,
359 pub wallet_address: Option<String>,
365 #[serde(default)]
367 #[builder(default)]
368 pub subaccount_number: u32,
369 #[serde(default)]
371 #[builder(default)]
372 pub authenticator_ids: Vec<u64>,
373 pub http_timeout_secs: Option<u64>,
375 pub max_retries: Option<u32>,
377 pub retry_delay_initial_ms: Option<u64>,
379 pub retry_delay_max_ms: Option<u64>,
381 #[serde(default = "default_grpc_rate_limit_per_second")]
384 pub grpc_rate_limit_per_second: Option<u32>,
385 pub proxy_url: Option<String>,
387 #[serde(default)]
389 #[builder(default)]
390 pub transport_backend: TransportBackend,
391}
392
393impl Default for DydxExecClientConfig {
394 fn default() -> Self {
395 Self {
396 grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
397 ..Self::builder().build()
398 }
399 }
400}
401
402impl DydxExecClientConfig {
403 #[must_use]
408 pub fn get_grpc_urls(&self) -> Vec<String> {
409 if !self.grpc_urls.is_empty() {
410 return self.grpc_urls.clone();
411 }
412
413 if let Some(ref endpoint) = self.grpc_endpoint {
414 return vec![endpoint.clone()];
415 }
416 urls::grpc_urls(self.network)
417 .iter()
418 .map(|&s| s.to_string())
419 .collect()
420 }
421
422 #[must_use]
424 pub fn get_ws_url(&self) -> String {
425 self.ws_endpoint
426 .clone()
427 .unwrap_or_else(|| urls::ws_url(self.network).to_string())
428 }
429
430 #[must_use]
432 pub fn get_http_url(&self) -> String {
433 self.http_endpoint
434 .clone()
435 .unwrap_or_else(|| urls::http_base_url(self.network).to_string())
436 }
437
438 #[must_use]
440 pub const fn get_chain_id(&self) -> ChainId {
441 self.network.chain_id()
442 }
443
444 #[must_use]
446 pub const fn is_testnet(&self) -> bool {
447 matches!(self.network, DydxNetwork::Testnet)
448 }
449
450 #[must_use]
452 pub fn grpc_quota(&self) -> Option<Quota> {
453 self.grpc_rate_limit_per_second
454 .and_then(NonZeroU32::new)
455 .and_then(Quota::per_second)
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use rstest::rstest;
462
463 use super::*;
464
465 #[rstest]
466 fn test_config_get_chain_id_mainnet() {
467 let config = DydxAdapterConfig {
468 network: DydxNetwork::Mainnet,
469 ..Default::default()
470 };
471 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
472 }
473
474 #[rstest]
475 fn test_config_get_chain_id_testnet() {
476 let config = DydxAdapterConfig {
477 network: DydxNetwork::Testnet,
478 ..Default::default()
479 };
480 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
481 }
482
483 #[rstest]
484 fn test_config_is_testnet() {
485 let mainnet_config = DydxAdapterConfig {
486 network: DydxNetwork::Mainnet,
487 ..Default::default()
488 };
489 assert!(!mainnet_config.is_testnet());
490
491 let testnet_config = DydxAdapterConfig {
492 network: DydxNetwork::Testnet,
493 ..Default::default()
494 };
495 assert!(testnet_config.is_testnet());
496 }
497
498 #[rstest]
499 fn test_config_default_uses_mainnet() {
500 let config = DydxAdapterConfig::default();
501 assert_eq!(config.network, DydxNetwork::Mainnet);
502 assert!(!config.is_testnet());
503 }
504
505 #[rstest]
506 fn test_config_serde_backwards_compat() {
507 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}"#;
509
510 let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
511 assert!(config.is_ok());
512 let config = config.unwrap();
513 assert_eq!(config.network, DydxNetwork::Mainnet);
515 }
516
517 #[rstest]
518 fn test_config_get_grpc_urls_fallback() {
519 let config = DydxAdapterConfig {
520 grpc_url: "https://primary.example.com".to_string(),
521 grpc_urls: vec![],
522 ..Default::default()
523 };
524
525 let urls = config.get_grpc_urls();
526 assert_eq!(urls.len(), 1);
527 assert_eq!(urls[0], "https://primary.example.com");
528 }
529
530 #[rstest]
531 fn test_config_get_grpc_urls_multiple() {
532 let config = DydxAdapterConfig {
533 grpc_url: "https://primary.example.com".to_string(),
534 grpc_urls: vec![
535 "https://fallback1.example.com".to_string(),
536 "https://fallback2.example.com".to_string(),
537 ],
538 ..Default::default()
539 };
540
541 let urls = config.get_grpc_urls();
542 assert_eq!(urls.len(), 2);
543 assert_eq!(urls[0], "https://fallback1.example.com");
544 assert_eq!(urls[1], "https://fallback2.example.com");
545 }
546
547 #[rstest]
548 fn test_for_network_mainnet_resolves_urls_and_chain_id() {
549 let config = DydxAdapterConfig::for_network(DydxNetwork::Mainnet);
550
551 assert_eq!(config.network, DydxNetwork::Mainnet);
552 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Mainnet));
553 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Mainnet));
554 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Mainnet)[0]);
555 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Mainnet)
556 .iter()
557 .map(|s| (*s).to_string())
558 .collect();
559 assert_eq!(config.grpc_urls, expected_grpc);
560 assert_eq!(config.chain_id, crate::common::consts::DYDX_CHAIN_ID);
561 assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
562 }
563
564 #[rstest]
565 fn test_for_network_testnet_resolves_urls_and_chain_id() {
566 let config = DydxAdapterConfig::for_network(DydxNetwork::Testnet);
567
568 assert_eq!(config.network, DydxNetwork::Testnet);
569 assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Testnet));
570 assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Testnet));
571 assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Testnet)[0]);
572 let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Testnet)
573 .iter()
574 .map(|s| (*s).to_string())
575 .collect();
576 assert_eq!(config.grpc_urls, expected_grpc);
577 assert_eq!(
578 config.chain_id,
579 crate::common::consts::DYDX_TESTNET_CHAIN_ID,
580 );
581 assert_eq!(config.get_chain_id(), ChainId::Testnet4);
582 }
583
584 #[rstest]
585 #[case(DydxNetwork::Mainnet)]
586 #[case(DydxNetwork::Testnet)]
587 fn test_for_network_preserves_grpc_rate_limit_default(#[case] network: DydxNetwork) {
588 let config = DydxAdapterConfig::for_network(network);
592 assert_eq!(config.grpc_rate_limit_per_second, Some(4));
593 assert!(config.grpc_quota().is_some());
594 }
595
596 #[rstest]
597 fn test_adapter_config_toml_requires_url_fields() {
598 let err = toml::from_str::<DydxAdapterConfig>(r#"network = "testnet""#).unwrap_err();
603 let message = err.to_string();
604 assert!(message.contains("missing field"), "{message}");
605 }
606
607 #[rstest]
608 fn test_exec_config_toml_empty_uses_defaults() {
609 let config: DydxExecClientConfig = toml::from_str("").unwrap();
610 let expected = DydxExecClientConfig::default();
611
612 assert_eq!(config.trader_id, expected.trader_id);
613 assert_eq!(config.account_id, expected.account_id);
614 assert_eq!(config.network, expected.network);
615 assert_eq!(config.subaccount_number, expected.subaccount_number);
616 assert_eq!(
617 config.grpc_rate_limit_per_second,
618 expected.grpc_rate_limit_per_second,
619 );
620 }
621}