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.core.nautilus_pyo3.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    /// WebSocket timeout in seconds.
51    #[builder(default = 30)]
52    pub ws_timeout_secs: 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
72impl Default for DeriveDataClientConfig {
73    fn default() -> Self {
74        Self::builder().build()
75    }
76}
77
78impl DeriveDataClientConfig {
79    #[must_use]
80    pub fn new() -> Self {
81        Self::default()
82    }
83
84    /// Returns the REST API base URL, respecting environment and overrides.
85    #[must_use]
86    pub fn rest_url(&self) -> String {
87        self.base_url_rest
88            .clone()
89            .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
90    }
91
92    /// Returns the WebSocket URL, respecting environment and overrides.
93    #[must_use]
94    pub fn ws_url(&self) -> String {
95        self.base_url_ws
96            .clone()
97            .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
98    }
99}
100
101/// Configuration for the Derive execution client.
102///
103/// `Debug` is implemented manually so that `session_key` is redacted; the
104/// derived `Debug` would leak the raw secret through any logger or Python
105/// `__repr__`.
106#[derive(Clone, Serialize, Deserialize, bon::Builder)]
107#[serde(default, deny_unknown_fields)]
108#[cfg_attr(
109    feature = "python",
110    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.derive", from_py_object)
111)]
112#[cfg_attr(
113    feature = "python",
114    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.derive")
115)]
116pub struct DeriveExecClientConfig {
117    /// Derive Chain smart-contract wallet address (`X-LYRAWALLET`). Falls back
118    /// to `DERIVE_WALLET_ADDRESS` (or `DERIVE_TESTNET_WALLET_ADDRESS` on
119    /// testnet) when unset.
120    pub wallet_address: Option<String>,
121    /// secp256k1 session-key private key in hex (with or without `0x` prefix).
122    /// Falls back to `DERIVE_SESSION_PRIVATE_KEY` (or
123    /// `DERIVE_TESTNET_SESSION_PRIVATE_KEY` on testnet) when unset.
124    pub session_key: Option<String>,
125    /// Subaccount identifier. Falls back to `DERIVE_SUBACCOUNT_ID` (or
126    /// `DERIVE_TESTNET_SUBACCOUNT_ID` on testnet) when unset.
127    pub subaccount_id: Option<u64>,
128    /// Override for the REST API base URL.
129    pub base_url_rest: Option<String>,
130    /// Override for the WebSocket URL.
131    pub base_url_ws: Option<String>,
132    /// Optional proxy URL for HTTP and WebSocket transports.
133    pub proxy_url: Option<String>,
134    /// The Derive environment to connect to.
135    #[builder(default)]
136    pub environment: DeriveEnvironment,
137    /// HTTP timeout in seconds.
138    #[builder(default = 10)]
139    pub http_timeout_secs: u64,
140    /// Maximum number of retry attempts for HTTP requests.
141    #[builder(default = 3)]
142    pub max_retries: u32,
143    /// Initial retry delay in milliseconds.
144    #[builder(default = 100)]
145    pub retry_delay_initial_ms: u64,
146    /// Maximum retry delay in milliseconds.
147    #[builder(default = 5000)]
148    pub retry_delay_max_ms: u64,
149    /// Per-contract USDC fee cap signed into every order.
150    pub max_fee_per_contract: Option<Decimal>,
151    /// WebSocket transport backend (defaults to `Sockudo` when that feature is enabled).
152    #[builder(default)]
153    pub transport_backend: TransportBackend,
154    /// Override for the EIP-712 domain separator. Falls back to the constant
155    /// for the configured environment when unset. The shipped constants are
156    /// placeholders that must be replaced or overridden before signing.
157    pub domain_separator: Option<String>,
158    /// Override for the EIP-712 action typehash. Falls back to the shipped
159    /// [`crate::common::consts::ACTION_TYPEHASH`] when unset.
160    pub action_typehash: Option<String>,
161    /// Override for the Trade module contract address. Falls back to the
162    /// shipped per-environment constant when unset.
163    pub trade_module_address: Option<String>,
164    /// Signature expiry TTL in seconds for normal orders and replaces (added
165    /// to the wall clock before signing). Must be greater than the venue
166    /// minimum ([`crate::common::consts::MIN_SIGNATURE_TTL`], 300s).
167    #[builder(default = 600)]
168    pub signature_expiry_secs: u64,
169    /// Slippage bound applied to market orders when deriving a worst-acceptable
170    /// limit price from the cached top-of-book quote. Expressed in basis points
171    /// (1 bp = 0.01%). Defaults to 50 bp = 0.5%.
172    #[builder(default = 50)]
173    pub market_order_slippage_bps: u32,
174    /// Maximum matching-engine requests per second for order writes sent over
175    /// the WebSocket (create/cancel/replace). Defaults to the Trader-tier limit
176    /// of 1 when unset; raise it for Market Maker accounts with higher
177    /// negotiated limits. See <https://docs.derive.xyz/reference/rate-limits>.
178    pub max_matching_requests_per_second: Option<u32>,
179}
180
181impl Default for DeriveExecClientConfig {
182    fn default() -> Self {
183        Self::builder().build()
184    }
185}
186
187impl Debug for DeriveExecClientConfig {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct(stringify!(DeriveExecClientConfig))
190            .field("wallet_address", &self.wallet_address)
191            .field(
192                "session_key",
193                &self.session_key.as_deref().map(|_| "***redacted***"),
194            )
195            .field("subaccount_id", &self.subaccount_id)
196            .field("base_url_rest", &self.base_url_rest)
197            .field("base_url_ws", &self.base_url_ws)
198            .field("proxy_url", &self.proxy_url)
199            .field("environment", &self.environment)
200            .field("http_timeout_secs", &self.http_timeout_secs)
201            .field("max_retries", &self.max_retries)
202            .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
203            .field("retry_delay_max_ms", &self.retry_delay_max_ms)
204            .field("max_fee_per_contract", &self.max_fee_per_contract)
205            .field("transport_backend", &self.transport_backend)
206            .field("domain_separator", &self.domain_separator)
207            .field("action_typehash", &self.action_typehash)
208            .field("trade_module_address", &self.trade_module_address)
209            .field("signature_expiry_secs", &self.signature_expiry_secs)
210            .field("market_order_slippage_bps", &self.market_order_slippage_bps)
211            .field(
212                "max_matching_requests_per_second",
213                &self.max_matching_requests_per_second,
214            )
215            .finish()
216    }
217}
218
219impl DeriveExecClientConfig {
220    #[must_use]
221    pub fn new() -> Self {
222        Self::default()
223    }
224
225    /// Returns true when wallet, session-key, and subaccount are all populated
226    /// **in this config**. Environment-variable fallbacks documented on the
227    /// individual fields are resolved at factory-construction time, not here;
228    /// callers that need a "credentials available anywhere" check should
229    /// inspect both this method and the relevant env vars.
230    #[must_use]
231    pub fn has_credentials(&self) -> bool {
232        self.wallet_address
233            .as_deref()
234            .is_some_and(|s| !s.trim().is_empty())
235            && self
236                .session_key
237                .as_deref()
238                .is_some_and(|s| !s.trim().is_empty())
239            && self.subaccount_id.is_some()
240    }
241
242    /// Returns the REST API base URL, respecting environment and overrides.
243    #[must_use]
244    pub fn rest_url(&self) -> String {
245        self.base_url_rest
246            .clone()
247            .unwrap_or_else(|| urls::rest_url(self.environment).to_string())
248    }
249
250    /// Returns the WebSocket URL, respecting environment and overrides.
251    #[must_use]
252    pub fn ws_url(&self) -> String {
253        self.base_url_ws
254            .clone()
255            .unwrap_or_else(|| urls::ws_url(self.environment).to_string())
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use rstest::rstest;
262
263    use super::*;
264
265    #[rstest]
266    fn test_data_config_defaults() {
267        let config = DeriveDataClientConfig::default();
268        assert_eq!(config.environment, DeriveEnvironment::Mainnet);
269        assert_eq!(config.http_timeout_secs, 10);
270        assert_eq!(config.ws_timeout_secs, 30);
271        assert_eq!(config.update_instruments_interval_mins, 60);
272        assert!(config.currencies.is_empty());
273        assert!(!config.include_expired);
274        assert!(config.auto_load_missing_instruments);
275    }
276
277    #[rstest]
278    fn test_data_config_urls_mainnet() {
279        let config = DeriveDataClientConfig::default();
280        assert!(config.rest_url().contains("api.lyra.finance"));
281        assert!(config.ws_url().contains("api.lyra.finance"));
282    }
283
284    #[rstest]
285    fn test_data_config_urls_testnet() {
286        let config = DeriveDataClientConfig {
287            environment: DeriveEnvironment::Testnet,
288            ..DeriveDataClientConfig::default()
289        };
290        assert!(config.rest_url().contains("demo"));
291        assert!(config.ws_url().contains("demo"));
292    }
293
294    #[rstest]
295    fn test_exec_config_defaults() {
296        let config = DeriveExecClientConfig::default();
297        assert_eq!(config.environment, DeriveEnvironment::Mainnet);
298        assert_eq!(config.http_timeout_secs, 10);
299        assert_eq!(config.max_retries, 3);
300        assert!(config.max_matching_requests_per_second.is_none());
301        assert!(!config.has_credentials());
302    }
303
304    #[rstest]
305    fn test_exec_config_has_credentials_requires_all_three_fields() {
306        let mut config = DeriveExecClientConfig {
307            wallet_address: Some("0x1234".to_string()),
308            ..DeriveExecClientConfig::default()
309        };
310        assert!(!config.has_credentials());
311
312        config.session_key = Some("0xabcd".to_string());
313        assert!(!config.has_credentials());
314
315        config.subaccount_id = Some(1);
316        assert!(config.has_credentials());
317    }
318
319    #[rstest]
320    fn test_exec_config_has_credentials_rejects_blank_strings() {
321        let config = DeriveExecClientConfig {
322            wallet_address: Some("   ".to_string()),
323            session_key: Some("0xabcd".to_string()),
324            subaccount_id: Some(1),
325            ..DeriveExecClientConfig::default()
326        };
327        assert!(!config.has_credentials());
328    }
329
330    #[rstest]
331    fn test_exec_config_debug_redacts_session_key() {
332        // Use a low-entropy sentinel rather than a hex private key so the
333        // assertion exercises Debug-redaction without tripping the secrets
334        // scanner on a synthetic test value. The redaction logic is
335        // string-content-agnostic.
336        let session_key = "FAKE_SESSION_KEY_SENTINEL";
337        let config = DeriveExecClientConfig {
338            wallet_address: Some("0xWALLET".to_string()),
339            session_key: Some(session_key.to_string()),
340            subaccount_id: Some(42),
341            ..DeriveExecClientConfig::default()
342        };
343        let debug = format!("{config:?}");
344        assert!(debug.contains("redacted"));
345        assert!(!debug.contains(session_key));
346        assert!(debug.contains("0xWALLET"));
347        assert!(debug.contains("42"));
348    }
349
350    #[rstest]
351    fn test_exec_config_debug_omits_session_key_marker_when_unset() {
352        let config = DeriveExecClientConfig::default();
353        let debug = format!("{config:?}");
354        assert!(!debug.contains("redacted"));
355        assert!(debug.contains("session_key: None"));
356    }
357}