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