Skip to main content

nautilus_dydx/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration structures for the dYdX adapter.
17
18use 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/// Configuration for the dYdX adapter.
30///
31/// URL fields (`base_url`, `ws_url`, `grpc_url`, `grpc_urls`) default to mainnet in the
32/// builder. Use [`DydxAdapterConfig::for_network`] to build a config whose URLs and chain
33/// ID match the target network, or override each URL explicitly.
34#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
35#[serde(deny_unknown_fields)]
36pub struct DydxAdapterConfig {
37    /// Network environment (mainnet or testnet).
38    #[serde(default)]
39    #[builder(default)]
40    pub network: DydxNetwork,
41    /// Base URL for the HTTP API.
42    #[builder(default = urls::http_base_url(DydxNetwork::Mainnet).to_string())]
43    pub base_url: String,
44    /// Base URL for the WebSocket API.
45    #[builder(default = urls::ws_url(DydxNetwork::Mainnet).to_string())]
46    pub ws_url: String,
47    /// Base URL for the gRPC API (Cosmos SDK transactions).
48    ///
49    /// For backwards compatibility, a single URL can be provided.
50    /// Consider using `grpc_urls` for fallback support.
51    #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet)[0].to_string())]
52    pub grpc_url: String,
53    /// List of gRPC URLs with fallback support.
54    ///
55    /// If provided, the client will attempt to connect to each URL in order
56    /// until a successful connection is established. This is recommended for
57    /// production use in DEX environments where nodes can fail.
58    #[serde(default)]
59    #[builder(default = urls::grpc_urls(DydxNetwork::Mainnet).iter().map(|&s| s.to_string()).collect())]
60    pub grpc_urls: Vec<String>,
61    /// Chain ID (e.g., "dydx-mainnet-1" for mainnet, "dydx-testnet-4" for testnet).
62    #[builder(default = DYDX_CHAIN_ID.to_string())]
63    pub chain_id: String,
64    /// Request timeout in seconds.
65    #[builder(default = 30)]
66    pub timeout_secs: u64,
67    /// Wallet address for the account.
68    ///
69    /// If not provided, falls back to environment variable:
70    /// - Mainnet: `DYDX_WALLET_ADDRESS`
71    /// - Testnet: `DYDX_TESTNET_WALLET_ADDRESS`
72    ///
73    /// Use `resolve_wallet_address()` to resolve from config or environment.
74    #[serde(default)]
75    pub wallet_address: Option<String>,
76    /// Subaccount number (default: 0).
77    #[serde(default)]
78    #[builder(default)]
79    pub subaccount: u32,
80    /// Private key (hex) for wallet signing.
81    ///
82    /// If not provided, falls back to environment variable:
83    /// - Mainnet: `DYDX_PRIVATE_KEY`
84    /// - Testnet: `DYDX_TESTNET_PRIVATE_KEY`
85    ///
86    /// Use `DydxCredential::resolve()` to resolve from config or environment.
87    #[serde(default)]
88    pub private_key: Option<String>,
89    /// Authenticator IDs for permissioned key trading.
90    ///
91    /// When provided, transactions will include a TxExtension to enable trading
92    /// via sub-accounts using delegated signing keys. This is an advanced feature
93    /// for institutional setups with separated hot/cold wallet architectures.
94    ///
95    /// See <https://docs.dydx.xyz/concepts/trading/authenticators> for details on
96    /// permissioned keys and authenticator configuration.
97    #[serde(default)]
98    #[builder(default)]
99    pub authenticator_ids: Vec<u64>,
100    /// Maximum number of retries for failed requests (default: 3).
101    #[serde(default = "default_max_retries")]
102    #[builder(default = 3)]
103    pub max_retries: u32,
104    /// Initial retry delay in milliseconds (default: 1000ms).
105    #[serde(default = "default_retry_delay_initial_ms")]
106    #[builder(default = 1000)]
107    pub retry_delay_initial_ms: u64,
108    /// Maximum retry delay in milliseconds (default: 10000ms).
109    #[serde(default = "default_retry_delay_max_ms")]
110    #[builder(default = 10000)]
111    pub retry_delay_max_ms: u64,
112    /// gRPC rate limit: maximum broadcast requests per second.
113    ///
114    /// Controls the rate of gRPC `broadcast_tx` calls to prevent 429 (ResourceExhausted)
115    /// errors from validator nodes. Known provider limits:
116    /// - Polkachu: 300 req/min (~5 req/s)
117    /// - KingNodes: 250 req/min (~4.2 req/s)
118    /// - AutoStake: 4 req/s
119    ///
120    /// Default: 4 requests per second (conservative, works across all public providers).
121    /// When `None`, rate limiting is disabled.
122    #[serde(default = "default_grpc_rate_limit_per_second")]
123    pub grpc_rate_limit_per_second: Option<u32>,
124    /// Optional proxy URL for HTTP and WebSocket transports.
125    #[serde(default)]
126    pub proxy_url: Option<String>,
127    /// WebSocket transport backend (defaults to `Tungstenite`).
128    #[serde(default)]
129    #[builder(default)]
130    pub transport_backend: TransportBackend,
131}
132
133fn default_max_retries() -> u32 {
134    3
135}
136
137fn default_retry_delay_initial_ms() -> u64 {
138    1000
139}
140
141fn default_retry_delay_max_ms() -> u64 {
142    10000
143}
144
145#[expect(
146    clippy::unnecessary_wraps,
147    reason = "serde default must match field type Option<u32>"
148)]
149fn default_grpc_rate_limit_per_second() -> Option<u32> {
150    Some(4)
151}
152
153fn default_data_http_timeout_secs() -> u64 {
154    60
155}
156
157fn default_data_max_retries() -> u64 {
158    3
159}
160
161fn default_data_retry_delay_initial_ms() -> u64 {
162    100
163}
164
165fn default_data_retry_delay_max_ms() -> u64 {
166    5000
167}
168
169impl DydxAdapterConfig {
170    /// Creates a config with URLs and chain ID resolved for the given network.
171    ///
172    /// Use this instead of `Default::default()` when constructing a testnet config
173    /// without explicit URL overrides. Retains the non-URL defaults from
174    /// [`Default::default`] (retries, timeouts, gRPC rate limit).
175    #[must_use]
176    pub fn for_network(network: DydxNetwork) -> Self {
177        let chain_id = match network {
178            DydxNetwork::Mainnet => crate::common::consts::DYDX_CHAIN_ID,
179            DydxNetwork::Testnet => crate::common::consts::DYDX_TESTNET_CHAIN_ID,
180        };
181        Self {
182            network,
183            base_url: urls::http_base_url(network).to_string(),
184            ws_url: urls::ws_url(network).to_string(),
185            grpc_url: urls::grpc_urls(network)[0].to_string(),
186            grpc_urls: urls::grpc_urls(network)
187                .iter()
188                .map(|&s| s.to_string())
189                .collect(),
190            chain_id: chain_id.to_string(),
191            ..Self::default()
192        }
193    }
194
195    /// Get the list of gRPC URLs to use for connection with fallback support.
196    ///
197    /// Returns `grpc_urls` if non-empty, otherwise falls back to a single-element
198    /// vector containing `grpc_url`.
199    #[must_use]
200    pub fn get_grpc_urls(&self) -> Vec<String> {
201        if self.grpc_urls.is_empty() {
202            vec![self.grpc_url.clone()]
203        } else {
204            self.grpc_urls.clone()
205        }
206    }
207
208    /// Map the configured network to the underlying chain ID.
209    ///
210    /// This is the recommended way to get the chain ID for transaction submission.
211    #[must_use]
212    pub const fn get_chain_id(&self) -> ChainId {
213        self.network.chain_id()
214    }
215
216    /// Returns whether this is a testnet configuration.
217    #[must_use]
218    pub const fn is_testnet(&self) -> bool {
219        matches!(self.network, DydxNetwork::Testnet)
220    }
221
222    /// Returns the gRPC rate limiting quota, if configured.
223    #[must_use]
224    pub fn grpc_quota(&self) -> Option<Quota> {
225        self.grpc_rate_limit_per_second
226            .and_then(NonZeroU32::new)
227            .and_then(Quota::per_second)
228    }
229}
230
231impl Default for DydxAdapterConfig {
232    fn default() -> Self {
233        Self {
234            grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
235            ..Self::builder().build()
236        }
237    }
238}
239
240/// Configuration for the dYdX data client.
241#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
242#[serde(deny_unknown_fields)]
243#[cfg_attr(
244    feature = "python",
245    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.dydx", from_py_object)
246)]
247#[cfg_attr(
248    feature = "python",
249    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.dydx")
250)]
251pub struct DydxDataClientConfig {
252    /// Base URL for the HTTP API.
253    pub base_url_http: Option<String>,
254    /// Base URL for the WebSocket API.
255    pub base_url_ws: Option<String>,
256    /// HTTP request timeout in seconds.
257    #[serde(default = "default_data_http_timeout_secs")]
258    #[builder(default = 60)]
259    pub http_timeout_secs: u64,
260    /// Maximum number of retry attempts for failed HTTP requests.
261    #[serde(default = "default_data_max_retries")]
262    #[builder(default = 3)]
263    pub max_retries: u64,
264    /// Initial retry delay in milliseconds.
265    #[serde(default = "default_data_retry_delay_initial_ms")]
266    #[builder(default = 100)]
267    pub retry_delay_initial_ms: u64,
268    /// Maximum retry delay in milliseconds.
269    #[serde(default = "default_data_retry_delay_max_ms")]
270    #[builder(default = 5000)]
271    pub retry_delay_max_ms: u64,
272    /// Network environment (mainnet or testnet).
273    #[serde(default)]
274    #[builder(default)]
275    pub network: DydxNetwork,
276    /// Optional proxy URL for HTTP and WebSocket transports.
277    pub proxy_url: Option<String>,
278    /// WebSocket transport backend (defaults to `Tungstenite`).
279    #[serde(default)]
280    #[builder(default)]
281    pub transport_backend: TransportBackend,
282}
283
284impl DydxDataClientConfig {
285    /// Returns whether this is a testnet configuration.
286    #[must_use]
287    pub const fn is_testnet(&self) -> bool {
288        matches!(self.network, DydxNetwork::Testnet)
289    }
290}
291
292impl Default for DydxDataClientConfig {
293    fn default() -> Self {
294        Self::builder().build()
295    }
296}
297
298/// Configuration for the dYdX execution client.
299#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
300#[serde(deny_unknown_fields)]
301#[cfg_attr(
302    feature = "python",
303    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.dydx", from_py_object)
304)]
305#[cfg_attr(
306    feature = "python",
307    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.dydx")
308)]
309pub struct DydxExecClientConfig {
310    /// The trader ID for the client.
311    #[builder(default = TraderId::from("TRADER-001"))]
312    pub trader_id: TraderId,
313    /// The account ID for the client.
314    #[builder(default = AccountId::from("DYDX-001"))]
315    pub account_id: AccountId,
316    /// Network environment (mainnet or testnet).
317    #[serde(default)]
318    #[builder(default)]
319    pub network: DydxNetwork,
320    /// gRPC endpoint URL (optional, uses default for network if not provided).
321    pub grpc_endpoint: Option<String>,
322    /// Additional gRPC URLs for fallback support.
323    #[serde(default)]
324    #[builder(default)]
325    pub grpc_urls: Vec<String>,
326    /// WebSocket endpoint URL (optional, uses default for network if not provided).
327    pub ws_endpoint: Option<String>,
328    /// HTTP endpoint URL (optional, uses default for network if not provided).
329    pub http_endpoint: Option<String>,
330    /// Private key (hex) for wallet signing.
331    ///
332    /// If not provided, falls back to environment variable:
333    /// - Mainnet: `DYDX_PRIVATE_KEY`
334    /// - Testnet: `DYDX_TESTNET_PRIVATE_KEY`
335    pub private_key: Option<String>,
336    /// Wallet address.
337    ///
338    /// If not provided, falls back to environment variable:
339    /// - Mainnet: `DYDX_WALLET_ADDRESS`
340    /// - Testnet: `DYDX_TESTNET_WALLET_ADDRESS`
341    pub wallet_address: Option<String>,
342    /// Subaccount number (default: 0).
343    #[serde(default)]
344    #[builder(default)]
345    pub subaccount_number: u32,
346    /// Authenticator IDs for permissioned key trading.
347    #[serde(default)]
348    #[builder(default)]
349    pub authenticator_ids: Vec<u64>,
350    /// HTTP request timeout in seconds.
351    pub http_timeout_secs: Option<u64>,
352    /// Maximum number of retry attempts.
353    pub max_retries: Option<u32>,
354    /// Initial retry delay in milliseconds.
355    pub retry_delay_initial_ms: Option<u64>,
356    /// Maximum retry delay in milliseconds.
357    pub retry_delay_max_ms: Option<u64>,
358    /// gRPC rate limit: maximum broadcast requests per second.
359    /// When `None`, rate limiting is disabled.
360    #[serde(default = "default_grpc_rate_limit_per_second")]
361    pub grpc_rate_limit_per_second: Option<u32>,
362    /// Optional proxy URL for HTTP and WebSocket transports.
363    pub proxy_url: Option<String>,
364    /// WebSocket transport backend (defaults to `Tungstenite`).
365    #[serde(default)]
366    #[builder(default)]
367    pub transport_backend: TransportBackend,
368}
369
370impl Default for DydxExecClientConfig {
371    fn default() -> Self {
372        Self {
373            grpc_rate_limit_per_second: default_grpc_rate_limit_per_second(),
374            ..Self::builder().build()
375        }
376    }
377}
378
379impl DydxExecClientConfig {
380    /// Returns the gRPC URLs to use, with fallback support.
381    ///
382    /// Returns `grpc_urls` if non-empty, otherwise uses `grpc_endpoint` if provided,
383    /// otherwise uses the default URLs for the configured network.
384    #[must_use]
385    pub fn get_grpc_urls(&self) -> Vec<String> {
386        if !self.grpc_urls.is_empty() {
387            return self.grpc_urls.clone();
388        }
389
390        if let Some(ref endpoint) = self.grpc_endpoint {
391            return vec![endpoint.clone()];
392        }
393        urls::grpc_urls(self.network)
394            .iter()
395            .map(|&s| s.to_string())
396            .collect()
397    }
398
399    /// Returns the WebSocket URL for the configured network.
400    #[must_use]
401    pub fn get_ws_url(&self) -> String {
402        self.ws_endpoint
403            .clone()
404            .unwrap_or_else(|| urls::ws_url(self.network).to_string())
405    }
406
407    /// Returns the HTTP URL for the configured network.
408    #[must_use]
409    pub fn get_http_url(&self) -> String {
410        self.http_endpoint
411            .clone()
412            .unwrap_or_else(|| urls::http_base_url(self.network).to_string())
413    }
414
415    /// Returns the chain ID for the configured network.
416    #[must_use]
417    pub const fn get_chain_id(&self) -> ChainId {
418        self.network.chain_id()
419    }
420
421    /// Returns whether this is a testnet configuration.
422    #[must_use]
423    pub const fn is_testnet(&self) -> bool {
424        matches!(self.network, DydxNetwork::Testnet)
425    }
426
427    /// Returns the gRPC rate limiting quota, if configured.
428    #[must_use]
429    pub fn grpc_quota(&self) -> Option<Quota> {
430        self.grpc_rate_limit_per_second
431            .and_then(NonZeroU32::new)
432            .and_then(Quota::per_second)
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use rstest::rstest;
439
440    use super::*;
441
442    #[rstest]
443    fn test_config_get_chain_id_mainnet() {
444        let config = DydxAdapterConfig {
445            network: DydxNetwork::Mainnet,
446            ..Default::default()
447        };
448        assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
449    }
450
451    #[rstest]
452    fn test_config_get_chain_id_testnet() {
453        let config = DydxAdapterConfig {
454            network: DydxNetwork::Testnet,
455            ..Default::default()
456        };
457        assert_eq!(config.get_chain_id(), ChainId::Testnet4);
458    }
459
460    #[rstest]
461    fn test_config_is_testnet() {
462        let mainnet_config = DydxAdapterConfig {
463            network: DydxNetwork::Mainnet,
464            ..Default::default()
465        };
466        assert!(!mainnet_config.is_testnet());
467
468        let testnet_config = DydxAdapterConfig {
469            network: DydxNetwork::Testnet,
470            ..Default::default()
471        };
472        assert!(testnet_config.is_testnet());
473    }
474
475    #[rstest]
476    fn test_config_default_uses_mainnet() {
477        let config = DydxAdapterConfig::default();
478        assert_eq!(config.network, DydxNetwork::Mainnet);
479        assert!(!config.is_testnet());
480    }
481
482    #[rstest]
483    fn test_config_serde_backwards_compat() {
484        // Test that configs missing network field can deserialize with default
485        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}"#;
486
487        let config: Result<DydxAdapterConfig, _> = serde_json::from_str(json);
488        assert!(config.is_ok());
489        let config = config.unwrap();
490        // Should default to Mainnet when network field is missing
491        assert_eq!(config.network, DydxNetwork::Mainnet);
492    }
493
494    #[rstest]
495    fn test_config_get_grpc_urls_fallback() {
496        let config = DydxAdapterConfig {
497            grpc_url: "https://primary.example.com".to_string(),
498            grpc_urls: vec![],
499            ..Default::default()
500        };
501
502        let urls = config.get_grpc_urls();
503        assert_eq!(urls.len(), 1);
504        assert_eq!(urls[0], "https://primary.example.com");
505    }
506
507    #[rstest]
508    fn test_config_get_grpc_urls_multiple() {
509        let config = DydxAdapterConfig {
510            grpc_url: "https://primary.example.com".to_string(),
511            grpc_urls: vec![
512                "https://fallback1.example.com".to_string(),
513                "https://fallback2.example.com".to_string(),
514            ],
515            ..Default::default()
516        };
517
518        let urls = config.get_grpc_urls();
519        assert_eq!(urls.len(), 2);
520        assert_eq!(urls[0], "https://fallback1.example.com");
521        assert_eq!(urls[1], "https://fallback2.example.com");
522    }
523
524    #[rstest]
525    fn test_for_network_mainnet_resolves_urls_and_chain_id() {
526        let config = DydxAdapterConfig::for_network(DydxNetwork::Mainnet);
527
528        assert_eq!(config.network, DydxNetwork::Mainnet);
529        assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Mainnet));
530        assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Mainnet));
531        assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Mainnet)[0]);
532        let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Mainnet)
533            .iter()
534            .map(|s| (*s).to_string())
535            .collect();
536        assert_eq!(config.grpc_urls, expected_grpc);
537        assert_eq!(config.chain_id, crate::common::consts::DYDX_CHAIN_ID);
538        assert_eq!(config.get_chain_id(), ChainId::Mainnet1);
539    }
540
541    #[rstest]
542    fn test_for_network_testnet_resolves_urls_and_chain_id() {
543        let config = DydxAdapterConfig::for_network(DydxNetwork::Testnet);
544
545        assert_eq!(config.network, DydxNetwork::Testnet);
546        assert_eq!(config.base_url, urls::http_base_url(DydxNetwork::Testnet));
547        assert_eq!(config.ws_url, urls::ws_url(DydxNetwork::Testnet));
548        assert_eq!(config.grpc_url, urls::grpc_urls(DydxNetwork::Testnet)[0]);
549        let expected_grpc: Vec<String> = urls::grpc_urls(DydxNetwork::Testnet)
550            .iter()
551            .map(|s| (*s).to_string())
552            .collect();
553        assert_eq!(config.grpc_urls, expected_grpc);
554        assert_eq!(
555            config.chain_id,
556            crate::common::consts::DYDX_TESTNET_CHAIN_ID,
557        );
558        assert_eq!(config.get_chain_id(), ChainId::Testnet4);
559    }
560
561    #[rstest]
562    #[case(DydxNetwork::Mainnet)]
563    #[case(DydxNetwork::Testnet)]
564    fn test_for_network_preserves_grpc_rate_limit_default(#[case] network: DydxNetwork) {
565        // Regression guard: earlier implementations spread `..Self::builder().build()`,
566        // which returned `None` and silently disabled gRPC throttling. The helper must
567        // retain the `Some(4)` default from `Default::default()`.
568        let config = DydxAdapterConfig::for_network(network);
569        assert_eq!(config.grpc_rate_limit_per_second, Some(4));
570        assert!(config.grpc_quota().is_some());
571    }
572}