1use nautilus_core::string::secret::SecretString;
19use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
20use nautilus_model::identifiers::AccountId;
21use nautilus_network::websocket::TransportBackend;
22use rust_decimal::Decimal;
23use serde::{Deserialize, Serialize};
24
25use crate::common::{enums::DeriveEnvironment, urls};
26
27#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
29#[serde(default, deny_unknown_fields)]
30#[cfg_attr(
31 feature = "python",
32 pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
33)]
34#[cfg_attr(
35 feature = "python",
36 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
37)]
38pub struct DeriveDataClientConfig {
39 #[builder(default)]
41 pub environment: DeriveEnvironment,
42 pub base_url_rest: Option<String>,
44 pub base_url_ws: Option<String>,
46 pub proxy_url: Option<SecretString>,
48 #[builder(default = 10)]
50 pub http_timeout_secs: u64,
51 pub ws_timeout_secs: Option<u64>,
54 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
57 pub book_snapshot_timeout_secs: u64,
58 #[builder(default = 120)]
61 pub book_stale_threshold_secs: u64,
62 #[builder(default = 60)]
64 pub update_instruments_interval_mins: u64,
65 #[builder(default)]
68 pub currencies: Vec<String>,
69 #[builder(default)]
71 pub include_expired: bool,
72 #[builder(default = true)]
75 pub auto_load_missing_instruments: bool,
76 #[builder(default)]
78 pub transport_backend: TransportBackend,
79}
80
81#[cfg(feature = "python")]
82nautilus_core::impl_pyo3_config_getters!(DeriveDataClientConfig {
83 environment: DeriveEnvironment,
84 base_url_rest: Option<String>,
85 base_url_ws: Option<String>,
86 http_timeout_secs: u64,
87 ws_timeout_secs: Option<u64>,
88 book_snapshot_timeout_secs: u64,
89 book_stale_threshold_secs: u64,
90 update_instruments_interval_mins: u64,
91 currencies: Vec<String>,
92 include_expired: bool,
93 auto_load_missing_instruments: bool,
94 transport_backend: TransportBackend,
95});
96
97impl Default for DeriveDataClientConfig {
98 fn default() -> Self {
99 Self::builder().build()
100 }
101}
102
103impl DeriveDataClientConfig {
104 #[must_use]
105 pub fn new() -> Self {
106 Self::default()
107 }
108
109 #[must_use]
111 pub fn rest_url(&self) -> String {
112 self.base_url_rest
113 .clone()
114 .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
115 }
116
117 #[must_use]
119 pub fn ws_url(&self) -> String {
120 self.base_url_ws
121 .clone()
122 .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
128#[serde(default, deny_unknown_fields)]
129#[cfg_attr(
130 feature = "python",
131 pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
132)]
133#[cfg_attr(
134 feature = "python",
135 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
136)]
137pub struct DeriveExecutionClientConfig {
138 #[builder(default = AccountId::from("DERIVE-001"))]
140 pub account_id: AccountId,
141 pub wallet_address: Option<String>,
145 pub session_key: Option<SecretString>,
149 pub subaccount_id: Option<u64>,
152 #[builder(default)]
154 pub environment: DeriveEnvironment,
155 pub base_url_rest: Option<String>,
157 pub base_url_ws: Option<String>,
159 pub proxy_url: Option<SecretString>,
161 #[builder(default = 10)]
163 pub http_timeout_secs: u64,
164 #[builder(default = 3)]
166 pub max_retries: u32,
167 #[builder(default = 100)]
169 pub retry_delay_initial_ms: u64,
170 #[builder(default = 5000)]
172 pub retry_delay_max_ms: u64,
173 pub ws_timeout_secs: Option<u64>,
176 pub max_fee_per_contract: Option<Decimal>,
179 #[builder(default)]
181 pub transport_backend: TransportBackend,
182 pub domain_separator: Option<String>,
185 pub action_typehash: Option<String>,
188 pub trade_module_address: Option<String>,
191 #[builder(default = 600)]
195 pub signature_expiry_secs: u64,
196 #[builder(default = 50)]
200 pub market_order_slippage_bps: u32,
201 pub max_matching_requests_per_second: Option<u32>,
207 pub max_per_instrument_matching_requests_per_second: Option<u32>,
214}
215
216#[cfg(feature = "python")]
217nautilus_core::impl_pyo3_config_getters!(DeriveExecutionClientConfig {
218 account_id: AccountId,
219 wallet_address: Option<String>,
220 subaccount_id: Option<u64>,
221 environment: DeriveEnvironment,
222 base_url_rest: Option<String>,
223 base_url_ws: Option<String>,
224 http_timeout_secs: u64,
225 max_retries: u32,
226 retry_delay_initial_ms: u64,
227 retry_delay_max_ms: u64,
228 ws_timeout_secs: Option<u64>,
229 max_fee_per_contract: Option<Decimal>,
230 transport_backend: TransportBackend,
231 domain_separator: Option<String>,
232 action_typehash: Option<String>,
233 trade_module_address: Option<String>,
234 signature_expiry_secs: u64,
235 market_order_slippage_bps: u32,
236 max_matching_requests_per_second: Option<u32>,
237 max_per_instrument_matching_requests_per_second: Option<u32>,
238});
239
240impl Default for DeriveExecutionClientConfig {
241 fn default() -> Self {
242 Self::builder().build()
243 }
244}
245
246impl DeriveExecutionClientConfig {
247 #[must_use]
248 pub fn new() -> Self {
249 Self::default()
250 }
251
252 #[must_use]
258 pub fn has_credentials(&self) -> bool {
259 self.wallet_address
260 .as_deref()
261 .is_some_and(|s| !s.trim().is_empty())
262 && self
263 .session_key
264 .as_ref()
265 .map(SecretString::expose_secret)
266 .is_some_and(|s| !s.trim().is_empty())
267 && self.subaccount_id.is_some()
268 }
269
270 pub fn validate(&self) -> anyhow::Result<()> {
276 let Some(max_fee_per_contract) = self.max_fee_per_contract else {
277 anyhow::bail!("max_fee_per_contract is required");
278 };
279
280 if max_fee_per_contract <= Decimal::ZERO {
281 anyhow::bail!("max_fee_per_contract must be greater than zero");
282 }
283
284 Ok(())
285 }
286
287 #[must_use]
289 pub fn rest_url(&self) -> String {
290 self.base_url_rest
291 .clone()
292 .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
293 }
294
295 #[must_use]
297 pub fn ws_url(&self) -> String {
298 self.base_url_ws
299 .clone()
300 .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use rstest::rstest;
307
308 use super::*;
309
310 #[rstest]
311 fn test_data_config_defaults() {
312 let config = DeriveDataClientConfig::default();
313 assert_eq!(config.environment, DeriveEnvironment::Mainnet);
314 assert_eq!(config.http_timeout_secs, 10);
315 assert_eq!(config.ws_timeout_secs, None);
316 assert_eq!(config.update_instruments_interval_mins, 60);
317 assert!(config.currencies.is_empty());
318 assert!(!config.include_expired);
319 assert!(config.auto_load_missing_instruments);
320 }
321
322 #[rstest]
323 fn test_data_config_urls_mainnet() {
324 let config = DeriveDataClientConfig::default();
325 assert_eq!(config.rest_url(), "https://api.derive.xyz/v3");
326 assert_eq!(config.ws_url(), "wss://api.derive.xyz/v3/ws");
327 }
328
329 #[rstest]
330 fn test_data_config_urls_testnet() {
331 let config = DeriveDataClientConfig {
332 environment: DeriveEnvironment::Testnet,
333 ..DeriveDataClientConfig::default()
334 };
335
336 assert_eq!(config.rest_url(), "https://testnet.api.derive.xyz/v3");
337 assert_eq!(config.ws_url(), "wss://testnet.api.derive.xyz/v3/ws");
338 }
339
340 #[rstest]
341 #[case(
342 DeriveEnvironment::Mainnet,
343 "https://api.derive.xyz/v3",
344 "wss://api.derive.xyz/v3/ws"
345 )]
346 #[case(
347 DeriveEnvironment::Testnet,
348 "https://testnet.api.derive.xyz/v3",
349 "wss://testnet.api.derive.xyz/v3/ws"
350 )]
351 fn test_exec_config_urls(
352 #[case] environment: DeriveEnvironment,
353 #[case] rest: &str,
354 #[case] ws: &str,
355 ) {
356 let config = DeriveExecutionClientConfig {
357 environment,
358 ..DeriveExecutionClientConfig::default()
359 };
360
361 assert_eq!(config.rest_url(), rest);
362 assert_eq!(config.ws_url(), ws);
363 }
364
365 #[rstest]
366 #[case(DeriveEnvironment::Mainnet)]
367 #[case(DeriveEnvironment::Testnet)]
368 fn test_config_url_overrides(#[case] environment: DeriveEnvironment) {
369 let rest = "https://dedicated.example.com";
370 let ws = "wss://dedicated.example.com/ws";
371
372 let data = DeriveDataClientConfig {
373 environment,
374 base_url_rest: Some(rest.to_string()),
375 base_url_ws: Some(ws.to_string()),
376 ..DeriveDataClientConfig::default()
377 };
378
379 let execution = DeriveExecutionClientConfig {
380 environment,
381 base_url_rest: Some(rest.to_string()),
382 base_url_ws: Some(ws.to_string()),
383 ..DeriveExecutionClientConfig::default()
384 };
385
386 assert_eq!(data.rest_url(), rest);
387 assert_eq!(data.ws_url(), ws);
388 assert_eq!(execution.rest_url(), rest);
389 assert_eq!(execution.ws_url(), ws);
390 }
391
392 #[rstest]
393 fn test_exec_config_defaults() {
394 let config = DeriveExecutionClientConfig::default();
395 assert_eq!(config.environment, DeriveEnvironment::Mainnet);
396 assert_eq!(config.http_timeout_secs, 10);
397 assert_eq!(config.max_retries, 3);
398 assert!(config.max_matching_requests_per_second.is_none());
399 assert!(
400 config
401 .max_per_instrument_matching_requests_per_second
402 .is_none()
403 );
404 assert!(!config.has_credentials());
405 }
406
407 #[rstest]
408 fn test_exec_config_has_credentials_requires_all_three_fields() {
409 let mut config = DeriveExecutionClientConfig {
410 wallet_address: Some("0x1234".to_string()),
411 ..DeriveExecutionClientConfig::default()
412 };
413
414 assert!(!config.has_credentials());
415
416 config.session_key = Some("0xabcd".into());
417 assert!(!config.has_credentials());
418
419 config.subaccount_id = Some(1);
420 assert!(config.has_credentials());
421 }
422
423 #[rstest]
424 fn test_exec_config_has_credentials_rejects_blank_strings() {
425 let config = DeriveExecutionClientConfig {
426 wallet_address: Some(" ".to_string()),
427 session_key: Some("0xabcd".into()),
428 subaccount_id: Some(1),
429 ..DeriveExecutionClientConfig::default()
430 };
431
432 assert!(!config.has_credentials());
433 }
434
435 #[rstest]
436 fn test_exec_config_debug_redacts_session_key() {
437 let session_key = "FAKE_SESSION_KEY_SENTINEL";
442
443 let config = DeriveExecutionClientConfig {
444 wallet_address: Some("0xWALLET".to_string()),
445 session_key: Some(session_key.into()),
446 subaccount_id: Some(42),
447 ..DeriveExecutionClientConfig::default()
448 };
449
450 let debug = format!("{config:?}");
451 assert!(debug.contains("redacted"));
452 assert!(!debug.contains(session_key));
453 assert!(debug.contains("0xWALLET"));
454 assert!(debug.contains("42"));
455 }
456
457 #[rstest]
458 fn test_exec_config_debug_omits_session_key_marker_when_unset() {
459 let config = DeriveExecutionClientConfig::default();
460 let debug = format!("{config:?}");
461 assert!(!debug.contains("redacted"));
462 assert!(debug.contains("session_key: None"));
463 }
464}