Skip to main content

nautilus_hyperliquid/
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 Hyperliquid adapter.
17
18use std::fmt::Debug;
19
20use nautilus_model::identifiers::AccountId;
21use nautilus_network::websocket::TransportBackend;
22use serde::{Deserialize, Serialize};
23
24use crate::common::{
25    consts::{info_url, ws_url},
26    enums::HyperliquidEnvironment,
27};
28
29/// Configuration for the Hyperliquid data client.
30///
31/// The `stale_stream_*` options control the stream health monitor. With recovery
32/// enabled, a stale stream is warned about first, targeted-resubscribed once per
33/// recovery cooldown (preserving its original `l2Book` options), and escalated to
34/// a full WebSocket reconnect after `stale_stream_max_targeted_resubscribes`
35/// failed attempts; fresh data resets the ladder. See the Hyperliquid integration
36/// guide ("Stream health and recovery") for details.
37#[derive(Clone, Serialize, Deserialize, bon::Builder)]
38#[serde(default, deny_unknown_fields)]
39#[cfg_attr(
40    feature = "python",
41    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
42)]
43#[cfg_attr(
44    feature = "python",
45    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
46)]
47pub struct HyperliquidDataClientConfig {
48    /// Optional private key for authenticated endpoints.
49    pub private_key: Option<String>,
50    /// Override for the WebSocket URL.
51    pub base_url_ws: Option<String>,
52    /// Override for the HTTP info URL.
53    pub base_url_http: Option<String>,
54    /// Optional proxy URL for HTTP and WebSocket transports.
55    pub proxy_url: Option<String>,
56    /// The target environment (mainnet or testnet).
57    #[builder(default)]
58    pub environment: HyperliquidEnvironment,
59    /// HTTP timeout in seconds.
60    #[builder(default = 60)]
61    pub http_timeout_secs: u64,
62    /// WebSocket timeout in seconds.
63    #[builder(default = 30)]
64    pub ws_timeout_secs: u64,
65    /// Receive-age threshold in seconds for warning about stale market-data streams.
66    /// Choose a value above the instrument's expected quiet period.
67    /// Set to 0 to disable the stream health monitor.
68    #[builder(default = 120)]
69    pub stale_stream_receive_timeout_secs: u64,
70    /// Interval in seconds for running market-data stream health checks.
71    /// Set to 0 to disable the stream health monitor.
72    #[builder(default = 15)]
73    pub stream_health_check_interval_secs: u64,
74    /// Cooldown in seconds between stale warnings for the same market-data stream.
75    #[builder(default = 60)]
76    pub stale_stream_warning_cooldown_secs: u64,
77    /// Enables automated stale-stream recovery. Off by default: the stream health
78    /// monitor warns only and never changes subscriptions.
79    #[builder(default = false)]
80    pub stale_stream_recovery_enabled: bool,
81    /// Cooldown in seconds between recovery actions for the same market-data stream.
82    /// Must be positive for recovery to run.
83    #[builder(default = 120)]
84    pub stale_stream_recovery_cooldown_secs: u64,
85    /// Targeted resubscribe attempts for a stale stream before escalating to a
86    /// full WebSocket reconnect.
87    #[builder(default = 3)]
88    pub stale_stream_max_targeted_resubscribes: u32,
89    /// Interval for refreshing instruments in minutes.
90    #[builder(default = 60)]
91    pub update_instruments_interval_mins: u64,
92    /// WebSocket transport backend (`Sockudo` by default; `Tungstenite` when
93    /// the `transport-sockudo` feature is disabled).
94    #[builder(default)]
95    pub transport_backend: TransportBackend,
96}
97
98#[cfg(feature = "python")]
99nautilus_core::impl_pyo3_config_getters!(HyperliquidDataClientConfig {
100    environment: HyperliquidEnvironment,
101    base_url_ws: Option<String>,
102    base_url_http: Option<String>,
103    http_timeout_secs: u64,
104    ws_timeout_secs: u64,
105    update_instruments_interval_mins: u64,
106    transport_backend: TransportBackend,
107    stale_stream_receive_timeout_secs: u64,
108    stream_health_check_interval_secs: u64,
109    stale_stream_warning_cooldown_secs: u64,
110    stale_stream_recovery_enabled: bool,
111    stale_stream_recovery_cooldown_secs: u64,
112    stale_stream_max_targeted_resubscribes: u32,
113});
114
115impl Default for HyperliquidDataClientConfig {
116    fn default() -> Self {
117        Self::builder().build()
118    }
119}
120
121impl HyperliquidDataClientConfig {
122    /// Creates a new configuration with default settings.
123    #[must_use]
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Returns `true` when private key is populated and non-empty.
129    #[must_use]
130    pub fn has_credentials(&self) -> bool {
131        self.private_key
132            .as_deref()
133            .is_some_and(|s| !s.trim().is_empty())
134    }
135
136    /// Returns the WebSocket URL, respecting the environment and overrides.
137    #[must_use]
138    pub fn ws_url(&self) -> String {
139        self.base_url_ws
140            .clone()
141            .unwrap_or_else(|| ws_url(self.environment).to_string())
142    }
143
144    /// Returns the HTTP info URL, respecting the environment and overrides.
145    #[must_use]
146    pub fn http_url(&self) -> String {
147        self.base_url_http
148            .clone()
149            .unwrap_or_else(|| info_url(self.environment).to_string())
150    }
151}
152
153impl Debug for HyperliquidDataClientConfig {
154    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        formatter
156            .debug_struct(stringify!(HyperliquidDataClientConfig))
157            .field(
158                "private_key",
159                &self.private_key.as_ref().map(|_| "[REDACTED]"),
160            )
161            .field("base_url_ws", &self.base_url_ws)
162            .field("base_url_http", &self.base_url_http)
163            .field("proxy_url", &self.proxy_url)
164            .field("environment", &self.environment)
165            .field("http_timeout_secs", &self.http_timeout_secs)
166            .field("ws_timeout_secs", &self.ws_timeout_secs)
167            .field(
168                "stale_stream_receive_timeout_secs",
169                &self.stale_stream_receive_timeout_secs,
170            )
171            .field(
172                "stream_health_check_interval_secs",
173                &self.stream_health_check_interval_secs,
174            )
175            .field(
176                "stale_stream_warning_cooldown_secs",
177                &self.stale_stream_warning_cooldown_secs,
178            )
179            .field(
180                "stale_stream_recovery_enabled",
181                &self.stale_stream_recovery_enabled,
182            )
183            .field(
184                "stale_stream_recovery_cooldown_secs",
185                &self.stale_stream_recovery_cooldown_secs,
186            )
187            .field(
188                "stale_stream_max_targeted_resubscribes",
189                &self.stale_stream_max_targeted_resubscribes,
190            )
191            .field(
192                "update_instruments_interval_mins",
193                &self.update_instruments_interval_mins,
194            )
195            .field("transport_backend", &self.transport_backend)
196            .finish()
197    }
198}
199
200/// Configuration for the Hyperliquid execution client.
201#[derive(Clone, Serialize, Deserialize, bon::Builder)]
202#[serde(default, deny_unknown_fields)]
203#[cfg_attr(
204    feature = "python",
205    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
206)]
207#[cfg_attr(
208    feature = "python",
209    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
210)]
211pub struct HyperliquidExecutionClientConfig {
212    /// Account identifier for the execution client.
213    #[builder(default = AccountId::from("HYPERLIQUID-001"))]
214    pub account_id: AccountId,
215    /// Private key for signing transactions.
216    ///
217    /// If not provided, falls back to environment variable:
218    /// - Mainnet: `HYPERLIQUID_PK`
219    /// - Testnet: `HYPERLIQUID_TESTNET_PK`
220    pub private_key: Option<String>,
221    /// Optional vault address for vault operations.
222    ///
223    /// If not provided, falls back to environment variable:
224    /// - Mainnet: `HYPERLIQUID_VAULT`
225    /// - Testnet: `HYPERLIQUID_TESTNET_VAULT`
226    pub vault_address: Option<String>,
227    /// Optional main account address when using an agent wallet (API sub-key).
228    /// When set, used for balance queries, position reports, and WS subscriptions
229    /// instead of the address derived from the private key.
230    ///
231    /// If not provided and no explicit vault address is set, falls back to
232    /// the `HYPERLIQUID_ACCOUNT_ADDRESS` environment variable.
233    pub account_address: Option<String>,
234    /// Override for the WebSocket URL.
235    pub base_url_ws: Option<String>,
236    /// Override for the HTTP info URL.
237    pub base_url_http: Option<String>,
238    /// Override for the exchange API URL.
239    pub base_url_exchange: Option<String>,
240    /// Optional proxy URL for HTTP and WebSocket transports.
241    pub proxy_url: Option<String>,
242    /// The target environment (mainnet or testnet).
243    #[builder(default)]
244    pub environment: HyperliquidEnvironment,
245    /// HTTP timeout in seconds.
246    #[builder(default = 60)]
247    pub http_timeout_secs: u64,
248    /// Maximum number of retry attempts for HTTP requests.
249    #[builder(default = 3)]
250    pub max_retries: u32,
251    /// Initial retry delay in milliseconds.
252    #[builder(default = 100)]
253    pub retry_delay_initial_ms: u64,
254    /// Maximum retry delay in milliseconds.
255    #[builder(default = 5000)]
256    pub retry_delay_max_ms: u64,
257    /// When true, normalize order prices to 5 significant figures
258    /// before submission (Hyperliquid requirement).
259    #[builder(default = true)]
260    pub normalize_prices: bool,
261    /// Slippage buffer in basis points applied to MARKET orders and
262    /// stop-to-limit trigger derivations. Can be overridden per-order via
263    /// `SubmitOrder.params["market_order_slippage_bps"]`.
264    #[builder(default = 50)]
265    pub market_order_slippage_bps: u32,
266    /// If true, attach Nautilus builder attribution to eligible mainnet orders.
267    #[builder(default = true)]
268    pub include_builder_attribution: bool,
269    /// WebSocket transport backend (`Sockudo` by default; `Tungstenite` when
270    /// the `transport-sockudo` feature is disabled).
271    #[builder(default)]
272    pub transport_backend: TransportBackend,
273    /// Timeout in seconds for WebSocket post trading requests.
274    #[builder(default = 10)]
275    pub ws_post_timeout_secs: u64,
276    /// Poll interval in seconds for `outcomeMeta` settlement detection.
277    /// Disabled by default; venue `Settlement` fills drive HIP-4 settlement
278    /// through the standard user-fills stream. Set to a non-zero value only
279    /// when the venue fill stream is unavailable.
280    #[builder(default = 0)]
281    pub outcome_settlement_poll_secs: u64,
282}
283
284#[cfg(feature = "python")]
285nautilus_core::impl_pyo3_config_getters!(HyperliquidExecutionClientConfig {
286    account_id: AccountId,
287    vault_address: Option<String>,
288    account_address: Option<String>,
289    environment: HyperliquidEnvironment,
290    base_url_ws: Option<String>,
291    base_url_http: Option<String>,
292    base_url_exchange: Option<String>,
293    http_timeout_secs: u64,
294    max_retries: u32,
295    retry_delay_initial_ms: u64,
296    retry_delay_max_ms: u64,
297    normalize_prices: bool,
298    market_order_slippage_bps: u32,
299    include_builder_attribution: bool,
300    ws_post_timeout_secs: u64,
301    transport_backend: TransportBackend,
302});
303
304impl Default for HyperliquidExecutionClientConfig {
305    fn default() -> Self {
306        Self::builder().build()
307    }
308}
309
310impl HyperliquidExecutionClientConfig {
311    /// Returns `true` when private key is populated and non-empty.
312    #[must_use]
313    pub fn has_credentials(&self) -> bool {
314        self.private_key
315            .as_deref()
316            .is_some_and(|s| !s.trim().is_empty())
317    }
318
319    /// Returns the WebSocket URL, respecting the environment and overrides.
320    #[must_use]
321    pub fn ws_url(&self) -> String {
322        self.base_url_ws
323            .clone()
324            .unwrap_or_else(|| ws_url(self.environment).to_string())
325    }
326
327    /// Returns the HTTP info URL, respecting the environment and overrides.
328    #[must_use]
329    pub fn http_url(&self) -> String {
330        self.base_url_http
331            .clone()
332            .unwrap_or_else(|| info_url(self.environment).to_string())
333    }
334}
335
336impl Debug for HyperliquidExecutionClientConfig {
337    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        formatter
339            .debug_struct(stringify!(HyperliquidExecutionClientConfig))
340            .field("account_id", &self.account_id)
341            .field(
342                "private_key",
343                &self.private_key.as_ref().map(|_| "[REDACTED]"),
344            )
345            .field("vault_address", &self.vault_address)
346            .field("account_address", &self.account_address)
347            .field("base_url_ws", &self.base_url_ws)
348            .field("base_url_http", &self.base_url_http)
349            .field("base_url_exchange", &self.base_url_exchange)
350            .field("proxy_url", &self.proxy_url)
351            .field("environment", &self.environment)
352            .field("http_timeout_secs", &self.http_timeout_secs)
353            .field("max_retries", &self.max_retries)
354            .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
355            .field("retry_delay_max_ms", &self.retry_delay_max_ms)
356            .field("normalize_prices", &self.normalize_prices)
357            .field("market_order_slippage_bps", &self.market_order_slippage_bps)
358            .field(
359                "include_builder_attribution",
360                &self.include_builder_attribution,
361            )
362            .field("transport_backend", &self.transport_backend)
363            .field("ws_post_timeout_secs", &self.ws_post_timeout_secs)
364            .field(
365                "outcome_settlement_poll_secs",
366                &self.outcome_settlement_poll_secs,
367            )
368            .finish()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use rstest::rstest;
375
376    use super::*;
377
378    #[rstest]
379    fn test_exec_config_default_account_address_is_none() {
380        let config = HyperliquidExecutionClientConfig::default();
381        assert!(config.account_address.is_none());
382    }
383
384    #[rstest]
385    fn test_exec_config_with_account_address() {
386        let config = HyperliquidExecutionClientConfig {
387            account_address: Some("0x1234".to_string()),
388            ..HyperliquidExecutionClientConfig::default()
389        };
390        assert_eq!(config.account_address.as_deref(), Some("0x1234"));
391    }
392
393    #[rstest]
394    fn test_data_config_toml_minimal() {
395        let config: HyperliquidDataClientConfig = toml::from_str(
396            r#"
397environment = "testnet"
398http_timeout_secs = 30
399update_instruments_interval_mins = 10
400transport_backend = "tungstenite"
401"#,
402        )
403        .unwrap();
404
405        assert_eq!(config.environment, HyperliquidEnvironment::Testnet);
406        assert_eq!(config.http_timeout_secs, 30);
407        assert_eq!(config.update_instruments_interval_mins, 10);
408        assert_eq!(config.transport_backend, TransportBackend::Tungstenite);
409        assert_eq!(config.stale_stream_receive_timeout_secs, 120);
410        assert_eq!(config.stream_health_check_interval_secs, 15);
411        assert_eq!(config.stale_stream_warning_cooldown_secs, 60);
412        assert!(!config.stale_stream_recovery_enabled);
413        assert_eq!(config.stale_stream_recovery_cooldown_secs, 120);
414        assert_eq!(config.stale_stream_max_targeted_resubscribes, 3);
415    }
416
417    #[rstest]
418    fn test_data_config_toml_stale_stream_settings() {
419        let config: HyperliquidDataClientConfig = toml::from_str(
420            "
421stale_stream_receive_timeout_secs = 30
422stream_health_check_interval_secs = 5
423stale_stream_warning_cooldown_secs = 20
424stale_stream_recovery_enabled = true
425stale_stream_recovery_cooldown_secs = 45
426stale_stream_max_targeted_resubscribes = 5
427",
428        )
429        .unwrap();
430
431        assert_eq!(config.stale_stream_receive_timeout_secs, 30);
432        assert_eq!(config.stream_health_check_interval_secs, 5);
433        assert_eq!(config.stale_stream_warning_cooldown_secs, 20);
434        assert!(config.stale_stream_recovery_enabled);
435        assert_eq!(config.stale_stream_recovery_cooldown_secs, 45);
436        assert_eq!(config.stale_stream_max_targeted_resubscribes, 5);
437    }
438
439    #[rstest]
440    fn test_exec_config_toml_empty_uses_defaults() {
441        let config: HyperliquidExecutionClientConfig = toml::from_str("").unwrap();
442        let expected = HyperliquidExecutionClientConfig::default();
443
444        assert_eq!(config.environment, expected.environment);
445        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
446        assert_eq!(config.max_retries, expected.max_retries);
447        assert_eq!(config.normalize_prices, expected.normalize_prices);
448        assert_eq!(
449            config.market_order_slippage_bps,
450            expected.market_order_slippage_bps,
451        );
452        assert_eq!(
453            config.include_builder_attribution,
454            expected.include_builder_attribution,
455        );
456        assert_eq!(config.transport_backend, expected.transport_backend);
457        assert_eq!(config.ws_post_timeout_secs, expected.ws_post_timeout_secs);
458        assert_eq!(
459            config.outcome_settlement_poll_secs,
460            expected.outcome_settlement_poll_secs,
461        );
462    }
463
464    #[rstest]
465    fn test_exec_config_toml_include_builder_attribution_false() {
466        let config: HyperliquidExecutionClientConfig =
467            toml::from_str("include_builder_attribution = false").unwrap();
468
469        assert!(!config.include_builder_attribution);
470    }
471
472    #[rstest]
473    fn test_data_config_debug_redacts_private_key() {
474        let config = HyperliquidDataClientConfig {
475            private_key: Some(
476                "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
477            ),
478            ..HyperliquidDataClientConfig::default()
479        };
480        let debug = format!("{config:?}");
481
482        assert!(debug.contains("[REDACTED]"));
483        assert!(!debug.contains("0123456789abcdef"));
484    }
485
486    #[rstest]
487    fn test_exec_config_debug_redacts_private_key() {
488        let config = HyperliquidExecutionClientConfig {
489            private_key: Some(
490                "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
491            ),
492            ..HyperliquidExecutionClientConfig::default()
493        };
494        let debug = format!("{config:?}");
495
496        assert!(debug.contains("[REDACTED]"));
497        assert!(!debug.contains("0123456789abcdef"));
498    }
499}