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