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_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/// Configuration for the Derive data client.
28#[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    /// The Derive environment to connect to.
40    #[builder(default)]
41    pub environment: DeriveEnvironment,
42    /// Override for the REST API base URL.
43    pub base_url_rest: Option<String>,
44    /// Override for the WebSocket URL.
45    pub base_url_ws: Option<String>,
46    /// Optional proxy URL for HTTP and WebSocket transports.
47    pub proxy_url: Option<SecretString>,
48    /// HTTP timeout in seconds.
49    #[builder(default = 10)]
50    pub http_timeout_secs: u64,
51    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
52    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
53    pub ws_timeout_secs: Option<u64>,
54    /// Maximum wait for an initial, post-reconnect, or recovery book snapshot, in seconds.
55    /// A zero value disables snapshot deadlines.
56    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
57    pub book_snapshot_timeout_secs: u64,
58    /// Maximum receive age of advancing book snapshots before recovery, in seconds.
59    /// Empty snapshots count as updates; a zero value disables stale book recovery.
60    #[builder(default = 120)]
61    pub book_stale_threshold_secs: u64,
62    /// Interval for refreshing instruments in minutes.
63    #[builder(default = 60)]
64    pub update_instruments_interval_mins: u64,
65    /// Underlying currencies to load on connect. Empty means lazy-load by
66    /// instrument ID when subscribing.
67    #[builder(default)]
68    pub currencies: Vec<String>,
69    /// Whether instrument loading includes expired instruments.
70    #[builder(default)]
71    pub include_expired: bool,
72    /// Whether subscriptions may fetch missing instruments before sending the
73    /// WebSocket request.
74    #[builder(default = true)]
75    pub auto_load_missing_instruments: bool,
76    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
77    #[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    /// Returns the REST API base URL, respecting environment and overrides.
110    #[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    /// Returns the WebSocket URL, respecting environment and overrides.
118    #[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/// Configuration for the Derive execution client.
127#[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    /// Account identifier for the execution client.
139    #[builder(default = AccountId::from("DERIVE-001"))]
140    pub account_id: AccountId,
141    /// Owner's EOA or multisig address (`X-DeriveWallet`). Falls back
142    /// to `DERIVE_WALLET_ADDRESS` (or `DERIVE_TESTNET_WALLET_ADDRESS` on
143    /// testnet) when unset.
144    pub wallet_address: Option<String>,
145    /// secp256k1 session-key private key in hex (with or without `0x` prefix).
146    /// Falls back to `DERIVE_SESSION_PRIVATE_KEY` (or
147    /// `DERIVE_TESTNET_SESSION_PRIVATE_KEY` on testnet) when unset.
148    pub session_key: Option<SecretString>,
149    /// Subaccount identifier. Falls back to `DERIVE_SUBACCOUNT_ID` (or
150    /// `DERIVE_TESTNET_SUBACCOUNT_ID` on testnet) when unset.
151    pub subaccount_id: Option<u64>,
152    /// The Derive environment to connect to.
153    #[builder(default)]
154    pub environment: DeriveEnvironment,
155    /// Override for the REST API base URL.
156    pub base_url_rest: Option<String>,
157    /// Override for the WebSocket URL.
158    pub base_url_ws: Option<String>,
159    /// Optional proxy URL for HTTP and WebSocket transports.
160    pub proxy_url: Option<SecretString>,
161    /// HTTP timeout in seconds.
162    #[builder(default = 10)]
163    pub http_timeout_secs: u64,
164    /// Maximum number of retry attempts for HTTP requests.
165    #[builder(default = 3)]
166    pub max_retries: u32,
167    /// Initial retry delay in milliseconds.
168    #[builder(default = 100)]
169    pub retry_delay_initial_ms: u64,
170    /// Maximum retry delay in milliseconds.
171    #[builder(default = 5000)]
172    pub retry_delay_max_ms: u64,
173    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
174    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
175    pub ws_timeout_secs: Option<u64>,
176    /// Per-contract USDC fee cap signed into every order. Required for
177    /// execution and must be greater than zero.
178    pub max_fee_per_contract: Option<Decimal>,
179    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
180    #[builder(default)]
181    pub transport_backend: TransportBackend,
182    /// Override for the EIP-712 domain separator. Falls back to the shipped
183    /// v3 constant for the configured environment when unset.
184    pub domain_separator: Option<String>,
185    /// Override for the EIP-712 action typehash. Falls back to the shipped
186    /// [`crate::common::consts::ACTION_TYPEHASH`] when unset.
187    pub action_typehash: Option<String>,
188    /// Override for the Trade module contract address. Falls back to the
189    /// shipped per-environment constant when unset.
190    pub trade_module_address: Option<String>,
191    /// Signature expiry TTL in seconds for normal orders and replaces (added
192    /// to the wall clock before signing). Must be greater than the venue
193    /// minimum ([`crate::common::consts::MIN_SIGNATURE_TTL`], 10s) and at most 120 days.
194    #[builder(default = 600)]
195    pub signature_expiry_secs: u64,
196    /// Slippage bound applied to market orders when deriving a worst-acceptable
197    /// limit price from the cached top-of-book quote. Expressed in basis points
198    /// (1 bp = 0.01%). Defaults to 50 bp = 0.5%.
199    #[builder(default = 50)]
200    pub market_order_slippage_bps: u32,
201    /// Maximum wallet matching requests per second across HTTP, WebSocket,
202    /// session keys, and subaccounts on the same API host. Defaults to 1 when
203    /// unset. The first explicit setting for a wallet is shared by all clients;
204    /// the limiter ignores conflicting later settings. Verify deployed budgets with
205    /// `public/getRateLimits`. See <https://docs.derive.xyz/rate-limits>.
206    pub max_matching_requests_per_second: Option<u32>,
207    /// Per-wallet/instrument token refill rate for perpetuals, spot, and options.
208    /// Defaults to 1 token per second; capacity is five times the configured rate.
209    /// Clients for the same wallet on the same API host share the first explicit
210    /// setting, independently of the wallet matching allowance. Instrument budgets
211    /// are absent from `public/getRateLimits`.
212    /// See <https://docs.derive.xyz/rate-limits>.
213    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    /// Returns true when wallet, session-key, and subaccount are all populated
253    /// **in this config**. Environment-variable fallbacks documented on the
254    /// individual fields are resolved at factory-construction time, not here;
255    /// callers that need a "credentials available anywhere" check should
256    /// inspect both this method and the relevant env vars.
257    #[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    /// Validates execution configuration invariants.
271    ///
272    /// # Errors
273    ///
274    /// Returns an error if `max_fee_per_contract` is missing or not greater than zero.
275    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    /// Returns the REST API base URL, respecting environment and overrides.
288    #[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    /// Returns the WebSocket URL, respecting environment and overrides.
296    #[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        // Use a low-entropy sentinel rather than a hex private key so the
438        // assertion exercises Debug-redaction without tripping the secrets
439        // scanner on a synthetic test value. The redaction logic is
440        // string-content-agnostic.
441        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}