Skip to main content

nautilus_derive/
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 Derive adapter.
17
18use 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/// Configuration for the Derive data client.
36#[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    /// The Derive environment to connect to.
48    #[builder(default)]
49    pub environment: DeriveEnvironment,
50    /// Override for the REST API base URL.
51    pub base_url_rest: Option<String>,
52    /// Override for the WebSocket URL.
53    pub base_url_ws: Option<String>,
54    /// Optional proxy URL for HTTP and WebSocket transports.
55    pub proxy_url: Option<SecretString>,
56    /// HTTP timeout in seconds.
57    #[builder(default = 10)]
58    pub http_timeout_secs: u64,
59    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
60    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
61    pub ws_timeout_secs: Option<u64>,
62    /// Maximum wait for an initial, post-reconnect, or recovery book snapshot, in seconds.
63    /// A zero value disables snapshot deadlines.
64    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
65    pub book_snapshot_timeout_secs: u64,
66    /// Maximum receive age of advancing book snapshots before recovery, in seconds.
67    /// Empty snapshots count as updates; a zero value disables stale book recovery.
68    #[builder(default = 120)]
69    pub book_stale_threshold_secs: u64,
70    /// Interval in minutes for refreshing instruments of configured currencies.
71    /// A zero value disables periodic refresh.
72    #[builder(default = 60)]
73    pub update_instruments_interval_mins: u64,
74    /// Underlying currencies to load on connect. Empty means lazy-load by
75    /// instrument ID when subscribing.
76    #[builder(default)]
77    pub currencies: Vec<String>,
78    /// Whether instrument loading includes expired instruments.
79    #[builder(default)]
80    pub include_expired: bool,
81    /// Whether subscriptions may fetch missing instruments before sending the
82    /// WebSocket request.
83    #[builder(default = true)]
84    pub auto_load_missing_instruments: bool,
85    /// WebSocket transport backend (defaults to `Sockudo`).
86    #[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    /// Validates that configured currency entries contain no duplicates.
119    ///
120    /// Nonblank currencies must also be distinct after trimming.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if currency entries repeat or nonblank entries match after trimming.
125    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    /// Returns the REST API base URL, respecting environment and overrides.
136    #[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    /// Returns the WebSocket URL, respecting environment and overrides.
144    #[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/// Configuration for the Derive execution client.
153#[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    /// Account identifier for the execution client.
165    #[builder(default = AccountId::from("DERIVE-001"))]
166    pub account_id: AccountId,
167    /// Owner's EOA or multisig address (`X-DeriveWallet`). Falls back
168    /// to `DERIVE_WALLET_ADDRESS` (or `DERIVE_TESTNET_WALLET_ADDRESS` on
169    /// testnet) when unset.
170    pub wallet_address: Option<String>,
171    /// secp256k1 session-key private key in hex (with or without `0x` prefix).
172    /// Falls back to `DERIVE_SESSION_PRIVATE_KEY` (or
173    /// `DERIVE_TESTNET_SESSION_PRIVATE_KEY` on testnet) when unset.
174    pub session_key: Option<SecretString>,
175    /// Subaccount identifier. Falls back to `DERIVE_SUBACCOUNT_ID` (or
176    /// `DERIVE_TESTNET_SUBACCOUNT_ID` on testnet) when unset.
177    pub subaccount_id: Option<u64>,
178    /// The Derive environment to connect to.
179    #[builder(default)]
180    pub environment: DeriveEnvironment,
181    /// Override for the REST API base URL.
182    pub base_url_rest: Option<String>,
183    /// Override for the WebSocket URL.
184    pub base_url_ws: Option<String>,
185    /// Optional proxy URL for HTTP and WebSocket transports.
186    pub proxy_url: Option<SecretString>,
187    /// HTTP timeout in seconds.
188    #[builder(default = 10)]
189    pub http_timeout_secs: u64,
190    /// Maximum number of retry attempts for HTTP requests.
191    #[builder(default = HTTP_MAX_RETRIES)]
192    pub max_retries: u32,
193    /// Initial retry delay in milliseconds.
194    #[builder(default = HTTP_RETRY_DELAY_INITIAL_MS)]
195    pub retry_delay_initial_ms: u64,
196    /// Maximum retry delay in milliseconds.
197    #[builder(default = HTTP_RETRY_DELAY_MAX_MS)]
198    pub retry_delay_max_ms: u64,
199    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
200    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
201    pub ws_timeout_secs: Option<u64>,
202    /// Per-contract USDC fee cap signed into every order. Required for
203    /// execution and must be greater than zero.
204    pub max_fee_per_contract: Option<Decimal>,
205    /// WebSocket transport backend (defaults to `Sockudo`).
206    #[builder(default)]
207    pub transport_backend: TransportBackend,
208    /// Override for the EIP-712 domain separator. Falls back to the shipped
209    /// v3 constant for the configured environment when unset.
210    pub domain_separator: Option<String>,
211    /// Override for the EIP-712 action typehash. Falls back to the shipped
212    /// [`crate::common::consts::ACTION_TYPEHASH`] when unset.
213    pub action_typehash: Option<String>,
214    /// Override for the Trade module contract address. Falls back to the
215    /// shipped per-environment constant when unset.
216    pub trade_module_address: Option<String>,
217    /// Signature expiry TTL in seconds for normal orders and replaces (added
218    /// to the wall clock before signing). Must be greater than the venue
219    /// minimum ([`crate::common::consts::MIN_SIGNATURE_TTL`], 10s) and at most 120 days.
220    #[builder(default = 600)]
221    pub signature_expiry_secs: u64,
222    /// Slippage bound applied to market orders when deriving a worst-acceptable
223    /// limit price from the cached top-of-book quote. Expressed in basis points
224    /// (1 bp = 0.01%). Defaults to 50 bp = 0.5%.
225    #[builder(default = 50)]
226    pub market_order_slippage_bps: u32,
227    /// Maximum wallet matching requests per second across HTTP, WebSocket,
228    /// session keys, and subaccounts on the same API host. Defaults to 1 when
229    /// unset. The first explicit setting for a wallet is shared by all clients;
230    /// the limiter ignores conflicting later settings. Verify deployed budgets with
231    /// `public/getRateLimits`. See <https://docs.derive.xyz/rate-limits>.
232    pub max_matching_requests_per_second: Option<u32>,
233    /// Per-wallet/instrument token refill rate for perpetuals, spot, and options.
234    /// Defaults to 1 token per second; capacity is five times the configured rate.
235    /// Clients for the same wallet on the same API host share the first explicit
236    /// setting, independently of the wallet matching allowance. Instrument budgets
237    /// are absent from `public/getRateLimits`.
238    /// See <https://docs.derive.xyz/rate-limits>.
239    pub max_per_instrument_matching_requests_per_second: Option<u32>,
240    /// Enables wallet-wide cancel-on-disconnect before accepting orders and tags resting limit orders
241    /// with `mmp: true`. Requires `Trade(All)` across all subaccounts and signature expiry at most
242    /// 15 minutes. False leaves the wallet setting unchanged. Shutdown never disables the setting.
243    #[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    /// Returns true when wallet, session-key, and subaccount are all populated
285    /// **in this config**. Environment-variable fallbacks documented on the
286    /// individual fields are resolved at factory-construction time, not here;
287    /// callers that need a "credentials available anywhere" check should
288    /// inspect both this method and the relevant env vars.
289    #[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    /// Validates execution configuration invariants.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if:
307    /// - `max_fee_per_contract` is missing or not greater than zero.
308    /// - Cancel-on-disconnect uses a signature expiry greater than 15 minutes.
309    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    /// Returns the REST API base URL, respecting environment and overrides.
328    #[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    /// Returns the WebSocket URL, respecting environment and overrides.
336    #[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        // Use a low-entropy sentinel rather than a hex private key so the
523        // assertion exercises Debug-redaction without tripping the secrets
524        // scanner on a synthetic test value. The redaction logic is
525        // string-content-agnostic.
526        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}