1use nautilus_common::config::{ConfigResult, check_unique_values};
19use nautilus_core::string::secret::SecretString;
20use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
21use nautilus_model::identifiers::AccountId;
22use nautilus_network::websocket::TransportBackend;
23use rust_decimal::Decimal;
24use serde::{Deserialize, Serialize};
25
26use crate::{
27 common::{
28 consts::{HTTP_MAX_RETRIES, HTTP_RETRY_DELAY_INITIAL_MS, HTTP_RETRY_DELAY_MAX_MS},
29 enums::DeriveEnvironment,
30 urls,
31 },
32 providers::normalize_currency,
33};
34
35#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
37#[serde(default, deny_unknown_fields)]
38#[cfg_attr(
39 feature = "python",
40 pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
41)]
42#[cfg_attr(
43 feature = "python",
44 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
45)]
46pub struct DeriveDataClientConfig {
47 #[builder(default)]
49 pub environment: DeriveEnvironment,
50 pub base_url_rest: Option<String>,
52 pub base_url_ws: Option<String>,
54 pub proxy_url: Option<SecretString>,
56 #[builder(default = 10)]
58 pub http_timeout_secs: u64,
59 pub ws_timeout_secs: Option<u64>,
62 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
65 pub book_snapshot_timeout_secs: u64,
66 #[builder(default = 120)]
69 pub book_stale_threshold_secs: u64,
70 #[builder(default = 60)]
73 pub update_instruments_interval_mins: u64,
74 #[builder(default)]
77 pub currencies: Vec<String>,
78 #[builder(default)]
80 pub include_expired: bool,
81 #[builder(default = true)]
84 pub auto_load_missing_instruments: bool,
85 #[builder(default)]
87 pub transport_backend: TransportBackend,
88}
89
90#[cfg(feature = "python")]
91nautilus_core::impl_pyo3_config_getters!(DeriveDataClientConfig {
92 environment: DeriveEnvironment,
93 base_url_rest: Option<String>,
94 base_url_ws: Option<String>,
95 http_timeout_secs: u64,
96 ws_timeout_secs: Option<u64>,
97 book_snapshot_timeout_secs: u64,
98 book_stale_threshold_secs: u64,
99 update_instruments_interval_mins: u64,
100 currencies: Vec<String>,
101 include_expired: bool,
102 auto_load_missing_instruments: bool,
103 transport_backend: TransportBackend,
104});
105
106impl Default for DeriveDataClientConfig {
107 fn default() -> Self {
108 Self::builder().build()
109 }
110}
111
112impl DeriveDataClientConfig {
113 #[must_use]
114 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn validate(&self) -> ConfigResult<()> {
126 check_unique_values("currencies", &self.currencies)?;
127 let normalized: Vec<_> = self
128 .currencies
129 .iter()
130 .filter_map(|currency| normalize_currency(currency))
131 .collect();
132 check_unique_values("currencies", &normalized)
133 }
134
135 #[must_use]
137 pub fn rest_url(&self) -> String {
138 self.base_url_rest
139 .clone()
140 .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
141 }
142
143 #[must_use]
145 pub fn ws_url(&self) -> String {
146 self.base_url_ws
147 .clone()
148 .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
154#[serde(default, deny_unknown_fields)]
155#[cfg_attr(
156 feature = "python",
157 pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
158)]
159#[cfg_attr(
160 feature = "python",
161 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
162)]
163pub struct DeriveExecutionClientConfig {
164 #[builder(default = AccountId::from("DERIVE-001"))]
166 pub account_id: AccountId,
167 pub wallet_address: Option<String>,
171 pub session_key: Option<SecretString>,
175 pub subaccount_id: Option<u64>,
178 #[builder(default)]
180 pub environment: DeriveEnvironment,
181 pub base_url_rest: Option<String>,
183 pub base_url_ws: Option<String>,
185 pub proxy_url: Option<SecretString>,
187 #[builder(default = 10)]
189 pub http_timeout_secs: u64,
190 #[builder(default = HTTP_MAX_RETRIES)]
192 pub max_retries: u32,
193 #[builder(default = HTTP_RETRY_DELAY_INITIAL_MS)]
195 pub retry_delay_initial_ms: u64,
196 #[builder(default = HTTP_RETRY_DELAY_MAX_MS)]
198 pub retry_delay_max_ms: u64,
199 pub ws_timeout_secs: Option<u64>,
202 pub max_fee_per_contract: Option<Decimal>,
205 #[builder(default)]
207 pub transport_backend: TransportBackend,
208 pub domain_separator: Option<String>,
211 pub action_typehash: Option<String>,
214 pub trade_module_address: Option<String>,
217 #[builder(default = 600)]
221 pub signature_expiry_secs: u64,
222 #[builder(default = 50)]
226 pub market_order_slippage_bps: u32,
227 pub max_matching_requests_per_second: Option<u32>,
233 pub max_per_instrument_matching_requests_per_second: Option<u32>,
240 #[builder(default)]
244 pub cancel_on_disconnect: bool,
245}
246
247#[cfg(feature = "python")]
248nautilus_core::impl_pyo3_config_getters!(DeriveExecutionClientConfig {
249 account_id: AccountId,
250 wallet_address: Option<String>,
251 subaccount_id: Option<u64>,
252 environment: DeriveEnvironment,
253 base_url_rest: Option<String>,
254 base_url_ws: Option<String>,
255 http_timeout_secs: u64,
256 max_retries: u32,
257 retry_delay_initial_ms: u64,
258 retry_delay_max_ms: u64,
259 ws_timeout_secs: Option<u64>,
260 max_fee_per_contract: Option<Decimal>,
261 transport_backend: TransportBackend,
262 domain_separator: Option<String>,
263 action_typehash: Option<String>,
264 trade_module_address: Option<String>,
265 signature_expiry_secs: u64,
266 market_order_slippage_bps: u32,
267 max_matching_requests_per_second: Option<u32>,
268 max_per_instrument_matching_requests_per_second: Option<u32>,
269 cancel_on_disconnect: bool,
270});
271
272impl Default for DeriveExecutionClientConfig {
273 fn default() -> Self {
274 Self::builder().build()
275 }
276}
277
278impl DeriveExecutionClientConfig {
279 #[must_use]
280 pub fn new() -> Self {
281 Self::default()
282 }
283
284 #[must_use]
290 pub fn has_credentials(&self) -> bool {
291 self.wallet_address
292 .as_deref()
293 .is_some_and(|s| !s.trim().is_empty())
294 && self
295 .session_key
296 .as_ref()
297 .map(SecretString::expose_secret)
298 .is_some_and(|s| !s.trim().is_empty())
299 && self.subaccount_id.is_some()
300 }
301
302 pub fn validate(&self) -> anyhow::Result<()> {
310 let Some(max_fee_per_contract) = self.max_fee_per_contract else {
311 anyhow::bail!("max_fee_per_contract is required");
312 };
313
314 if max_fee_per_contract <= Decimal::ZERO {
315 anyhow::bail!("max_fee_per_contract must be greater than zero");
316 }
317
318 if self.cancel_on_disconnect && self.signature_expiry_secs > 15 * 60 {
319 anyhow::bail!(
320 "cancel_on_disconnect requires signature_expiry_secs at most 900 seconds"
321 );
322 }
323
324 Ok(())
325 }
326
327 #[must_use]
329 pub fn rest_url(&self) -> String {
330 self.base_url_rest
331 .clone()
332 .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
333 }
334
335 #[must_use]
337 pub fn ws_url(&self) -> String {
338 self.base_url_ws
339 .clone()
340 .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use nautilus_common::config::ConfigError;
347 use rstest::rstest;
348
349 use super::*;
350
351 #[rstest]
352 #[case("currencies")]
353 fn test_data_config_rejects_duplicate_selections(#[case] field: &str) {
354 let mut input = serde_json::to_value(DeriveDataClientConfig::default()).unwrap();
355 input[field] = serde_json::json!(["first", "second", "first"]);
356 let config: DeriveDataClientConfig = serde_json::from_value(input).unwrap();
357 let original = serde_json::to_value(&config).unwrap();
358 let result = config.validate();
359
360 assert_eq!(result, Err(ConfigError::duplicate(field, None)));
361 assert_eq!(serde_json::to_value(&config).unwrap(), original);
362 }
363
364 #[rstest]
365 fn test_data_config_defaults() {
366 let config = DeriveDataClientConfig::default();
367 assert_eq!(config.environment, DeriveEnvironment::Mainnet);
368 assert_eq!(config.http_timeout_secs, 10);
369 assert_eq!(config.ws_timeout_secs, None);
370 assert_eq!(config.update_instruments_interval_mins, 60);
371 assert!(config.currencies.is_empty());
372 assert!(!config.include_expired);
373 assert!(config.auto_load_missing_instruments);
374 }
375
376 #[rstest]
377 fn test_data_config_urls_mainnet() {
378 let config = DeriveDataClientConfig::default();
379 assert_eq!(config.rest_url(), "https://api.derive.xyz/v3");
380 assert_eq!(config.ws_url(), "wss://api.derive.xyz/v3/ws");
381 }
382
383 #[rstest]
384 fn test_data_config_urls_testnet() {
385 let config = DeriveDataClientConfig {
386 environment: DeriveEnvironment::Testnet,
387 ..DeriveDataClientConfig::default()
388 };
389
390 assert_eq!(config.rest_url(), "https://testnet.api.derive.xyz/v3");
391 assert_eq!(config.ws_url(), "wss://testnet.api.derive.xyz/v3/ws");
392 }
393
394 #[rstest]
395 #[case(
396 DeriveEnvironment::Mainnet,
397 "https://api.derive.xyz/v3",
398 "wss://api.derive.xyz/v3/ws"
399 )]
400 #[case(
401 DeriveEnvironment::Testnet,
402 "https://testnet.api.derive.xyz/v3",
403 "wss://testnet.api.derive.xyz/v3/ws"
404 )]
405 fn test_exec_config_urls(
406 #[case] environment: DeriveEnvironment,
407 #[case] rest: &str,
408 #[case] ws: &str,
409 ) {
410 let config = DeriveExecutionClientConfig {
411 environment,
412 ..DeriveExecutionClientConfig::default()
413 };
414
415 assert_eq!(config.rest_url(), rest);
416 assert_eq!(config.ws_url(), ws);
417 }
418
419 #[rstest]
420 #[case(DeriveEnvironment::Mainnet)]
421 #[case(DeriveEnvironment::Testnet)]
422 fn test_config_url_overrides(#[case] environment: DeriveEnvironment) {
423 let rest = "https://dedicated.example.com";
424 let ws = "wss://dedicated.example.com/ws";
425
426 let data = DeriveDataClientConfig {
427 environment,
428 base_url_rest: Some(rest.to_string()),
429 base_url_ws: Some(ws.to_string()),
430 ..DeriveDataClientConfig::default()
431 };
432
433 let execution = DeriveExecutionClientConfig {
434 environment,
435 base_url_rest: Some(rest.to_string()),
436 base_url_ws: Some(ws.to_string()),
437 ..DeriveExecutionClientConfig::default()
438 };
439
440 assert_eq!(data.rest_url(), rest);
441 assert_eq!(data.ws_url(), ws);
442 assert_eq!(execution.rest_url(), rest);
443 assert_eq!(execution.ws_url(), ws);
444 }
445
446 #[rstest]
447 fn test_exec_config_defaults() {
448 let config = DeriveExecutionClientConfig::default();
449 assert!(!config.cancel_on_disconnect);
450 assert_eq!(config.environment, DeriveEnvironment::Mainnet);
451 assert_eq!(config.http_timeout_secs, 10);
452 assert_eq!(config.max_retries, 3);
453 assert!(config.max_matching_requests_per_second.is_none());
454 assert!(
455 config
456 .max_per_instrument_matching_requests_per_second
457 .is_none()
458 );
459 assert!(!config.has_credentials());
460 }
461
462 #[rstest]
463 #[case::enabled_maximum(true, 900, true)]
464 #[case::enabled_too_long(true, 901, false)]
465 #[case::disabled_long_expiry(false, 901, true)]
466 fn test_exec_config_cancel_on_disconnect_signature_expiry(
467 #[case] enabled: bool,
468 #[case] expiry_secs: u64,
469 #[case] valid: bool,
470 ) {
471 let config = DeriveExecutionClientConfig::builder()
472 .cancel_on_disconnect(enabled)
473 .signature_expiry_secs(expiry_secs)
474 .max_fee_per_contract(Decimal::ONE)
475 .build();
476 let result = config.validate();
477 let decoded: DeriveExecutionClientConfig =
478 serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
479
480 assert_eq!(decoded.cancel_on_disconnect, enabled);
481 assert_eq!(decoded.signature_expiry_secs, expiry_secs);
482 assert_eq!(result.is_ok(), valid);
483
484 if !valid {
485 assert_eq!(
486 result.unwrap_err().to_string(),
487 "cancel_on_disconnect requires signature_expiry_secs at most 900 seconds"
488 );
489 }
490 }
491
492 #[rstest]
493 fn test_exec_config_has_credentials_requires_all_three_fields() {
494 let mut config = DeriveExecutionClientConfig {
495 wallet_address: Some("0x1234".to_string()),
496 ..DeriveExecutionClientConfig::default()
497 };
498
499 assert!(!config.has_credentials());
500
501 config.session_key = Some("0xabcd".into());
502 assert!(!config.has_credentials());
503
504 config.subaccount_id = Some(1);
505 assert!(config.has_credentials());
506 }
507
508 #[rstest]
509 fn test_exec_config_has_credentials_rejects_blank_strings() {
510 let config = DeriveExecutionClientConfig {
511 wallet_address: Some(" ".to_string()),
512 session_key: Some("0xabcd".into()),
513 subaccount_id: Some(1),
514 ..DeriveExecutionClientConfig::default()
515 };
516
517 assert!(!config.has_credentials());
518 }
519
520 #[rstest]
521 fn test_exec_config_debug_redacts_session_key() {
522 let session_key = "FAKE_SESSION_KEY_SENTINEL";
527
528 let config = DeriveExecutionClientConfig {
529 wallet_address: Some("0xWALLET".to_string()),
530 session_key: Some(session_key.into()),
531 subaccount_id: Some(42),
532 ..DeriveExecutionClientConfig::default()
533 };
534
535 let debug = format!("{config:?}");
536 assert!(debug.contains("redacted"));
537 assert!(!debug.contains(session_key));
538 assert!(debug.contains("0xWALLET"));
539 assert!(debug.contains("42"));
540 }
541
542 #[rstest]
543 fn test_exec_config_debug_omits_session_key_marker_when_unset() {
544 let config = DeriveExecutionClientConfig::default();
545 let debug = format!("{config:?}");
546 assert!(!debug.contains("redacted"));
547 assert!(debug.contains("session_key: None"));
548 }
549
550 #[rstest]
551 #[case::trimmed(vec!["BTC", "ETH", " BTC "], true)]
552 #[case::blank(vec!["", ""], true)]
553 #[case::distinct(vec![" ETH ", "BTC"], false)]
554 fn test_currencies_normalized_uniqueness(#[case] values: Vec<&str>, #[case] duplicate: bool) {
555 let currencies: Vec<_> = values.into_iter().map(str::to_string).collect();
556
557 let config = DeriveDataClientConfig {
558 currencies: currencies.clone(),
559 ..Default::default()
560 };
561
562 let expected = if duplicate {
563 Err(ConfigError::duplicate("currencies", None))
564 } else {
565 Ok(())
566 };
567
568 let result = config.validate();
569 assert_eq!(result, expected);
570 assert_eq!(config.currencies, currencies);
571 }
572}