1use nautilus_core::string::secret::SecretString;
19use nautilus_model::identifiers::AccountId;
20use nautilus_network::websocket::TransportBackend;
21use rust_decimal::Decimal;
22use serde::{Deserialize, Serialize};
23
24use crate::common::{enums::DeriveEnvironment, urls};
25
26#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
28#[serde(default, deny_unknown_fields)]
29#[cfg_attr(
30 feature = "python",
31 pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
32)]
33#[cfg_attr(
34 feature = "python",
35 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
36)]
37pub struct DeriveDataClientConfig {
38 #[builder(default)]
40 pub environment: DeriveEnvironment,
41 pub base_url_rest: Option<String>,
43 pub base_url_ws: Option<String>,
45 pub proxy_url: Option<SecretString>,
47 #[builder(default = 10)]
49 pub http_timeout_secs: u64,
50 pub ws_timeout_secs: Option<u64>,
53 #[builder(default = 60)]
55 pub update_instruments_interval_mins: u64,
56 #[builder(default)]
59 pub currencies: Vec<String>,
60 #[builder(default)]
62 pub include_expired: bool,
63 #[builder(default = true)]
66 pub auto_load_missing_instruments: bool,
67 #[builder(default)]
69 pub transport_backend: TransportBackend,
70}
71
72#[cfg(feature = "python")]
73nautilus_core::impl_pyo3_config_getters!(DeriveDataClientConfig {
74 environment: DeriveEnvironment,
75 base_url_rest: Option<String>,
76 base_url_ws: Option<String>,
77 http_timeout_secs: u64,
78 ws_timeout_secs: Option<u64>,
79 update_instruments_interval_mins: u64,
80 currencies: Vec<String>,
81 include_expired: bool,
82 auto_load_missing_instruments: bool,
83 transport_backend: TransportBackend,
84});
85
86impl Default for DeriveDataClientConfig {
87 fn default() -> Self {
88 Self::builder().build()
89 }
90}
91
92impl DeriveDataClientConfig {
93 #[must_use]
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 #[must_use]
100 pub fn rest_url(&self) -> String {
101 self.base_url_rest
102 .clone()
103 .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
104 }
105
106 #[must_use]
108 pub fn ws_url(&self) -> String {
109 self.base_url_ws
110 .clone()
111 .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
117#[serde(default, deny_unknown_fields)]
118#[cfg_attr(
119 feature = "python",
120 pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
121)]
122#[cfg_attr(
123 feature = "python",
124 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
125)]
126pub struct DeriveExecutionClientConfig {
127 #[builder(default = AccountId::from("DERIVE-001"))]
129 pub account_id: AccountId,
130 pub wallet_address: Option<String>,
134 pub session_key: Option<SecretString>,
138 pub subaccount_id: Option<u64>,
141 #[builder(default)]
143 pub environment: DeriveEnvironment,
144 pub base_url_rest: Option<String>,
146 pub base_url_ws: Option<String>,
148 pub proxy_url: Option<SecretString>,
150 #[builder(default = 10)]
152 pub http_timeout_secs: u64,
153 #[builder(default = 3)]
155 pub max_retries: u32,
156 #[builder(default = 100)]
158 pub retry_delay_initial_ms: u64,
159 #[builder(default = 5000)]
161 pub retry_delay_max_ms: u64,
162 pub ws_timeout_secs: Option<u64>,
165 pub max_fee_per_contract: Option<Decimal>,
168 #[builder(default)]
170 pub transport_backend: TransportBackend,
171 pub domain_separator: Option<String>,
175 pub action_typehash: Option<String>,
178 pub trade_module_address: Option<String>,
181 #[builder(default = 600)]
185 pub signature_expiry_secs: u64,
186 #[builder(default = 50)]
190 pub market_order_slippage_bps: u32,
191 pub max_matching_requests_per_second: Option<u32>,
196 pub max_per_instrument_matching_requests_per_second: Option<u32>,
203}
204
205#[cfg(feature = "python")]
206nautilus_core::impl_pyo3_config_getters!(DeriveExecutionClientConfig {
207 account_id: AccountId,
208 wallet_address: Option<String>,
209 subaccount_id: Option<u64>,
210 environment: DeriveEnvironment,
211 base_url_rest: Option<String>,
212 base_url_ws: Option<String>,
213 http_timeout_secs: u64,
214 max_retries: u32,
215 retry_delay_initial_ms: u64,
216 retry_delay_max_ms: u64,
217 ws_timeout_secs: Option<u64>,
218 max_fee_per_contract: Option<Decimal>,
219 domain_separator: Option<String>,
220 action_typehash: Option<String>,
221 trade_module_address: Option<String>,
222 signature_expiry_secs: u64,
223 market_order_slippage_bps: u32,
224 max_matching_requests_per_second: Option<u32>,
225 max_per_instrument_matching_requests_per_second: Option<u32>,
226 transport_backend: TransportBackend,
227});
228
229impl Default for DeriveExecutionClientConfig {
230 fn default() -> Self {
231 Self::builder().build()
232 }
233}
234
235impl DeriveExecutionClientConfig {
236 #[must_use]
237 pub fn new() -> Self {
238 Self::default()
239 }
240
241 #[must_use]
247 pub fn has_credentials(&self) -> bool {
248 self.wallet_address
249 .as_deref()
250 .is_some_and(|s| !s.trim().is_empty())
251 && self
252 .session_key
253 .as_ref()
254 .map(SecretString::expose_secret)
255 .is_some_and(|s| !s.trim().is_empty())
256 && self.subaccount_id.is_some()
257 }
258
259 pub fn validate(&self) -> anyhow::Result<()> {
266 let Some(max_fee_per_contract) = self.max_fee_per_contract else {
267 anyhow::bail!("max_fee_per_contract is required");
268 };
269
270 if max_fee_per_contract <= Decimal::ZERO {
271 anyhow::bail!("max_fee_per_contract must be greater than zero");
272 }
273 Ok(())
274 }
275
276 #[must_use]
278 pub fn rest_url(&self) -> String {
279 self.base_url_rest
280 .clone()
281 .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
282 }
283
284 #[must_use]
286 pub fn ws_url(&self) -> String {
287 self.base_url_ws
288 .clone()
289 .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use rstest::rstest;
296
297 use super::*;
298
299 #[rstest]
300 fn test_data_config_defaults() {
301 let config = DeriveDataClientConfig::default();
302 assert_eq!(config.environment, DeriveEnvironment::Mainnet);
303 assert_eq!(config.http_timeout_secs, 10);
304 assert_eq!(config.ws_timeout_secs, None);
305 assert_eq!(config.update_instruments_interval_mins, 60);
306 assert!(config.currencies.is_empty());
307 assert!(!config.include_expired);
308 assert!(config.auto_load_missing_instruments);
309 }
310
311 #[rstest]
312 fn test_data_config_urls_mainnet() {
313 let config = DeriveDataClientConfig::default();
314 assert!(config.rest_url().contains("api.lyra.finance"));
315 assert!(config.ws_url().contains("api.lyra.finance"));
316 }
317
318 #[rstest]
319 fn test_data_config_urls_testnet() {
320 let config = DeriveDataClientConfig {
321 environment: DeriveEnvironment::Testnet,
322 ..DeriveDataClientConfig::default()
323 };
324 assert!(config.rest_url().contains("demo"));
325 assert!(config.ws_url().contains("demo"));
326 }
327
328 #[rstest]
329 fn test_exec_config_defaults() {
330 let config = DeriveExecutionClientConfig::default();
331 assert_eq!(config.environment, DeriveEnvironment::Mainnet);
332 assert_eq!(config.http_timeout_secs, 10);
333 assert_eq!(config.max_retries, 3);
334 assert!(config.max_matching_requests_per_second.is_none());
335 assert!(
336 config
337 .max_per_instrument_matching_requests_per_second
338 .is_none()
339 );
340 assert!(!config.has_credentials());
341 }
342
343 #[rstest]
344 fn test_exec_config_has_credentials_requires_all_three_fields() {
345 let mut config = DeriveExecutionClientConfig {
346 wallet_address: Some("0x1234".to_string()),
347 ..DeriveExecutionClientConfig::default()
348 };
349 assert!(!config.has_credentials());
350
351 config.session_key = Some("0xabcd".into());
352 assert!(!config.has_credentials());
353
354 config.subaccount_id = Some(1);
355 assert!(config.has_credentials());
356 }
357
358 #[rstest]
359 fn test_exec_config_has_credentials_rejects_blank_strings() {
360 let config = DeriveExecutionClientConfig {
361 wallet_address: Some(" ".to_string()),
362 session_key: Some("0xabcd".into()),
363 subaccount_id: Some(1),
364 ..DeriveExecutionClientConfig::default()
365 };
366 assert!(!config.has_credentials());
367 }
368
369 #[rstest]
370 fn test_exec_config_debug_redacts_session_key() {
371 let session_key = "FAKE_SESSION_KEY_SENTINEL";
376 let config = DeriveExecutionClientConfig {
377 wallet_address: Some("0xWALLET".to_string()),
378 session_key: Some(session_key.into()),
379 subaccount_id: Some(42),
380 ..DeriveExecutionClientConfig::default()
381 };
382 let debug = format!("{config:?}");
383 assert!(debug.contains("redacted"));
384 assert!(!debug.contains(session_key));
385 assert!(debug.contains("0xWALLET"));
386 assert!(debug.contains("42"));
387 }
388
389 #[rstest]
390 fn test_exec_config_debug_omits_session_key_marker_when_unset() {
391 let config = DeriveExecutionClientConfig::default();
392 let debug = format!("{config:?}");
393 assert!(!debug.contains("redacted"));
394 assert!(debug.contains("session_key: None"));
395 }
396}