1#[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#[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 pub private_key: Option<SecretString>,
54 #[builder(default)]
56 pub environment: HyperliquidEnvironment,
57 pub base_url_http: Option<String>,
59 pub base_url_ws: Option<String>,
61 pub proxy_url: Option<SecretString>,
63 #[builder(default = 60)]
65 pub http_timeout_secs: u64,
66 #[builder(default = 30)]
68 pub ws_timeout_secs: u64,
69 #[builder(default = 120)]
73 pub stale_stream_receive_timeout_secs: u64,
74 #[builder(default = 15)]
77 pub stream_health_check_interval_secs: u64,
78 #[builder(default = 60)]
80 pub stale_stream_warning_cooldown_secs: u64,
81 #[builder(default = false)]
84 pub stale_stream_recovery_enabled: bool,
85 #[builder(default = 120)]
88 pub stale_stream_recovery_cooldown_secs: u64,
89 #[builder(default = 3)]
92 pub stale_stream_max_targeted_resubscribes: u32,
93 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
98 pub book_snapshot_timeout_secs: u64,
99 #[builder(default = 60)]
103 pub update_instruments_interval_mins: u64,
104 #[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 #[must_use]
137 pub fn new() -> Self {
138 Self::default()
139 }
140
141 #[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 #[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 #[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#[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 #[builder(default = AccountId::from("HYPERLIQUID-001"))]
181 pub account_id: AccountId,
182 pub private_key: Option<SecretString>,
188 pub vault_address: Option<String>,
194 pub account_address: Option<String>,
201 #[builder(default)]
203 pub environment: HyperliquidEnvironment,
204 pub base_url_http: Option<String>,
206 pub base_url_ws: Option<String>,
208 pub base_url_exchange: Option<String>,
210 pub proxy_url: Option<SecretString>,
212 #[builder(default = 60)]
214 pub http_timeout_secs: u64,
215 #[builder(default = 3)]
218 pub max_retries: u32,
219 #[builder(default = 100)]
222 pub retry_delay_initial_ms: u64,
223 #[builder(default = 5000)]
226 pub retry_delay_max_ms: u64,
227 #[builder(default = true)]
230 pub normalize_prices: bool,
231 #[builder(default = 50)]
235 pub market_order_slippage_bps: u32,
236 #[builder(default = true)]
238 pub include_builder_attribution: bool,
239 #[builder(default)]
242 pub transport_backend: TransportBackend,
243 #[builder(default = 10)]
245 pub ws_post_timeout_secs: u64,
246 #[builder(default = 0)]
251 pub outcome_settlement_poll_secs: u64,
252 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 #[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 #[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 #[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}