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