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 std::fmt::Debug;
19
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(Clone, Debug, 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    /// Override for the REST API base URL.
40    pub base_url_rest: Option<String>,
41    /// Override for the WebSocket URL.
42    pub base_url_ws: Option<String>,
43    /// Optional proxy URL for HTTP and WebSocket transports.
44    pub proxy_url: Option<String>,
45    /// The Derive environment to connect to.
46    #[builder(default)]
47    pub environment: DeriveEnvironment,
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    /// Interval for refreshing instruments in minutes.
55    #[builder(default = 60)]
56    pub update_instruments_interval_mins: u64,
57    /// Underlying currencies to load on connect. Empty means lazy-load by
58    /// instrument ID when subscribing.
59    #[builder(default)]
60    pub currencies: Vec<String>,
61    /// Whether instrument loading includes expired instruments.
62    #[builder(default)]
63    pub include_expired: bool,
64    /// Whether subscriptions may fetch missing instruments before sending the
65    /// WebSocket request.
66    #[builder(default = true)]
67    pub auto_load_missing_instruments: bool,
68    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
69    #[builder(default)]
70    pub transport_backend: TransportBackend,
71}
72
73#[cfg(feature = "python")]
74nautilus_core::impl_pyo3_config_getters!(DeriveDataClientConfig {
75    base_url_rest: Option<String>,
76    base_url_ws: Option<String>,
77    environment: DeriveEnvironment,
78    http_timeout_secs: u64,
79    ws_timeout_secs: Option<u64>,
80    update_instruments_interval_mins: u64,
81    currencies: Vec<String>,
82    include_expired: bool,
83    auto_load_missing_instruments: bool,
84    transport_backend: TransportBackend,
85});
86
87impl Default for DeriveDataClientConfig {
88    fn default() -> Self {
89        Self::builder().build()
90    }
91}
92
93impl DeriveDataClientConfig {
94    #[must_use]
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Returns the REST API base URL, respecting environment and overrides.
100    #[must_use]
101    pub fn rest_url(&self) -> String {
102        self.base_url_rest
103            .clone()
104            .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
105    }
106
107    /// Returns the WebSocket URL, respecting environment and overrides.
108    #[must_use]
109    pub fn ws_url(&self) -> String {
110        self.base_url_ws
111            .clone()
112            .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
113    }
114}
115
116/// Configuration for the Derive execution client.
117///
118/// `Debug` is implemented manually so that `session_key` is redacted; the
119/// derived `Debug` would leak the raw secret through any logger or Python
120/// `__repr__`.
121#[derive(Clone, Serialize, Deserialize, bon::Builder)]
122#[serde(default, deny_unknown_fields)]
123#[cfg_attr(
124    feature = "python",
125    pyo3::pyclass(module = "nautilus_trader.adapters.derive", from_py_object)
126)]
127#[cfg_attr(
128    feature = "python",
129    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
130)]
131pub struct DeriveExecutionClientConfig {
132    /// Account identifier for the execution client.
133    #[builder(default = AccountId::from("DERIVE-001"))]
134    pub account_id: AccountId,
135    /// Derive Chain smart-contract wallet address (`X-LYRAWALLET`). Falls back
136    /// to `DERIVE_WALLET_ADDRESS` (or `DERIVE_TESTNET_WALLET_ADDRESS` on
137    /// testnet) when unset.
138    pub wallet_address: Option<String>,
139    /// secp256k1 session-key private key in hex (with or without `0x` prefix).
140    /// Falls back to `DERIVE_SESSION_PRIVATE_KEY` (or
141    /// `DERIVE_TESTNET_SESSION_PRIVATE_KEY` on testnet) when unset.
142    pub session_key: Option<String>,
143    /// Subaccount identifier. Falls back to `DERIVE_SUBACCOUNT_ID` (or
144    /// `DERIVE_TESTNET_SUBACCOUNT_ID` on testnet) when unset.
145    pub subaccount_id: Option<u64>,
146    /// Override for the REST API base URL.
147    pub base_url_rest: Option<String>,
148    /// Override for the WebSocket URL.
149    pub base_url_ws: Option<String>,
150    /// Optional proxy URL for HTTP and WebSocket transports.
151    pub proxy_url: Option<String>,
152    /// The Derive environment to connect to.
153    #[builder(default)]
154    pub environment: DeriveEnvironment,
155    /// HTTP timeout in seconds.
156    #[builder(default = 10)]
157    pub http_timeout_secs: u64,
158    /// Maximum number of retry attempts for HTTP requests.
159    #[builder(default = 3)]
160    pub max_retries: u32,
161    /// Initial retry delay in milliseconds.
162    #[builder(default = 100)]
163    pub retry_delay_initial_ms: u64,
164    /// Maximum retry delay in milliseconds.
165    #[builder(default = 5000)]
166    pub retry_delay_max_ms: u64,
167    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
168    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
169    pub ws_timeout_secs: Option<u64>,
170    /// Per-contract USDC fee cap signed into every order. Required for
171    /// execution and must be greater than zero.
172    pub max_fee_per_contract: Option<Decimal>,
173    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
174    #[builder(default)]
175    pub transport_backend: TransportBackend,
176    /// Override for the EIP-712 domain separator. Falls back to the constant
177    /// for the configured environment when unset. The shipped constants are
178    /// placeholders that must be replaced or overridden before signing.
179    pub domain_separator: Option<String>,
180    /// Override for the EIP-712 action typehash. Falls back to the shipped
181    /// [`crate::common::consts::ACTION_TYPEHASH`] when unset.
182    pub action_typehash: Option<String>,
183    /// Override for the Trade module contract address. Falls back to the
184    /// shipped per-environment constant when unset.
185    pub trade_module_address: Option<String>,
186    /// Signature expiry TTL in seconds for normal orders and replaces (added
187    /// to the wall clock before signing). Must be greater than the venue
188    /// minimum ([`crate::common::consts::MIN_SIGNATURE_TTL`], 300s).
189    #[builder(default = 600)]
190    pub signature_expiry_secs: u64,
191    /// Slippage bound applied to market orders when deriving a worst-acceptable
192    /// limit price from the cached top-of-book quote. Expressed in basis points
193    /// (1 bp = 0.01%). Defaults to 50 bp = 0.5%.
194    #[builder(default = 50)]
195    pub market_order_slippage_bps: u32,
196    /// Maximum matching-engine requests per second for order writes sent over
197    /// the WebSocket (create/cancel/replace). Defaults to the Trader-tier limit
198    /// of 1 when unset; raise it for Market Maker accounts with higher
199    /// negotiated limits. See <https://docs.derive.xyz/reference/rate-limits>.
200    pub max_matching_requests_per_second: Option<u32>,
201    /// Maximum per-instrument matching requests per second for instrument-
202    /// scoped order writes sent over the WebSocket. Defaults to the Trader-tier
203    /// limit of 1 when unset; raise it for Market Maker accounts with higher
204    /// negotiated per-instrument limits. This allowance is independent of
205    /// `max_matching_requests_per_second`, which never inflates it. See
206    /// <https://docs.derive.xyz/reference/rate-limits>.
207    pub max_per_instrument_matching_requests_per_second: Option<u32>,
208}
209
210#[cfg(feature = "python")]
211nautilus_core::impl_pyo3_config_getters!(DeriveExecutionClientConfig {
212    account_id: AccountId,
213    wallet_address: Option<String>,
214    subaccount_id: Option<u64>,
215    base_url_rest: Option<String>,
216    base_url_ws: Option<String>,
217    environment: DeriveEnvironment,
218    http_timeout_secs: u64,
219    max_retries: u32,
220    retry_delay_initial_ms: u64,
221    retry_delay_max_ms: u64,
222    ws_timeout_secs: Option<u64>,
223    max_fee_per_contract: Option<Decimal>,
224    domain_separator: Option<String>,
225    action_typehash: Option<String>,
226    trade_module_address: Option<String>,
227    signature_expiry_secs: u64,
228    market_order_slippage_bps: u32,
229    max_matching_requests_per_second: Option<u32>,
230    max_per_instrument_matching_requests_per_second: Option<u32>,
231    transport_backend: TransportBackend,
232});
233
234impl Default for DeriveExecutionClientConfig {
235    fn default() -> Self {
236        Self::builder().build()
237    }
238}
239
240impl Debug for DeriveExecutionClientConfig {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.debug_struct(stringify!(DeriveExecutionClientConfig))
243            .field("account_id", &self.account_id)
244            .field("wallet_address", &self.wallet_address)
245            .field(
246                "session_key",
247                &self.session_key.as_deref().map(|_| "***redacted***"),
248            )
249            .field("subaccount_id", &self.subaccount_id)
250            .field("base_url_rest", &self.base_url_rest)
251            .field("base_url_ws", &self.base_url_ws)
252            .field("proxy_url", &self.proxy_url)
253            .field("environment", &self.environment)
254            .field("http_timeout_secs", &self.http_timeout_secs)
255            .field("max_retries", &self.max_retries)
256            .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
257            .field("retry_delay_max_ms", &self.retry_delay_max_ms)
258            .field("max_fee_per_contract", &self.max_fee_per_contract)
259            .field("transport_backend", &self.transport_backend)
260            .field("domain_separator", &self.domain_separator)
261            .field("action_typehash", &self.action_typehash)
262            .field("trade_module_address", &self.trade_module_address)
263            .field("signature_expiry_secs", &self.signature_expiry_secs)
264            .field("market_order_slippage_bps", &self.market_order_slippage_bps)
265            .field(
266                "max_matching_requests_per_second",
267                &self.max_matching_requests_per_second,
268            )
269            .field(
270                "max_per_instrument_matching_requests_per_second",
271                &self.max_per_instrument_matching_requests_per_second,
272            )
273            .finish()
274    }
275}
276
277impl DeriveExecutionClientConfig {
278    #[must_use]
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Returns true when wallet, session-key, and subaccount are all populated
284    /// **in this config**. Environment-variable fallbacks documented on the
285    /// individual fields are resolved at factory-construction time, not here;
286    /// callers that need a "credentials available anywhere" check should
287    /// inspect both this method and the relevant env vars.
288    #[must_use]
289    pub fn has_credentials(&self) -> bool {
290        self.wallet_address
291            .as_deref()
292            .is_some_and(|s| !s.trim().is_empty())
293            && self
294                .session_key
295                .as_deref()
296                .is_some_and(|s| !s.trim().is_empty())
297            && self.subaccount_id.is_some()
298    }
299
300    /// Validates execution configuration invariants.
301    ///
302    /// # Errors
303    ///
304    /// Returns an error when `max_fee_per_contract` is missing or not greater
305    /// than zero.
306    pub fn validate(&self) -> anyhow::Result<()> {
307        let Some(max_fee_per_contract) = self.max_fee_per_contract else {
308            anyhow::bail!("max_fee_per_contract is required");
309        };
310
311        if max_fee_per_contract <= Decimal::ZERO {
312            anyhow::bail!("max_fee_per_contract must be greater than zero");
313        }
314        Ok(())
315    }
316
317    /// Returns the REST API base URL, respecting environment and overrides.
318    #[must_use]
319    pub fn rest_url(&self) -> String {
320        self.base_url_rest
321            .clone()
322            .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
323    }
324
325    /// Returns the WebSocket URL, respecting environment and overrides.
326    #[must_use]
327    pub fn ws_url(&self) -> String {
328        self.base_url_ws
329            .clone()
330            .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use rstest::rstest;
337
338    use super::*;
339
340    #[rstest]
341    fn test_data_config_defaults() {
342        let config = DeriveDataClientConfig::default();
343        assert_eq!(config.environment, DeriveEnvironment::Mainnet);
344        assert_eq!(config.http_timeout_secs, 10);
345        assert_eq!(config.ws_timeout_secs, None);
346        assert_eq!(config.update_instruments_interval_mins, 60);
347        assert!(config.currencies.is_empty());
348        assert!(!config.include_expired);
349        assert!(config.auto_load_missing_instruments);
350    }
351
352    #[rstest]
353    fn test_data_config_urls_mainnet() {
354        let config = DeriveDataClientConfig::default();
355        assert!(config.rest_url().contains("api.lyra.finance"));
356        assert!(config.ws_url().contains("api.lyra.finance"));
357    }
358
359    #[rstest]
360    fn test_data_config_urls_testnet() {
361        let config = DeriveDataClientConfig {
362            environment: DeriveEnvironment::Testnet,
363            ..DeriveDataClientConfig::default()
364        };
365        assert!(config.rest_url().contains("demo"));
366        assert!(config.ws_url().contains("demo"));
367    }
368
369    #[rstest]
370    fn test_exec_config_defaults() {
371        let config = DeriveExecutionClientConfig::default();
372        assert_eq!(config.environment, DeriveEnvironment::Mainnet);
373        assert_eq!(config.http_timeout_secs, 10);
374        assert_eq!(config.max_retries, 3);
375        assert!(config.max_matching_requests_per_second.is_none());
376        assert!(
377            config
378                .max_per_instrument_matching_requests_per_second
379                .is_none()
380        );
381        assert!(!config.has_credentials());
382    }
383
384    #[rstest]
385    fn test_exec_config_has_credentials_requires_all_three_fields() {
386        let mut config = DeriveExecutionClientConfig {
387            wallet_address: Some("0x1234".to_string()),
388            ..DeriveExecutionClientConfig::default()
389        };
390        assert!(!config.has_credentials());
391
392        config.session_key = Some("0xabcd".to_string());
393        assert!(!config.has_credentials());
394
395        config.subaccount_id = Some(1);
396        assert!(config.has_credentials());
397    }
398
399    #[rstest]
400    fn test_exec_config_has_credentials_rejects_blank_strings() {
401        let config = DeriveExecutionClientConfig {
402            wallet_address: Some("   ".to_string()),
403            session_key: Some("0xabcd".to_string()),
404            subaccount_id: Some(1),
405            ..DeriveExecutionClientConfig::default()
406        };
407        assert!(!config.has_credentials());
408    }
409
410    #[rstest]
411    fn test_exec_config_debug_redacts_session_key() {
412        // Use a low-entropy sentinel rather than a hex private key so the
413        // assertion exercises Debug-redaction without tripping the secrets
414        // scanner on a synthetic test value. The redaction logic is
415        // string-content-agnostic.
416        let session_key = "FAKE_SESSION_KEY_SENTINEL";
417        let config = DeriveExecutionClientConfig {
418            wallet_address: Some("0xWALLET".to_string()),
419            session_key: Some(session_key.to_string()),
420            subaccount_id: Some(42),
421            ..DeriveExecutionClientConfig::default()
422        };
423        let debug = format!("{config:?}");
424        assert!(debug.contains("redacted"));
425        assert!(!debug.contains(session_key));
426        assert!(debug.contains("0xWALLET"));
427        assert!(debug.contains("42"));
428    }
429
430    #[rstest]
431    fn test_exec_config_debug_omits_session_key_marker_when_unset() {
432        let config = DeriveExecutionClientConfig::default();
433        let debug = format!("{config:?}");
434        assert!(!debug.contains("redacted"));
435        assert!(debug.contains("session_key: None"));
436    }
437}