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_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/// Configuration for the Derive data client.
27#[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    /// The Derive environment to connect to.
39    #[builder(default)]
40    pub environment: DeriveEnvironment,
41    /// Override for the REST API base URL.
42    pub base_url_rest: Option<String>,
43    /// Override for the WebSocket URL.
44    pub base_url_ws: Option<String>,
45    /// Optional proxy URL for HTTP and WebSocket transports.
46    pub proxy_url: Option<SecretString>,
47    /// HTTP timeout in seconds.
48    #[builder(default = 10)]
49    pub http_timeout_secs: u64,
50    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
51    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
52    pub ws_timeout_secs: Option<u64>,
53    /// Interval for refreshing instruments in minutes.
54    #[builder(default = 60)]
55    pub update_instruments_interval_mins: u64,
56    /// Underlying currencies to load on connect. Empty means lazy-load by
57    /// instrument ID when subscribing.
58    #[builder(default)]
59    pub currencies: Vec<String>,
60    /// Whether instrument loading includes expired instruments.
61    #[builder(default)]
62    pub include_expired: bool,
63    /// Whether subscriptions may fetch missing instruments before sending the
64    /// WebSocket request.
65    #[builder(default = true)]
66    pub auto_load_missing_instruments: bool,
67    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
68    #[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    /// Returns the REST API base URL, respecting environment and overrides.
99    #[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    /// Returns the WebSocket URL, respecting environment and overrides.
107    #[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/// Configuration for the Derive execution client.
116#[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    /// Account identifier for the execution client.
128    #[builder(default = AccountId::from("DERIVE-001"))]
129    pub account_id: AccountId,
130    /// Derive Chain smart-contract wallet address (`X-LYRAWALLET`). Falls back
131    /// to `DERIVE_WALLET_ADDRESS` (or `DERIVE_TESTNET_WALLET_ADDRESS` on
132    /// testnet) when unset.
133    pub wallet_address: Option<String>,
134    /// secp256k1 session-key private key in hex (with or without `0x` prefix).
135    /// Falls back to `DERIVE_SESSION_PRIVATE_KEY` (or
136    /// `DERIVE_TESTNET_SESSION_PRIVATE_KEY` on testnet) when unset.
137    pub session_key: Option<SecretString>,
138    /// Subaccount identifier. Falls back to `DERIVE_SUBACCOUNT_ID` (or
139    /// `DERIVE_TESTNET_SUBACCOUNT_ID` on testnet) when unset.
140    pub subaccount_id: Option<u64>,
141    /// The Derive environment to connect to.
142    #[builder(default)]
143    pub environment: DeriveEnvironment,
144    /// Override for the REST API base URL.
145    pub base_url_rest: Option<String>,
146    /// Override for the WebSocket URL.
147    pub base_url_ws: Option<String>,
148    /// Optional proxy URL for HTTP and WebSocket transports.
149    pub proxy_url: Option<SecretString>,
150    /// HTTP timeout in seconds.
151    #[builder(default = 10)]
152    pub http_timeout_secs: u64,
153    /// Maximum number of retry attempts for HTTP requests.
154    #[builder(default = 3)]
155    pub max_retries: u32,
156    /// Initial retry delay in milliseconds.
157    #[builder(default = 100)]
158    pub retry_delay_initial_ms: u64,
159    /// Maximum retry delay in milliseconds.
160    #[builder(default = 5000)]
161    pub retry_delay_max_ms: u64,
162    /// Optional per-operation WebSocket timeout in seconds (login, subscribe,
163    /// reads, writes). When unset, the low-level `WS_REQUEST_TIMEOUT` applies.
164    pub ws_timeout_secs: Option<u64>,
165    /// Per-contract USDC fee cap signed into every order. Required for
166    /// execution and must be greater than zero.
167    pub max_fee_per_contract: Option<Decimal>,
168    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
169    #[builder(default)]
170    pub transport_backend: TransportBackend,
171    /// Override for the EIP-712 domain separator. Falls back to the constant
172    /// for the configured environment when unset. The shipped constants are
173    /// placeholders that must be replaced or overridden before signing.
174    pub domain_separator: Option<String>,
175    /// Override for the EIP-712 action typehash. Falls back to the shipped
176    /// [`crate::common::consts::ACTION_TYPEHASH`] when unset.
177    pub action_typehash: Option<String>,
178    /// Override for the Trade module contract address. Falls back to the
179    /// shipped per-environment constant when unset.
180    pub trade_module_address: Option<String>,
181    /// Signature expiry TTL in seconds for normal orders and replaces (added
182    /// to the wall clock before signing). Must be greater than the venue
183    /// minimum ([`crate::common::consts::MIN_SIGNATURE_TTL`], 300s).
184    #[builder(default = 600)]
185    pub signature_expiry_secs: u64,
186    /// Slippage bound applied to market orders when deriving a worst-acceptable
187    /// limit price from the cached top-of-book quote. Expressed in basis points
188    /// (1 bp = 0.01%). Defaults to 50 bp = 0.5%.
189    #[builder(default = 50)]
190    pub market_order_slippage_bps: u32,
191    /// Maximum matching-engine requests per second for order writes sent over
192    /// the WebSocket (create/cancel/replace). Defaults to the Trader-tier limit
193    /// of 1 when unset; raise it for Market Maker accounts with higher
194    /// negotiated limits. See <https://docs.derive.xyz/reference/rate-limits>.
195    pub max_matching_requests_per_second: Option<u32>,
196    /// Maximum per-instrument matching requests per second for instrument-
197    /// scoped order writes sent over the WebSocket. Defaults to the Trader-tier
198    /// limit of 1 when unset; raise it for Market Maker accounts with higher
199    /// negotiated per-instrument limits. This allowance is independent of
200    /// `max_matching_requests_per_second`, which never inflates it. See
201    /// <https://docs.derive.xyz/reference/rate-limits>.
202    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    /// Returns true when wallet, session-key, and subaccount are all populated
242    /// **in this config**. Environment-variable fallbacks documented on the
243    /// individual fields are resolved at factory-construction time, not here;
244    /// callers that need a "credentials available anywhere" check should
245    /// inspect both this method and the relevant env vars.
246    #[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    /// Validates execution configuration invariants.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error when `max_fee_per_contract` is missing or not greater
264    /// than zero.
265    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    /// Returns the REST API base URL, respecting environment and overrides.
277    #[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    /// Returns the WebSocket URL, respecting environment and overrides.
285    #[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        // Use a low-entropy sentinel rather than a hex private key so the
372        // assertion exercises Debug-redaction without tripping the secrets
373        // scanner on a synthetic test value. The redaction logic is
374        // string-content-agnostic.
375        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}