1use std::{
17 str::FromStr,
18 sync::{
19 Arc, Mutex,
20 atomic::{AtomicBool, AtomicU8, Ordering},
21 },
22 time::Duration,
23};
24
25use ahash::{AHashMap, AHashSet};
26use anyhow::Context;
27use arc_swap::ArcSwap;
28use dashmap::DashMap;
29use nautilus_common::{
30 cache::{InstrumentLookupError, fifo::FifoCacheMap},
31 live::get_runtime,
32};
33use nautilus_core::{AtomicMap, MUTEX_POISONED};
34use nautilus_model::{
35 data::BarType,
36 enums::{OrderSide, OrderType, TimeInForce},
37 identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
38 instruments::{Instrument, InstrumentAny},
39 orders::{Order, OrderAny},
40 reports::OrderStatusReport,
41 types::{Price, Quantity},
42};
43use nautilus_network::{
44 mode::ConnectionMode,
45 websocket::{
46 AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
47 channel_message_handler,
48 },
49};
50use rust_decimal::Decimal;
51use ustr::Ustr;
52
53use crate::{
54 common::{
55 consts::{HTTP_TIMEOUT, ws_url},
56 enums::{HyperliquidBarInterval, HyperliquidEnvironment},
57 parse::{
58 bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
59 determine_order_list_grouping, extract_error_message, extract_inner_error,
60 extract_inner_errors, normalize_price,
61 order_to_hyperliquid_request_with_asset_and_cloid, round_to_sig_figs,
62 time_in_force_to_hyperliquid_tif,
63 },
64 },
65 http::{
66 client::HyperliquidHttpClient,
67 error::{Error as HyperliquidError, Result as HyperliquidResult},
68 models::{
69 HyperliquidExchangeResponse, HyperliquidExecAction,
70 HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
71 HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecModifyOrderRequest,
72 HyperliquidExecModifyTarget, HyperliquidExecOrderKind,
73 HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
74 HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
75 },
76 rate_limits::{WeightedLimiter, exec_action_weight},
77 },
78 websocket::{
79 book::{BookStreamOptions, BookStreamRegistry, BookStreamRelease, BookStreamUse},
80 enums::HyperliquidWsChannel,
81 handler::{FeedHandler, HandlerCommand},
82 messages::{
83 NautilusWsMessage, PostRequest, PostResponse, PostResponsePayload, SubscriptionRequest,
84 },
85 post::{PostIds, PostRouter},
86 trades::{TradeStreamRegistry, TradeStreamUse},
87 },
88};
89
90const HYPERLIQUID_HEARTBEAT_MSG: &str = r#"{"method":"ping"}"#;
91
92pub(super) const CLOID_CACHE_CAPACITY: usize = 10_000;
95
96pub(super) type CloidCache = Arc<Mutex<FifoCacheMap<Ustr, ClientOrderId, CLOID_CACHE_CAPACITY>>>;
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101pub(super) enum AssetContextDataType {
102 MarkPrice,
103 IndexPrice,
104 FundingRate,
105 OpenInterest,
106}
107
108#[derive(Debug)]
113#[cfg_attr(
114 feature = "python",
115 pyo3::pyclass(
116 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
117 from_py_object
118 )
119)]
120#[cfg_attr(
121 feature = "python",
122 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
123)]
124pub struct HyperliquidWebSocketClient {
125 url: String,
126 connection_mode: Arc<ArcSwap<AtomicU8>>,
127 signal: Arc<AtomicBool>,
128 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
129 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
130 auth_tracker: AuthTracker,
131 subscriptions: SubscriptionState,
132 book_streams: BookStreamRegistry,
133 trade_streams: TradeStreamRegistry,
134 trade_stream_lock: Arc<Mutex<()>>,
135 quote_streams: Arc<DashMap<Ustr, ()>>,
136 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
137 bar_types: Arc<AtomicMap<String, BarType>>,
138 asset_context_subs: Arc<DashMap<Ustr, AHashSet<AssetContextDataType>>>,
139 all_dex_asset_ctxs_instrument_ids: Arc<AtomicMap<Ustr, Vec<Option<InstrumentId>>>>,
140 cloid_cache: CloidCache,
141 post_router: Arc<PostRouter>,
142 post_ids: Arc<PostIds>,
143 post_limiter: Arc<WeightedLimiter>,
144 post_timeout: Duration,
145 task_handle: Option<tokio::task::JoinHandle<()>>,
146 account_id: Option<AccountId>,
147 transport_backend: TransportBackend,
148 proxy_url: Option<String>,
149}
150
151impl Clone for HyperliquidWebSocketClient {
152 fn clone(&self) -> Self {
153 Self {
154 url: self.url.clone(),
155 connection_mode: Arc::clone(&self.connection_mode),
156 signal: Arc::clone(&self.signal),
157 cmd_tx: Arc::clone(&self.cmd_tx),
158 out_rx: None,
159 auth_tracker: self.auth_tracker.clone(),
160 subscriptions: self.subscriptions.clone(),
161 book_streams: self.book_streams.clone(),
162 trade_streams: self.trade_streams.clone(),
163 trade_stream_lock: Arc::clone(&self.trade_stream_lock),
164 quote_streams: Arc::clone(&self.quote_streams),
165 instruments: Arc::clone(&self.instruments),
166 bar_types: Arc::clone(&self.bar_types),
167 asset_context_subs: Arc::clone(&self.asset_context_subs),
168 all_dex_asset_ctxs_instrument_ids: Arc::clone(&self.all_dex_asset_ctxs_instrument_ids),
169 cloid_cache: Arc::clone(&self.cloid_cache),
170 post_router: Arc::clone(&self.post_router),
171 post_ids: Arc::clone(&self.post_ids),
172 post_limiter: Arc::clone(&self.post_limiter),
173 post_timeout: self.post_timeout,
174 task_handle: None,
175 account_id: self.account_id,
176 transport_backend: self.transport_backend,
177 proxy_url: self.proxy_url.clone(),
178 }
179 }
180}
181
182impl HyperliquidWebSocketClient {
183 pub fn new(
191 url: Option<String>,
192 environment: HyperliquidEnvironment,
193 account_id: Option<AccountId>,
194 transport_backend: TransportBackend,
195 proxy_url: Option<String>,
196 ) -> Self {
197 let url = url.unwrap_or_else(|| ws_url(environment).to_string());
198 let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
199 ConnectionMode::Closed as u8,
200 ))));
201 Self {
202 url,
203 connection_mode,
204 signal: Arc::new(AtomicBool::new(false)),
205 auth_tracker: AuthTracker::new(),
206 subscriptions: SubscriptionState::new(':'),
207 book_streams: BookStreamRegistry::default(),
208 trade_streams: TradeStreamRegistry::default(),
209 trade_stream_lock: Arc::new(Mutex::new(())),
210 quote_streams: Arc::new(DashMap::new()),
211 instruments: Arc::new(AtomicMap::new()),
212 bar_types: Arc::new(AtomicMap::new()),
213 asset_context_subs: Arc::new(DashMap::new()),
214 all_dex_asset_ctxs_instrument_ids: Arc::new(AtomicMap::new()),
215 cloid_cache: Arc::new(Mutex::new(FifoCacheMap::new())),
216 post_router: PostRouter::new(),
217 post_ids: Arc::new(PostIds::new(1)),
218 post_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
219 post_timeout: HTTP_TIMEOUT,
220 cmd_tx: {
221 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
223 Arc::new(tokio::sync::RwLock::new(tx))
224 },
225 out_rx: None,
226 task_handle: None,
227 account_id,
228 transport_backend,
229 proxy_url,
230 }
231 }
232
233 pub async fn connect(&mut self) -> anyhow::Result<()> {
235 if self.is_active() {
236 log::warn!("WebSocket already connected");
237 return Ok(());
238 }
239
240 self.book_streams.clear();
243
244 let (message_handler, raw_rx) = channel_message_handler();
245 let cfg = WebSocketConfig {
246 url: self.url.clone(),
247 headers: vec![],
248 heartbeat: Some(30),
249 heartbeat_msg: Some(HYPERLIQUID_HEARTBEAT_MSG.to_string()),
250 reconnect_timeout_ms: Some(15_000),
251 reconnect_delay_initial_ms: Some(250),
252 reconnect_delay_max_ms: Some(5_000),
253 reconnect_backoff_factor: Some(2.0),
254 reconnect_jitter_ms: Some(200),
255 reconnect_max_attempts: None,
256 idle_timeout_ms: None,
257 backend: self.transport_backend,
258 proxy_url: self.proxy_url.clone(),
259 };
260 let client =
261 WebSocketClient::connect(cfg, Some(message_handler), None, None, vec![], None).await?;
262
263 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
265 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
266
267 *self.cmd_tx.write().await = cmd_tx.clone();
270 self.out_rx = Some(out_rx);
271
272 self.connection_mode.store(client.connection_mode_atomic());
273 log::debug!("Hyperliquid WebSocket connected: {}", self.url);
274
275 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
277 anyhow::bail!("Failed to send SetClient command: {e}");
278 }
279
280 let instruments_vec: Vec<InstrumentAny> =
282 self.instruments.load().values().cloned().collect();
283
284 if !instruments_vec.is_empty()
285 && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
286 {
287 log::error!("Failed to send InitializeInstruments: {e}");
288 }
289
290 for (coin, uses) in self.trade_streams.snapshot() {
291 if let Err(e) = cmd_tx.send(HandlerCommand::UpdateTradeSubs { coin, uses }) {
292 log::error!("Failed to send UpdateTradeSubs: {e}");
293 }
294 }
295
296 let all_dex_asset_ctxs_instrument_ids = self
297 .all_dex_asset_ctxs_instrument_ids
298 .load()
299 .iter()
300 .map(|(dex, instrument_ids)| (*dex, instrument_ids.clone()))
301 .collect();
302
303 if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(
304 all_dex_asset_ctxs_instrument_ids,
305 )) {
306 log::error!("Failed to send CacheAllDexAssetCtxsInstrumentIds: {e}");
307 }
308
309 let signal = Arc::clone(&self.signal);
311 let account_id = self.account_id;
312 let subscriptions = self.subscriptions.clone();
313 let book_streams = self.book_streams.clone();
314 let cmd_tx_for_reconnect = cmd_tx.clone();
315 let cloid_cache = Arc::clone(&self.cloid_cache);
316 let post_router = Arc::clone(&self.post_router);
317
318 let stream_handle = get_runtime().spawn(async move {
319 let mut handler = FeedHandler::new(
320 signal,
321 cmd_rx,
322 raw_rx,
323 out_tx,
324 account_id,
325 subscriptions.clone(),
326 cloid_cache,
327 post_router,
328 );
329
330 let resubscribe_all = || {
331 let topics = subscriptions.all_topics();
332 if topics.is_empty() {
333 log::debug!("No active subscriptions to restore after reconnection");
334 return;
335 }
336
337 log::info!(
338 "Resubscribing to {} active subscriptions after reconnection",
339 topics.len()
340 );
341
342 for topic in topics {
343 match subscription_from_topic(&topic) {
344 Ok(mut subscription) => {
345 if let SubscriptionRequest::L2Book {
348 coin,
349 n_sig_figs,
350 mantissa,
351 } = &mut subscription
352 && let Some(options) = book_streams.options(coin)
353 {
354 *n_sig_figs = options.n_sig_figs;
355 *mantissa = options.mantissa;
356 }
357
358 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
359 subscriptions: vec![subscription],
360 }) {
361 log::error!("Failed to send resubscribe command: {e}");
362 }
363 }
364 Err(e) => {
365 log::error!(
366 "Failed to reconstruct subscription from topic: topic={topic}, {e}"
367 );
368 }
369 }
370 }
371 };
372
373 loop {
374 match handler.next().await {
375 Some(NautilusWsMessage::Reconnected) => {
376 log::info!("WebSocket reconnected");
377 resubscribe_all();
378 }
379 Some(msg) => {
380 if handler.send(msg).is_err() {
381 if handler.is_stopped() {
382 log::debug!("Failed to send message (receiver dropped)");
383 } else {
384 log::error!("Failed to send message (receiver dropped)");
385 }
386 break;
387 }
388 }
389 None => {
390 if handler.is_stopped() {
391 log::debug!("Stop signal received, ending message processing");
392 break;
393 }
394 log::warn!("WebSocket stream ended unexpectedly");
395 break;
396 }
397 }
398 }
399 log::debug!("Handler task completed");
400 });
401 self.task_handle = Some(stream_handle);
402 Ok(())
403 }
404
405 pub fn take_task_handle(&mut self) -> Option<tokio::task::JoinHandle<()>> {
408 self.task_handle.take()
409 }
410
411 pub fn set_task_handle(&mut self, handle: tokio::task::JoinHandle<()>) {
412 self.task_handle = Some(handle);
413 }
414
415 pub fn set_post_timeout(&mut self, timeout: Duration) {
416 self.post_timeout = timeout;
417 }
418
419 pub(crate) fn abort(&mut self) {
422 self.signal.store(true, Ordering::Relaxed);
423 self.connection_mode
424 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
425
426 if let Some(handle) = self.task_handle.take() {
427 handle.abort();
428 }
429 }
430
431 pub(crate) fn reset_runtime_state(&mut self) {
437 self.subscriptions = SubscriptionState::new(':');
438 self.book_streams = BookStreamRegistry::default();
439 self.trade_streams = TradeStreamRegistry::default();
440 self.trade_stream_lock = Arc::new(Mutex::new(()));
441 self.quote_streams = Arc::new(DashMap::new());
442 self.instruments = Arc::new(AtomicMap::new());
443 self.bar_types = Arc::new(AtomicMap::new());
444 self.asset_context_subs = Arc::new(DashMap::new());
445 self.all_dex_asset_ctxs_instrument_ids = Arc::new(AtomicMap::new());
446 self.cloid_cache = Arc::new(Mutex::new(FifoCacheMap::new()));
447 self.out_rx = None;
448 self.connection_mode
449 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
450 self.signal.store(false, Ordering::Relaxed);
451 }
452
453 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
455 log::debug!("Disconnecting Hyperliquid WebSocket");
456 self.signal.store(true, Ordering::Relaxed);
457
458 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
459 log::debug!(
460 "Failed to send disconnect command (handler may already be shut down): {e}"
461 );
462 }
463
464 if let Some(handle) = self.task_handle.take() {
465 log::debug!("Waiting for task handle to complete");
466 let abort_handle = handle.abort_handle();
467 tokio::select! {
468 result = handle => {
469 match result {
470 Ok(()) => log::debug!("Task handle completed successfully"),
471 Err(e) if e.is_cancelled() => {
472 log::debug!("Task was cancelled");
473 }
474 Err(e) => log::error!("Task handle encountered an error: {e:?}"),
475 }
476 }
477 () = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {
478 log::warn!("Timeout waiting for task handle, aborting task");
479 abort_handle.abort();
480 }
481 }
482 } else {
483 log::debug!("No task handle to await");
484 }
485 log::debug!("Disconnected");
486 Ok(())
487 }
488
489 pub fn request_reconnect(&self) -> bool {
497 ConnectionMode::request_reconnect(&self.connection_mode.load())
498 }
499
500 pub async fn post_action_exec(
506 &self,
507 signer: &HyperliquidHttpClient,
508 action: &HyperliquidExecAction,
509 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
510 self.post_action_exec_with_timeout(signer, action, self.post_timeout, None)
511 .await
512 }
513
514 pub async fn post_action_exec_with_timeout(
516 &self,
517 signer: &HyperliquidHttpClient,
518 action: &HyperliquidExecAction,
519 timeout: Duration,
520 expires_after: Option<u64>,
521 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
522 let weight = exec_action_weight(action);
523 self.post_limiter.acquire(weight).await;
524
525 let payload = signer.sign_action_exec_request(action, expires_after)?;
526 let response = self
527 .send_post_request(PostRequest::Action { payload }, timeout)
528 .await?;
529
530 match response.response {
531 PostResponsePayload::Action { payload } => {
532 let parsed: HyperliquidExchangeResponse =
533 serde_json::from_value(payload).map_err(HyperliquidError::Serde)?;
534
535 match &parsed {
536 HyperliquidExchangeResponse::Status {
537 status,
538 response: response_data,
539 } if status != RESPONSE_STATUS_OK => {
540 let error_msg = response_data
541 .as_str()
542 .map_or_else(|| response_data.to_string(), |s| s.to_string());
543 Err(HyperliquidError::bad_request(format!(
544 "API error: {error_msg}"
545 )))
546 }
547 HyperliquidExchangeResponse::Error { error } => {
548 Err(HyperliquidError::bad_request(format!("API error: {error}")))
549 }
550 _ => Ok(parsed),
551 }
552 }
553 PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
554 PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
555 "expected action post response, received info payload: {payload}"
556 ))),
557 }
558 }
559
560 #[allow(
571 clippy::too_many_arguments,
572 reason = "matches the Python and HTTP order submit surface"
573 )]
574 pub async fn submit_order(
575 &self,
576 signer: &HyperliquidHttpClient,
577 instrument_id: InstrumentId,
578 client_order_id: ClientOrderId,
579 order_side: OrderSide,
580 order_type: OrderType,
581 quantity: Quantity,
582 time_in_force: TimeInForce,
583 price: Option<Price>,
584 trigger_price: Option<Price>,
585 post_only: bool,
586 reduce_only: bool,
587 ) -> HyperliquidResult<Option<OrderStatusReport>> {
588 let symbol = instrument_id.symbol.inner();
589 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
590 HyperliquidError::bad_request(format!(
591 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
592 ))
593 })?;
594 let is_buy = matches!(order_side, OrderSide::Buy);
595 let price_precision = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
596
597 let price_decimal = match price {
598 Some(px) if signer.normalize_prices() => {
599 normalize_price(px.as_decimal(), price_precision).normalize()
600 }
601 Some(px) => px.as_decimal().normalize(),
602 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
603 None if matches!(
604 order_type,
605 OrderType::StopMarket | OrderType::MarketIfTouched
606 ) =>
607 {
608 match trigger_price {
609 Some(tp) => {
610 let derived = derive_limit_from_trigger(
611 tp.as_decimal().normalize(),
612 is_buy,
613 signer.market_order_slippage_bps(),
614 );
615 let sig_rounded = round_to_sig_figs(derived, 5);
616 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
617 }
618 None => Decimal::ZERO,
619 }
620 }
621 None => {
622 return Err(HyperliquidError::bad_request(
623 "Limit orders require a price",
624 ));
625 }
626 };
627
628 let size_decimal = quantity.as_decimal().normalize();
629 let kind = hyperliquid_order_kind(
630 order_type,
631 time_in_force,
632 post_only,
633 trigger_price,
634 signer.normalize_prices(),
635 price_precision,
636 )?;
637
638 let order = HyperliquidExecPlaceOrderRequest {
639 asset,
640 is_buy,
641 price: price_decimal,
642 size: size_decimal,
643 reduce_only,
644 kind,
645 cloid: Some(signer.get_or_generate_client_order_id_cloid(client_order_id)),
646 };
647
648 if let Some(cloid) = order.cloid {
649 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
650 }
651 let action = HyperliquidExecAction::Order {
652 orders: vec![order],
653 grouping: HyperliquidExecGrouping::Na,
654 builder: signer.builder_attribution(),
655 };
656 let response = self.post_action_exec(signer, &action).await?;
657
658 ensure_ws_action_accepted(&response, "Order submission")?;
660
661 match signer.build_submit_order_report(
663 instrument_id,
664 client_order_id,
665 order_side,
666 order_type,
667 quantity,
668 time_in_force,
669 price,
670 trigger_price,
671 response,
672 ) {
673 Ok(report) => Ok(report),
674 Err(e) => {
675 log::warn!(
676 "Failed to build submit report for {client_order_id}: {e}; awaiting WS reconciliation"
677 );
678 Ok(None)
679 }
680 }
681 }
682
683 pub async fn submit_orders(
690 &self,
691 signer: &HyperliquidHttpClient,
692 orders: &[&OrderAny],
693 ) -> HyperliquidResult<Vec<OrderStatusReport>> {
694 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
695 let mut client_order_ids = Vec::with_capacity(orders.len());
696
697 for order in orders {
698 let instrument_id = order.instrument_id();
699 let symbol = instrument_id.symbol.inner();
700 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
701 HyperliquidError::bad_request(format!(
702 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
703 ))
704 })?;
705 let price_decimals = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
706 let request = order_to_hyperliquid_request_with_asset_and_cloid(
707 order,
708 asset,
709 price_decimals,
710 signer.normalize_prices(),
711 signer.market_order_slippage_bps(),
712 None,
713 )
714 .map_err(|e| HyperliquidError::bad_request(format!("Failed to convert order: {e}")))?;
715 client_order_ids.push(order.client_order_id());
716 hyperliquid_orders.push(request);
717 }
718
719 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
720 let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
721 request.cloid = Some(cloid);
722 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
723 }
724
725 let grouping =
726 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
727 let action = HyperliquidExecAction::Order {
728 orders: hyperliquid_orders,
729 grouping,
730 builder: signer.builder_attribution(),
731 };
732 let response = self.post_action_exec(signer, &action).await?;
733
734 ensure_ws_action_accepted(&response, "Order list submission")?;
735
736 match signer.build_submit_orders_reports(orders, grouping, response) {
738 Ok(reports) => Ok(reports),
739 Err(e) => {
740 log::warn!(
741 "Failed to build submit reports for order list: {e}; awaiting WS reconciliation"
742 );
743 Ok(Vec::new())
744 }
745 }
746 }
747
748 pub async fn cancel_order(
750 &self,
751 signer: &HyperliquidHttpClient,
752 instrument_id: InstrumentId,
753 client_order_id: Option<ClientOrderId>,
754 venue_order_id: Option<VenueOrderId>,
755 ) -> HyperliquidResult<()> {
756 let symbol = instrument_id.symbol.inner();
757 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
758 HyperliquidError::bad_request(format!(
759 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
760 ))
761 })?;
762 let action = if let Some(client_order_id) = client_order_id {
763 if let Some(cloid) = signer.cached_client_order_id_cloid(&client_order_id) {
764 HyperliquidExecAction::CancelByCloid {
765 cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
766 fast: None,
767 }
768 } else if let Some(oid) = venue_order_id {
769 let oid = oid
770 .as_str()
771 .parse::<u64>()
772 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
773 HyperliquidExecAction::Cancel {
774 cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
775 fast: None,
776 }
777 } else {
778 let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
779 HyperliquidExecAction::CancelByCloid {
780 cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
781 fast: None,
782 }
783 }
784 } else if let Some(oid) = venue_order_id {
785 let oid = oid
786 .as_str()
787 .parse::<u64>()
788 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
789 HyperliquidExecAction::Cancel {
790 cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
791 fast: None,
792 }
793 } else {
794 return Err(HyperliquidError::bad_request(
795 "Either client_order_id or venue_order_id must be provided",
796 ));
797 };
798 let response = self.post_action_exec(signer, &action).await?;
799
800 ensure_ws_action_accepted(&response, "Cancel order")
801 }
802
803 pub async fn cancel_orders(
805 &self,
806 signer: &HyperliquidHttpClient,
807 cancels: &[(InstrumentId, ClientOrderId, Option<VenueOrderId>)],
808 ) -> HyperliquidResult<Vec<Option<String>>> {
809 let mut cloid_requests = Vec::new();
810 let mut cloid_indices = Vec::new();
811 let mut oid_requests = Vec::new();
812 let mut oid_indices = Vec::new();
813 let mut results = vec![None; cancels.len()];
814
815 for (index, (instrument_id, client_order_id, venue_order_id)) in cancels.iter().enumerate()
816 {
817 let symbol = instrument_id.symbol.inner();
818 let Some(asset) = signer.get_asset_index_for_symbol(symbol) else {
819 results[index] = Some(format!(
820 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
821 ));
822 continue;
823 };
824
825 if let Some(cloid) = signer.cached_client_order_id_cloid(client_order_id) {
826 cloid_requests.push(HyperliquidExecCancelByCloidRequest { asset, cloid });
827 cloid_indices.push(index);
828 } else if let Some(venue_order_id) = venue_order_id {
829 match venue_order_id.as_str().parse::<u64>() {
830 Ok(oid) => {
831 oid_requests.push(HyperliquidExecCancelOrderRequest { asset, oid });
832 oid_indices.push(index);
833 }
834 Err(_) => {
835 results[index] = Some("Invalid venue order ID format".to_string());
836 }
837 }
838 } else {
839 let cloid = signer.get_or_generate_client_order_id_cloid(*client_order_id);
840 cloid_requests.push(HyperliquidExecCancelByCloidRequest { asset, cloid });
841 cloid_indices.push(index);
842 }
843 }
844
845 if cloid_requests.is_empty() && oid_requests.is_empty() {
846 return Ok(results);
847 }
848
849 if !cloid_requests.is_empty() {
850 let action = HyperliquidExecAction::CancelByCloid {
851 cancels: cloid_requests,
852 fast: None,
853 };
854 let errors = self
855 .post_cancel_action_errors(signer, &action, cloid_indices.len())
856 .await?;
857
858 for (index, error) in cloid_indices.into_iter().zip(errors) {
859 results[index] = error;
860 }
861 }
862
863 if !oid_requests.is_empty() {
864 let action = HyperliquidExecAction::Cancel {
865 cancels: oid_requests,
866 fast: None,
867 };
868 let errors = self
869 .post_cancel_action_errors(signer, &action, oid_indices.len())
870 .await?;
871
872 for (index, error) in oid_indices.into_iter().zip(errors) {
873 results[index] = error;
874 }
875 }
876
877 Ok(results)
878 }
879
880 async fn post_cancel_action_errors(
881 &self,
882 signer: &HyperliquidHttpClient,
883 action: &HyperliquidExecAction,
884 request_count: usize,
885 ) -> HyperliquidResult<Vec<Option<String>>> {
886 match self.post_cancel_action(signer, action).await {
887 Ok(response) if response.is_ok() => {
888 match cancel_errors_for_requests(extract_inner_errors(&response), request_count) {
889 Ok(errors) => Ok(errors),
890 Err(e) => Ok(vec![Some(e.to_string()); request_count]),
891 }
892 }
893 Ok(response) => Ok(vec![
894 Some(format!(
895 "Cancel orders failed: {}",
896 extract_error_message(&response)
897 ));
898 request_count
899 ]),
900 Err(e) => Err(e),
901 }
902 }
903
904 async fn post_cancel_action(
905 &self,
906 signer: &HyperliquidHttpClient,
907 action: &HyperliquidExecAction,
908 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
909 let weight = exec_action_weight(action);
910 self.post_limiter.acquire(weight).await;
911
912 let payload = signer.sign_action_exec_request(action, None)?;
913 let response = self
914 .send_post_request(PostRequest::Action { payload }, self.post_timeout)
915 .await?;
916
917 match response.response {
918 PostResponsePayload::Action { payload } => {
919 serde_json::from_value(payload).map_err(HyperliquidError::Serde)
920 }
921 PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
922 PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
923 "expected action post response, received info payload: {payload}"
924 ))),
925 }
926 }
927
928 #[allow(
930 clippy::too_many_arguments,
931 reason = "matches the Python and HTTP order modify surface"
932 )]
933 pub async fn modify_order(
934 &self,
935 signer: &HyperliquidHttpClient,
936 instrument_id: InstrumentId,
937 venue_order_id: Option<VenueOrderId>,
938 order_side: OrderSide,
939 order_type: OrderType,
940 price: Price,
941 quantity: Quantity,
942 trigger_price: Option<Price>,
943 reduce_only: bool,
944 post_only: bool,
945 time_in_force: TimeInForce,
946 client_order_id: Option<ClientOrderId>,
947 ) -> HyperliquidResult<()> {
948 let symbol = instrument_id.symbol.inner();
949 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
950 HyperliquidError::bad_request(format!(
951 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
952 ))
953 })?;
954 let oid = match client_order_id
955 .as_ref()
956 .and_then(|id| signer.unique_cached_client_order_id_cloid(id))
957 {
958 Some(cloid) => HyperliquidExecModifyTarget::Cloid(cloid),
959 None => {
960 let Some(venue_order_id) = venue_order_id.as_ref() else {
961 return Err(HyperliquidError::bad_request(
962 "venue_order_id or unique cached CLOID is required for modify",
963 ));
964 };
965 HyperliquidExecModifyTarget::from_venue_order_id(venue_order_id)
966 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?
967 }
968 };
969 let is_buy = matches!(order_side, OrderSide::Buy);
970 let price_decimals = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
971 let price = if signer.normalize_prices() {
972 normalize_price(price.as_decimal(), price_decimals).normalize()
973 } else {
974 price.as_decimal().normalize()
975 };
976 let kind = hyperliquid_order_kind(
977 order_type,
978 time_in_force,
979 post_only,
980 trigger_price,
981 signer.normalize_prices(),
982 price_decimals,
983 )?;
984 let cloid =
985 client_order_id.map(|id| (id, signer.get_or_generate_client_order_id_cloid(id)));
986 let order = HyperliquidExecPlaceOrderRequest {
987 asset,
988 is_buy,
989 price,
990 size: quantity.as_decimal().normalize(),
991 reduce_only,
992 kind,
993 cloid: cloid.map(|(_, cloid)| cloid),
994 };
995
996 if let Some((client_order_id, cloid)) = cloid {
997 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
998 }
999 let action = HyperliquidExecAction::Modify {
1000 modify: HyperliquidExecModifyOrderRequest { oid, order },
1001 };
1002 let response = self.post_action_exec(signer, &action).await?;
1003
1004 ensure_ws_action_accepted(&response, "Modify order")
1005 }
1006
1007 async fn send_post_request(
1008 &self,
1009 request: PostRequest,
1010 timeout: Duration,
1011 ) -> HyperliquidResult<PostResponse> {
1012 let id = self.post_ids.next();
1013
1014 match tokio::time::timeout(timeout, async {
1015 let rx = self.post_router.register(id).await?;
1016
1017 let send_result = self
1018 .cmd_tx
1019 .read()
1020 .await
1021 .send(HandlerCommand::Post { id, request });
1022
1023 if let Err(e) = send_result {
1024 self.post_router.cancel(id).await;
1025 return Err(HyperliquidError::transport(format!(
1026 "post command channel closed: {e}"
1027 )));
1028 }
1029
1030 self.post_router.await_with_timeout(id, rx, timeout).await
1031 })
1032 .await
1033 {
1034 Ok(result) => result,
1035 Err(_elapsed) => {
1036 self.post_router.cancel(id).await;
1037 Err(HyperliquidError::Timeout)
1038 }
1039 }
1040 }
1041
1042 pub fn is_active(&self) -> bool {
1044 let mode = self.connection_mode.load();
1045 mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8
1046 }
1047
1048 pub fn url(&self) -> &str {
1050 &self.url
1051 }
1052
1053 pub fn cache_instruments(&mut self, instruments: Vec<InstrumentAny>) {
1060 let mut map = AHashMap::new();
1061
1062 for inst in instruments {
1063 let coin = inst.raw_symbol().inner();
1064 map.insert(coin, inst);
1065 }
1066 let count = map.len();
1067 self.instruments.store(map);
1068 log::debug!("Hyperliquid instrument cache initialized with {count} instruments");
1069 }
1070
1071 pub fn cache_instrument(&self, instrument: InstrumentAny) {
1075 let coin = instrument.raw_symbol().inner();
1076 self.instruments.insert(coin, instrument.clone());
1077
1078 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1081 let _ = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument));
1082 }
1083 }
1084
1085 #[must_use]
1087 pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
1088 self.instruments.clone()
1089 }
1090
1091 pub fn cache_spot_fill_coins(&self, mapping: AHashMap<Ustr, Ustr>) {
1097 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1098 let _ = cmd_tx.send(HandlerCommand::CacheSpotFillCoins(mapping));
1099 }
1100 }
1101
1102 #[allow(
1110 clippy::missing_panics_doc,
1111 reason = "cloid cache mutex poisoning is not expected"
1112 )]
1113 pub fn cache_cloid_mapping(&self, cloid: Ustr, client_order_id: ClientOrderId) {
1114 log::debug!("Caching cloid mapping: {cloid} -> {client_order_id}");
1115 self.cloid_cache
1116 .lock()
1117 .expect(MUTEX_POISONED)
1118 .insert(cloid, client_order_id);
1119 }
1120
1121 #[allow(
1126 clippy::missing_panics_doc,
1127 reason = "cloid cache mutex poisoning is not expected"
1128 )]
1129 pub fn remove_cloid_mapping(&self, cloid: &Ustr) {
1130 if self
1131 .cloid_cache
1132 .lock()
1133 .expect(MUTEX_POISONED)
1134 .remove(cloid)
1135 .is_some()
1136 {
1137 log::debug!("Removed cloid mapping: {cloid}");
1138 }
1139 }
1140
1141 #[allow(
1145 clippy::missing_panics_doc,
1146 reason = "cloid cache mutex poisoning is not expected"
1147 )]
1148 pub fn clear_cloid_cache(&self) {
1149 let mut cache = self.cloid_cache.lock().expect(MUTEX_POISONED);
1150 let count = cache.len();
1151 cache.clear();
1152
1153 if count > 0 {
1154 log::debug!("Cleared {count} cloid mappings from cache");
1155 }
1156 }
1157
1158 #[must_use]
1160 #[allow(
1161 clippy::missing_panics_doc,
1162 reason = "cloid cache mutex poisoning is not expected"
1163 )]
1164 pub fn cloid_cache_len(&self) -> usize {
1165 self.cloid_cache.lock().expect(MUTEX_POISONED).len()
1166 }
1167
1168 #[must_use]
1172 #[allow(
1173 clippy::missing_panics_doc,
1174 reason = "cloid cache mutex poisoning is not expected"
1175 )]
1176 pub fn get_cloid_mapping(&self, cloid: &Ustr) -> Option<ClientOrderId> {
1177 self.cloid_cache
1178 .lock()
1179 .expect(MUTEX_POISONED)
1180 .get(cloid)
1181 .copied()
1182 }
1183
1184 pub fn get_instrument(&self, id: &InstrumentId) -> Option<InstrumentAny> {
1188 self.instruments
1189 .load()
1190 .values()
1191 .find(|inst| inst.id() == *id)
1192 .cloned()
1193 }
1194
1195 pub fn get_instrument_by_symbol(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1197 self.instruments.get_cloned(symbol)
1198 }
1199
1200 pub fn subscription_count(&self) -> usize {
1202 self.subscriptions.len()
1203 }
1204
1205 pub fn get_bar_type(&self, coin: &str, interval: &str) -> Option<BarType> {
1209 let key = format!("candle:{coin}:{interval}");
1211 self.bar_types.load().get(&key).copied()
1212 }
1213
1214 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1216 self.subscribe_book_with_options(instrument_id, None, None)
1217 .await
1218 }
1219
1220 pub async fn subscribe_book_with_options(
1227 &self,
1228 instrument_id: InstrumentId,
1229 n_sig_figs: Option<u32>,
1230 mantissa: Option<u32>,
1231 ) -> anyhow::Result<()> {
1232 let instrument = self
1233 .get_instrument(&instrument_id)
1234 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1235 let coin = instrument.raw_symbol().inner();
1236
1237 let cmd_tx = self.cmd_tx.read().await;
1238
1239 cmd_tx
1241 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1242 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1243
1244 self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Deltas, n_sig_figs, mantissa)
1245 }
1246
1247 pub async fn subscribe_book_depth10(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1253 self.subscribe_book_depth10_with_options(instrument_id, None, None)
1254 .await
1255 }
1256
1257 pub async fn subscribe_book_depth10_with_options(
1264 &self,
1265 instrument_id: InstrumentId,
1266 n_sig_figs: Option<u32>,
1267 mantissa: Option<u32>,
1268 ) -> anyhow::Result<()> {
1269 let instrument = self
1270 .get_instrument(&instrument_id)
1271 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1272 let coin = instrument.raw_symbol().inner();
1273
1274 let cmd_tx = self.cmd_tx.read().await;
1275
1276 cmd_tx
1277 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1278 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1279
1280 cmd_tx
1281 .send(HandlerCommand::SetDepth10Sub {
1282 coin,
1283 subscribed: true,
1284 })
1285 .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1286
1287 self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Depth10, n_sig_figs, mantissa)
1288 }
1289
1290 pub async fn unsubscribe_book_depth10(
1295 &self,
1296 instrument_id: InstrumentId,
1297 ) -> anyhow::Result<()> {
1298 let instrument = self
1299 .get_instrument(&instrument_id)
1300 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1301 let coin = instrument.raw_symbol().inner();
1302
1303 let cmd_tx = self.cmd_tx.read().await;
1304
1305 cmd_tx
1306 .send(HandlerCommand::SetDepth10Sub {
1307 coin,
1308 subscribed: false,
1309 })
1310 .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1311
1312 self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Depth10)
1313 }
1314
1315 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1317 let instrument = self
1318 .get_instrument(&instrument_id)
1319 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1320 let coin = instrument.raw_symbol().inner();
1321
1322 let cmd_tx = self.cmd_tx.read().await;
1323 self.quote_streams.insert(coin, ());
1324
1325 if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument.clone())) {
1327 self.quote_streams.remove(&coin);
1328 anyhow::bail!("Failed to send UpdateInstrument command: {e}");
1329 }
1330
1331 let subscription = SubscriptionRequest::Bbo { coin };
1332
1333 if let Err(e) = cmd_tx.send(HandlerCommand::Subscribe {
1334 subscriptions: vec![subscription],
1335 }) {
1336 self.quote_streams.remove(&coin);
1337 anyhow::bail!("Failed to send subscribe command: {e}");
1338 }
1339 Ok(())
1340 }
1341
1342 pub async fn subscribe_all_mids(&self) -> anyhow::Result<()> {
1344 self.subscribe_all_mids_with_dex(None).await
1345 }
1346
1347 pub async fn subscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1349 self.cmd_tx
1350 .read()
1351 .await
1352 .send(HandlerCommand::Subscribe {
1353 subscriptions: vec![SubscriptionRequest::AllDexsAssetCtxs],
1354 })
1355 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1356 Ok(())
1357 }
1358
1359 pub async fn subscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1361 let cmd_tx = self.cmd_tx.read().await;
1362
1363 let subscription = SubscriptionRequest::AllMids {
1364 dex: dex.map(ToString::to_string),
1365 };
1366
1367 cmd_tx
1368 .send(HandlerCommand::Subscribe {
1369 subscriptions: vec![subscription],
1370 })
1371 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1372 Ok(())
1373 }
1374
1375 pub async fn unsubscribe_all_mids(&self) -> anyhow::Result<()> {
1377 self.unsubscribe_all_mids_with_dex(None).await
1378 }
1379
1380 pub async fn unsubscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1382 self.cmd_tx
1383 .read()
1384 .await
1385 .send(HandlerCommand::Unsubscribe {
1386 subscriptions: vec![SubscriptionRequest::AllDexsAssetCtxs],
1387 })
1388 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1389 Ok(())
1390 }
1391
1392 pub async fn unsubscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1394 let cmd_tx = self.cmd_tx.read().await;
1395
1396 let subscription = SubscriptionRequest::AllMids {
1397 dex: dex.map(ToString::to_string),
1398 };
1399
1400 cmd_tx
1401 .send(HandlerCommand::Unsubscribe {
1402 subscriptions: vec![subscription],
1403 })
1404 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1405 Ok(())
1406 }
1407
1408 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1410 self.subscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1411 .await
1412 }
1413
1414 pub async fn subscribe_public_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1416 self.subscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1417 .await
1418 }
1419
1420 async fn subscribe_trade_stream(
1421 &self,
1422 instrument_id: InstrumentId,
1423 stream_use: TradeStreamUse,
1424 ) -> anyhow::Result<()> {
1425 let instrument = self
1426 .get_instrument(&instrument_id)
1427 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1428 let coin = instrument.raw_symbol().inner();
1429
1430 let cmd_tx = self.cmd_tx.read().await;
1431
1432 cmd_tx
1434 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1435 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1436
1437 let _trade_stream_guard = self.trade_stream_lock.lock().expect(MUTEX_POISONED);
1440 let registration = self.trade_streams.register(coin, stream_use);
1441 cmd_tx
1442 .send(HandlerCommand::UpdateTradeSubs {
1443 coin,
1444 uses: registration.uses,
1445 })
1446 .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1447
1448 if registration.subscribe {
1449 cmd_tx
1450 .send(HandlerCommand::Subscribe {
1451 subscriptions: vec![SubscriptionRequest::Trades { coin }],
1452 })
1453 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1454 }
1455 Ok(())
1456 }
1457
1458 pub async fn subscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1460 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1461 .await
1462 }
1463
1464 pub async fn subscribe_index_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1466 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1467 .await
1468 }
1469
1470 pub async fn subscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1472 let instrument_id = bar_type.instrument_id();
1473 let instrument = self
1474 .get_instrument(&instrument_id)
1475 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1476 let coin = instrument.raw_symbol().inner();
1477 let interval = bar_type_to_interval(&bar_type)?;
1478 let subscription = SubscriptionRequest::Candle { coin, interval };
1479
1480 let key = format!("candle:{coin}:{interval}");
1482 self.bar_types.insert(key.clone(), bar_type);
1483
1484 let cmd_tx = self.cmd_tx.read().await;
1485
1486 cmd_tx
1487 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1488 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1489
1490 cmd_tx
1491 .send(HandlerCommand::AddBarType { key, bar_type })
1492 .map_err(|e| anyhow::anyhow!("Failed to send AddBarType command: {e}"))?;
1493
1494 cmd_tx
1495 .send(HandlerCommand::Subscribe {
1496 subscriptions: vec![subscription],
1497 })
1498 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1499 Ok(())
1500 }
1501
1502 pub async fn subscribe_funding_rates(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1504 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1505 .await
1506 }
1507
1508 pub async fn subscribe_open_interest(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1510 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1511 .await
1512 }
1513
1514 pub async fn subscribe_order_updates(&self, user: &str) -> anyhow::Result<()> {
1516 let subscription = SubscriptionRequest::OrderUpdates {
1517 user: user.to_string(),
1518 };
1519 self.cmd_tx
1520 .read()
1521 .await
1522 .send(HandlerCommand::Subscribe {
1523 subscriptions: vec![subscription],
1524 })
1525 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1526 Ok(())
1527 }
1528
1529 pub async fn subscribe_user_events(&self, user: &str) -> anyhow::Result<()> {
1531 let subscription = SubscriptionRequest::UserEvents {
1532 user: user.to_string(),
1533 };
1534 self.cmd_tx
1535 .read()
1536 .await
1537 .send(HandlerCommand::Subscribe {
1538 subscriptions: vec![subscription],
1539 })
1540 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1541 Ok(())
1542 }
1543
1544 pub async fn subscribe_user_fills(&self, user: &str) -> anyhow::Result<()> {
1549 let subscription = SubscriptionRequest::UserFills {
1550 user: user.to_string(),
1551 aggregate_by_time: None,
1552 };
1553 self.cmd_tx
1554 .read()
1555 .await
1556 .send(HandlerCommand::Subscribe {
1557 subscriptions: vec![subscription],
1558 })
1559 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1560 Ok(())
1561 }
1562
1563 pub async fn subscribe_all_user_channels(&self, user: &str) -> anyhow::Result<()> {
1568 self.subscribe_order_updates(user).await?;
1569 self.subscribe_user_events(user).await?;
1570 Ok(())
1571 }
1572
1573 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1578 let instrument = self
1579 .get_instrument(&instrument_id)
1580 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1581 let coin = instrument.raw_symbol().inner();
1582
1583 let cmd_tx = self.cmd_tx.read().await;
1584
1585 self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Deltas)
1586 }
1587
1588 pub async fn resubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1597 let instrument = self
1598 .get_instrument(&instrument_id)
1599 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1600 let coin = instrument.raw_symbol().inner();
1601
1602 let cmd_tx = self.cmd_tx.write().await;
1604
1605 let Some(options) = self.book_streams.options(&coin) else {
1606 log::debug!("Skipping l2Book resubscribe for {coin}: stream no longer registered");
1607 return Ok(());
1608 };
1609
1610 let subscription = SubscriptionRequest::L2Book {
1611 coin,
1612 mantissa: options.mantissa,
1613 n_sig_figs: options.n_sig_figs,
1614 };
1615
1616 Self::send_stream_resubscribe(&cmd_tx, subscription)
1617 }
1618
1619 fn send_book_stream_subscribe(
1620 &self,
1621 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1622 coin: Ustr,
1623 stream_use: BookStreamUse,
1624 n_sig_figs: Option<u32>,
1625 mantissa: Option<u32>,
1626 ) -> anyhow::Result<()> {
1627 let registration = self.book_streams.register(
1628 coin,
1629 stream_use,
1630 BookStreamOptions {
1631 n_sig_figs,
1632 mantissa,
1633 },
1634 );
1635
1636 if registration.options_mismatch {
1637 log::warn!(
1638 "Requested l2Book options for {coin} (n_sig_figs={n_sig_figs:?}, mantissa={mantissa:?}) \
1639 differ from the active stream ({:?}), keeping active options",
1640 registration.options,
1641 );
1642 }
1643
1644 if registration.subscribe {
1645 let subscription = SubscriptionRequest::L2Book {
1646 coin,
1647 mantissa: registration.options.mantissa,
1648 n_sig_figs: registration.options.n_sig_figs,
1649 };
1650
1651 cmd_tx
1652 .send(HandlerCommand::Subscribe {
1653 subscriptions: vec![subscription],
1654 })
1655 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1656 }
1657 Ok(())
1658 }
1659
1660 fn send_book_stream_unsubscribe(
1661 &self,
1662 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1663 coin: Ustr,
1664 stream_use: BookStreamUse,
1665 ) -> anyhow::Result<()> {
1666 match self.book_streams.release(&coin, stream_use) {
1667 BookStreamRelease::Unsubscribe(options) => {
1668 let subscription = SubscriptionRequest::L2Book {
1669 coin,
1670 mantissa: options.mantissa,
1671 n_sig_figs: options.n_sig_figs,
1672 };
1673
1674 cmd_tx
1675 .send(HandlerCommand::Unsubscribe {
1676 subscriptions: vec![subscription],
1677 })
1678 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1679 }
1680 BookStreamRelease::Retained => {
1681 let remaining_use = match stream_use {
1682 BookStreamUse::Deltas => "depth10",
1683 BookStreamUse::Depth10 => "deltas",
1684 };
1685 log::debug!("Keeping shared l2Book stream for {coin}: {remaining_use} use remains");
1686 }
1687 }
1688 Ok(())
1689 }
1690
1691 pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1693 let instrument = self
1694 .get_instrument(&instrument_id)
1695 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1696 let coin = instrument.raw_symbol().inner();
1697
1698 let subscription = SubscriptionRequest::Bbo { coin };
1699 let cmd_tx = self.cmd_tx.read().await;
1700
1701 self.quote_streams.remove(&coin);
1702
1703 cmd_tx
1704 .send(HandlerCommand::Unsubscribe {
1705 subscriptions: vec![subscription],
1706 })
1707 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1708 Ok(())
1709 }
1710
1711 pub async fn resubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1715 let instrument = self
1716 .get_instrument(&instrument_id)
1717 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1718 let coin = instrument.raw_symbol().inner();
1719
1720 let cmd_tx = self.cmd_tx.write().await;
1722
1723 if !self.quote_streams.contains_key(&coin) {
1724 log::debug!("Skipping bbo resubscribe for {coin}: stream no longer registered");
1725 return Ok(());
1726 }
1727
1728 Self::send_stream_resubscribe(&cmd_tx, SubscriptionRequest::Bbo { coin })
1729 }
1730
1731 fn send_stream_resubscribe(
1732 cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1733 subscription: SubscriptionRequest,
1734 ) -> anyhow::Result<()> {
1735 cmd_tx
1736 .send(HandlerCommand::Unsubscribe {
1737 subscriptions: vec![subscription.clone()],
1738 })
1739 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1740
1741 cmd_tx
1742 .send(HandlerCommand::Subscribe {
1743 subscriptions: vec![subscription],
1744 })
1745 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1746 Ok(())
1747 }
1748
1749 pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1751 self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1752 .await
1753 }
1754
1755 pub async fn unsubscribe_public_trades(
1757 &self,
1758 instrument_id: InstrumentId,
1759 ) -> anyhow::Result<()> {
1760 self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1761 .await
1762 }
1763
1764 async fn unsubscribe_trade_stream(
1765 &self,
1766 instrument_id: InstrumentId,
1767 stream_use: TradeStreamUse,
1768 ) -> anyhow::Result<()> {
1769 let instrument = self
1770 .get_instrument(&instrument_id)
1771 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1772 let coin = instrument.raw_symbol().inner();
1773
1774 let cmd_tx = self.cmd_tx.read().await;
1775 let _trade_stream_guard = self.trade_stream_lock.lock().expect(MUTEX_POISONED);
1778 let release = self.trade_streams.release(&coin, stream_use);
1779 cmd_tx
1780 .send(HandlerCommand::UpdateTradeSubs {
1781 coin,
1782 uses: release.uses,
1783 })
1784 .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1785
1786 if release.unsubscribe {
1787 cmd_tx
1788 .send(HandlerCommand::Unsubscribe {
1789 subscriptions: vec![SubscriptionRequest::Trades { coin }],
1790 })
1791 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1792 }
1793 Ok(())
1794 }
1795
1796 pub async fn unsubscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1798 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1799 .await
1800 }
1801
1802 pub async fn unsubscribe_index_prices(
1804 &self,
1805 instrument_id: InstrumentId,
1806 ) -> anyhow::Result<()> {
1807 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1808 .await
1809 }
1810
1811 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1813 let instrument_id = bar_type.instrument_id();
1814 let instrument = self
1815 .get_instrument(&instrument_id)
1816 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1817 let coin = instrument.raw_symbol().inner();
1818 let interval = bar_type_to_interval(&bar_type)?;
1819 let subscription = SubscriptionRequest::Candle { coin, interval };
1820
1821 let key = format!("candle:{coin}:{interval}");
1822 self.bar_types.remove(&key);
1823
1824 let cmd_tx = self.cmd_tx.read().await;
1825
1826 cmd_tx
1827 .send(HandlerCommand::RemoveBarType { key })
1828 .map_err(|e| anyhow::anyhow!("Failed to send RemoveBarType command: {e}"))?;
1829
1830 cmd_tx
1831 .send(HandlerCommand::Unsubscribe {
1832 subscriptions: vec![subscription],
1833 })
1834 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1835 Ok(())
1836 }
1837
1838 pub async fn unsubscribe_funding_rates(
1840 &self,
1841 instrument_id: InstrumentId,
1842 ) -> anyhow::Result<()> {
1843 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1844 .await
1845 }
1846
1847 pub async fn unsubscribe_open_interest(
1849 &self,
1850 instrument_id: InstrumentId,
1851 ) -> anyhow::Result<()> {
1852 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1853 .await
1854 }
1855
1856 pub fn cache_all_dex_asset_ctxs_instrument_ids(
1858 &self,
1859 mapping: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1860 ) {
1861 self.all_dex_asset_ctxs_instrument_ids
1862 .store(mapping.clone());
1863
1864 if let Ok(cmd_tx) = self.cmd_tx.try_read()
1865 && let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mapping))
1866 {
1867 log::debug!(
1868 "Failed to send CacheAllDexAssetCtxsInstrumentIds command (handler may not be connected yet): {e}"
1869 );
1870 }
1871 }
1872
1873 async fn subscribe_asset_context_data(
1874 &self,
1875 instrument_id: InstrumentId,
1876 data_type: AssetContextDataType,
1877 ) -> anyhow::Result<()> {
1878 let instrument = self
1879 .get_instrument(&instrument_id)
1880 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1881 let coin = instrument.raw_symbol().inner();
1882
1883 let mut entry = self.asset_context_subs.entry(coin).or_default();
1884 let is_first_subscription = entry.is_empty();
1885 entry.insert(data_type);
1886 let data_types = entry.clone();
1887 drop(entry);
1888
1889 let cmd_tx = self.cmd_tx.read().await;
1890
1891 cmd_tx
1892 .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
1893 .map_err(|e| anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}"))?;
1894
1895 if is_first_subscription {
1896 log::debug!(
1897 "First asset context subscription for coin '{coin}', subscribing to ActiveAssetCtx"
1898 );
1899 let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
1900
1901 cmd_tx
1902 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1903 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1904
1905 cmd_tx
1906 .send(HandlerCommand::Subscribe {
1907 subscriptions: vec![subscription],
1908 })
1909 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1910 } else {
1911 log::debug!(
1912 "Already subscribed to ActiveAssetCtx for coin '{coin}', adding {data_type:?} to tracked types"
1913 );
1914 }
1915
1916 Ok(())
1917 }
1918
1919 async fn unsubscribe_asset_context_data(
1920 &self,
1921 instrument_id: InstrumentId,
1922 data_type: AssetContextDataType,
1923 ) -> anyhow::Result<()> {
1924 let instrument = self
1925 .get_instrument(&instrument_id)
1926 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1927 let coin = instrument.raw_symbol().inner();
1928
1929 if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
1930 entry.remove(&data_type);
1931 let should_unsubscribe = entry.is_empty();
1932 let data_types = entry.clone();
1933 drop(entry);
1934
1935 let cmd_tx = self.cmd_tx.read().await;
1936
1937 if should_unsubscribe {
1938 self.asset_context_subs.remove(&coin);
1939
1940 log::debug!(
1941 "Last asset context subscription removed for coin '{coin}', unsubscribing from ActiveAssetCtx"
1942 );
1943 let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
1944
1945 cmd_tx
1946 .send(HandlerCommand::UpdateAssetContextSubs {
1947 coin,
1948 data_types: AHashSet::new(),
1949 })
1950 .map_err(|e| {
1951 anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
1952 })?;
1953
1954 cmd_tx
1955 .send(HandlerCommand::Unsubscribe {
1956 subscriptions: vec![subscription],
1957 })
1958 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1959 } else {
1960 log::debug!(
1961 "Removed {data_type:?} from tracked types for coin '{coin}', but keeping ActiveAssetCtx subscription"
1962 );
1963
1964 cmd_tx
1965 .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
1966 .map_err(|e| {
1967 anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
1968 })?;
1969 }
1970 }
1971
1972 Ok(())
1973 }
1974
1975 pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
1979 if let Some(ref mut rx) = self.out_rx {
1980 rx.recv().await
1981 } else {
1982 None
1983 }
1984 }
1985}
1986
1987fn cancel_errors_for_requests(
1988 errors: Vec<Option<String>>,
1989 request_count: usize,
1990) -> HyperliquidResult<Vec<Option<String>>> {
1991 if errors.is_empty() {
1992 return Ok(vec![None; request_count]);
1993 }
1994
1995 if errors.len() != request_count {
1996 return Err(HyperliquidError::exchange(format!(
1997 "Cancel orders returned {} statuses for {request_count} cancels",
1998 errors.len()
1999 )));
2000 }
2001
2002 Ok(errors)
2003}
2004
2005fn map_post_payload_error(payload: String, weight: u32) -> HyperliquidError {
2006 let lower = payload.to_ascii_lowercase();
2007 let message = format!("WebSocket post error: {payload}");
2008
2009 if starts_with_status(&lower, &["429"])
2010 || lower.contains("too many requests")
2011 || lower.contains("rate limit")
2012 {
2013 HyperliquidError::rate_limit("exchange", weight, None)
2014 } else if starts_with_status(&lower, &["401", "403"])
2015 || lower.contains("unauthorized")
2016 || lower.contains("forbidden")
2017 || lower.contains("authentication")
2018 || lower.contains("authorization")
2019 || lower.contains("invalid signature")
2020 || contains_word(&lower, "auth")
2021 {
2022 HyperliquidError::auth(message)
2023 } else if starts_with_status(&lower, &["400"]) || lower.contains("bad request") {
2024 HyperliquidError::bad_request(message)
2025 } else if starts_with_status(&lower, &["500", "502", "503", "504"]) {
2026 HyperliquidError::exchange(message)
2027 } else {
2028 HyperliquidError::exchange(payload)
2029 }
2030}
2031
2032fn hyperliquid_order_kind(
2033 order_type: OrderType,
2034 time_in_force: TimeInForce,
2035 post_only: bool,
2036 trigger_price: Option<Price>,
2037 normalize_prices_enabled: bool,
2038 price_precision: u8,
2039) -> HyperliquidResult<HyperliquidExecOrderKind> {
2040 match order_type {
2041 OrderType::Market => Ok(HyperliquidExecOrderKind::Limit {
2042 limit: HyperliquidExecLimitParams {
2043 tif: HyperliquidExecTif::Ioc,
2044 },
2045 }),
2046 OrderType::Limit => {
2047 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2048 .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2049 Ok(HyperliquidExecOrderKind::Limit {
2050 limit: HyperliquidExecLimitParams { tif },
2051 })
2052 }
2053 OrderType::StopMarket
2054 | OrderType::StopLimit
2055 | OrderType::MarketIfTouched
2056 | OrderType::LimitIfTouched => {
2057 let trigger_price = trigger_price.ok_or_else(|| {
2058 HyperliquidError::bad_request("Trigger orders require a trigger price")
2059 })?;
2060 let trigger_px = if normalize_prices_enabled {
2061 normalize_price(trigger_price.as_decimal(), price_precision).normalize()
2062 } else {
2063 trigger_price.as_decimal().normalize()
2064 };
2065 let tpsl = match order_type {
2066 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2067 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
2068 _ => unreachable!(),
2069 };
2070 let is_market = matches!(
2071 order_type,
2072 OrderType::StopMarket | OrderType::MarketIfTouched
2073 );
2074
2075 Ok(HyperliquidExecOrderKind::Trigger {
2076 trigger: HyperliquidExecTriggerParams {
2077 is_market,
2078 trigger_px,
2079 tpsl,
2080 },
2081 })
2082 }
2083 _ => Err(HyperliquidError::bad_request(format!(
2084 "Order type {order_type:?} not supported"
2085 ))),
2086 }
2087}
2088
2089fn ensure_ws_action_accepted(
2090 response: &HyperliquidExchangeResponse,
2091 action_name: &str,
2092) -> HyperliquidResult<()> {
2093 if response.is_ok() {
2094 if let Some(error_msg) = extract_inner_errors(response).into_iter().flatten().next() {
2095 return Err(HyperliquidError::bad_request(format!(
2096 "{action_name} rejected: {error_msg}"
2097 )));
2098 }
2099
2100 if let Some(error_msg) = extract_inner_error(response) {
2101 return Err(HyperliquidError::bad_request(format!(
2102 "{action_name} rejected: {error_msg}"
2103 )));
2104 }
2105
2106 return Ok(());
2107 }
2108
2109 Err(HyperliquidError::bad_request(format!(
2110 "{action_name} failed: {}",
2111 extract_error_message(response)
2112 )))
2113}
2114
2115fn starts_with_status(payload: &str, statuses: &[&str]) -> bool {
2116 let trimmed = payload.trim_start();
2117 statuses
2118 .iter()
2119 .any(|status| starts_with_status_token(trimmed, status))
2120 || trimmed.strip_prefix("http").is_some_and(|rest| {
2121 let rest = rest
2122 .trim_start_matches(|c: char| c.is_ascii_whitespace() || matches!(c, ':' | '/'));
2123 statuses
2124 .iter()
2125 .any(|status| starts_with_status_token(rest, status))
2126 })
2127}
2128
2129fn starts_with_status_token(payload: &str, status: &str) -> bool {
2130 payload.strip_prefix(status).is_some_and(|rest| {
2131 rest.chars()
2132 .next()
2133 .is_none_or(|c| !c.is_ascii_alphanumeric())
2134 })
2135}
2136
2137fn contains_word(payload: &str, word: &str) -> bool {
2138 payload
2139 .split(|c: char| !c.is_ascii_alphanumeric())
2140 .any(|part| part == word)
2141}
2142
2143fn subscription_from_topic(topic: &str) -> anyhow::Result<SubscriptionRequest> {
2146 let (kind, rest) = topic
2147 .split_once(':')
2148 .map_or((topic, None), |(k, r)| (k, Some(r)));
2149
2150 let channel = HyperliquidWsChannel::from_wire_str(kind)
2151 .ok_or_else(|| anyhow::anyhow!("Unknown subscription channel: {kind}"))?;
2152
2153 match channel {
2154 HyperliquidWsChannel::AllMids => Ok(SubscriptionRequest::AllMids {
2155 dex: rest.map(|s| s.to_string()),
2156 }),
2157 HyperliquidWsChannel::AllDexsAssetCtxs => Ok(SubscriptionRequest::AllDexsAssetCtxs),
2158 HyperliquidWsChannel::Notification => Ok(SubscriptionRequest::Notification {
2159 user: rest.context("Missing user")?.to_string(),
2160 }),
2161 HyperliquidWsChannel::WebData2 => Ok(SubscriptionRequest::WebData2 {
2162 user: rest.context("Missing user")?.to_string(),
2163 }),
2164 HyperliquidWsChannel::Candle => {
2165 let rest = rest.context("Missing candle params")?;
2167 let (coin, interval_str) = rest.rsplit_once(':').context("Missing interval")?;
2168 let interval = HyperliquidBarInterval::from_str(interval_str)?;
2169 Ok(SubscriptionRequest::Candle {
2170 coin: Ustr::from(coin),
2171 interval,
2172 })
2173 }
2174 HyperliquidWsChannel::L2Book => Ok(SubscriptionRequest::L2Book {
2175 coin: Ustr::from(rest.context("Missing coin")?),
2176 mantissa: None,
2177 n_sig_figs: None,
2178 }),
2179 HyperliquidWsChannel::Trades => Ok(SubscriptionRequest::Trades {
2180 coin: Ustr::from(rest.context("Missing coin")?),
2181 }),
2182 HyperliquidWsChannel::OrderUpdates => Ok(SubscriptionRequest::OrderUpdates {
2183 user: rest.context("Missing user")?.to_string(),
2184 }),
2185 HyperliquidWsChannel::UserEvents => Ok(SubscriptionRequest::UserEvents {
2186 user: rest.context("Missing user")?.to_string(),
2187 }),
2188 HyperliquidWsChannel::UserFills => Ok(SubscriptionRequest::UserFills {
2189 user: rest.context("Missing user")?.to_string(),
2190 aggregate_by_time: None,
2191 }),
2192 HyperliquidWsChannel::UserFundings => Ok(SubscriptionRequest::UserFundings {
2193 user: rest.context("Missing user")?.to_string(),
2194 }),
2195 HyperliquidWsChannel::UserNonFundingLedgerUpdates => {
2196 Ok(SubscriptionRequest::UserNonFundingLedgerUpdates {
2197 user: rest.context("Missing user")?.to_string(),
2198 })
2199 }
2200 HyperliquidWsChannel::ActiveAssetCtx => Ok(SubscriptionRequest::ActiveAssetCtx {
2201 coin: Ustr::from(rest.context("Missing coin")?),
2202 }),
2203 HyperliquidWsChannel::ActiveSpotAssetCtx => Ok(SubscriptionRequest::ActiveSpotAssetCtx {
2204 coin: Ustr::from(rest.context("Missing coin")?),
2205 }),
2206 HyperliquidWsChannel::ActiveAssetData => {
2207 let rest = rest.context("Missing params")?;
2209 let (user, coin) = rest.split_once(':').context("Missing coin")?;
2210 Ok(SubscriptionRequest::ActiveAssetData {
2211 user: user.to_string(),
2212 coin: coin.to_string(),
2213 })
2214 }
2215 HyperliquidWsChannel::UserTwapSliceFills => Ok(SubscriptionRequest::UserTwapSliceFills {
2216 user: rest.context("Missing user")?.to_string(),
2217 }),
2218 HyperliquidWsChannel::UserTwapHistory => Ok(SubscriptionRequest::UserTwapHistory {
2219 user: rest.context("Missing user")?.to_string(),
2220 }),
2221 HyperliquidWsChannel::Bbo => Ok(SubscriptionRequest::Bbo {
2222 coin: Ustr::from(rest.context("Missing coin")?),
2223 }),
2224
2225 HyperliquidWsChannel::SubscriptionResponse
2227 | HyperliquidWsChannel::User
2228 | HyperliquidWsChannel::Post
2229 | HyperliquidWsChannel::Pong
2230 | HyperliquidWsChannel::Error => {
2231 anyhow::bail!("Not a subscription channel: {kind}")
2232 }
2233 }
2234}
2235
2236#[cfg(test)]
2237mod tests {
2238 use rstest::rstest;
2239
2240 use super::*;
2241 use crate::{
2242 common::{consts::INFLIGHT_MAX, enums::HyperliquidBarInterval},
2243 websocket::handler::subscription_to_key,
2244 };
2245
2246 fn subscription_topic(sub: &SubscriptionRequest) -> String {
2248 subscription_to_key(sub)
2249 }
2250
2251 #[rstest]
2252 #[case(SubscriptionRequest::Trades { coin: "BTC".into() }, "trades:BTC")]
2253 #[case(SubscriptionRequest::Bbo { coin: "BTC".into() }, "bbo:BTC")]
2254 #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() }, "orderUpdates:0x123")]
2255 #[case(SubscriptionRequest::UserEvents { user: "0xabc".to_string() }, "userEvents:0xabc")]
2256 fn test_subscription_topic_generation(
2257 #[case] subscription: SubscriptionRequest,
2258 #[case] expected_topic: &str,
2259 ) {
2260 assert_eq!(subscription_topic(&subscription), expected_topic);
2261 }
2262
2263 #[rstest]
2264 fn test_subscription_topics_unique() {
2265 let sub1 = SubscriptionRequest::Trades { coin: "BTC".into() };
2266 let sub2 = SubscriptionRequest::Bbo { coin: "BTC".into() };
2267
2268 let topic1 = subscription_topic(&sub1);
2269 let topic2 = subscription_topic(&sub2);
2270
2271 assert_ne!(topic1, topic2);
2272 }
2273
2274 #[rstest]
2275 #[case(SubscriptionRequest::Trades { coin: "BTC".into() })]
2276 #[case(SubscriptionRequest::Bbo { coin: "ETH".into() })]
2277 #[case(SubscriptionRequest::Candle { coin: "SOL".into(), interval: HyperliquidBarInterval::OneHour })]
2278 #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() })]
2279 #[case(SubscriptionRequest::Trades { coin: "vntls:vCURSOR".into() })]
2280 #[case(SubscriptionRequest::L2Book { coin: "vntls:vCURSOR".into(), mantissa: None, n_sig_figs: None })]
2281 #[case(SubscriptionRequest::Candle { coin: "vntls:vCURSOR".into(), interval: HyperliquidBarInterval::OneHour })]
2282 fn test_subscription_reconstruction(#[case] subscription: SubscriptionRequest) {
2283 let topic = subscription_topic(&subscription);
2284 let reconstructed = subscription_from_topic(&topic).expect("Failed to reconstruct");
2285 assert_eq!(subscription_topic(&reconstructed), topic);
2286 }
2287
2288 #[rstest]
2289 fn test_subscription_topic_candle() {
2290 let sub = SubscriptionRequest::Candle {
2291 coin: "BTC".into(),
2292 interval: HyperliquidBarInterval::OneHour,
2293 };
2294
2295 let topic = subscription_topic(&sub);
2296 assert_eq!(topic, "candle:BTC:1h");
2297 }
2298
2299 #[rstest]
2300 fn set_post_timeout_updates_client_and_clone() {
2301 let mut client = HyperliquidWebSocketClient::new(
2302 None,
2303 HyperliquidEnvironment::Testnet,
2304 None,
2305 TransportBackend::default(),
2306 None,
2307 );
2308 let timeout = std::time::Duration::from_secs(7);
2309
2310 client.set_post_timeout(timeout);
2311
2312 assert_eq!(client.post_timeout, timeout);
2313 assert_eq!(client.clone().post_timeout, timeout);
2314 }
2315
2316 #[rstest]
2317 #[tokio::test(flavor = "multi_thread")]
2318 async fn send_post_request_times_out_while_waiting_for_inflight_slot() {
2319 let client = HyperliquidWebSocketClient::new(
2320 None,
2321 HyperliquidEnvironment::Testnet,
2322 None,
2323 TransportBackend::default(),
2324 None,
2325 );
2326 let mut receivers = Vec::with_capacity(INFLIGHT_MAX);
2327 for offset in 0..INFLIGHT_MAX {
2328 receivers.push(
2329 client
2330 .post_router
2331 .register(10_000 + offset as u64)
2332 .await
2333 .unwrap(),
2334 );
2335 }
2336
2337 let err = client
2338 .send_post_request(
2339 PostRequest::Info {
2340 payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
2341 },
2342 std::time::Duration::from_millis(25),
2343 )
2344 .await
2345 .expect_err("request should timeout before acquiring an inflight slot");
2346
2347 assert!(matches!(err, HyperliquidError::Timeout));
2348 assert_eq!(receivers.len(), INFLIGHT_MAX);
2349 }
2350
2351 #[rstest]
2352 fn cancel_errors_for_requests_accepts_empty_as_success() {
2353 let errors = cancel_errors_for_requests(Vec::new(), 2).unwrap();
2354
2355 assert_eq!(errors, vec![None, None]);
2356 }
2357
2358 #[rstest]
2359 fn cancel_errors_for_requests_rejects_status_count_mismatch() {
2360 let err = cancel_errors_for_requests(vec![None], 2).expect_err("mismatch should fail");
2361
2362 assert!(
2363 err.to_string()
2364 .contains("returned 1 statuses for 2 cancels")
2365 );
2366 }
2367
2368 #[rstest]
2369 fn test_post_payload_error_maps_rate_limit() {
2370 let err = map_post_payload_error("429 Too Many Requests".to_string(), 3);
2371
2372 assert!(matches!(
2373 err,
2374 HyperliquidError::RateLimit {
2375 scope: "exchange",
2376 weight: 3,
2377 retry_after_ms: None,
2378 }
2379 ));
2380 }
2381
2382 #[rstest]
2383 #[case("401 Unauthorized")]
2384 #[case("HTTP 403: forbidden")]
2385 #[case("invalid signature")]
2386 #[case("authentication failed")]
2387 fn test_post_payload_error_maps_auth(#[case] payload: &str) {
2388 let err = map_post_payload_error(payload.to_string(), 1);
2389
2390 assert!(matches!(err, HyperliquidError::Auth(_)));
2391 }
2392
2393 #[rstest]
2394 #[case("400 Bad Request")]
2395 #[case("HTTP 400: malformed payload")]
2396 #[case("bad request: missing action")]
2397 fn test_post_payload_error_maps_bad_request(#[case] payload: &str) {
2398 let err = map_post_payload_error(payload.to_string(), 1);
2399
2400 assert!(matches!(err, HyperliquidError::BadRequest(_)));
2401 }
2402
2403 #[rstest]
2404 #[case("500 Internal Server Error")]
2405 #[case("HTTP 503: service unavailable")]
2406 fn test_post_payload_error_maps_exchange_status(#[case] payload: &str) {
2407 let err = map_post_payload_error(payload.to_string(), 1);
2408
2409 assert!(matches!(err, HyperliquidError::Exchange(_)));
2410 }
2411
2412 #[rstest]
2413 #[case("order 429001 rejected")]
2414 #[case("asset 5001 is not tradable")]
2415 #[case("authoritative nonce window exceeded")]
2416 fn test_post_payload_error_does_not_match_embedded_codes_or_words(#[case] payload: &str) {
2417 let err = map_post_payload_error(payload.to_string(), 1);
2418
2419 assert!(matches!(err, HyperliquidError::Exchange(_)));
2420 }
2421}