1use std::{
17 str::FromStr,
18 sync::{
19 Arc,
20 atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
21 },
22 time::Duration,
23};
24
25use ahash::{AHashMap, AHashSet};
26use anyhow::Context;
27use arc_swap::ArcSwap;
28use dashmap::DashMap;
29use nautilus_common::cache::{InstrumentLookupError, fifo::FifoCacheMap};
30#[cfg(test)]
31use nautilus_common::live::get_runtime;
32#[cfg(test)]
33use nautilus_core::string::secret::REDACTED;
34use nautilus_core::{AtomicMap, string::secret::SecretString};
35use nautilus_live::{
36 SocketControl,
37 book::snapshot::SnapshotGate,
38 task::{SharedTaskSlot, TaskJoinOutcome},
39};
40use nautilus_model::{
41 data::BarType,
42 enums::{OrderSide, OrderType, TimeInForce},
43 identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
44 instruments::{Instrument, InstrumentAny},
45 orders::{Order, OrderAny},
46 reports::OrderStatusReport,
47 types::{Price, Quantity},
48};
49use nautilus_network::{
50 ConnectionUrl, SocketStateSink,
51 http::create_standard_nautilus_headers,
52 mode::ConnectionMode,
53 websocket::{
54 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
55 channel_message_handler,
56 },
57};
58use parking_lot::Mutex;
59use rust_decimal::Decimal;
60use tokio_util::sync::CancellationToken;
61use ustr::Ustr;
62
63use crate::{
64 common::{
65 consts::{HEARTBEAT_INTERVAL, HTTP_TIMEOUT, ws_url},
66 enums::{HyperliquidBarInterval, HyperliquidEnvironment},
67 parse::{
68 bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
69 determine_order_list_grouping, extract_error_message, extract_inner_error,
70 extract_inner_errors, normalize_or_validate_wire_price,
71 order_to_hyperliquid_request_with_optional_decimals, round_to_sig_figs,
72 time_in_force_to_hyperliquid_tif,
73 },
74 },
75 http::{
76 client::HyperliquidHttpClient,
77 error::{Error as HyperliquidError, Result as HyperliquidResult},
78 models::{
79 HyperliquidExchangeAction, HyperliquidExchangeCancelByCloidRequest,
80 HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
81 HyperliquidExchangeLimitParams, HyperliquidExchangeModifyOrderRequest,
82 HyperliquidExchangeModifyTarget, HyperliquidExchangeOrderKind,
83 HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeResponse,
84 HyperliquidExchangeTif, HyperliquidExchangeTpSl, HyperliquidExchangeTriggerParams,
85 RESPONSE_STATUS_OK,
86 },
87 rate_limits::exec_action_weight,
88 },
89 websocket::{
90 book::{
91 BookStreamOptions, BookStreamRegistration, BookStreamRegistry, BookStreamRelease,
92 BookStreamUse,
93 },
94 enums::HyperliquidWsChannel,
95 error::HyperliquidWsError,
96 handler::{FeedHandler, HandlerCommand},
97 messages::{
98 NautilusWsMessage, PostRequest, PostResponse, PostResponsePayload, SubscriptionRequest,
99 },
100 post::{PostIds, PostRouter},
101 rate_limits::{WebSocketRateLimits, shared_websocket_limits},
102 trades::{TradeStreamRegistry, TradeStreamUse},
103 },
104};
105
106static NEXT_WEBSOCKET_CLIENT_ID: AtomicU64 = AtomicU64::new(1);
107
108pub(super) const CLOID_CACHE_CAPACITY: usize = 10_000;
111
112pub(super) type CloidCache = Arc<Mutex<FifoCacheMap<Ustr, ClientOrderId, CLOID_CACHE_CAPACITY>>>;
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub(super) enum AssetContextDataType {
118 MarkPrice,
119 IndexPrice,
120 FundingRate,
121 OpenInterest,
122}
123
124#[derive(Debug)]
129pub struct HyperliquidWebSocketClient {
130 url: ConnectionUrl,
131 connection_mode: Arc<ArcSwap<AtomicU8>>,
132 signal: Arc<AtomicBool>,
133 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
134 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
135 auth_tracker: AuthTracker,
136 subscriptions: SubscriptionState,
137 book_streams: BookStreamRegistry,
138 trade_streams: TradeStreamRegistry,
139 trade_stream_lock: Arc<Mutex<()>>,
140 quote_streams: Arc<DashMap<Ustr, ()>>,
141 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
142 bar_types: Arc<AtomicMap<String, BarType>>,
143 asset_context_subs: Arc<DashMap<Ustr, AHashSet<AssetContextDataType>>>,
144 all_dex_asset_ctxs_instrument_ids: Arc<AtomicMap<Ustr, Vec<Option<InstrumentId>>>>,
145 cloid_cache: CloidCache,
146 post_router: Arc<PostRouter>,
147 post_ids: Arc<PostIds>,
148 rate_limits: Arc<WebSocketRateLimits>,
149 client_id: u64,
150 connection_permit: Arc<Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>,
151 post_timeout: Duration,
152 task_handle: Arc<SharedTaskSlot<()>>,
153 connect_lock: Arc<tokio::sync::Mutex<()>>,
154 account_id: Option<AccountId>,
155 transport_backend: TransportBackend,
156 proxy_url: Option<SecretString>,
157 socket_sink: Option<SocketStateSink>,
158 socket_control: Option<SocketControl>,
159}
160
161impl Clone for HyperliquidWebSocketClient {
162 fn clone(&self) -> Self {
163 Self {
164 url: self.url.clone(),
165 connection_mode: Arc::clone(&self.connection_mode),
166 signal: Arc::clone(&self.signal),
167 cmd_tx: Arc::clone(&self.cmd_tx),
168 out_rx: None,
169 auth_tracker: self.auth_tracker.clone(),
170 subscriptions: self.subscriptions.clone(),
171 book_streams: self.book_streams.clone(),
172 trade_streams: self.trade_streams.clone(),
173 trade_stream_lock: Arc::clone(&self.trade_stream_lock),
174 quote_streams: Arc::clone(&self.quote_streams),
175 instruments: Arc::clone(&self.instruments),
176 bar_types: Arc::clone(&self.bar_types),
177 asset_context_subs: Arc::clone(&self.asset_context_subs),
178 all_dex_asset_ctxs_instrument_ids: Arc::clone(&self.all_dex_asset_ctxs_instrument_ids),
179 cloid_cache: Arc::clone(&self.cloid_cache),
180 post_router: Arc::clone(&self.post_router),
181 post_ids: Arc::clone(&self.post_ids),
182 rate_limits: Arc::clone(&self.rate_limits),
183 client_id: self.client_id,
184 connection_permit: Arc::clone(&self.connection_permit),
185 post_timeout: self.post_timeout,
186 task_handle: Arc::clone(&self.task_handle),
187 connect_lock: Arc::clone(&self.connect_lock),
188 account_id: self.account_id,
189 transport_backend: self.transport_backend,
190 proxy_url: self.proxy_url.clone(),
191 socket_sink: self.socket_sink.clone(),
192 socket_control: self.socket_control.clone(),
193 }
194 }
195}
196
197impl HyperliquidWebSocketClient {
198 pub fn new(
206 url: Option<String>,
207 environment: HyperliquidEnvironment,
208 account_id: Option<AccountId>,
209 transport_backend: TransportBackend,
210 proxy_url: Option<String>,
211 ) -> Self {
212 let url = url.unwrap_or_else(|| ws_url(environment).to_string());
213 let rate_limits = shared_websocket_limits(environment, &url, proxy_url.as_deref());
214 let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
215 ConnectionMode::Closed as u8,
216 ))));
217 Self {
218 url: url.into(),
219 connection_mode,
220 signal: Arc::new(AtomicBool::new(false)),
221 auth_tracker: AuthTracker::new(),
222 subscriptions: SubscriptionState::new(':'),
223 book_streams: BookStreamRegistry::default(),
224 trade_streams: TradeStreamRegistry::default(),
225 trade_stream_lock: Arc::new(Mutex::new(())),
226 quote_streams: Arc::new(DashMap::new()),
227 instruments: Arc::new(AtomicMap::new()),
228 bar_types: Arc::new(AtomicMap::new()),
229 asset_context_subs: Arc::new(DashMap::new()),
230 all_dex_asset_ctxs_instrument_ids: Arc::new(AtomicMap::new()),
231 cloid_cache: Arc::new(Mutex::new(FifoCacheMap::new())),
232 post_router: PostRouter::with_inflight(Arc::clone(&rate_limits.post_slots)),
233 post_ids: Arc::new(PostIds::new(1)),
234 rate_limits,
235 client_id: NEXT_WEBSOCKET_CLIENT_ID.fetch_add(1, Ordering::Relaxed),
236 connection_permit: Arc::new(Mutex::new(None)),
237 post_timeout: HTTP_TIMEOUT,
238 cmd_tx: {
239 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
241 Arc::new(tokio::sync::RwLock::new(tx))
242 },
243 out_rx: None,
244 task_handle: Arc::new(SharedTaskSlot::new()),
245 connect_lock: Arc::new(tokio::sync::Mutex::new(())),
246 account_id,
247 transport_backend,
248 proxy_url: proxy_url.map(SecretString::from),
249 socket_sink: None,
250 socket_control: None,
251 }
252 }
253
254 #[must_use]
256 pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
257 self.socket_sink = Some(state_sink);
258 self
259 }
260
261 #[must_use]
263 pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
264 self.socket_control = Some(control);
265 self
266 }
267
268 pub async fn connect(&mut self) -> anyhow::Result<()> {
270 let connect_lock = Arc::clone(&self.connect_lock);
271 let _guard = connect_lock.lock().await;
272 self.connect_locked().await
273 }
274
275 async fn connect_locked(&mut self) -> anyhow::Result<()> {
276 if self.is_active() {
277 log::warn!("WebSocket already connected");
278 return Ok(());
279 }
280
281 if !self.task_handle.is_empty() {
282 self.disconnect_locked().await?;
283 }
284
285 if self.connection_permit.lock().is_none() {
286 let permit = Arc::clone(&self.rate_limits.connection_slots)
287 .try_acquire_owned()
288 .map_err(|_| {
289 anyhow::anyhow!(
290 "Hyperliquid allows at most {} WebSocket connections per route",
291 crate::common::consts::HYPERLIQUID_WS_CONNECTIONS_MAX,
292 )
293 })?;
294 *self.connection_permit.lock() = Some(permit);
295 }
296
297 self.book_streams.clear();
300
301 let (message_handler, raw_rx) = channel_message_handler();
302 let headers = create_standard_nautilus_headers();
303
304 let cfg = WebSocketConfig {
305 url: self.url.expose().to_owned(),
306 headers,
307 heartbeat_interval_secs: None,
308 heartbeat_payload: None,
309 connect_timeout_ms: Some(15_000),
310 reconnect_delay_initial_ms: Some(250),
311 reconnect_delay_max_ms: Some(5_000),
312 reconnect_backoff_factor: Some(2.0),
313 reconnect_jitter_ms: Some(200),
314 reconnect_max_attempts: None,
315 heartbeat_timeout_secs: Some(HEARTBEAT_INTERVAL.as_secs() * 3),
316 idle_timeout_ms: None,
317 writer_capacity: None,
318 backend: self.transport_backend,
319 proxy_url: self
320 .proxy_url
321 .as_ref()
322 .map(|value| value.expose_secret().to_owned()),
323 max_message_size_bytes: None,
324 max_frame_size_bytes: None,
325 };
326 let connection_rate_keys: Arc<[Ustr]> = Arc::from([self.rate_limits.connection_key()]);
327 let client_result = WebSocketClient::builder()
328 .config(cfg)
329 .message_handler(message_handler)
330 .rate_limiter(Arc::clone(&self.rate_limits.messages))
331 .connection_rate_limiter(Arc::clone(&self.rate_limits.connections))
332 .connection_rate_keys(connection_rate_keys)
333 .maybe_state_sink(
334 self.socket_control
335 .as_ref()
336 .map(SocketControl::sink)
337 .or_else(|| self.socket_sink.clone()),
338 )
339 .connect()
340 .await;
341 let client = match client_result {
342 Ok(client) => client,
343 Err(e) => {
344 self.connection_permit.lock().take();
345 return Err(e.into());
346 }
347 };
348
349 if let Some(control) = &self.socket_control {
350 let handle = client.reconnect_handle();
351 control.register(move || handle.request_reconnect());
352 }
353
354 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
356 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
357
358 *self.cmd_tx.write().await = cmd_tx.clone();
361 self.out_rx = Some(out_rx);
362
363 self.connection_mode.store(client.connection_mode_atomic());
364 log::debug!("Hyperliquid WebSocket connected: {}", self.url.origin());
365
366 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
368 self.release_limit_reservations();
369 anyhow::bail!("Failed to send SetClient command: {e}");
370 }
371
372 let instruments_vec: Vec<InstrumentAny> =
374 self.instruments.load().values().cloned().collect();
375
376 if !instruments_vec.is_empty()
377 && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
378 {
379 log::error!("Failed to send InitializeInstruments: {e}");
380 }
381
382 for (coin, uses) in self.trade_streams.snapshot() {
383 if let Err(e) = cmd_tx.send(HandlerCommand::UpdateTradeSubs { coin, uses }) {
384 log::error!("Failed to send UpdateTradeSubs: {e}");
385 }
386 }
387
388 let all_dex_asset_ctxs_instrument_ids = self
389 .all_dex_asset_ctxs_instrument_ids
390 .load()
391 .iter()
392 .map(|(dex, instrument_ids)| (*dex, instrument_ids.clone()))
393 .collect();
394
395 if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(
396 all_dex_asset_ctxs_instrument_ids,
397 )) {
398 log::error!("Failed to send CacheAllDexAssetCtxsInstrumentIds: {e}");
399 }
400
401 let signal = Arc::clone(&self.signal);
403 let account_id = self.account_id;
404 let subscriptions = self.subscriptions.clone();
405 let book_streams = self.book_streams.clone();
406 let cmd_tx_for_reconnect = cmd_tx.clone();
407 let cloid_cache = Arc::clone(&self.cloid_cache);
408 let post_router = Arc::clone(&self.post_router);
409 let rate_limits = Arc::clone(&self.rate_limits);
410 let client_id = self.client_id;
411 let connection_permit = Arc::clone(&self.connection_permit);
412
413 if let Err(e) = self.task_handle.spawn(async move {
414 let mut handler = FeedHandler::new(
415 signal,
416 cmd_rx,
417 raw_rx,
418 out_tx,
419 account_id,
420 subscriptions.clone(),
421 cloid_cache,
422 post_router,
423 Arc::clone(&rate_limits),
424 client_id,
425 );
426
427 let resubscribe_all = || {
428 let topics = subscriptions.all_topics();
429 if topics.is_empty() {
430 log::debug!("No active subscriptions to restore after reconnection");
431 return;
432 }
433
434 log::info!(
435 "Resubscribing to {} active subscriptions after reconnection",
436 topics.len()
437 );
438
439 for topic in topics {
440 match subscription_from_topic(&topic) {
441 Ok(mut subscription) => {
442 if let SubscriptionRequest::L2Book {
445 coin,
446 n_sig_figs,
447 mantissa,
448 } = &mut subscription
449 && let Some(options) = book_streams.options(coin)
450 {
451 *n_sig_figs = options.n_sig_figs;
452 *mantissa = options.mantissa;
453 }
454
455 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
456 subscriptions: vec![subscription],
457 }) {
458 log::error!("Failed to send resubscribe command: {e}");
459 }
460 }
461 Err(e) => {
462 log::error!(
463 "Failed to reconstruct subscription from topic: topic={topic}, {e}"
464 );
465 }
466 }
467 }
468 };
469
470 loop {
471 match handler.next().await {
472 Some(NautilusWsMessage::Reconnected) => {
473 log::info!("WebSocket reconnected");
474 let pending_unsubscribe = subscriptions.pending_unsubscribe_topics();
475 rate_limits.release_subscriptions(
476 client_id,
477 pending_unsubscribe.iter().map(String::as_str),
478 );
479 subscriptions.reset_after_reconnect();
480 resubscribe_all();
481
482 if handler.send(NautilusWsMessage::Reconnected).is_err() {
483 if handler.is_stopped() {
484 log::debug!("Failed to send reconnect event (receiver dropped)");
485 } else {
486 log::error!("Failed to send reconnect event (receiver dropped)");
487 }
488 break;
489 }
490 }
491 Some(msg) => {
492 if handler.send(msg).is_err() {
493 if handler.is_stopped() {
494 log::debug!("Failed to send message (receiver dropped)");
495 } else {
496 log::error!("Failed to send message (receiver dropped)");
497 }
498 break;
499 }
500 }
501 None => {
502 if handler.is_stopped() {
503 log::debug!("Stop signal received, ending message processing");
504 break;
505 }
506 log::warn!("WebSocket stream ended unexpectedly");
507 break;
508 }
509 }
510 }
511 rate_limits.release_client(client_id);
512 connection_permit.lock().take();
513 log::debug!("Handler task completed");
514 }) {
515 self.out_rx = None;
516 self.release_limit_reservations();
517 anyhow::bail!("Failed to start Hyperliquid WebSocket handler task: {e}");
518 }
519 Ok(())
520 }
521
522 pub fn set_post_timeout(&mut self, timeout: Duration) {
523 self.post_timeout = timeout;
524 }
525
526 pub(crate) fn begin_shutdown(&self) {
527 self.signal.store(true, Ordering::Relaxed);
528 }
529
530 pub(crate) fn reset_runtime_state(&mut self) {
536 self.release_limit_reservations();
537 self.subscriptions = SubscriptionState::new(':');
538 self.book_streams = BookStreamRegistry::default();
539 self.trade_streams = TradeStreamRegistry::default();
540 self.trade_stream_lock = Arc::new(Mutex::new(()));
541 self.quote_streams = Arc::new(DashMap::new());
542 self.instruments = Arc::new(AtomicMap::new());
543 self.bar_types = Arc::new(AtomicMap::new());
544 self.asset_context_subs = Arc::new(DashMap::new());
545 self.all_dex_asset_ctxs_instrument_ids = Arc::new(AtomicMap::new());
546 self.cloid_cache = Arc::new(Mutex::new(FifoCacheMap::new()));
547 self.out_rx = None;
548
549 if let Some(control) = &self.socket_control {
550 control.deregister();
551 }
552 self.connection_mode
553 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
554 self.signal.store(false, Ordering::Relaxed);
555 }
556
557 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
559 let connect_lock = Arc::clone(&self.connect_lock);
560 let _guard = connect_lock.lock().await;
561 self.disconnect_locked().await
562 }
563
564 async fn disconnect_locked(&self) -> anyhow::Result<()> {
565 log::debug!("Disconnecting Hyperliquid WebSocket");
566
567 if let Some(control) = &self.socket_control {
568 control.deregister();
569 }
570 self.signal.store(true, Ordering::Relaxed);
571
572 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
573 log::debug!(
574 "Failed to send disconnect command (handler may already be shut down): {e}"
575 );
576 }
577
578 if self.task_handle.is_empty() {
579 log::debug!("No task handle to await");
580 } else {
581 log::debug!("Waiting for task handle to complete");
582
583 if let Some(outcome) = self
584 .task_handle
585 .finish(Duration::from_secs(2), Duration::from_secs(2))
586 .await
587 {
588 match outcome {
589 TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
590 TaskJoinOutcome::Failed(error) => {
591 self.release_limit_reservations();
592 anyhow::bail!("Hyperliquid WebSocket handler failed: {error}");
593 }
594 TaskJoinOutcome::Incomplete => {
595 self.release_limit_reservations();
596 anyhow::bail!("Hyperliquid WebSocket handler did not stop after abort");
597 }
598 }
599 }
600 }
601 self.release_limit_reservations();
602 log::debug!("Disconnected");
603 Ok(())
604 }
605
606 pub fn request_reconnect(&self) -> bool {
614 ConnectionMode::request_reconnect(&self.connection_mode.load())
615 }
616
617 pub async fn post_action_exec(
623 &self,
624 signer: &HyperliquidHttpClient,
625 action: &HyperliquidExchangeAction,
626 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
627 self.post_action_exec_with_timeout(signer, action, self.post_timeout, None)
628 .await
629 }
630
631 pub async fn post_action_exec_with_timeout(
633 &self,
634 signer: &HyperliquidHttpClient,
635 action: &HyperliquidExchangeAction,
636 timeout: Duration,
637 expires_after: Option<u64>,
638 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
639 self.post_action_result(signer, action, timeout, expires_after)
640 .await
641 .map_err(PostRequestError::into_error)
642 }
643
644 pub(crate) async fn post_action_command(
645 &self,
646 signer: &HyperliquidHttpClient,
647 action: &HyperliquidExchangeAction,
648 ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
649 self.post_action_result(signer, action, self.post_timeout, None)
650 .await
651 }
652
653 async fn post_action_result(
654 &self,
655 signer: &HyperliquidHttpClient,
656 action: &HyperliquidExchangeAction,
657 timeout: Duration,
658 expires_after: Option<u64>,
659 ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
660 let weight = exec_action_weight(action);
661 let payload = signer
662 .sign_action_exec_request(action, expires_after)
663 .map_err(PostRequestError::BeforeDispatch)?;
664 let response = self
665 .send_post_request_result(PostRequest::Action { payload }, timeout)
666 .await?;
667
668 match response.response {
669 PostResponsePayload::Action { payload } => {
670 let parsed: HyperliquidExchangeResponse = serde_json::from_value(payload)
671 .map_err(HyperliquidError::Serde)
672 .map_err(PostRequestError::AfterDispatch)?;
673
674 match &parsed {
675 HyperliquidExchangeResponse::Status { status, response }
676 if status != RESPONSE_STATUS_OK =>
677 {
678 let reason = response
679 .as_str()
680 .map_or_else(|| response.to_string(), str::to_string);
681 let error = HyperliquidError::bad_request(format!("API error: {reason}"));
682
683 if status == "err" {
684 Err(PostRequestError::Rejected {
685 error,
686 reason: extract_error_message(&parsed),
687 })
688 } else {
689 Err(PostRequestError::AfterDispatch(error))
690 }
691 }
692 HyperliquidExchangeResponse::Error { error } => {
693 Err(PostRequestError::Rejected {
694 error: HyperliquidError::bad_request(format!("API error: {error}")),
695 reason: error.clone(),
696 })
697 }
698 _ => Ok(parsed),
699 }
700 }
701 PostResponsePayload::Error { payload } => Err(PostRequestError::AfterDispatch(
702 map_post_payload_error(payload, weight),
703 )),
704 PostResponsePayload::Info { payload } => {
705 Err(PostRequestError::AfterDispatch(HyperliquidError::decode(
706 format!("expected action post response, received info payload: {payload}"),
707 )))
708 }
709 }
710 }
711
712 #[allow(
723 clippy::too_many_arguments,
724 reason = "matches the Python and HTTP order submit surface"
725 )]
726 pub async fn submit_order(
727 &self,
728 signer: &HyperliquidHttpClient,
729 instrument_id: InstrumentId,
730 client_order_id: ClientOrderId,
731 order_side: OrderSide,
732 order_type: OrderType,
733 quantity: Quantity,
734 time_in_force: TimeInForce,
735 price: Option<Price>,
736 trigger_price: Option<Price>,
737 post_only: bool,
738 reduce_only: bool,
739 ) -> HyperliquidResult<Option<OrderStatusReport>> {
740 let symbol = instrument_id.symbol.inner();
741 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
742 HyperliquidError::bad_request(format!(
743 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
744 ))
745 })?;
746 let is_buy = matches!(order_side, OrderSide::Buy);
747 let price_precision = signer.get_price_precision_for_symbol(symbol);
748
749 let price_decimal = match price {
750 Some(px) => normalize_or_validate_wire_price(
751 px.as_decimal(),
752 "Price",
753 price_precision,
754 signer.normalize_prices(),
755 )
756 .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?,
757 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
758 None if matches!(
759 order_type,
760 OrderType::StopMarket | OrderType::MarketIfTouched
761 ) =>
762 {
763 match trigger_price {
764 Some(tp) => {
765 let derived = derive_limit_from_trigger(
766 tp.as_decimal().normalize(),
767 is_buy,
768 signer.market_order_slippage_bps(),
769 );
770 let sig_rounded = round_to_sig_figs(derived, 5);
771 clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
772 .normalize()
773 }
774 None => Decimal::ZERO,
775 }
776 }
777 None => {
778 return Err(HyperliquidError::bad_request(
779 "Limit orders require a price",
780 ));
781 }
782 };
783
784 let size_decimal = quantity.as_decimal().normalize();
785 let kind = hyperliquid_order_kind(
786 order_type,
787 time_in_force,
788 post_only,
789 trigger_price,
790 signer.normalize_prices(),
791 price_precision,
792 )?;
793
794 let order = HyperliquidExchangePlaceOrderRequest {
795 asset,
796 is_buy,
797 price: price_decimal,
798 size: size_decimal,
799 reduce_only,
800 kind,
801 cloid: Some(signer.get_or_generate_client_order_id_cloid(client_order_id)),
802 };
803
804 if let Some(cloid) = order.cloid {
805 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
806 }
807 let action = HyperliquidExchangeAction::Order {
808 orders: vec![order],
809 grouping: HyperliquidExchangeGrouping::Na,
810 builder: signer.builder_attribution(),
811 };
812 let response = self.post_action_exec(signer, &action).await?;
813
814 ensure_ws_action_accepted(&response, "Order submission")?;
816
817 match signer.build_submit_order_report(
819 instrument_id,
820 client_order_id,
821 order_side,
822 order_type,
823 quantity,
824 time_in_force,
825 price,
826 trigger_price,
827 response,
828 ) {
829 Ok(report) => Ok(report),
830 Err(e) => {
831 log::warn!(
832 "Failed to build submit report for {client_order_id}: {e}; awaiting WS reconciliation"
833 );
834 Ok(None)
835 }
836 }
837 }
838
839 pub async fn submit_orders(
852 &self,
853 signer: &HyperliquidHttpClient,
854 orders: &[&OrderAny],
855 ) -> HyperliquidResult<Vec<OrderStatusReport>> {
856 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
857 let mut client_order_ids = Vec::with_capacity(orders.len());
858
859 for order in orders {
860 if order.is_quote_quantity() {
861 return Err(HyperliquidError::bad_request(format!(
862 "Quote-denominated quantity order {} must submit through the execution \
863 client for quote-to-base conversion",
864 order.client_order_id()
865 )));
866 }
867
868 let instrument_id = order.instrument_id();
869 let symbol = instrument_id.symbol.inner();
870 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
871 HyperliquidError::bad_request(format!(
872 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
873 ))
874 })?;
875 let price_decimals = signer.get_price_precision_for_symbol(symbol);
876 let request = order_to_hyperliquid_request_with_optional_decimals(
877 order,
878 asset,
879 price_decimals,
880 signer.normalize_prices(),
881 signer.market_order_slippage_bps(),
882 None,
883 )
884 .map_err(|e| HyperliquidError::bad_request(format!("Failed to convert order: {e}")))?;
885 client_order_ids.push(order.client_order_id());
886 hyperliquid_orders.push(request);
887 }
888
889 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
890 let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
891 request.cloid = Some(cloid);
892 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
893 }
894
895 let grouping =
896 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
897 let action = HyperliquidExchangeAction::Order {
898 orders: hyperliquid_orders,
899 grouping,
900 builder: signer.builder_attribution(),
901 };
902 let response = self.post_action_exec(signer, &action).await?;
903
904 ensure_ws_action_accepted(&response, "Order list submission")?;
905
906 match signer.build_submit_orders_reports(orders, grouping, response) {
908 Ok(reports) => Ok(reports),
909 Err(e) => {
910 log::warn!(
911 "Failed to build submit reports for order list: {e}; awaiting WS reconciliation"
912 );
913 Ok(Vec::new())
914 }
915 }
916 }
917
918 pub async fn cancel_order(
920 &self,
921 signer: &HyperliquidHttpClient,
922 instrument_id: InstrumentId,
923 client_order_id: Option<ClientOrderId>,
924 venue_order_id: Option<VenueOrderId>,
925 ) -> HyperliquidResult<()> {
926 let symbol = instrument_id.symbol.inner();
927 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
928 HyperliquidError::bad_request(format!(
929 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
930 ))
931 })?;
932 let action = if let Some(client_order_id) = client_order_id {
933 if let Some(cloid) = signer.cached_client_order_id_cloid(&client_order_id) {
934 HyperliquidExchangeAction::CancelByCloid {
935 cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
936 fast: None,
937 }
938 } else if let Some(oid) = venue_order_id {
939 let oid = oid
940 .as_str()
941 .parse::<u64>()
942 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
943 HyperliquidExchangeAction::Cancel {
944 cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
945 fast: None,
946 }
947 } else {
948 let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
949 HyperliquidExchangeAction::CancelByCloid {
950 cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
951 fast: None,
952 }
953 }
954 } else if let Some(oid) = venue_order_id {
955 let oid = oid
956 .as_str()
957 .parse::<u64>()
958 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
959 HyperliquidExchangeAction::Cancel {
960 cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
961 fast: None,
962 }
963 } else {
964 return Err(HyperliquidError::bad_request(
965 "Either client_order_id or venue_order_id must be provided",
966 ));
967 };
968 let response = self.post_action_exec(signer, &action).await?;
969
970 ensure_ws_action_accepted(&response, "Cancel order")
971 }
972
973 pub async fn cancel_orders(
975 &self,
976 signer: &HyperliquidHttpClient,
977 cancels: &[(InstrumentId, ClientOrderId, Option<VenueOrderId>)],
978 ) -> HyperliquidResult<Vec<Option<String>>> {
979 let mut cloid_requests = Vec::new();
980 let mut cloid_indices = Vec::new();
981 let mut oid_requests = Vec::new();
982 let mut oid_indices = Vec::new();
983 let mut results = vec![None; cancels.len()];
984
985 for (index, (instrument_id, client_order_id, venue_order_id)) in cancels.iter().enumerate()
986 {
987 let symbol = instrument_id.symbol.inner();
988 let Some(asset) = signer.get_asset_index_for_symbol(symbol) else {
989 results[index] = Some(format!(
990 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
991 ));
992 continue;
993 };
994
995 if let Some(cloid) = signer.cached_client_order_id_cloid(client_order_id) {
996 cloid_requests.push(HyperliquidExchangeCancelByCloidRequest { asset, cloid });
997 cloid_indices.push(index);
998 } else if let Some(venue_order_id) = venue_order_id {
999 match venue_order_id.as_str().parse::<u64>() {
1000 Ok(oid) => {
1001 oid_requests.push(HyperliquidExchangeCancelOrderRequest { asset, oid });
1002 oid_indices.push(index);
1003 }
1004 Err(_) => {
1005 results[index] = Some("Invalid venue order ID format".to_string());
1006 }
1007 }
1008 } else {
1009 let cloid = signer.get_or_generate_client_order_id_cloid(*client_order_id);
1010 cloid_requests.push(HyperliquidExchangeCancelByCloidRequest { asset, cloid });
1011 cloid_indices.push(index);
1012 }
1013 }
1014
1015 if cloid_requests.is_empty() && oid_requests.is_empty() {
1016 return Ok(results);
1017 }
1018
1019 if !cloid_requests.is_empty() {
1020 let action = HyperliquidExchangeAction::CancelByCloid {
1021 cancels: cloid_requests,
1022 fast: None,
1023 };
1024 let errors = self
1025 .post_cancel_action_errors(signer, &action, cloid_indices.len())
1026 .await?;
1027
1028 for (index, error) in cloid_indices.into_iter().zip(errors) {
1029 results[index] = error;
1030 }
1031 }
1032
1033 if !oid_requests.is_empty() {
1034 let action = HyperliquidExchangeAction::Cancel {
1035 cancels: oid_requests,
1036 fast: None,
1037 };
1038 let errors = self
1039 .post_cancel_action_errors(signer, &action, oid_indices.len())
1040 .await?;
1041
1042 for (index, error) in oid_indices.into_iter().zip(errors) {
1043 results[index] = error;
1044 }
1045 }
1046
1047 Ok(results)
1048 }
1049
1050 async fn post_cancel_action_errors(
1051 &self,
1052 signer: &HyperliquidHttpClient,
1053 action: &HyperliquidExchangeAction,
1054 request_count: usize,
1055 ) -> HyperliquidResult<Vec<Option<String>>> {
1056 match self.post_cancel_action(signer, action).await {
1057 Ok(response) if response.is_ok() => {
1058 match cancel_errors_for_requests(extract_inner_errors(&response), request_count) {
1059 Ok(errors) => Ok(errors),
1060 Err(e) => Ok(vec![Some(e.to_string()); request_count]),
1061 }
1062 }
1063 Ok(response) => Ok(vec![
1064 Some(format!(
1065 "Cancel orders failed: {}",
1066 extract_error_message(&response)
1067 ));
1068 request_count
1069 ]),
1070 Err(e) => Err(e),
1071 }
1072 }
1073
1074 async fn post_cancel_action(
1075 &self,
1076 signer: &HyperliquidHttpClient,
1077 action: &HyperliquidExchangeAction,
1078 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
1079 let weight = exec_action_weight(action);
1080
1081 let payload = signer.sign_action_exec_request(action, None)?;
1082 let response = self
1083 .send_post_request(PostRequest::Action { payload }, self.post_timeout)
1084 .await?;
1085
1086 match response.response {
1087 PostResponsePayload::Action { payload } => {
1088 serde_json::from_value(payload).map_err(HyperliquidError::Serde)
1089 }
1090 PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
1091 PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
1092 "expected action post response, received info payload: {payload}"
1093 ))),
1094 }
1095 }
1096
1097 #[allow(
1099 clippy::too_many_arguments,
1100 reason = "matches the Python and HTTP order modify surface"
1101 )]
1102 pub async fn modify_order(
1103 &self,
1104 signer: &HyperliquidHttpClient,
1105 instrument_id: InstrumentId,
1106 venue_order_id: Option<VenueOrderId>,
1107 order_side: OrderSide,
1108 order_type: OrderType,
1109 price: Price,
1110 quantity: Quantity,
1111 trigger_price: Option<Price>,
1112 reduce_only: bool,
1113 post_only: bool,
1114 time_in_force: TimeInForce,
1115 client_order_id: Option<ClientOrderId>,
1116 ) -> HyperliquidResult<()> {
1117 let symbol = instrument_id.symbol.inner();
1118 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1119 HyperliquidError::bad_request(format!(
1120 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1121 ))
1122 })?;
1123 let oid = match client_order_id
1124 .as_ref()
1125 .and_then(|id| signer.unique_cached_client_order_id_cloid(id))
1126 {
1127 Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
1128 None => {
1129 let Some(venue_order_id) = venue_order_id.as_ref() else {
1130 return Err(HyperliquidError::bad_request(
1131 "venue_order_id or unique cached CLOID is required for modify",
1132 ));
1133 };
1134 HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
1135 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?
1136 }
1137 };
1138 let is_buy = matches!(order_side, OrderSide::Buy);
1139 let price_decimals = signer.get_price_precision_for_symbol(symbol);
1140 let price = normalize_or_validate_wire_price(
1141 price.as_decimal(),
1142 "Price",
1143 price_decimals,
1144 signer.normalize_prices(),
1145 )
1146 .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
1147 let kind = hyperliquid_order_kind(
1148 order_type,
1149 time_in_force,
1150 post_only,
1151 trigger_price,
1152 signer.normalize_prices(),
1153 price_decimals,
1154 )?;
1155 let cloid =
1156 client_order_id.map(|id| (id, signer.get_or_generate_client_order_id_cloid(id)));
1157 let order = HyperliquidExchangePlaceOrderRequest {
1158 asset,
1159 is_buy,
1160 price,
1161 size: quantity.as_decimal().normalize(),
1162 reduce_only,
1163 kind,
1164 cloid: cloid.map(|(_, cloid)| cloid),
1165 };
1166
1167 if let Some((client_order_id, cloid)) = cloid {
1168 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
1169 }
1170 let action = HyperliquidExchangeAction::Modify {
1171 modify: HyperliquidExchangeModifyOrderRequest { oid, order },
1172 };
1173 let response = self.post_action_exec(signer, &action).await?;
1174
1175 ensure_ws_action_accepted(&response, "Modify order")
1176 }
1177
1178 async fn send_post_request(
1179 &self,
1180 request: PostRequest,
1181 timeout: Duration,
1182 ) -> HyperliquidResult<PostResponse> {
1183 self.send_post_request_result(request, timeout)
1184 .await
1185 .map_err(PostRequestError::into_error)
1186 }
1187
1188 async fn send_post_request_result(
1189 &self,
1190 request: PostRequest,
1191 timeout: Duration,
1192 ) -> Result<PostResponse, PostRequestError> {
1193 let id = self.post_ids.next();
1194 let Some(deadline) = tokio::time::Instant::now().checked_add(timeout) else {
1195 return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1196 };
1197
1198 let cancellation_token = CancellationToken::new();
1199 let rx = tokio::select! {
1200 biased;
1201 () = tokio::time::sleep_until(deadline) => return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout)),
1202 result = self.post_router.register_with_cancellation(id, &cancellation_token) => result.map_err(PostRequestError::BeforeDispatch)?,
1203 };
1204
1205 let _cancellation_guard = cancellation_token.drop_guard_ref();
1206
1207 let send_result = {
1208 let cmd_tx = tokio::select! {
1209 biased;
1210 () = tokio::time::sleep_until(deadline) => {
1211 self.cancel_post_registration(id, &cancellation_token).await;
1212 return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1213 }
1214 cmd_tx = self.cmd_tx.read() => cmd_tx,
1215 };
1216
1217 if cancellation_token.is_cancelled() || tokio::time::Instant::now() >= deadline {
1218 self.cancel_post_registration(id, &cancellation_token).await;
1219 return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1220 }
1221
1222 cmd_tx.send(HandlerCommand::Post {
1223 id,
1224 request,
1225 deadline,
1226 cancellation_token: cancellation_token.clone(),
1227 })
1228 };
1229
1230 if let Err(e) = send_result {
1231 self.cancel_post_registration(id, &cancellation_token).await;
1232 return Err(PostRequestError::BeforeDispatch(
1233 HyperliquidError::transport(format!("post command channel closed: {e}")),
1234 ));
1235 }
1236
1237 self.await_post_response(id, rx, deadline, &cancellation_token)
1238 .await
1239 .map_err(PostRequestError::AfterDispatch)
1240 }
1241
1242 async fn await_post_response(
1243 &self,
1244 id: u64,
1245 rx: tokio::sync::oneshot::Receiver<PostResponse>,
1246 deadline: tokio::time::Instant,
1247 cancellation_token: &CancellationToken,
1248 ) -> HyperliquidResult<PostResponse> {
1249 tokio::select! {
1250 biased;
1251 result = rx => match result {
1252 Ok(response) => Ok(response),
1253 Err(_closed) => {
1254 self.cancel_post_registration(id, cancellation_token).await;
1255 Err(HyperliquidError::transport("post response channel closed"))
1256 },
1257 },
1258 () = tokio::time::sleep_until(deadline) => {
1259 self.cancel_post_registration(id, cancellation_token).await;
1260 Err(HyperliquidError::Timeout)
1261 },
1262 }
1263 }
1264
1265 async fn cancel_post_registration(&self, id: u64, cancellation_token: &CancellationToken) {
1266 cancellation_token.cancel();
1267 self.post_router
1268 .cancel_registration(id, cancellation_token)
1269 .await;
1270 }
1271
1272 pub fn is_active(&self) -> bool {
1274 let mode = self.connection_mode.load();
1275 mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8
1276 }
1277
1278 pub fn url(&self) -> &str {
1280 self.url.expose()
1281 }
1282
1283 pub fn cache_instruments(&mut self, instruments: Vec<InstrumentAny>) {
1290 let mut map = AHashMap::new();
1291
1292 for inst in instruments {
1293 let coin = inst.raw_symbol().inner();
1294 map.insert(coin, inst);
1295 }
1296 let count = map.len();
1297 self.instruments.store(map);
1298 log::debug!("Hyperliquid instrument cache initialized with {count} instruments");
1299 }
1300
1301 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1305 let coin = instrument.raw_symbol().inner();
1306 self.instruments.insert(coin, instrument.clone());
1307
1308 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1311 let _ = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument));
1312 }
1313 }
1314
1315 #[must_use]
1317 pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
1318 self.instruments.clone()
1319 }
1320
1321 pub fn cache_spot_fill_coins(&self, mapping: AHashMap<Ustr, Ustr>) {
1327 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1328 let _ = cmd_tx.send(HandlerCommand::CacheSpotFillCoins(mapping));
1329 }
1330 }
1331
1332 pub fn cache_cloid_mapping(&self, cloid: Ustr, client_order_id: ClientOrderId) {
1340 log::debug!("Caching cloid mapping: {cloid} -> {client_order_id}");
1341 self.cloid_cache.lock().insert(cloid, client_order_id);
1342 }
1343
1344 pub fn remove_cloid_mapping(&self, cloid: &Ustr) {
1349 if self.cloid_cache.lock().remove(cloid).is_some() {
1350 log::debug!("Removed cloid mapping: {cloid}");
1351 }
1352 }
1353
1354 pub fn clear_cloid_cache(&self) {
1358 let mut cache = self.cloid_cache.lock();
1359 let count = cache.len();
1360 cache.clear();
1361
1362 if count > 0 {
1363 log::debug!("Cleared {count} cloid mappings from cache");
1364 }
1365 }
1366
1367 #[must_use]
1369 pub fn cloid_cache_len(&self) -> usize {
1370 self.cloid_cache.lock().len()
1371 }
1372
1373 #[must_use]
1377 pub fn get_cloid_mapping(&self, cloid: &Ustr) -> Option<ClientOrderId> {
1378 self.cloid_cache.lock().get(cloid).copied()
1379 }
1380
1381 pub fn get_instrument(&self, id: &InstrumentId) -> Option<InstrumentAny> {
1385 self.instruments
1386 .load()
1387 .values()
1388 .find(|inst| inst.id() == *id)
1389 .cloned()
1390 }
1391
1392 pub fn get_instrument_by_symbol(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1394 self.instruments.get_cloned(symbol)
1395 }
1396
1397 pub fn subscription_count(&self) -> usize {
1399 self.subscriptions.len()
1400 }
1401
1402 pub(crate) async fn wait_for_subscriptions_confirmed(
1403 &self,
1404 timeout: Duration,
1405 ) -> anyhow::Result<()> {
1406 tokio::time::timeout(timeout, async {
1407 while !self.subscriptions.pending_subscribe_topics().is_empty() {
1408 tokio::time::sleep(Duration::from_millis(10)).await;
1409 }
1410 })
1411 .await
1412 .map_err(|_| {
1413 anyhow::anyhow!(
1414 "Subscriptions still pending after {timeout:?}: {:?}",
1415 self.subscriptions.pending_subscribe_topics()
1416 )
1417 })
1418 }
1419
1420 pub fn get_bar_type(&self, coin: &str, interval: &str) -> Option<BarType> {
1424 let key = format!("candle:{coin}:{interval}");
1426 self.bar_types.load().get(&key).copied()
1427 }
1428
1429 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1431 self.subscribe_book_with_options(instrument_id, None, None)
1432 .await
1433 }
1434
1435 pub async fn subscribe_book_with_options(
1442 &self,
1443 instrument_id: InstrumentId,
1444 n_sig_figs: Option<u32>,
1445 mantissa: Option<u32>,
1446 ) -> anyhow::Result<()> {
1447 let instrument = self
1448 .get_instrument(&instrument_id)
1449 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1450 let coin = instrument.raw_symbol().inner();
1451
1452 let cmd_tx = self.cmd_tx.read().await;
1453
1454 cmd_tx
1456 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1457 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1458
1459 self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Deltas, n_sig_figs, mantissa)
1460 }
1461
1462 pub(crate) async fn subscribe_book_gated(
1469 &self,
1470 instrument_id: InstrumentId,
1471 n_sig_figs: Option<u32>,
1472 mantissa: Option<u32>,
1473 cancel: CancellationToken,
1474 gate: SnapshotGate,
1475 ) -> Result<(), HyperliquidWsError> {
1476 let Some(instrument) = self.get_instrument(&instrument_id) else {
1477 let message = InstrumentLookupError::not_found(instrument_id).to_string();
1478 return Err(HyperliquidWsError::ClientError(message));
1479 };
1480
1481 let coin = instrument.raw_symbol().inner();
1482 let (completion, receiver) = tokio::sync::oneshot::channel();
1483 let cmd_tx = self.cmd_tx.read().await;
1484
1485 if cancel.is_cancelled() {
1486 return Ok(());
1487 }
1488
1489 cmd_tx
1490 .send(HandlerCommand::UpdateInstrument(instrument))
1491 .map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?;
1492
1493 let registration =
1494 self.register_book_stream(coin, BookStreamUse::Deltas, n_sig_figs, mantissa);
1495
1496 let subscription = SubscriptionRequest::L2Book {
1497 coin,
1498 mantissa: registration.options.mantissa,
1499 n_sig_figs: registration.options.n_sig_figs,
1500 };
1501
1502 let replace = !registration.subscribe;
1503 let write_cancel = cancel.clone();
1504
1505 let write = move |subscription| HandlerCommand::WriteBook {
1506 subscription,
1507 replace,
1508 cancel: write_cancel,
1509 gate,
1510 completion,
1511 };
1512
1513 let sent = if registration.subscribe {
1514 self.send_reserved_command(&cmd_tx, subscription, write)
1515 } else {
1516 cmd_tx
1517 .send(write(subscription))
1518 .map_err(|e| anyhow::anyhow!("Failed to send book write command: {e}"))
1519 };
1520
1521 if let Err(e) = sent {
1522 if registration.subscribe {
1523 self.book_streams.release(&coin, BookStreamUse::Deltas);
1524 }
1525
1526 return Err(HyperliquidWsError::ChannelSend(e.to_string()));
1527 }
1528
1529 drop(cmd_tx);
1530
1531 tokio::select! {
1532 biased;
1533 () = cancel.cancelled() => Ok(()),
1534 result = receiver => result.map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?,
1535 }
1536 }
1537
1538 pub async fn subscribe_book_depth(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1544 self.subscribe_book_depth_with_options(instrument_id, None, None)
1545 .await
1546 }
1547
1548 pub async fn subscribe_book_depth_with_options(
1555 &self,
1556 instrument_id: InstrumentId,
1557 n_sig_figs: Option<u32>,
1558 mantissa: Option<u32>,
1559 ) -> anyhow::Result<()> {
1560 let instrument = self
1561 .get_instrument(&instrument_id)
1562 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1563 let coin = instrument.raw_symbol().inner();
1564
1565 let cmd_tx = self.cmd_tx.read().await;
1566
1567 cmd_tx
1568 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1569 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1570
1571 cmd_tx
1572 .send(HandlerCommand::SetDepthSub {
1573 coin,
1574 subscribed: true,
1575 })
1576 .map_err(|e| anyhow::anyhow!("Failed to send SetDepthSub command: {e}"))?;
1577
1578 self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Depth, n_sig_figs, mantissa)
1579 }
1580
1581 pub async fn unsubscribe_book_depth(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1586 let instrument = self
1587 .get_instrument(&instrument_id)
1588 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1589 let coin = instrument.raw_symbol().inner();
1590
1591 let cmd_tx = self.cmd_tx.read().await;
1592
1593 cmd_tx
1594 .send(HandlerCommand::SetDepthSub {
1595 coin,
1596 subscribed: false,
1597 })
1598 .map_err(|e| anyhow::anyhow!("Failed to send SetDepthSub command: {e}"))?;
1599
1600 self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Depth)
1601 }
1602
1603 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1605 let instrument = self
1606 .get_instrument(&instrument_id)
1607 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1608 let coin = instrument.raw_symbol().inner();
1609
1610 let cmd_tx = self.cmd_tx.read().await;
1611 self.quote_streams.insert(coin, ());
1612
1613 if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument.clone())) {
1615 self.quote_streams.remove(&coin);
1616 anyhow::bail!("Failed to send UpdateInstrument command: {e}");
1617 }
1618
1619 let subscription = SubscriptionRequest::Bbo { coin };
1620
1621 if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
1622 self.quote_streams.remove(&coin);
1623 return Err(e);
1624 }
1625 Ok(())
1626 }
1627
1628 pub async fn subscribe_all_mids(&self) -> anyhow::Result<()> {
1630 self.subscribe_all_mids_with_dex(None).await
1631 }
1632
1633 pub async fn subscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1635 self.send_subscription(
1636 &*self.cmd_tx.read().await,
1637 SubscriptionRequest::AllDexsAssetCtxs,
1638 )?;
1639 Ok(())
1640 }
1641
1642 pub async fn subscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1644 let cmd_tx = self.cmd_tx.read().await;
1645
1646 let subscription = SubscriptionRequest::AllMids {
1647 dex: dex.map(ToString::to_string),
1648 };
1649
1650 self.send_subscription(&cmd_tx, subscription)?;
1651 Ok(())
1652 }
1653
1654 pub async fn unsubscribe_all_mids(&self) -> anyhow::Result<()> {
1656 self.unsubscribe_all_mids_with_dex(None).await
1657 }
1658
1659 pub async fn unsubscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1661 self.send_unsubscription(
1662 &*self.cmd_tx.read().await,
1663 SubscriptionRequest::AllDexsAssetCtxs,
1664 )?;
1665 Ok(())
1666 }
1667
1668 pub async fn unsubscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1670 let cmd_tx = self.cmd_tx.read().await;
1671
1672 let subscription = SubscriptionRequest::AllMids {
1673 dex: dex.map(ToString::to_string),
1674 };
1675
1676 self.send_unsubscription(&cmd_tx, subscription)?;
1677 Ok(())
1678 }
1679
1680 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1682 self.subscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1683 .await
1684 }
1685
1686 pub async fn subscribe_public_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1688 self.subscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1689 .await
1690 }
1691
1692 async fn subscribe_trade_stream(
1693 &self,
1694 instrument_id: InstrumentId,
1695 stream_use: TradeStreamUse,
1696 ) -> anyhow::Result<()> {
1697 let instrument = self
1698 .get_instrument(&instrument_id)
1699 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1700 let coin = instrument.raw_symbol().inner();
1701
1702 let cmd_tx = self.cmd_tx.read().await;
1703
1704 cmd_tx
1706 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1707 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1708
1709 let _trade_stream_guard = self.trade_stream_lock.lock();
1712 let registration = self.trade_streams.register(coin, stream_use);
1713 cmd_tx
1714 .send(HandlerCommand::UpdateTradeSubs {
1715 coin,
1716 uses: registration.uses,
1717 })
1718 .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1719
1720 if registration.subscribe
1721 && let Err(e) = self.send_subscription(&cmd_tx, SubscriptionRequest::Trades { coin })
1722 {
1723 let rollback = self.trade_streams.release(&coin, stream_use);
1724 let _ = cmd_tx.send(HandlerCommand::UpdateTradeSubs {
1725 coin,
1726 uses: rollback.uses,
1727 });
1728 return Err(e);
1729 }
1730 Ok(())
1731 }
1732
1733 pub async fn subscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1735 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1736 .await
1737 }
1738
1739 pub async fn subscribe_index_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1741 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1742 .await
1743 }
1744
1745 pub async fn subscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1747 let instrument_id = bar_type.instrument_id();
1748 let instrument = self
1749 .get_instrument(&instrument_id)
1750 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1751 let coin = instrument.raw_symbol().inner();
1752 let interval = bar_type_to_interval(&bar_type)?;
1753 let subscription = SubscriptionRequest::Candle { coin, interval };
1754
1755 let key = format!("candle:{coin}:{interval}");
1757 self.bar_types.insert(key.clone(), bar_type);
1758
1759 let cmd_tx = self.cmd_tx.read().await;
1760
1761 cmd_tx
1762 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1763 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1764
1765 cmd_tx
1766 .send(HandlerCommand::AddBarType {
1767 key: key.clone(),
1768 bar_type,
1769 })
1770 .map_err(|e| anyhow::anyhow!("Failed to send AddBarType command: {e}"))?;
1771
1772 if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
1773 self.bar_types.remove(&key);
1774 let _ = cmd_tx.send(HandlerCommand::RemoveBarType { key });
1775 return Err(e);
1776 }
1777 Ok(())
1778 }
1779
1780 pub async fn subscribe_funding_rates(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1782 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1783 .await
1784 }
1785
1786 pub async fn subscribe_open_interest(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1788 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1789 .await
1790 }
1791
1792 pub async fn subscribe_order_updates(&self, user: &str) -> anyhow::Result<()> {
1794 let subscription = SubscriptionRequest::OrderUpdates {
1795 user: user.to_string(),
1796 };
1797 self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1798 Ok(())
1799 }
1800
1801 pub async fn subscribe_user_events(&self, user: &str) -> anyhow::Result<()> {
1803 let subscription = SubscriptionRequest::UserEvents {
1804 user: user.to_string(),
1805 };
1806 self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1807 Ok(())
1808 }
1809
1810 pub async fn subscribe_user_fills(&self, user: &str) -> anyhow::Result<()> {
1815 let subscription = SubscriptionRequest::UserFills {
1816 user: user.to_string(),
1817 aggregate_by_time: None,
1818 };
1819 self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1820 Ok(())
1821 }
1822
1823 pub async fn subscribe_all_user_channels(&self, user: &str) -> anyhow::Result<()> {
1831 self.subscribe_order_updates(user).await?;
1832 self.subscribe_user_events(user).await?;
1833 Ok(())
1834 }
1835
1836 pub async fn subscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1840 let subscription = SubscriptionRequest::UserTwapHistory {
1841 user: user.to_string(),
1842 };
1843 self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1844 Ok(())
1845 }
1846
1847 pub async fn unsubscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1849 let subscription = SubscriptionRequest::UserTwapHistory {
1850 user: user.to_string(),
1851 };
1852 self.send_unsubscription(&*self.cmd_tx.read().await, subscription)?;
1853 Ok(())
1854 }
1855
1856 pub async fn subscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1860 let subscription = SubscriptionRequest::UserTwapSliceFills {
1861 user: user.to_string(),
1862 };
1863 self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1864 Ok(())
1865 }
1866
1867 pub async fn unsubscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1869 let subscription = SubscriptionRequest::UserTwapSliceFills {
1870 user: user.to_string(),
1871 };
1872 self.send_unsubscription(&*self.cmd_tx.read().await, subscription)?;
1873 Ok(())
1874 }
1875
1876 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1881 let instrument = self
1882 .get_instrument(&instrument_id)
1883 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1884 let coin = instrument.raw_symbol().inner();
1885
1886 let cmd_tx = self.cmd_tx.read().await;
1887
1888 self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Deltas)
1889 }
1890
1891 pub async fn resubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1900 let instrument = self
1901 .get_instrument(&instrument_id)
1902 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1903 let coin = instrument.raw_symbol().inner();
1904
1905 let cmd_tx = self.cmd_tx.write().await;
1907
1908 let Some(options) = self.book_streams.options(&coin) else {
1909 log::debug!("Skipping l2Book resubscribe for {coin}: stream no longer registered");
1910 return Ok(());
1911 };
1912
1913 let subscription = SubscriptionRequest::L2Book {
1914 coin,
1915 mantissa: options.mantissa,
1916 n_sig_figs: options.n_sig_figs,
1917 };
1918
1919 self.send_stream_resubscribe(&cmd_tx, subscription)
1920 }
1921
1922 pub(crate) async fn resubscribe_book_gated(
1928 &self,
1929 instrument_id: InstrumentId,
1930 cancel: CancellationToken,
1931 gate: SnapshotGate,
1932 ) -> Result<(), HyperliquidWsError> {
1933 let Some(instrument) = self.get_instrument(&instrument_id) else {
1934 let message = InstrumentLookupError::not_found(instrument_id).to_string();
1935 return Err(HyperliquidWsError::ClientError(message));
1936 };
1937
1938 let coin = instrument.raw_symbol().inner();
1939 let (completion, receiver) = tokio::sync::oneshot::channel();
1940
1941 let cmd_tx = self.cmd_tx.write().await;
1943
1944 if cancel.is_cancelled() {
1945 return Err(HyperliquidWsError::ClientError(
1946 "Book recovery canceled".to_string(),
1947 ));
1948 }
1949
1950 let Some(options) = self.book_streams.options(&coin) else {
1951 return Err(HyperliquidWsError::ClientError(format!(
1952 "l2Book stream for {coin} is no longer registered"
1953 )));
1954 };
1955
1956 let subscription = SubscriptionRequest::L2Book {
1957 coin,
1958 mantissa: options.mantissa,
1959 n_sig_figs: options.n_sig_figs,
1960 };
1961
1962 cmd_tx
1963 .send(HandlerCommand::WriteBook {
1964 subscription,
1965 replace: true,
1966 cancel,
1967 gate,
1968 completion,
1969 })
1970 .map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?;
1971
1972 drop(cmd_tx);
1973
1974 receiver
1975 .await
1976 .map_err(|e| HyperliquidWsError::ChannelSend(e.to_string()))?
1977 }
1978
1979 fn send_book_stream_subscribe(
1980 &self,
1981 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1982 coin: Ustr,
1983 stream_use: BookStreamUse,
1984 n_sig_figs: Option<u32>,
1985 mantissa: Option<u32>,
1986 ) -> anyhow::Result<()> {
1987 let registration = self.register_book_stream(coin, stream_use, n_sig_figs, mantissa);
1988
1989 if registration.subscribe {
1990 let subscription = SubscriptionRequest::L2Book {
1991 coin,
1992 mantissa: registration.options.mantissa,
1993 n_sig_figs: registration.options.n_sig_figs,
1994 };
1995
1996 if let Err(e) = self.send_subscription(cmd_tx, subscription) {
1997 self.book_streams.release(&coin, stream_use);
1998 return Err(e);
1999 }
2000 }
2001
2002 Ok(())
2003 }
2004
2005 fn register_book_stream(
2006 &self,
2007 coin: Ustr,
2008 stream_use: BookStreamUse,
2009 n_sig_figs: Option<u32>,
2010 mantissa: Option<u32>,
2011 ) -> BookStreamRegistration {
2012 let registration = self.book_streams.register(
2013 coin,
2014 stream_use,
2015 BookStreamOptions {
2016 n_sig_figs,
2017 mantissa,
2018 },
2019 );
2020
2021 if registration.options_mismatch {
2022 log::warn!(
2023 "Requested l2Book options for {coin} (n_sig_figs={n_sig_figs:?}, mantissa={mantissa:?}) \
2024 differ from the active stream ({:?}), keeping active options",
2025 registration.options,
2026 );
2027 }
2028
2029 registration
2030 }
2031
2032 fn send_book_stream_unsubscribe(
2033 &self,
2034 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2035 coin: Ustr,
2036 stream_use: BookStreamUse,
2037 ) -> anyhow::Result<()> {
2038 match self.book_streams.release(&coin, stream_use) {
2039 BookStreamRelease::Unsubscribe(options) => {
2040 let subscription = SubscriptionRequest::L2Book {
2041 coin,
2042 mantissa: options.mantissa,
2043 n_sig_figs: options.n_sig_figs,
2044 };
2045
2046 self.send_unsubscription(cmd_tx, subscription)?;
2047 }
2048 BookStreamRelease::Retained => {
2049 let remaining_use = match stream_use {
2050 BookStreamUse::Deltas => "depth",
2051 BookStreamUse::Depth => "deltas",
2052 };
2053 log::debug!("Keeping shared l2Book stream for {coin}: {remaining_use} use remains");
2054 }
2055 }
2056 Ok(())
2057 }
2058
2059 pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2061 let instrument = self
2062 .get_instrument(&instrument_id)
2063 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2064 let coin = instrument.raw_symbol().inner();
2065
2066 let subscription = SubscriptionRequest::Bbo { coin };
2067 let cmd_tx = self.cmd_tx.read().await;
2068
2069 self.quote_streams.remove(&coin);
2070
2071 self.send_unsubscription(&cmd_tx, subscription)?;
2072 Ok(())
2073 }
2074
2075 pub async fn resubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2079 let instrument = self
2080 .get_instrument(&instrument_id)
2081 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2082 let coin = instrument.raw_symbol().inner();
2083
2084 let cmd_tx = self.cmd_tx.write().await;
2086
2087 if !self.quote_streams.contains_key(&coin) {
2088 log::debug!("Skipping bbo resubscribe for {coin}: stream no longer registered");
2089 return Ok(());
2090 }
2091
2092 self.send_stream_resubscribe(&cmd_tx, SubscriptionRequest::Bbo { coin })
2093 }
2094
2095 fn send_stream_resubscribe(
2096 &self,
2097 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2098 subscription: SubscriptionRequest,
2099 ) -> anyhow::Result<()> {
2100 cmd_tx
2101 .send(HandlerCommand::Resubscribe { subscription })
2102 .map_err(|e| anyhow::anyhow!("Failed to send resubscribe command: {e}"))
2103 }
2104
2105 pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2107 self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
2108 .await
2109 }
2110
2111 pub async fn unsubscribe_public_trades(
2113 &self,
2114 instrument_id: InstrumentId,
2115 ) -> anyhow::Result<()> {
2116 self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
2117 .await
2118 }
2119
2120 async fn unsubscribe_trade_stream(
2121 &self,
2122 instrument_id: InstrumentId,
2123 stream_use: TradeStreamUse,
2124 ) -> anyhow::Result<()> {
2125 let instrument = self
2126 .get_instrument(&instrument_id)
2127 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2128 let coin = instrument.raw_symbol().inner();
2129
2130 let cmd_tx = self.cmd_tx.read().await;
2131 let _trade_stream_guard = self.trade_stream_lock.lock();
2134 let release = self.trade_streams.release(&coin, stream_use);
2135 cmd_tx
2136 .send(HandlerCommand::UpdateTradeSubs {
2137 coin,
2138 uses: release.uses,
2139 })
2140 .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
2141
2142 if release.unsubscribe {
2143 self.send_unsubscription(&cmd_tx, SubscriptionRequest::Trades { coin })?;
2144 }
2145 Ok(())
2146 }
2147
2148 pub async fn unsubscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
2150 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
2151 .await
2152 }
2153
2154 pub async fn unsubscribe_index_prices(
2156 &self,
2157 instrument_id: InstrumentId,
2158 ) -> anyhow::Result<()> {
2159 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
2160 .await
2161 }
2162
2163 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
2165 let instrument_id = bar_type.instrument_id();
2166 let instrument = self
2167 .get_instrument(&instrument_id)
2168 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2169 let coin = instrument.raw_symbol().inner();
2170 let interval = bar_type_to_interval(&bar_type)?;
2171 let subscription = SubscriptionRequest::Candle { coin, interval };
2172
2173 let key = format!("candle:{coin}:{interval}");
2174 self.bar_types.remove(&key);
2175
2176 let cmd_tx = self.cmd_tx.read().await;
2177
2178 cmd_tx
2179 .send(HandlerCommand::RemoveBarType { key })
2180 .map_err(|e| anyhow::anyhow!("Failed to send RemoveBarType command: {e}"))?;
2181
2182 self.send_unsubscription(&cmd_tx, subscription)?;
2183 Ok(())
2184 }
2185
2186 pub async fn unsubscribe_funding_rates(
2188 &self,
2189 instrument_id: InstrumentId,
2190 ) -> anyhow::Result<()> {
2191 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
2192 .await
2193 }
2194
2195 pub async fn unsubscribe_open_interest(
2197 &self,
2198 instrument_id: InstrumentId,
2199 ) -> anyhow::Result<()> {
2200 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
2201 .await
2202 }
2203
2204 pub fn cache_all_dex_asset_ctxs_instrument_ids(
2213 &self,
2214 mapping: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
2215 ) {
2216 self.all_dex_asset_ctxs_instrument_ids.rcu(|cached| {
2217 cached.extend(mapping.iter().map(|(dex, ids)| (*dex, ids.clone())));
2218 });
2219
2220 if let Ok(cmd_tx) = self.cmd_tx.try_read()
2221 && let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mapping))
2222 {
2223 log::debug!(
2224 "Failed to send CacheAllDexAssetCtxsInstrumentIds command (handler may not be connected yet): {e}"
2225 );
2226 }
2227 }
2228
2229 async fn subscribe_asset_context_data(
2230 &self,
2231 instrument_id: InstrumentId,
2232 data_type: AssetContextDataType,
2233 ) -> anyhow::Result<()> {
2234 let instrument = self
2235 .get_instrument(&instrument_id)
2236 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2237 let coin = instrument.raw_symbol().inner();
2238
2239 let mut entry = self.asset_context_subs.entry(coin).or_default();
2240 let is_first_subscription = entry.is_empty();
2241 entry.insert(data_type);
2242 let data_types = entry.clone();
2243 drop(entry);
2244
2245 let cmd_tx = self.cmd_tx.read().await;
2246
2247 cmd_tx
2248 .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2249 .map_err(|e| anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}"))?;
2250
2251 if is_first_subscription {
2252 log::debug!(
2253 "First asset context subscription for coin '{coin}', subscribing to ActiveAssetCtx"
2254 );
2255 let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2256
2257 cmd_tx
2258 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
2259 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
2260
2261 if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
2262 if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2263 entry.remove(&data_type);
2264 let rollback = entry.clone();
2265 let remove_entry = entry.is_empty();
2266 drop(entry);
2267
2268 if remove_entry {
2269 self.asset_context_subs.remove(&coin);
2270 }
2271 let _ = cmd_tx.send(HandlerCommand::UpdateAssetContextSubs {
2272 coin,
2273 data_types: rollback,
2274 });
2275 }
2276 return Err(e);
2277 }
2278 } else {
2279 log::debug!(
2280 "Already subscribed to ActiveAssetCtx for coin '{coin}', adding {data_type:?} to tracked types"
2281 );
2282 }
2283
2284 Ok(())
2285 }
2286
2287 async fn unsubscribe_asset_context_data(
2288 &self,
2289 instrument_id: InstrumentId,
2290 data_type: AssetContextDataType,
2291 ) -> anyhow::Result<()> {
2292 let instrument = self
2293 .get_instrument(&instrument_id)
2294 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2295 let coin = instrument.raw_symbol().inner();
2296
2297 if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2298 entry.remove(&data_type);
2299 let should_unsubscribe = entry.is_empty();
2300 let data_types = entry.clone();
2301 drop(entry);
2302
2303 let cmd_tx = self.cmd_tx.read().await;
2304
2305 if should_unsubscribe {
2306 self.asset_context_subs.remove(&coin);
2307
2308 log::debug!(
2309 "Last asset context subscription removed for coin '{coin}', unsubscribing from ActiveAssetCtx"
2310 );
2311 let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2312
2313 cmd_tx
2314 .send(HandlerCommand::UpdateAssetContextSubs {
2315 coin,
2316 data_types: AHashSet::new(),
2317 })
2318 .map_err(|e| {
2319 anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2320 })?;
2321
2322 self.send_unsubscription(&cmd_tx, subscription)?;
2323 } else {
2324 log::debug!(
2325 "Removed {data_type:?} from tracked types for coin '{coin}', but keeping ActiveAssetCtx subscription"
2326 );
2327
2328 cmd_tx
2329 .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2330 .map_err(|e| {
2331 anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2332 })?;
2333 }
2334 }
2335
2336 Ok(())
2337 }
2338
2339 fn send_subscription(
2340 &self,
2341 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2342 subscription: SubscriptionRequest,
2343 ) -> anyhow::Result<()> {
2344 self.send_reserved_command(cmd_tx, subscription, |subscription| {
2345 HandlerCommand::Subscribe {
2346 subscriptions: vec![subscription],
2347 }
2348 })
2349 }
2350
2351 fn send_reserved_command(
2352 &self,
2353 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2354 subscription: SubscriptionRequest,
2355 command: impl FnOnce(SubscriptionRequest) -> HandlerCommand,
2356 ) -> anyhow::Result<()> {
2357 let key = crate::websocket::handler::subscription_to_key(&subscription);
2358 let reserved = self
2359 .rate_limits
2360 .reserve_subscription(self.client_id, &subscription)
2361 .map_err(anyhow::Error::msg)?;
2362
2363 if let Err(e) = cmd_tx.send(command(subscription)) {
2364 if reserved {
2365 self.rate_limits.release_subscription(self.client_id, &key);
2366 }
2367 anyhow::bail!("Failed to send subscribe command: {e}");
2368 }
2369 Ok(())
2370 }
2371
2372 fn send_unsubscription(
2373 &self,
2374 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2375 subscription: SubscriptionRequest,
2376 ) -> anyhow::Result<()> {
2377 cmd_tx
2378 .send(HandlerCommand::Unsubscribe {
2379 subscriptions: vec![subscription],
2380 })
2381 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))
2382 }
2383
2384 pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
2388 if let Some(ref mut rx) = self.out_rx {
2389 rx.recv().await
2390 } else {
2391 None
2392 }
2393 }
2394
2395 fn release_limit_reservations(&self) {
2396 self.rate_limits.release_client(self.client_id);
2397 self.connection_permit.lock().take();
2398 }
2399}
2400
2401impl Drop for HyperliquidWebSocketClient {
2402 fn drop(&mut self) {
2403 if Arc::strong_count(&self.task_handle) == 1 {
2404 self.release_limit_reservations();
2405
2406 if self.task_handle.is_empty() {
2407 return;
2408 }
2409
2410 self.signal.store(true, Ordering::Relaxed);
2411 self.task_handle.abort();
2412
2413 if let Some(control) = &self.socket_control {
2414 control.deregister();
2415 }
2416 }
2417 }
2418}
2419
2420#[derive(Debug)]
2421pub(crate) enum PostRequestError {
2422 BeforeDispatch(HyperliquidError),
2423 AfterDispatch(HyperliquidError),
2424 Rejected {
2425 error: HyperliquidError,
2426 reason: String,
2427 },
2428}
2429
2430impl PostRequestError {
2431 fn into_error(self) -> HyperliquidError {
2432 match self {
2433 Self::BeforeDispatch(error)
2434 | Self::AfterDispatch(error)
2435 | Self::Rejected { error, .. } => error,
2436 }
2437 }
2438}
2439
2440fn cancel_errors_for_requests(
2441 errors: Vec<Option<String>>,
2442 request_count: usize,
2443) -> HyperliquidResult<Vec<Option<String>>> {
2444 if errors.is_empty() {
2445 return Ok(vec![None; request_count]);
2446 }
2447
2448 if errors.len() != request_count {
2449 return Err(HyperliquidError::exchange(format!(
2450 "Cancel orders returned {} statuses for {request_count} cancels",
2451 errors.len()
2452 )));
2453 }
2454
2455 Ok(errors)
2456}
2457
2458fn map_post_payload_error(payload: String, weight: u32) -> HyperliquidError {
2459 let lower = payload.to_ascii_lowercase();
2460 let message = format!("WebSocket post error: {payload}");
2461
2462 if starts_with_status(&lower, &["429"])
2463 || lower.contains("too many requests")
2464 || lower.contains("rate limit")
2465 {
2466 HyperliquidError::rate_limit("exchange", weight, None)
2467 } else if starts_with_status(&lower, &["401", "403"])
2468 || lower.contains("unauthorized")
2469 || lower.contains("forbidden")
2470 || lower.contains("authentication")
2471 || lower.contains("authorization")
2472 || lower.contains("invalid signature")
2473 || contains_word(&lower, "auth")
2474 {
2475 HyperliquidError::auth(message)
2476 } else if starts_with_status(&lower, &["400"]) || lower.contains("bad request") {
2477 HyperliquidError::bad_request(message)
2478 } else if starts_with_status(&lower, &["500", "502", "503", "504"]) {
2479 HyperliquidError::exchange(message)
2480 } else {
2481 HyperliquidError::exchange(payload)
2482 }
2483}
2484
2485fn hyperliquid_order_kind(
2486 order_type: OrderType,
2487 time_in_force: TimeInForce,
2488 post_only: bool,
2489 trigger_price: Option<Price>,
2490 normalize_prices_enabled: bool,
2491 price_precision: Option<u8>,
2492) -> HyperliquidResult<HyperliquidExchangeOrderKind> {
2493 match order_type {
2494 OrderType::Market => Ok(HyperliquidExchangeOrderKind::Limit {
2495 limit: HyperliquidExchangeLimitParams {
2496 tif: HyperliquidExchangeTif::Ioc,
2497 },
2498 }),
2499 OrderType::Limit => {
2500 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2501 .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2502 Ok(HyperliquidExchangeOrderKind::Limit {
2503 limit: HyperliquidExchangeLimitParams { tif },
2504 })
2505 }
2506 OrderType::StopMarket
2507 | OrderType::StopLimit
2508 | OrderType::MarketIfTouched
2509 | OrderType::LimitIfTouched => {
2510 let trigger_price = trigger_price.ok_or_else(|| {
2511 HyperliquidError::bad_request("Trigger orders require a trigger price")
2512 })?;
2513 let trigger_px = normalize_or_validate_wire_price(
2514 trigger_price.as_decimal(),
2515 "Trigger price",
2516 price_precision,
2517 normalize_prices_enabled,
2518 )
2519 .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2520 let tpsl = match order_type {
2521 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2522 OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2523 HyperliquidExchangeTpSl::Tp
2524 }
2525 _ => unreachable!(),
2526 };
2527 let is_market = matches!(
2528 order_type,
2529 OrderType::StopMarket | OrderType::MarketIfTouched
2530 );
2531
2532 Ok(HyperliquidExchangeOrderKind::Trigger {
2533 trigger: HyperliquidExchangeTriggerParams {
2534 is_market,
2535 trigger_px,
2536 tpsl,
2537 },
2538 })
2539 }
2540 _ => Err(HyperliquidError::bad_request(format!(
2541 "Order type {order_type:?} not supported"
2542 ))),
2543 }
2544}
2545
2546fn ensure_ws_action_accepted(
2547 response: &HyperliquidExchangeResponse,
2548 action_name: &str,
2549) -> HyperliquidResult<()> {
2550 if response.is_ok() {
2551 if let Some(error_msg) = extract_inner_errors(response).into_iter().flatten().next() {
2552 return Err(HyperliquidError::bad_request(format!(
2553 "{action_name} rejected: {error_msg}"
2554 )));
2555 }
2556
2557 if let Some(error_msg) = extract_inner_error(response) {
2558 return Err(HyperliquidError::bad_request(format!(
2559 "{action_name} rejected: {error_msg}"
2560 )));
2561 }
2562
2563 return Ok(());
2564 }
2565
2566 Err(HyperliquidError::bad_request(format!(
2567 "{action_name} failed: {}",
2568 extract_error_message(response)
2569 )))
2570}
2571
2572fn starts_with_status(payload: &str, statuses: &[&str]) -> bool {
2573 let trimmed = payload.trim_start();
2574 statuses
2575 .iter()
2576 .any(|status| starts_with_status_token(trimmed, status))
2577 || trimmed.strip_prefix("http").is_some_and(|rest| {
2578 let rest = rest
2579 .trim_start_matches(|c: char| c.is_ascii_whitespace() || matches!(c, ':' | '/'));
2580 statuses
2581 .iter()
2582 .any(|status| starts_with_status_token(rest, status))
2583 })
2584}
2585
2586fn starts_with_status_token(payload: &str, status: &str) -> bool {
2587 payload.strip_prefix(status).is_some_and(|rest| {
2588 rest.chars()
2589 .next()
2590 .is_none_or(|c| !c.is_ascii_alphanumeric())
2591 })
2592}
2593
2594fn contains_word(payload: &str, word: &str) -> bool {
2595 payload
2596 .split(|c: char| !c.is_ascii_alphanumeric())
2597 .any(|part| part == word)
2598}
2599
2600fn subscription_from_topic(topic: &str) -> anyhow::Result<SubscriptionRequest> {
2603 let (kind, rest) = topic
2604 .split_once(':')
2605 .map_or((topic, None), |(k, r)| (k, Some(r)));
2606
2607 let channel = HyperliquidWsChannel::from_wire_str(kind)
2608 .ok_or_else(|| anyhow::anyhow!("Unknown subscription channel: {kind}"))?;
2609
2610 match channel {
2611 HyperliquidWsChannel::AllMids => Ok(SubscriptionRequest::AllMids {
2612 dex: rest.map(|s| s.to_string()),
2613 }),
2614 HyperliquidWsChannel::AllDexsAssetCtxs => Ok(SubscriptionRequest::AllDexsAssetCtxs),
2615 HyperliquidWsChannel::Notification => Ok(SubscriptionRequest::Notification {
2616 user: rest.context("Missing user")?.to_string(),
2617 }),
2618 HyperliquidWsChannel::WebData2 => Ok(SubscriptionRequest::WebData2 {
2619 user: rest.context("Missing user")?.to_string(),
2620 }),
2621 HyperliquidWsChannel::Candle => {
2622 let rest = rest.context("Missing candle params")?;
2624 let (coin, interval_str) = rest.rsplit_once(':').context("Missing interval")?;
2625 let interval = HyperliquidBarInterval::from_str(interval_str)?;
2626 Ok(SubscriptionRequest::Candle {
2627 coin: Ustr::from(coin),
2628 interval,
2629 })
2630 }
2631 HyperliquidWsChannel::L2Book => Ok(SubscriptionRequest::L2Book {
2632 coin: Ustr::from(rest.context("Missing coin")?),
2633 mantissa: None,
2634 n_sig_figs: None,
2635 }),
2636 HyperliquidWsChannel::Trades => Ok(SubscriptionRequest::Trades {
2637 coin: Ustr::from(rest.context("Missing coin")?),
2638 }),
2639 HyperliquidWsChannel::OrderUpdates => Ok(SubscriptionRequest::OrderUpdates {
2640 user: rest.context("Missing user")?.to_string(),
2641 }),
2642 HyperliquidWsChannel::UserEvents => Ok(SubscriptionRequest::UserEvents {
2643 user: rest.context("Missing user")?.to_string(),
2644 }),
2645 HyperliquidWsChannel::UserFills => Ok(SubscriptionRequest::UserFills {
2646 user: rest.context("Missing user")?.to_string(),
2647 aggregate_by_time: None,
2648 }),
2649 HyperliquidWsChannel::UserFundings => Ok(SubscriptionRequest::UserFundings {
2650 user: rest.context("Missing user")?.to_string(),
2651 }),
2652 HyperliquidWsChannel::UserNonFundingLedgerUpdates => {
2653 Ok(SubscriptionRequest::UserNonFundingLedgerUpdates {
2654 user: rest.context("Missing user")?.to_string(),
2655 })
2656 }
2657 HyperliquidWsChannel::ActiveAssetCtx => Ok(SubscriptionRequest::ActiveAssetCtx {
2658 coin: Ustr::from(rest.context("Missing coin")?),
2659 }),
2660 HyperliquidWsChannel::ActiveSpotAssetCtx => Ok(SubscriptionRequest::ActiveSpotAssetCtx {
2661 coin: Ustr::from(rest.context("Missing coin")?),
2662 }),
2663 HyperliquidWsChannel::ActiveAssetData => {
2664 let rest = rest.context("Missing params")?;
2666 let (user, coin) = rest.split_once(':').context("Missing coin")?;
2667 Ok(SubscriptionRequest::ActiveAssetData {
2668 user: user.to_string(),
2669 coin: coin.to_string(),
2670 })
2671 }
2672 HyperliquidWsChannel::UserTwapSliceFills => Ok(SubscriptionRequest::UserTwapSliceFills {
2673 user: rest.context("Missing user")?.to_string(),
2674 }),
2675 HyperliquidWsChannel::UserTwapHistory => Ok(SubscriptionRequest::UserTwapHistory {
2676 user: rest.context("Missing user")?.to_string(),
2677 }),
2678 HyperliquidWsChannel::Bbo => Ok(SubscriptionRequest::Bbo {
2679 coin: Ustr::from(rest.context("Missing coin")?),
2680 }),
2681
2682 HyperliquidWsChannel::SubscriptionResponse
2684 | HyperliquidWsChannel::User
2685 | HyperliquidWsChannel::Post
2686 | HyperliquidWsChannel::Pong
2687 | HyperliquidWsChannel::Error => {
2688 anyhow::bail!("Not a subscription channel: {kind}")
2689 }
2690 }
2691}
2692
2693#[cfg(test)]
2694mod tests {
2695 use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
2696 use nautilus_model::{identifiers::ClientId, instruments::stubs::crypto_perpetual_ethusdt};
2697 use nautilus_network::mode::ReconnectRequestOutcome;
2698 use rstest::rstest;
2699 use ustr::Ustr;
2700
2701 use super::*;
2702 use crate::{
2703 common::{
2704 consts::{HYPERLIQUID_WS_POST_INFLIGHT_MAX, HYPERLIQUID_WS_SUBSCRIPTIONS_MAX},
2705 enums::HyperliquidBarInterval,
2706 },
2707 websocket::handler::subscription_to_key,
2708 };
2709
2710 #[rstest]
2711 #[case("wss://stream.example.com/feed", "wss://stream.example.com")]
2712 #[case(
2713 "wss://SYN-user:SYN-password@stream.example.com:9443/SYN-path?token=SYN-query#SYN-fragment",
2714 "wss://stream.example.com:9443"
2715 )]
2716 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
2717 let client = HyperliquidWebSocketClient::new(
2718 Some(url.to_string()),
2719 HyperliquidEnvironment::Testnet,
2720 None,
2721 TransportBackend::default(),
2722 None,
2723 );
2724 let debug = format!("{client:?}");
2725 assert!(debug.contains(&format!("url: {origin:?}")));
2726 assert!(!debug.contains("SYN-"));
2727 assert_eq!(client.url.expose(), url);
2728 }
2729
2730 #[rstest]
2731 fn test_debug_redacts_proxy_url() {
2732 let proxy_url = "http://user:password@proxy.example:8080";
2733 let client = HyperliquidWebSocketClient::new(
2734 Some("wss://test".to_string()),
2735 HyperliquidEnvironment::Testnet,
2736 None,
2737 TransportBackend::default(),
2738 Some(proxy_url.to_string()),
2739 );
2740
2741 let debug = format!("{client:?}");
2742
2743 assert!(debug.contains(REDACTED));
2744 assert!(!debug.contains(proxy_url));
2745 }
2746
2747 #[rstest]
2748 fn test_cache_all_dex_asset_ctxs_instrument_ids_keeps_dexes_absent_from_update() {
2749 let client = HyperliquidWebSocketClient::new(
2750 Some("wss://test".to_string()),
2751 HyperliquidEnvironment::Testnet,
2752 None,
2753 TransportBackend::default(),
2754 None,
2755 );
2756 let btc = Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"));
2757 let eth = Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"));
2758 let tsla = Some(InstrumentId::from("xyz:TSLA-USD-PERP.HYPERLIQUID"));
2759
2760 client.cache_all_dex_asset_ctxs_instrument_ids(AHashMap::from_iter([
2761 (Ustr::from(""), vec![btc]),
2762 (Ustr::from("xyz"), vec![tsla]),
2763 ]));
2764 client.cache_all_dex_asset_ctxs_instrument_ids(AHashMap::from_iter([(
2766 Ustr::from(""),
2767 vec![btc, eth],
2768 )]));
2769
2770 let cached = client.all_dex_asset_ctxs_instrument_ids.load();
2771 assert_eq!(cached.len(), 2);
2772 assert_eq!(cached.get(&Ustr::from("")), Some(&vec![btc, eth]));
2773 assert_eq!(cached.get(&Ustr::from("xyz")), Some(&vec![tsla]));
2774 }
2775
2776 #[tokio::test]
2777 async fn test_drop_clone_does_not_stop_handler() {
2778 let client = HyperliquidWebSocketClient::new(
2779 Some("wss://test".to_string()),
2780 HyperliquidEnvironment::Testnet,
2781 None,
2782 TransportBackend::default(),
2783 None,
2784 );
2785 client
2786 .task_handle
2787 .insert(get_runtime().spawn(std::future::pending()));
2788 let clone = client.clone();
2789
2790 drop(clone);
2791
2792 assert!(!client.signal.load(Ordering::Acquire));
2793 assert!(!client.task_handle.is_empty());
2794 }
2795
2796 fn subscription_topic(sub: &SubscriptionRequest) -> String {
2798 subscription_to_key(sub)
2799 }
2800
2801 #[rstest]
2802 #[case(SubscriptionRequest::Trades { coin: "BTC".into() }, "trades:BTC")]
2803 #[case(SubscriptionRequest::Bbo { coin: "BTC".into() }, "bbo:BTC")]
2804 #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() }, "orderUpdates:0x123")]
2805 #[case(SubscriptionRequest::UserEvents { user: "0xabc".to_string() }, "userEvents:0xabc")]
2806 fn test_subscription_topic_generation(
2807 #[case] subscription: SubscriptionRequest,
2808 #[case] expected_topic: &str,
2809 ) {
2810 assert_eq!(subscription_topic(&subscription), expected_topic);
2811 }
2812
2813 #[rstest]
2814 fn test_subscription_topics_unique() {
2815 let sub1 = SubscriptionRequest::Trades { coin: "BTC".into() };
2816 let sub2 = SubscriptionRequest::Bbo { coin: "BTC".into() };
2817
2818 let topic1 = subscription_topic(&sub1);
2819 let topic2 = subscription_topic(&sub2);
2820
2821 assert_ne!(topic1, topic2);
2822 }
2823
2824 #[rstest]
2825 #[case(SubscriptionRequest::Trades { coin: "BTC".into() })]
2826 #[case(SubscriptionRequest::Bbo { coin: "ETH".into() })]
2827 #[case(SubscriptionRequest::Candle { coin: "SOL".into(), interval: HyperliquidBarInterval::OneHour })]
2828 #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() })]
2829 #[case(SubscriptionRequest::Trades { coin: "vntls:vCURSOR".into() })]
2830 #[case(SubscriptionRequest::L2Book { coin: "vntls:vCURSOR".into(), mantissa: None, n_sig_figs: None })]
2831 #[case(SubscriptionRequest::Candle { coin: "vntls:vCURSOR".into(), interval: HyperliquidBarInterval::OneHour })]
2832 fn test_subscription_reconstruction(#[case] subscription: SubscriptionRequest) {
2833 let topic = subscription_topic(&subscription);
2834 let reconstructed = subscription_from_topic(&topic).expect("Failed to reconstruct");
2835 assert_eq!(subscription_topic(&reconstructed), topic);
2836 }
2837
2838 #[rstest]
2839 fn test_subscription_topic_candle() {
2840 let sub = SubscriptionRequest::Candle {
2841 coin: "BTC".into(),
2842 interval: HyperliquidBarInterval::OneHour,
2843 };
2844
2845 let topic = subscription_topic(&sub);
2846 assert_eq!(topic, "candle:BTC:1h");
2847 }
2848
2849 #[rstest]
2850 fn with_state_sink_survives_clone() {
2851 let client = HyperliquidWebSocketClient::new(
2852 None,
2853 HyperliquidEnvironment::Testnet,
2854 None,
2855 TransportBackend::default(),
2856 None,
2857 )
2858 .with_state_sink(SocketStateSink::new(|_| {}));
2859
2860 let cloned = client.clone();
2861 assert!(client.socket_sink.is_some());
2862 assert!(cloned.socket_sink.is_some());
2863 }
2864
2865 #[rstest]
2866 fn clone_can_own_socket_registration() {
2867 let registry = SocketReconnectRegistry::default();
2868 let endpoint = Ustr::from("hyperliquid-data-streams");
2869 let client = HyperliquidWebSocketClient::new(
2870 None,
2871 HyperliquidEnvironment::Testnet,
2872 None,
2873 TransportBackend::default(),
2874 None,
2875 )
2876 .with_socket_control(SocketControl::with_registry(
2877 ClientId::from("HYPERLIQUID"),
2878 None,
2879 endpoint,
2880 ®istry,
2881 ));
2882 let cloned = client.clone();
2883 let _sink = cloned.socket_control.as_ref().unwrap().sink();
2884 cloned
2885 .socket_control
2886 .as_ref()
2887 .unwrap()
2888 .register(|| ReconnectRequestOutcome::Accepted);
2889
2890 assert!(cloned.socket_control.is_some());
2891 assert!(cloned.socket_sink.is_none());
2892 assert!(
2893 registry
2894 .handle(ClientId::from("HYPERLIQUID"), endpoint)
2895 .is_some()
2896 );
2897
2898 client.socket_control.as_ref().unwrap().deregister();
2899 assert!(
2900 registry
2901 .handle(ClientId::from("HYPERLIQUID"), endpoint)
2902 .is_some()
2903 );
2904
2905 let handle = registry
2906 .handle(ClientId::from("HYPERLIQUID"), endpoint)
2907 .unwrap();
2908 assert_eq!(
2909 handle.request_reconnect(),
2910 SocketReconnectRequestOutcome::Accepted
2911 );
2912 drop(cloned);
2913 assert!(
2914 registry
2915 .handle(ClientId::from("HYPERLIQUID"), endpoint)
2916 .is_none()
2917 );
2918 }
2919
2920 #[rstest]
2921 fn set_post_timeout_updates_client_and_clone() {
2922 let mut client = HyperliquidWebSocketClient::new(
2923 None,
2924 HyperliquidEnvironment::Testnet,
2925 None,
2926 TransportBackend::default(),
2927 None,
2928 );
2929 let timeout = std::time::Duration::from_secs(7);
2930
2931 client.set_post_timeout(timeout);
2932
2933 assert_eq!(client.post_timeout, timeout);
2934 assert_eq!(client.clone().post_timeout, timeout);
2935 }
2936
2937 #[rstest]
2938 #[tokio::test]
2939 async fn post_action_command_without_credentials_is_not_sent() {
2940 let client = HyperliquidWebSocketClient::new(
2941 None,
2942 HyperliquidEnvironment::Testnet,
2943 None,
2944 TransportBackend::default(),
2945 None,
2946 );
2947 let signer = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 10, None).unwrap();
2948 let action = HyperliquidExchangeAction::Cancel {
2949 cancels: Vec::new(),
2950 fast: None,
2951 };
2952 let result = client.post_action_command(&signer, &action).await;
2953 let PostRequestError::BeforeDispatch(error) = result.unwrap_err() else {
2954 panic!("Expected failure before dispatch");
2955 };
2956 assert_eq!(
2957 error.to_string(),
2958 "auth error: credentials required for exchange operations"
2959 );
2960 }
2961
2962 #[rstest]
2963 #[tokio::test]
2964 async fn wait_for_subscriptions_confirmed_waits_for_pending_topics() {
2965 let client = HyperliquidWebSocketClient::new(
2966 None,
2967 HyperliquidEnvironment::Testnet,
2968 None,
2969 TransportBackend::default(),
2970 None,
2971 );
2972 let topic = "orderUpdates:0xabc";
2973 client.subscriptions.mark_subscribe(topic);
2974
2975 let pending = client
2976 .wait_for_subscriptions_confirmed(Duration::from_millis(30))
2977 .await;
2978 client.subscriptions.confirm_subscribe(topic);
2979 let confirmed = client
2980 .wait_for_subscriptions_confirmed(Duration::from_millis(30))
2981 .await;
2982
2983 assert_eq!(
2984 pending.unwrap_err().to_string(),
2985 r#"Subscriptions still pending after 30ms: ["orderUpdates:0xabc"]"#,
2986 );
2987 assert!(confirmed.is_ok());
2988 }
2989
2990 #[tokio::test]
2991 async fn failed_connect_releases_connection_slot() {
2992 let mut client = HyperliquidWebSocketClient::new(
2993 Some("invalid-websocket-url".to_string()),
2994 HyperliquidEnvironment::Testnet,
2995 None,
2996 TransportBackend::default(),
2997 None,
2998 );
2999 let available_before = client.rate_limits.connection_slots.available_permits();
3000
3001 client
3002 .connect()
3003 .await
3004 .expect_err("invalid URL should fail the connection attempt");
3005
3006 assert!(client.connection_permit.lock().is_none());
3007 assert_eq!(
3008 client.rate_limits.connection_slots.available_permits(),
3009 available_before
3010 );
3011 }
3012
3013 #[rstest]
3014 #[case::before_dispatch(false)]
3015 #[case::after_dispatch(true)]
3016 #[tokio::test(start_paused = true)]
3017 async fn post_request_timeout_preserves_dispatch_evidence(#[case] dispatched: bool) {
3018 let client = HyperliquidWebSocketClient::new(
3019 None,
3020 HyperliquidEnvironment::Testnet,
3021 None,
3022 TransportBackend::default(),
3023 None,
3024 );
3025 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3026 *client.cmd_tx.write().await = tx;
3027 let timeout = if dispatched {
3028 Duration::from_millis(100)
3029 } else {
3030 Duration::ZERO
3031 };
3032 let started = tokio::time::Instant::now();
3033 let failure = client
3034 .send_post_request_result(
3035 PostRequest::Info {
3036 payload: serde_json::json!({"type": "meta"}),
3037 },
3038 timeout,
3039 )
3040 .await
3041 .unwrap_err();
3042
3043 assert_eq!(tokio::time::Instant::now() - started, timeout);
3044
3045 if dispatched {
3046 assert!(matches!(
3047 failure,
3048 PostRequestError::AfterDispatch(HyperliquidError::Timeout)
3049 ));
3050 let HandlerCommand::Post {
3051 id,
3052 request,
3053 deadline,
3054 cancellation_token,
3055 } = rx.try_recv().unwrap()
3056 else {
3057 panic!("Expected post command");
3058 };
3059 assert_eq!(id, 1);
3060 assert_eq!(
3061 serde_json::to_value(request).unwrap(),
3062 serde_json::json!({"type": "info", "payload": {"type": "meta"}})
3063 );
3064 assert_eq!(deadline, started + timeout);
3065 assert!(cancellation_token.is_cancelled());
3066 } else {
3067 assert!(matches!(
3068 failure,
3069 PostRequestError::BeforeDispatch(HyperliquidError::Timeout)
3070 ));
3071 }
3072 assert!(matches!(
3073 rx.try_recv(),
3074 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3075 ));
3076 }
3077
3078 #[rstest]
3079 #[tokio::test(flavor = "multi_thread")]
3080 async fn send_post_request_times_out_while_waiting_for_inflight_slot() {
3081 let client = HyperliquidWebSocketClient::new(
3082 None,
3083 HyperliquidEnvironment::Testnet,
3084 None,
3085 TransportBackend::default(),
3086 None,
3087 );
3088 let mut receivers = Vec::with_capacity(HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3089 for offset in 0..HYPERLIQUID_WS_POST_INFLIGHT_MAX {
3090 receivers.push(
3091 client
3092 .post_router
3093 .register(10_000 + offset as u64)
3094 .await
3095 .unwrap(),
3096 );
3097 }
3098
3099 let err = client
3100 .send_post_request(
3101 PostRequest::Info {
3102 payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
3103 },
3104 std::time::Duration::from_millis(25),
3105 )
3106 .await
3107 .expect_err("request should timeout before acquiring an inflight slot");
3108
3109 assert!(matches!(err, HyperliquidError::Timeout));
3110 assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3111 }
3112
3113 #[rstest]
3114 #[tokio::test(flavor = "multi_thread")]
3115 async fn send_post_request_shares_inflight_limit_across_clients() {
3116 let url = Some("wss://shared-post-limit.test/ws".to_string());
3117 let first = HyperliquidWebSocketClient::new(
3118 url.clone(),
3119 HyperliquidEnvironment::Testnet,
3120 None,
3121 TransportBackend::default(),
3122 None,
3123 );
3124 let second = HyperliquidWebSocketClient::new(
3125 url,
3126 HyperliquidEnvironment::Testnet,
3127 None,
3128 TransportBackend::default(),
3129 None,
3130 );
3131 let mut receivers = Vec::with_capacity(HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3132
3133 for offset in 0..HYPERLIQUID_WS_POST_INFLIGHT_MAX / 2 {
3134 receivers.push(first.post_router.register(offset as u64).await.unwrap());
3135 receivers.push(
3136 second
3137 .post_router
3138 .register(10_000 + offset as u64)
3139 .await
3140 .unwrap(),
3141 );
3142 }
3143
3144 let error = second
3145 .send_post_request(
3146 PostRequest::Info {
3147 payload: serde_json::json!({"type": "meta"}),
3148 },
3149 Duration::from_millis(25),
3150 )
3151 .await
3152 .expect_err("shared in-flight limit should block the next post");
3153
3154 assert!(matches!(error, HyperliquidError::Timeout));
3155 assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3156 }
3157
3158 #[rstest]
3159 fn subscription_limit_is_rejected_before_queueing_across_clients() {
3160 let url = Some("wss://shared-subscription-limit.test/ws".to_string());
3161 let first = HyperliquidWebSocketClient::new(
3162 url.clone(),
3163 HyperliquidEnvironment::Testnet,
3164 None,
3165 TransportBackend::default(),
3166 None,
3167 );
3168 let second = HyperliquidWebSocketClient::new(
3169 url,
3170 HyperliquidEnvironment::Testnet,
3171 None,
3172 TransportBackend::default(),
3173 None,
3174 );
3175 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3176
3177 for index in 0..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
3178 let subscription = SubscriptionRequest::Trades {
3179 coin: Ustr::from(&format!("COIN-{index}")),
3180 };
3181 let client = if index % 2 == 0 { &first } else { &second };
3182 client.send_subscription(&cmd_tx, subscription).unwrap();
3183 }
3184
3185 let error = first
3186 .send_subscription(
3187 &cmd_tx,
3188 SubscriptionRequest::Trades {
3189 coin: Ustr::from("OVER-LIMIT"),
3190 },
3191 )
3192 .expect_err("shared subscription limit should reject the next subscription");
3193
3194 assert!(error.to_string().contains("at most 1000"));
3195 assert_eq!(cmd_rx.len(), HYPERLIQUID_WS_SUBSCRIPTIONS_MAX);
3196 }
3197
3198 #[rstest]
3199 #[tokio::test]
3200 async fn subscribe_book_gated_at_subscription_limit_releases_book_stream() {
3201 let client = HyperliquidWebSocketClient::new(
3202 Some("wss://gated-book-limit.test/ws".to_string()),
3203 HyperliquidEnvironment::Testnet,
3204 None,
3205 TransportBackend::default(),
3206 None,
3207 );
3208 let instrument = InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt());
3209 let coin = instrument.raw_symbol().inner();
3210 client.cache_instrument(instrument.clone());
3211 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3212 *client.cmd_tx.write().await = cmd_tx.clone();
3213
3214 for index in 0..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
3215 let subscription = SubscriptionRequest::Trades {
3216 coin: Ustr::from(&format!("COIN-{index}")),
3217 };
3218
3219 client.send_subscription(&cmd_tx, subscription).unwrap();
3220 }
3221
3222 let result = client
3223 .subscribe_book_gated(
3224 instrument.id(),
3225 None,
3226 None,
3227 CancellationToken::new(),
3228 SnapshotGate::default(),
3229 )
3230 .await;
3231
3232 let error = result.expect_err("subscription limit should reject the book stream");
3233 assert!(error.to_string().contains("at most 1000"));
3234 assert_eq!(client.book_streams.options(&coin), None);
3235 }
3236
3237 #[rstest]
3238 fn stream_resubscribe_queues_one_atomic_command() {
3239 let client = HyperliquidWebSocketClient::new(
3240 Some("wss://atomic-resubscribe.test/ws".to_string()),
3241 HyperliquidEnvironment::Testnet,
3242 None,
3243 TransportBackend::default(),
3244 None,
3245 );
3246 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3247 let subscription = SubscriptionRequest::Bbo {
3248 coin: Ustr::from("BTC"),
3249 };
3250
3251 client
3252 .send_stream_resubscribe(&cmd_tx, subscription)
3253 .unwrap();
3254
3255 assert!(matches!(
3256 cmd_rx.try_recv(),
3257 Ok(HandlerCommand::Resubscribe { subscription })
3258 if subscription_to_key(&subscription) == "bbo:BTC"
3259 ));
3260 assert!(matches!(
3261 cmd_rx.try_recv(),
3262 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3263 ));
3264 }
3265
3266 #[rstest]
3267 #[tokio::test(start_paused = true)]
3268 async fn send_post_request_uses_deadline_while_waiting_for_command_channel() {
3269 let client = HyperliquidWebSocketClient::new(
3270 None,
3271 HyperliquidEnvironment::Testnet,
3272 None,
3273 TransportBackend::default(),
3274 None,
3275 );
3276 let _cmd_tx = client.cmd_tx.write().await;
3277 let timeout = Duration::from_millis(100);
3278 let started = tokio::time::Instant::now();
3279
3280 let error = client
3281 .send_post_request(
3282 PostRequest::Info {
3283 payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
3284 },
3285 timeout,
3286 )
3287 .await
3288 .unwrap_err();
3289
3290 assert!(matches!(error, HyperliquidError::Timeout));
3291 assert_eq!(tokio::time::Instant::now() - started, timeout);
3292 client
3293 .post_router
3294 .register(1)
3295 .await
3296 .expect("post ID should be reusable when timeout returns");
3297 }
3298
3299 #[rstest]
3300 #[tokio::test(start_paused = true)]
3301 async fn post_response_ready_before_deadline_wins_deadline_race() {
3302 let client = HyperliquidWebSocketClient::new(
3303 None,
3304 HyperliquidEnvironment::Testnet,
3305 None,
3306 TransportBackend::default(),
3307 None,
3308 );
3309 let id = 1;
3310 let cancellation_token = CancellationToken::new();
3311 let rx = client
3312 .post_router
3313 .register_with_cancellation(id, &cancellation_token)
3314 .await
3315 .unwrap();
3316 let payload = serde_json::json!({"type": "meta", "data": {"universe": []}});
3317 client
3318 .post_router
3319 .complete(PostResponse {
3320 id,
3321 response: PostResponsePayload::Info {
3322 payload: payload.clone(),
3323 },
3324 })
3325 .await;
3326
3327 let response = client
3328 .await_post_response(id, rx, tokio::time::Instant::now(), &cancellation_token)
3329 .await
3330 .expect("ready response should win");
3331
3332 assert_eq!(response.id, id);
3333 let PostResponsePayload::Info {
3334 payload: response_payload,
3335 } = response.response
3336 else {
3337 panic!("expected info response");
3338 };
3339 assert_eq!(response_payload, payload);
3340 }
3341
3342 #[rstest]
3343 #[tokio::test]
3344 async fn dropping_post_request_cancels_work_and_releases_registration() {
3345 let client = HyperliquidWebSocketClient::new(
3346 None,
3347 HyperliquidEnvironment::Testnet,
3348 None,
3349 TransportBackend::default(),
3350 None,
3351 );
3352 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3353 *client.cmd_tx.write().await = cmd_tx;
3354 let request_client = client.clone();
3355 let task = tokio::spawn(async move {
3356 request_client
3357 .send_post_request(
3358 PostRequest::Info {
3359 payload: serde_json::json!({"type": "userRateLimit", "user": "0x123"}),
3360 },
3361 Duration::from_secs(60),
3362 )
3363 .await
3364 });
3365 let command = cmd_rx.recv().await.expect("post command should be queued");
3366 let HandlerCommand::Post {
3367 id,
3368 cancellation_token,
3369 ..
3370 } = command
3371 else {
3372 panic!("expected post command");
3373 };
3374
3375 task.abort();
3376 assert!(task.await.unwrap_err().is_cancelled());
3377 assert!(cancellation_token.is_cancelled());
3378
3379 let reused = tokio::time::timeout(Duration::from_secs(1), async {
3380 loop {
3381 match client.post_router.register(id).await {
3382 Ok(rx) => break Ok(rx),
3383 Err(e) if e.to_string().contains("already registered") => {
3384 tokio::task::yield_now().await;
3385 }
3386 Err(e) => break Err(e),
3387 }
3388 }
3389 })
3390 .await
3391 .expect("post ID should be reusable after cancellation")
3392 .expect("post ID reuse should succeed");
3393 client
3394 .post_router
3395 .cancel_registration(id, &cancellation_token)
3396 .await;
3397 client
3398 .post_router
3399 .register(id)
3400 .await
3401 .expect_err("stale cancellation must not remove the reused registration");
3402
3403 let mut receivers = vec![reused];
3404 tokio::time::timeout(Duration::from_secs(1), async {
3405 for offset in 1..HYPERLIQUID_WS_POST_INFLIGHT_MAX {
3406 receivers.push(
3407 client
3408 .post_router
3409 .register(10_000 + offset as u64)
3410 .await
3411 .unwrap(),
3412 );
3413 }
3414 })
3415 .await
3416 .expect("cancellation should release the inflight permit");
3417
3418 assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3419 }
3420
3421 #[rstest]
3422 #[tokio::test(start_paused = true)]
3423 async fn post_timeout_after_queueing_preserves_unknown_outcome_and_releases_registration() {
3424 let client = HyperliquidWebSocketClient::new(
3425 None,
3426 HyperliquidEnvironment::Testnet,
3427 None,
3428 TransportBackend::default(),
3429 None,
3430 );
3431 let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3432 *client.cmd_tx.write().await = cmd_tx;
3433 let request_client = client.clone();
3434 let task = tokio::spawn(async move {
3435 request_client
3436 .send_post_request(
3437 PostRequest::Info {
3438 payload: serde_json::json!({"type": "userRateLimit", "user": "0x123"}),
3439 },
3440 Duration::from_millis(100),
3441 )
3442 .await
3443 });
3444 let command = cmd_rx.recv().await.expect("post command should be queued");
3445 let HandlerCommand::Post {
3446 id,
3447 cancellation_token,
3448 ..
3449 } = command
3450 else {
3451 panic!("expected post command");
3452 };
3453
3454 let error = task.await.unwrap().unwrap_err();
3455
3456 assert!(matches!(&error, HyperliquidError::Timeout));
3457 assert!(error.is_transport_error());
3458 assert!(cancellation_token.is_cancelled());
3459 client
3460 .post_router
3461 .register(id)
3462 .await
3463 .expect("post ID should be reusable when timeout returns");
3464 }
3465
3466 #[rstest]
3467 fn cancel_errors_for_requests_accepts_empty_as_success() {
3468 let errors = cancel_errors_for_requests(Vec::new(), 2).unwrap();
3469
3470 assert_eq!(errors, vec![None, None]);
3471 }
3472
3473 #[rstest]
3474 fn cancel_errors_for_requests_rejects_status_count_mismatch() {
3475 let err = cancel_errors_for_requests(vec![None], 2).expect_err("mismatch should fail");
3476
3477 assert!(
3478 err.to_string()
3479 .contains("returned 1 statuses for 2 cancels")
3480 );
3481 }
3482
3483 #[rstest]
3484 fn test_post_payload_error_maps_rate_limit() {
3485 let err = map_post_payload_error("429 Too Many Requests".to_string(), 3);
3486
3487 assert!(matches!(
3488 err,
3489 HyperliquidError::RateLimit {
3490 scope: "exchange",
3491 weight: 3,
3492 retry_after_ms: None,
3493 }
3494 ));
3495 }
3496
3497 #[rstest]
3498 #[case("401 Unauthorized")]
3499 #[case("HTTP 403: forbidden")]
3500 #[case("invalid signature")]
3501 #[case("authentication failed")]
3502 fn test_post_payload_error_maps_auth(#[case] payload: &str) {
3503 let err = map_post_payload_error(payload.to_string(), 1);
3504
3505 assert!(matches!(err, HyperliquidError::Auth(_)));
3506 }
3507
3508 #[rstest]
3509 #[case("400 Bad Request")]
3510 #[case("HTTP 400: malformed payload")]
3511 #[case("bad request: missing action")]
3512 fn test_post_payload_error_maps_bad_request(#[case] payload: &str) {
3513 let err = map_post_payload_error(payload.to_string(), 1);
3514
3515 assert!(matches!(err, HyperliquidError::BadRequest(_)));
3516 }
3517
3518 #[rstest]
3519 #[case("500 Internal Server Error")]
3520 #[case("HTTP 503: service unavailable")]
3521 fn test_post_payload_error_maps_exchange_status(#[case] payload: &str) {
3522 let err = map_post_payload_error(payload.to_string(), 1);
3523
3524 assert!(matches!(err, HyperliquidError::Exchange(_)));
3525 }
3526
3527 #[rstest]
3528 #[case("order 429001 rejected")]
3529 #[case("asset 5001 is not tradable")]
3530 #[case("authoritative nonce window exceeded")]
3531 fn test_post_payload_error_does_not_match_embedded_codes_or_words(#[case] payload: &str) {
3532 let err = map_post_payload_error(payload.to_string(), 1);
3533
3534 assert!(matches!(err, HyperliquidError::Exchange(_)));
3535 }
3536}