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 HyperliquidExecOrderKind, HyperliquidExecPlaceOrderRequest, HyperliquidExecTif,
73 HyperliquidExecTpSl, HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
74 },
75 rate_limits::{WeightedLimiter, exec_action_weight},
76 },
77 websocket::{
78 enums::HyperliquidWsChannel,
79 handler::{FeedHandler, HandlerCommand},
80 messages::{
81 NautilusWsMessage, PostRequest, PostResponse, PostResponsePayload, SubscriptionRequest,
82 },
83 post::{PostIds, PostRouter},
84 },
85};
86
87const HYPERLIQUID_HEARTBEAT_MSG: &str = r#"{"method":"ping"}"#;
88
89pub(super) const CLOID_CACHE_CAPACITY: usize = 10_000;
92
93pub(super) type CloidCache = Arc<Mutex<FifoCacheMap<Ustr, ClientOrderId, CLOID_CACHE_CAPACITY>>>;
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98pub(super) enum AssetContextDataType {
99 MarkPrice,
100 IndexPrice,
101 FundingRate,
102 OpenInterest,
103}
104
105#[derive(Debug)]
110#[cfg_attr(
111 feature = "python",
112 pyo3::pyclass(
113 module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
114 from_py_object
115 )
116)]
117#[cfg_attr(
118 feature = "python",
119 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
120)]
121pub struct HyperliquidWebSocketClient {
122 url: String,
123 connection_mode: Arc<ArcSwap<AtomicU8>>,
124 signal: Arc<AtomicBool>,
125 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
126 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
127 auth_tracker: AuthTracker,
128 subscriptions: SubscriptionState,
129 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
130 bar_types: Arc<AtomicMap<String, BarType>>,
131 asset_context_subs: Arc<DashMap<Ustr, AHashSet<AssetContextDataType>>>,
132 all_dex_asset_ctxs_instrument_ids: Arc<AtomicMap<Ustr, Vec<Option<InstrumentId>>>>,
133 cloid_cache: CloidCache,
134 post_router: Arc<PostRouter>,
135 post_ids: Arc<PostIds>,
136 post_limiter: Arc<WeightedLimiter>,
137 post_timeout: Duration,
138 task_handle: Option<tokio::task::JoinHandle<()>>,
139 account_id: Option<AccountId>,
140 transport_backend: TransportBackend,
141 proxy_url: Option<String>,
142}
143
144impl Clone for HyperliquidWebSocketClient {
145 fn clone(&self) -> Self {
146 Self {
147 url: self.url.clone(),
148 connection_mode: Arc::clone(&self.connection_mode),
149 signal: Arc::clone(&self.signal),
150 cmd_tx: Arc::clone(&self.cmd_tx),
151 out_rx: None,
152 auth_tracker: self.auth_tracker.clone(),
153 subscriptions: self.subscriptions.clone(),
154 instruments: Arc::clone(&self.instruments),
155 bar_types: Arc::clone(&self.bar_types),
156 asset_context_subs: Arc::clone(&self.asset_context_subs),
157 all_dex_asset_ctxs_instrument_ids: Arc::clone(&self.all_dex_asset_ctxs_instrument_ids),
158 cloid_cache: Arc::clone(&self.cloid_cache),
159 post_router: Arc::clone(&self.post_router),
160 post_ids: Arc::clone(&self.post_ids),
161 post_limiter: Arc::clone(&self.post_limiter),
162 post_timeout: self.post_timeout,
163 task_handle: None,
164 account_id: self.account_id,
165 transport_backend: self.transport_backend,
166 proxy_url: self.proxy_url.clone(),
167 }
168 }
169}
170
171impl HyperliquidWebSocketClient {
172 pub fn new(
180 url: Option<String>,
181 environment: HyperliquidEnvironment,
182 account_id: Option<AccountId>,
183 transport_backend: TransportBackend,
184 proxy_url: Option<String>,
185 ) -> Self {
186 let url = url.unwrap_or_else(|| ws_url(environment).to_string());
187 let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
188 ConnectionMode::Closed as u8,
189 ))));
190 Self {
191 url,
192 connection_mode,
193 signal: Arc::new(AtomicBool::new(false)),
194 auth_tracker: AuthTracker::new(),
195 subscriptions: SubscriptionState::new(':'),
196 instruments: Arc::new(AtomicMap::new()),
197 bar_types: Arc::new(AtomicMap::new()),
198 asset_context_subs: Arc::new(DashMap::new()),
199 all_dex_asset_ctxs_instrument_ids: Arc::new(AtomicMap::new()),
200 cloid_cache: Arc::new(Mutex::new(FifoCacheMap::new())),
201 post_router: PostRouter::new(),
202 post_ids: Arc::new(PostIds::new(1)),
203 post_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
204 post_timeout: HTTP_TIMEOUT,
205 cmd_tx: {
206 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
208 Arc::new(tokio::sync::RwLock::new(tx))
209 },
210 out_rx: None,
211 task_handle: None,
212 account_id,
213 transport_backend,
214 proxy_url,
215 }
216 }
217
218 pub async fn connect(&mut self) -> anyhow::Result<()> {
220 if self.is_active() {
221 log::warn!("WebSocket already connected");
222 return Ok(());
223 }
224 let (message_handler, raw_rx) = channel_message_handler();
225 let cfg = WebSocketConfig {
226 url: self.url.clone(),
227 headers: vec![],
228 heartbeat: Some(30),
229 heartbeat_msg: Some(HYPERLIQUID_HEARTBEAT_MSG.to_string()),
230 reconnect_timeout_ms: Some(15_000),
231 reconnect_delay_initial_ms: Some(250),
232 reconnect_delay_max_ms: Some(5_000),
233 reconnect_backoff_factor: Some(2.0),
234 reconnect_jitter_ms: Some(200),
235 reconnect_max_attempts: None,
236 idle_timeout_ms: None,
237 backend: self.transport_backend,
238 proxy_url: self.proxy_url.clone(),
239 };
240 let client =
241 WebSocketClient::connect(cfg, Some(message_handler), None, None, vec![], None).await?;
242
243 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
245 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
246
247 *self.cmd_tx.write().await = cmd_tx.clone();
250 self.out_rx = Some(out_rx);
251
252 self.connection_mode.store(client.connection_mode_atomic());
253 log::debug!("Hyperliquid WebSocket connected: {}", self.url);
254
255 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
257 anyhow::bail!("Failed to send SetClient command: {e}");
258 }
259
260 let instruments_vec: Vec<InstrumentAny> =
262 self.instruments.load().values().cloned().collect();
263
264 if !instruments_vec.is_empty()
265 && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
266 {
267 log::error!("Failed to send InitializeInstruments: {e}");
268 }
269
270 let all_dex_asset_ctxs_instrument_ids = self
271 .all_dex_asset_ctxs_instrument_ids
272 .load()
273 .iter()
274 .map(|(dex, instrument_ids)| (*dex, instrument_ids.clone()))
275 .collect();
276
277 if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(
278 all_dex_asset_ctxs_instrument_ids,
279 )) {
280 log::error!("Failed to send CacheAllDexAssetCtxsInstrumentIds: {e}");
281 }
282
283 let signal = Arc::clone(&self.signal);
285 let account_id = self.account_id;
286 let subscriptions = self.subscriptions.clone();
287 let cmd_tx_for_reconnect = cmd_tx.clone();
288 let cloid_cache = Arc::clone(&self.cloid_cache);
289 let post_router = Arc::clone(&self.post_router);
290
291 let stream_handle = get_runtime().spawn(async move {
292 let mut handler = FeedHandler::new(
293 signal,
294 cmd_rx,
295 raw_rx,
296 out_tx,
297 account_id,
298 subscriptions.clone(),
299 cloid_cache,
300 post_router,
301 );
302
303 let resubscribe_all = || {
304 let topics = subscriptions.all_topics();
305 if topics.is_empty() {
306 log::debug!("No active subscriptions to restore after reconnection");
307 return;
308 }
309
310 log::info!(
311 "Resubscribing to {} active subscriptions after reconnection",
312 topics.len()
313 );
314
315 for topic in topics {
316 match subscription_from_topic(&topic) {
317 Ok(subscription) => {
318 if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
319 subscriptions: vec![subscription],
320 }) {
321 log::error!("Failed to send resubscribe command: {e}");
322 }
323 }
324 Err(e) => {
325 log::error!(
326 "Failed to reconstruct subscription from topic: topic={topic}, {e}"
327 );
328 }
329 }
330 }
331 };
332
333 loop {
334 match handler.next().await {
335 Some(NautilusWsMessage::Reconnected) => {
336 log::info!("WebSocket reconnected");
337 resubscribe_all();
338 }
339 Some(msg) => {
340 if handler.send(msg).is_err() {
341 if handler.is_stopped() {
342 log::debug!("Failed to send message (receiver dropped)");
343 } else {
344 log::error!("Failed to send message (receiver dropped)");
345 }
346 break;
347 }
348 }
349 None => {
350 if handler.is_stopped() {
351 log::debug!("Stop signal received, ending message processing");
352 break;
353 }
354 log::warn!("WebSocket stream ended unexpectedly");
355 break;
356 }
357 }
358 }
359 log::debug!("Handler task completed");
360 });
361 self.task_handle = Some(stream_handle);
362 Ok(())
363 }
364
365 pub fn take_task_handle(&mut self) -> Option<tokio::task::JoinHandle<()>> {
368 self.task_handle.take()
369 }
370
371 pub fn set_task_handle(&mut self, handle: tokio::task::JoinHandle<()>) {
372 self.task_handle = Some(handle);
373 }
374
375 pub fn set_post_timeout(&mut self, timeout: Duration) {
376 self.post_timeout = timeout;
377 }
378
379 pub(crate) fn abort(&mut self) {
382 self.signal.store(true, Ordering::Relaxed);
383 self.connection_mode
384 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
385
386 if let Some(handle) = self.task_handle.take() {
387 handle.abort();
388 }
389 }
390
391 pub async fn disconnect(&mut self) -> anyhow::Result<()> {
393 log::debug!("Disconnecting Hyperliquid WebSocket");
394 self.signal.store(true, Ordering::Relaxed);
395
396 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
397 log::debug!(
398 "Failed to send disconnect command (handler may already be shut down): {e}"
399 );
400 }
401
402 if let Some(handle) = self.task_handle.take() {
403 log::debug!("Waiting for task handle to complete");
404 let abort_handle = handle.abort_handle();
405 tokio::select! {
406 result = handle => {
407 match result {
408 Ok(()) => log::debug!("Task handle completed successfully"),
409 Err(e) if e.is_cancelled() => {
410 log::debug!("Task was cancelled");
411 }
412 Err(e) => log::error!("Task handle encountered an error: {e:?}"),
413 }
414 }
415 () = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {
416 log::warn!("Timeout waiting for task handle, aborting task");
417 abort_handle.abort();
418 }
419 }
420 } else {
421 log::debug!("No task handle to await");
422 }
423 log::debug!("Disconnected");
424 Ok(())
425 }
426
427 pub async fn post_action_exec(
433 &self,
434 signer: &HyperliquidHttpClient,
435 action: &HyperliquidExecAction,
436 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
437 self.post_action_exec_with_timeout(signer, action, self.post_timeout, None)
438 .await
439 }
440
441 pub async fn post_action_exec_with_timeout(
443 &self,
444 signer: &HyperliquidHttpClient,
445 action: &HyperliquidExecAction,
446 timeout: Duration,
447 expires_after: Option<u64>,
448 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
449 let weight = exec_action_weight(action);
450 self.post_limiter.acquire(weight).await;
451
452 let payload = signer.sign_action_exec_request(action, expires_after)?;
453 let response = self
454 .send_post_request(PostRequest::Action { payload }, timeout)
455 .await?;
456
457 match response.response {
458 PostResponsePayload::Action { payload } => {
459 let parsed: HyperliquidExchangeResponse =
460 serde_json::from_value(payload).map_err(HyperliquidError::Serde)?;
461
462 match &parsed {
463 HyperliquidExchangeResponse::Status {
464 status,
465 response: response_data,
466 } if status != RESPONSE_STATUS_OK => {
467 let error_msg = response_data
468 .as_str()
469 .map_or_else(|| response_data.to_string(), |s| s.to_string());
470 Err(HyperliquidError::bad_request(format!(
471 "API error: {error_msg}"
472 )))
473 }
474 HyperliquidExchangeResponse::Error { error } => {
475 Err(HyperliquidError::bad_request(format!("API error: {error}")))
476 }
477 _ => Ok(parsed),
478 }
479 }
480 PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
481 PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
482 "expected action post response, received info payload: {payload}"
483 ))),
484 }
485 }
486
487 #[allow(
498 clippy::too_many_arguments,
499 reason = "matches the Python and HTTP order submit surface"
500 )]
501 pub async fn submit_order(
502 &self,
503 signer: &HyperliquidHttpClient,
504 instrument_id: InstrumentId,
505 client_order_id: ClientOrderId,
506 order_side: OrderSide,
507 order_type: OrderType,
508 quantity: Quantity,
509 time_in_force: TimeInForce,
510 price: Option<Price>,
511 trigger_price: Option<Price>,
512 post_only: bool,
513 reduce_only: bool,
514 ) -> HyperliquidResult<Option<OrderStatusReport>> {
515 let symbol = instrument_id.symbol.inner();
516 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
517 HyperliquidError::bad_request(format!(
518 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
519 ))
520 })?;
521 let is_buy = matches!(order_side, OrderSide::Buy);
522 let price_precision = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
523
524 let price_decimal = match price {
525 Some(px) if signer.normalize_prices() => {
526 normalize_price(px.as_decimal(), price_precision).normalize()
527 }
528 Some(px) => px.as_decimal().normalize(),
529 None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
530 None if matches!(
531 order_type,
532 OrderType::StopMarket | OrderType::MarketIfTouched
533 ) =>
534 {
535 match trigger_price {
536 Some(tp) => {
537 let derived = derive_limit_from_trigger(
538 tp.as_decimal().normalize(),
539 is_buy,
540 signer.market_order_slippage_bps(),
541 );
542 let sig_rounded = round_to_sig_figs(derived, 5);
543 clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
544 }
545 None => Decimal::ZERO,
546 }
547 }
548 None => {
549 return Err(HyperliquidError::bad_request(
550 "Limit orders require a price",
551 ));
552 }
553 };
554
555 let size_decimal = quantity.as_decimal().normalize();
556 let kind = hyperliquid_order_kind(
557 order_type,
558 time_in_force,
559 post_only,
560 trigger_price,
561 signer.normalize_prices(),
562 price_precision,
563 )?;
564
565 let order = HyperliquidExecPlaceOrderRequest {
566 asset,
567 is_buy,
568 price: price_decimal,
569 size: size_decimal,
570 reduce_only,
571 kind,
572 cloid: Some(signer.get_or_generate_client_order_id_cloid(client_order_id)),
573 };
574
575 if let Some(cloid) = order.cloid {
576 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
577 }
578 let action = HyperliquidExecAction::Order {
579 orders: vec![order],
580 grouping: HyperliquidExecGrouping::Na,
581 builder: signer.builder_attribution(),
582 };
583 let response = self.post_action_exec(signer, &action).await?;
584
585 ensure_ws_action_accepted(&response, "Order submission")?;
587
588 match signer.build_submit_order_report(
590 instrument_id,
591 client_order_id,
592 order_side,
593 order_type,
594 quantity,
595 time_in_force,
596 price,
597 trigger_price,
598 response,
599 ) {
600 Ok(report) => Ok(report),
601 Err(e) => {
602 log::warn!(
603 "Failed to build submit report for {client_order_id}: {e}; awaiting WS reconciliation"
604 );
605 Ok(None)
606 }
607 }
608 }
609
610 pub async fn submit_orders(
617 &self,
618 signer: &HyperliquidHttpClient,
619 orders: &[&OrderAny],
620 ) -> HyperliquidResult<Vec<OrderStatusReport>> {
621 let mut hyperliquid_orders = Vec::with_capacity(orders.len());
622 let mut client_order_ids = Vec::with_capacity(orders.len());
623
624 for order in orders {
625 let instrument_id = order.instrument_id();
626 let symbol = instrument_id.symbol.inner();
627 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
628 HyperliquidError::bad_request(format!(
629 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
630 ))
631 })?;
632 let price_decimals = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
633 let request = order_to_hyperliquid_request_with_asset_and_cloid(
634 order,
635 asset,
636 price_decimals,
637 signer.normalize_prices(),
638 signer.market_order_slippage_bps(),
639 None,
640 )
641 .map_err(|e| HyperliquidError::bad_request(format!("Failed to convert order: {e}")))?;
642 client_order_ids.push(order.client_order_id());
643 hyperliquid_orders.push(request);
644 }
645
646 for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
647 let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
648 request.cloid = Some(cloid);
649 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
650 }
651
652 let grouping =
653 determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
654 let action = HyperliquidExecAction::Order {
655 orders: hyperliquid_orders,
656 grouping,
657 builder: signer.builder_attribution(),
658 };
659 let response = self.post_action_exec(signer, &action).await?;
660
661 ensure_ws_action_accepted(&response, "Order list submission")?;
662
663 match signer.build_submit_orders_reports(orders, grouping, response) {
665 Ok(reports) => Ok(reports),
666 Err(e) => {
667 log::warn!(
668 "Failed to build submit reports for order list: {e}; awaiting WS reconciliation"
669 );
670 Ok(Vec::new())
671 }
672 }
673 }
674
675 pub async fn cancel_order(
677 &self,
678 signer: &HyperliquidHttpClient,
679 instrument_id: InstrumentId,
680 client_order_id: Option<ClientOrderId>,
681 venue_order_id: Option<VenueOrderId>,
682 ) -> HyperliquidResult<()> {
683 let symbol = instrument_id.symbol.inner();
684 let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
685 HyperliquidError::bad_request(format!(
686 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
687 ))
688 })?;
689 let action = if let Some(client_order_id) = client_order_id {
690 if let Some(cloid) = signer.cached_client_order_id_cloid(&client_order_id) {
691 HyperliquidExecAction::CancelByCloid {
692 cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
693 }
694 } else if let Some(oid) = venue_order_id {
695 let oid = oid
696 .as_str()
697 .parse::<u64>()
698 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
699 HyperliquidExecAction::Cancel {
700 cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
701 }
702 } else {
703 let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
704 HyperliquidExecAction::CancelByCloid {
705 cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
706 }
707 }
708 } else if let Some(oid) = venue_order_id {
709 let oid = oid
710 .as_str()
711 .parse::<u64>()
712 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
713 HyperliquidExecAction::Cancel {
714 cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
715 }
716 } else {
717 return Err(HyperliquidError::bad_request(
718 "Either client_order_id or venue_order_id must be provided",
719 ));
720 };
721 let response = self.post_action_exec(signer, &action).await?;
722
723 ensure_ws_action_accepted(&response, "Cancel order")
724 }
725
726 pub async fn cancel_orders(
728 &self,
729 signer: &HyperliquidHttpClient,
730 cancels: &[(InstrumentId, ClientOrderId, Option<VenueOrderId>)],
731 ) -> HyperliquidResult<Vec<Option<String>>> {
732 let mut cloid_requests = Vec::new();
733 let mut cloid_indices = Vec::new();
734 let mut oid_requests = Vec::new();
735 let mut oid_indices = Vec::new();
736 let mut results = vec![None; cancels.len()];
737
738 for (index, (instrument_id, client_order_id, venue_order_id)) in cancels.iter().enumerate()
739 {
740 let symbol = instrument_id.symbol.inner();
741 let Some(asset) = signer.get_asset_index_for_symbol(symbol) else {
742 results[index] = Some(format!(
743 "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
744 ));
745 continue;
746 };
747
748 if let Some(cloid) = signer.cached_client_order_id_cloid(client_order_id) {
749 cloid_requests.push(HyperliquidExecCancelByCloidRequest { asset, cloid });
750 cloid_indices.push(index);
751 } else if let Some(venue_order_id) = venue_order_id {
752 match venue_order_id.as_str().parse::<u64>() {
753 Ok(oid) => {
754 oid_requests.push(HyperliquidExecCancelOrderRequest { asset, oid });
755 oid_indices.push(index);
756 }
757 Err(_) => {
758 results[index] = Some("Invalid venue order ID format".to_string());
759 }
760 }
761 } else {
762 let cloid = signer.get_or_generate_client_order_id_cloid(*client_order_id);
763 cloid_requests.push(HyperliquidExecCancelByCloidRequest { asset, cloid });
764 cloid_indices.push(index);
765 }
766 }
767
768 if cloid_requests.is_empty() && oid_requests.is_empty() {
769 return Ok(results);
770 }
771
772 if !cloid_requests.is_empty() {
773 let action = HyperliquidExecAction::CancelByCloid {
774 cancels: cloid_requests,
775 };
776 let errors = self
777 .post_cancel_action_errors(signer, &action, cloid_indices.len())
778 .await?;
779
780 for (index, error) in cloid_indices.into_iter().zip(errors) {
781 results[index] = error;
782 }
783 }
784
785 if !oid_requests.is_empty() {
786 let action = HyperliquidExecAction::Cancel {
787 cancels: oid_requests,
788 };
789 let errors = self
790 .post_cancel_action_errors(signer, &action, oid_indices.len())
791 .await?;
792
793 for (index, error) in oid_indices.into_iter().zip(errors) {
794 results[index] = error;
795 }
796 }
797
798 Ok(results)
799 }
800
801 async fn post_cancel_action_errors(
802 &self,
803 signer: &HyperliquidHttpClient,
804 action: &HyperliquidExecAction,
805 request_count: usize,
806 ) -> HyperliquidResult<Vec<Option<String>>> {
807 match self.post_cancel_action(signer, action).await {
808 Ok(response) if response.is_ok() => {
809 match cancel_errors_for_requests(extract_inner_errors(&response), request_count) {
810 Ok(errors) => Ok(errors),
811 Err(e) => Ok(vec![Some(e.to_string()); request_count]),
812 }
813 }
814 Ok(response) => Ok(vec![
815 Some(format!(
816 "Cancel orders failed: {}",
817 extract_error_message(&response)
818 ));
819 request_count
820 ]),
821 Err(e) => Err(e),
822 }
823 }
824
825 async fn post_cancel_action(
826 &self,
827 signer: &HyperliquidHttpClient,
828 action: &HyperliquidExecAction,
829 ) -> HyperliquidResult<HyperliquidExchangeResponse> {
830 let weight = exec_action_weight(action);
831 self.post_limiter.acquire(weight).await;
832
833 let payload = signer.sign_action_exec_request(action, None)?;
834 let response = self
835 .send_post_request(PostRequest::Action { payload }, self.post_timeout)
836 .await?;
837
838 match response.response {
839 PostResponsePayload::Action { payload } => {
840 serde_json::from_value(payload).map_err(HyperliquidError::Serde)
841 }
842 PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
843 PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
844 "expected action post response, received info payload: {payload}"
845 ))),
846 }
847 }
848
849 #[allow(
851 clippy::too_many_arguments,
852 reason = "matches the Python and HTTP order modify surface"
853 )]
854 pub async fn modify_order(
855 &self,
856 signer: &HyperliquidHttpClient,
857 instrument_id: InstrumentId,
858 venue_order_id: VenueOrderId,
859 order_side: OrderSide,
860 order_type: OrderType,
861 price: Price,
862 quantity: Quantity,
863 trigger_price: Option<Price>,
864 reduce_only: bool,
865 post_only: bool,
866 time_in_force: TimeInForce,
867 client_order_id: Option<ClientOrderId>,
868 ) -> HyperliquidResult<()> {
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 oid = venue_order_id
876 .as_str()
877 .parse::<u64>()
878 .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
879 let is_buy = matches!(order_side, OrderSide::Buy);
880 let price_decimals = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
881 let price = if signer.normalize_prices() {
882 normalize_price(price.as_decimal(), price_decimals).normalize()
883 } else {
884 price.as_decimal().normalize()
885 };
886 let kind = hyperliquid_order_kind(
887 order_type,
888 time_in_force,
889 post_only,
890 trigger_price,
891 signer.normalize_prices(),
892 price_decimals,
893 )?;
894 let cloid =
895 client_order_id.map(|id| (id, signer.get_or_generate_client_order_id_cloid(id)));
896 let order = HyperliquidExecPlaceOrderRequest {
897 asset,
898 is_buy,
899 price,
900 size: quantity.as_decimal().normalize(),
901 reduce_only,
902 kind,
903 cloid: cloid.map(|(_, cloid)| cloid),
904 };
905
906 if let Some((client_order_id, cloid)) = cloid {
907 self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
908 }
909 let action = HyperliquidExecAction::Modify {
910 modify: HyperliquidExecModifyOrderRequest { oid, order },
911 };
912 let response = self.post_action_exec(signer, &action).await?;
913
914 ensure_ws_action_accepted(&response, "Modify order")
915 }
916
917 async fn send_post_request(
918 &self,
919 request: PostRequest,
920 timeout: Duration,
921 ) -> HyperliquidResult<PostResponse> {
922 let id = self.post_ids.next();
923
924 match tokio::time::timeout(timeout, async {
925 let rx = self.post_router.register(id).await?;
926
927 let send_result = self
928 .cmd_tx
929 .read()
930 .await
931 .send(HandlerCommand::Post { id, request });
932
933 if let Err(e) = send_result {
934 self.post_router.cancel(id).await;
935 return Err(HyperliquidError::transport(format!(
936 "post command channel closed: {e}"
937 )));
938 }
939
940 self.post_router.await_with_timeout(id, rx, timeout).await
941 })
942 .await
943 {
944 Ok(result) => result,
945 Err(_elapsed) => {
946 self.post_router.cancel(id).await;
947 Err(HyperliquidError::Timeout)
948 }
949 }
950 }
951
952 pub fn is_active(&self) -> bool {
954 let mode = self.connection_mode.load();
955 mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8
956 }
957
958 pub fn url(&self) -> &str {
960 &self.url
961 }
962
963 pub fn cache_instruments(&mut self, instruments: Vec<InstrumentAny>) {
970 let mut map = AHashMap::new();
971
972 for inst in instruments {
973 let coin = inst.raw_symbol().inner();
974 map.insert(coin, inst);
975 }
976 let count = map.len();
977 self.instruments.store(map);
978 log::debug!("Hyperliquid instrument cache initialized with {count} instruments");
979 }
980
981 pub fn cache_instrument(&self, instrument: InstrumentAny) {
985 let coin = instrument.raw_symbol().inner();
986 self.instruments.insert(coin, instrument.clone());
987
988 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
991 let _ = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument));
992 }
993 }
994
995 #[must_use]
997 pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
998 self.instruments.clone()
999 }
1000
1001 pub fn cache_spot_fill_coins(&self, mapping: AHashMap<Ustr, Ustr>) {
1007 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1008 let _ = cmd_tx.send(HandlerCommand::CacheSpotFillCoins(mapping));
1009 }
1010 }
1011
1012 #[allow(
1020 clippy::missing_panics_doc,
1021 reason = "cloid cache mutex poisoning is not expected"
1022 )]
1023 pub fn cache_cloid_mapping(&self, cloid: Ustr, client_order_id: ClientOrderId) {
1024 log::debug!("Caching cloid mapping: {cloid} -> {client_order_id}");
1025 self.cloid_cache
1026 .lock()
1027 .expect(MUTEX_POISONED)
1028 .insert(cloid, client_order_id);
1029 }
1030
1031 #[allow(
1036 clippy::missing_panics_doc,
1037 reason = "cloid cache mutex poisoning is not expected"
1038 )]
1039 pub fn remove_cloid_mapping(&self, cloid: &Ustr) {
1040 if self
1041 .cloid_cache
1042 .lock()
1043 .expect(MUTEX_POISONED)
1044 .remove(cloid)
1045 .is_some()
1046 {
1047 log::debug!("Removed cloid mapping: {cloid}");
1048 }
1049 }
1050
1051 #[allow(
1055 clippy::missing_panics_doc,
1056 reason = "cloid cache mutex poisoning is not expected"
1057 )]
1058 pub fn clear_cloid_cache(&self) {
1059 let mut cache = self.cloid_cache.lock().expect(MUTEX_POISONED);
1060 let count = cache.len();
1061 cache.clear();
1062
1063 if count > 0 {
1064 log::debug!("Cleared {count} cloid mappings from cache");
1065 }
1066 }
1067
1068 #[must_use]
1070 #[allow(
1071 clippy::missing_panics_doc,
1072 reason = "cloid cache mutex poisoning is not expected"
1073 )]
1074 pub fn cloid_cache_len(&self) -> usize {
1075 self.cloid_cache.lock().expect(MUTEX_POISONED).len()
1076 }
1077
1078 #[must_use]
1082 #[allow(
1083 clippy::missing_panics_doc,
1084 reason = "cloid cache mutex poisoning is not expected"
1085 )]
1086 pub fn get_cloid_mapping(&self, cloid: &Ustr) -> Option<ClientOrderId> {
1087 self.cloid_cache
1088 .lock()
1089 .expect(MUTEX_POISONED)
1090 .get(cloid)
1091 .copied()
1092 }
1093
1094 pub fn get_instrument(&self, id: &InstrumentId) -> Option<InstrumentAny> {
1098 self.instruments
1099 .load()
1100 .values()
1101 .find(|inst| inst.id() == *id)
1102 .cloned()
1103 }
1104
1105 pub fn get_instrument_by_symbol(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1107 self.instruments.get_cloned(symbol)
1108 }
1109
1110 pub fn subscription_count(&self) -> usize {
1112 self.subscriptions.len()
1113 }
1114
1115 pub fn get_bar_type(&self, coin: &str, interval: &str) -> Option<BarType> {
1119 let key = format!("candle:{coin}:{interval}");
1121 self.bar_types.load().get(&key).copied()
1122 }
1123
1124 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1126 self.subscribe_book_with_options(instrument_id, None, None)
1127 .await
1128 }
1129
1130 pub async fn subscribe_book_with_options(
1133 &self,
1134 instrument_id: InstrumentId,
1135 n_sig_figs: Option<u32>,
1136 mantissa: Option<u32>,
1137 ) -> anyhow::Result<()> {
1138 let instrument = self
1139 .get_instrument(&instrument_id)
1140 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1141 let coin = instrument.raw_symbol().inner();
1142
1143 let cmd_tx = self.cmd_tx.read().await;
1144
1145 cmd_tx
1147 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1148 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1149
1150 let subscription = SubscriptionRequest::L2Book {
1151 coin,
1152 mantissa,
1153 n_sig_figs,
1154 };
1155
1156 cmd_tx
1157 .send(HandlerCommand::Subscribe {
1158 subscriptions: vec![subscription],
1159 })
1160 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1161 Ok(())
1162 }
1163
1164 pub async fn subscribe_book_depth10(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1170 self.subscribe_book_depth10_with_options(instrument_id, None, None)
1171 .await
1172 }
1173
1174 pub async fn subscribe_book_depth10_with_options(
1177 &self,
1178 instrument_id: InstrumentId,
1179 n_sig_figs: Option<u32>,
1180 mantissa: Option<u32>,
1181 ) -> anyhow::Result<()> {
1182 let instrument = self
1183 .get_instrument(&instrument_id)
1184 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1185 let coin = instrument.raw_symbol().inner();
1186
1187 let cmd_tx = self.cmd_tx.read().await;
1188
1189 cmd_tx
1190 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1191 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1192
1193 cmd_tx
1194 .send(HandlerCommand::SetDepth10Sub {
1195 coin,
1196 subscribed: true,
1197 })
1198 .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1199
1200 let subscription = SubscriptionRequest::L2Book {
1201 coin,
1202 mantissa,
1203 n_sig_figs,
1204 };
1205
1206 cmd_tx
1207 .send(HandlerCommand::Subscribe {
1208 subscriptions: vec![subscription],
1209 })
1210 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1211 Ok(())
1212 }
1213
1214 pub async fn unsubscribe_book_depth10(
1221 &self,
1222 instrument_id: InstrumentId,
1223 ) -> anyhow::Result<()> {
1224 let instrument = self
1225 .get_instrument(&instrument_id)
1226 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1227 let coin = instrument.raw_symbol().inner();
1228
1229 self.cmd_tx
1230 .read()
1231 .await
1232 .send(HandlerCommand::SetDepth10Sub {
1233 coin,
1234 subscribed: false,
1235 })
1236 .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1237 Ok(())
1238 }
1239
1240 pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1242 let instrument = self
1243 .get_instrument(&instrument_id)
1244 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1245 let coin = instrument.raw_symbol().inner();
1246
1247 let cmd_tx = self.cmd_tx.read().await;
1248
1249 cmd_tx
1251 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1252 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1253
1254 let subscription = SubscriptionRequest::Bbo { coin };
1255
1256 cmd_tx
1257 .send(HandlerCommand::Subscribe {
1258 subscriptions: vec![subscription],
1259 })
1260 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1261 Ok(())
1262 }
1263
1264 pub async fn subscribe_all_mids(&self) -> anyhow::Result<()> {
1266 self.subscribe_all_mids_with_dex(None).await
1267 }
1268
1269 pub async fn subscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1271 self.cmd_tx
1272 .read()
1273 .await
1274 .send(HandlerCommand::Subscribe {
1275 subscriptions: vec![SubscriptionRequest::AllDexsAssetCtxs],
1276 })
1277 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1278 Ok(())
1279 }
1280
1281 pub async fn subscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1283 let cmd_tx = self.cmd_tx.read().await;
1284
1285 let subscription = SubscriptionRequest::AllMids {
1286 dex: dex.map(ToString::to_string),
1287 };
1288
1289 cmd_tx
1290 .send(HandlerCommand::Subscribe {
1291 subscriptions: vec![subscription],
1292 })
1293 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1294 Ok(())
1295 }
1296
1297 pub async fn unsubscribe_all_mids(&self) -> anyhow::Result<()> {
1299 self.unsubscribe_all_mids_with_dex(None).await
1300 }
1301
1302 pub async fn unsubscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1304 self.cmd_tx
1305 .read()
1306 .await
1307 .send(HandlerCommand::Unsubscribe {
1308 subscriptions: vec![SubscriptionRequest::AllDexsAssetCtxs],
1309 })
1310 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1311 Ok(())
1312 }
1313
1314 pub async fn unsubscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1316 let cmd_tx = self.cmd_tx.read().await;
1317
1318 let subscription = SubscriptionRequest::AllMids {
1319 dex: dex.map(ToString::to_string),
1320 };
1321
1322 cmd_tx
1323 .send(HandlerCommand::Unsubscribe {
1324 subscriptions: vec![subscription],
1325 })
1326 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1327 Ok(())
1328 }
1329
1330 pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1332 let instrument = self
1333 .get_instrument(&instrument_id)
1334 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1335 let coin = instrument.raw_symbol().inner();
1336
1337 let cmd_tx = self.cmd_tx.read().await;
1338
1339 cmd_tx
1341 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1342 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1343
1344 let subscription = SubscriptionRequest::Trades { coin };
1345
1346 cmd_tx
1347 .send(HandlerCommand::Subscribe {
1348 subscriptions: vec![subscription],
1349 })
1350 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1351 Ok(())
1352 }
1353
1354 pub async fn subscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1356 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1357 .await
1358 }
1359
1360 pub async fn subscribe_index_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1362 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1363 .await
1364 }
1365
1366 pub async fn subscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1368 let instrument_id = bar_type.instrument_id();
1369 let instrument = self
1370 .get_instrument(&instrument_id)
1371 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1372 let coin = instrument.raw_symbol().inner();
1373 let interval = bar_type_to_interval(&bar_type)?;
1374 let subscription = SubscriptionRequest::Candle { coin, interval };
1375
1376 let key = format!("candle:{coin}:{interval}");
1378 self.bar_types.insert(key.clone(), bar_type);
1379
1380 let cmd_tx = self.cmd_tx.read().await;
1381
1382 cmd_tx
1383 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1384 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1385
1386 cmd_tx
1387 .send(HandlerCommand::AddBarType { key, bar_type })
1388 .map_err(|e| anyhow::anyhow!("Failed to send AddBarType command: {e}"))?;
1389
1390 cmd_tx
1391 .send(HandlerCommand::Subscribe {
1392 subscriptions: vec![subscription],
1393 })
1394 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1395 Ok(())
1396 }
1397
1398 pub async fn subscribe_funding_rates(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1400 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1401 .await
1402 }
1403
1404 pub async fn subscribe_open_interest(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1406 self.subscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1407 .await
1408 }
1409
1410 pub async fn subscribe_order_updates(&self, user: &str) -> anyhow::Result<()> {
1412 let subscription = SubscriptionRequest::OrderUpdates {
1413 user: user.to_string(),
1414 };
1415 self.cmd_tx
1416 .read()
1417 .await
1418 .send(HandlerCommand::Subscribe {
1419 subscriptions: vec![subscription],
1420 })
1421 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1422 Ok(())
1423 }
1424
1425 pub async fn subscribe_user_events(&self, user: &str) -> anyhow::Result<()> {
1427 let subscription = SubscriptionRequest::UserEvents {
1428 user: user.to_string(),
1429 };
1430 self.cmd_tx
1431 .read()
1432 .await
1433 .send(HandlerCommand::Subscribe {
1434 subscriptions: vec![subscription],
1435 })
1436 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1437 Ok(())
1438 }
1439
1440 pub async fn subscribe_user_fills(&self, user: &str) -> anyhow::Result<()> {
1445 let subscription = SubscriptionRequest::UserFills {
1446 user: user.to_string(),
1447 aggregate_by_time: None,
1448 };
1449 self.cmd_tx
1450 .read()
1451 .await
1452 .send(HandlerCommand::Subscribe {
1453 subscriptions: vec![subscription],
1454 })
1455 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1456 Ok(())
1457 }
1458
1459 pub async fn subscribe_all_user_channels(&self, user: &str) -> anyhow::Result<()> {
1464 self.subscribe_order_updates(user).await?;
1465 self.subscribe_user_events(user).await?;
1466 Ok(())
1467 }
1468
1469 pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1471 let instrument = self
1472 .get_instrument(&instrument_id)
1473 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1474 let coin = instrument.raw_symbol().inner();
1475
1476 let subscription = SubscriptionRequest::L2Book {
1477 coin,
1478 mantissa: None,
1479 n_sig_figs: None,
1480 };
1481
1482 self.cmd_tx
1483 .read()
1484 .await
1485 .send(HandlerCommand::Unsubscribe {
1486 subscriptions: vec![subscription],
1487 })
1488 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1489 Ok(())
1490 }
1491
1492 pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1494 let instrument = self
1495 .get_instrument(&instrument_id)
1496 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1497 let coin = instrument.raw_symbol().inner();
1498
1499 let subscription = SubscriptionRequest::Bbo { coin };
1500
1501 self.cmd_tx
1502 .read()
1503 .await
1504 .send(HandlerCommand::Unsubscribe {
1505 subscriptions: vec![subscription],
1506 })
1507 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1508 Ok(())
1509 }
1510
1511 pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1513 let instrument = self
1514 .get_instrument(&instrument_id)
1515 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1516 let coin = instrument.raw_symbol().inner();
1517
1518 let subscription = SubscriptionRequest::Trades { coin };
1519
1520 self.cmd_tx
1521 .read()
1522 .await
1523 .send(HandlerCommand::Unsubscribe {
1524 subscriptions: vec![subscription],
1525 })
1526 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1527 Ok(())
1528 }
1529
1530 pub async fn unsubscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1532 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1533 .await
1534 }
1535
1536 pub async fn unsubscribe_index_prices(
1538 &self,
1539 instrument_id: InstrumentId,
1540 ) -> anyhow::Result<()> {
1541 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1542 .await
1543 }
1544
1545 pub async fn unsubscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1547 let instrument_id = bar_type.instrument_id();
1548 let instrument = self
1549 .get_instrument(&instrument_id)
1550 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1551 let coin = instrument.raw_symbol().inner();
1552 let interval = bar_type_to_interval(&bar_type)?;
1553 let subscription = SubscriptionRequest::Candle { coin, interval };
1554
1555 let key = format!("candle:{coin}:{interval}");
1556 self.bar_types.remove(&key);
1557
1558 let cmd_tx = self.cmd_tx.read().await;
1559
1560 cmd_tx
1561 .send(HandlerCommand::RemoveBarType { key })
1562 .map_err(|e| anyhow::anyhow!("Failed to send RemoveBarType command: {e}"))?;
1563
1564 cmd_tx
1565 .send(HandlerCommand::Unsubscribe {
1566 subscriptions: vec![subscription],
1567 })
1568 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1569 Ok(())
1570 }
1571
1572 pub async fn unsubscribe_funding_rates(
1574 &self,
1575 instrument_id: InstrumentId,
1576 ) -> anyhow::Result<()> {
1577 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1578 .await
1579 }
1580
1581 pub async fn unsubscribe_open_interest(
1583 &self,
1584 instrument_id: InstrumentId,
1585 ) -> anyhow::Result<()> {
1586 self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1587 .await
1588 }
1589
1590 pub fn cache_all_dex_asset_ctxs_instrument_ids(
1592 &self,
1593 mapping: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1594 ) {
1595 self.all_dex_asset_ctxs_instrument_ids
1596 .store(mapping.clone());
1597
1598 if let Ok(cmd_tx) = self.cmd_tx.try_read()
1599 && let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mapping))
1600 {
1601 log::debug!(
1602 "Failed to send CacheAllDexAssetCtxsInstrumentIds command (handler may not be connected yet): {e}"
1603 );
1604 }
1605 }
1606
1607 async fn subscribe_asset_context_data(
1608 &self,
1609 instrument_id: InstrumentId,
1610 data_type: AssetContextDataType,
1611 ) -> anyhow::Result<()> {
1612 let instrument = self
1613 .get_instrument(&instrument_id)
1614 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1615 let coin = instrument.raw_symbol().inner();
1616
1617 let mut entry = self.asset_context_subs.entry(coin).or_default();
1618 let is_first_subscription = entry.is_empty();
1619 entry.insert(data_type);
1620 let data_types = entry.clone();
1621 drop(entry);
1622
1623 let cmd_tx = self.cmd_tx.read().await;
1624
1625 cmd_tx
1626 .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
1627 .map_err(|e| anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}"))?;
1628
1629 if is_first_subscription {
1630 log::debug!(
1631 "First asset context subscription for coin '{coin}', subscribing to ActiveAssetCtx"
1632 );
1633 let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
1634
1635 cmd_tx
1636 .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1637 .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1638
1639 cmd_tx
1640 .send(HandlerCommand::Subscribe {
1641 subscriptions: vec![subscription],
1642 })
1643 .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1644 } else {
1645 log::debug!(
1646 "Already subscribed to ActiveAssetCtx for coin '{coin}', adding {data_type:?} to tracked types"
1647 );
1648 }
1649
1650 Ok(())
1651 }
1652
1653 async fn unsubscribe_asset_context_data(
1654 &self,
1655 instrument_id: InstrumentId,
1656 data_type: AssetContextDataType,
1657 ) -> anyhow::Result<()> {
1658 let instrument = self
1659 .get_instrument(&instrument_id)
1660 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1661 let coin = instrument.raw_symbol().inner();
1662
1663 if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
1664 entry.remove(&data_type);
1665 let should_unsubscribe = entry.is_empty();
1666 let data_types = entry.clone();
1667 drop(entry);
1668
1669 let cmd_tx = self.cmd_tx.read().await;
1670
1671 if should_unsubscribe {
1672 self.asset_context_subs.remove(&coin);
1673
1674 log::debug!(
1675 "Last asset context subscription removed for coin '{coin}', unsubscribing from ActiveAssetCtx"
1676 );
1677 let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
1678
1679 cmd_tx
1680 .send(HandlerCommand::UpdateAssetContextSubs {
1681 coin,
1682 data_types: AHashSet::new(),
1683 })
1684 .map_err(|e| {
1685 anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
1686 })?;
1687
1688 cmd_tx
1689 .send(HandlerCommand::Unsubscribe {
1690 subscriptions: vec![subscription],
1691 })
1692 .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1693 } else {
1694 log::debug!(
1695 "Removed {data_type:?} from tracked types for coin '{coin}', but keeping ActiveAssetCtx subscription"
1696 );
1697
1698 cmd_tx
1699 .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
1700 .map_err(|e| {
1701 anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
1702 })?;
1703 }
1704 }
1705
1706 Ok(())
1707 }
1708
1709 pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
1713 if let Some(ref mut rx) = self.out_rx {
1714 rx.recv().await
1715 } else {
1716 None
1717 }
1718 }
1719}
1720
1721fn cancel_errors_for_requests(
1722 errors: Vec<Option<String>>,
1723 request_count: usize,
1724) -> HyperliquidResult<Vec<Option<String>>> {
1725 if errors.is_empty() {
1726 return Ok(vec![None; request_count]);
1727 }
1728
1729 if errors.len() != request_count {
1730 return Err(HyperliquidError::exchange(format!(
1731 "Cancel orders returned {} statuses for {request_count} cancels",
1732 errors.len()
1733 )));
1734 }
1735
1736 Ok(errors)
1737}
1738
1739fn map_post_payload_error(payload: String, weight: u32) -> HyperliquidError {
1740 let lower = payload.to_ascii_lowercase();
1741 let message = format!("WebSocket post error: {payload}");
1742
1743 if starts_with_status(&lower, &["429"])
1744 || lower.contains("too many requests")
1745 || lower.contains("rate limit")
1746 {
1747 HyperliquidError::rate_limit("exchange", weight, None)
1748 } else if starts_with_status(&lower, &["401", "403"])
1749 || lower.contains("unauthorized")
1750 || lower.contains("forbidden")
1751 || lower.contains("authentication")
1752 || lower.contains("authorization")
1753 || lower.contains("invalid signature")
1754 || contains_word(&lower, "auth")
1755 {
1756 HyperliquidError::auth(message)
1757 } else if starts_with_status(&lower, &["400"]) || lower.contains("bad request") {
1758 HyperliquidError::bad_request(message)
1759 } else if starts_with_status(&lower, &["500", "502", "503", "504"]) {
1760 HyperliquidError::exchange(message)
1761 } else {
1762 HyperliquidError::exchange(payload)
1763 }
1764}
1765
1766fn hyperliquid_order_kind(
1767 order_type: OrderType,
1768 time_in_force: TimeInForce,
1769 post_only: bool,
1770 trigger_price: Option<Price>,
1771 normalize_prices_enabled: bool,
1772 price_precision: u8,
1773) -> HyperliquidResult<HyperliquidExecOrderKind> {
1774 match order_type {
1775 OrderType::Market => Ok(HyperliquidExecOrderKind::Limit {
1776 limit: HyperliquidExecLimitParams {
1777 tif: HyperliquidExecTif::Ioc,
1778 },
1779 }),
1780 OrderType::Limit => {
1781 let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1782 .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
1783 Ok(HyperliquidExecOrderKind::Limit {
1784 limit: HyperliquidExecLimitParams { tif },
1785 })
1786 }
1787 OrderType::StopMarket
1788 | OrderType::StopLimit
1789 | OrderType::MarketIfTouched
1790 | OrderType::LimitIfTouched => {
1791 let trigger_price = trigger_price.ok_or_else(|| {
1792 HyperliquidError::bad_request("Trigger orders require a trigger price")
1793 })?;
1794 let trigger_px = if normalize_prices_enabled {
1795 normalize_price(trigger_price.as_decimal(), price_precision).normalize()
1796 } else {
1797 trigger_price.as_decimal().normalize()
1798 };
1799 let tpsl = match order_type {
1800 OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1801 OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
1802 _ => unreachable!(),
1803 };
1804 let is_market = matches!(
1805 order_type,
1806 OrderType::StopMarket | OrderType::MarketIfTouched
1807 );
1808
1809 Ok(HyperliquidExecOrderKind::Trigger {
1810 trigger: HyperliquidExecTriggerParams {
1811 is_market,
1812 trigger_px,
1813 tpsl,
1814 },
1815 })
1816 }
1817 _ => Err(HyperliquidError::bad_request(format!(
1818 "Order type {order_type:?} not supported"
1819 ))),
1820 }
1821}
1822
1823fn ensure_ws_action_accepted(
1824 response: &HyperliquidExchangeResponse,
1825 action_name: &str,
1826) -> HyperliquidResult<()> {
1827 if response.is_ok() {
1828 if let Some(error_msg) = extract_inner_errors(response).into_iter().flatten().next() {
1829 return Err(HyperliquidError::bad_request(format!(
1830 "{action_name} rejected: {error_msg}"
1831 )));
1832 }
1833
1834 if let Some(error_msg) = extract_inner_error(response) {
1835 return Err(HyperliquidError::bad_request(format!(
1836 "{action_name} rejected: {error_msg}"
1837 )));
1838 }
1839
1840 return Ok(());
1841 }
1842
1843 Err(HyperliquidError::bad_request(format!(
1844 "{action_name} failed: {}",
1845 extract_error_message(response)
1846 )))
1847}
1848
1849fn starts_with_status(payload: &str, statuses: &[&str]) -> bool {
1850 let trimmed = payload.trim_start();
1851 statuses
1852 .iter()
1853 .any(|status| starts_with_status_token(trimmed, status))
1854 || trimmed.strip_prefix("http").is_some_and(|rest| {
1855 let rest = rest
1856 .trim_start_matches(|c: char| c.is_ascii_whitespace() || matches!(c, ':' | '/'));
1857 statuses
1858 .iter()
1859 .any(|status| starts_with_status_token(rest, status))
1860 })
1861}
1862
1863fn starts_with_status_token(payload: &str, status: &str) -> bool {
1864 payload.strip_prefix(status).is_some_and(|rest| {
1865 rest.chars()
1866 .next()
1867 .is_none_or(|c| !c.is_ascii_alphanumeric())
1868 })
1869}
1870
1871fn contains_word(payload: &str, word: &str) -> bool {
1872 payload
1873 .split(|c: char| !c.is_ascii_alphanumeric())
1874 .any(|part| part == word)
1875}
1876
1877fn subscription_from_topic(topic: &str) -> anyhow::Result<SubscriptionRequest> {
1880 let (kind, rest) = topic
1881 .split_once(':')
1882 .map_or((topic, None), |(k, r)| (k, Some(r)));
1883
1884 let channel = HyperliquidWsChannel::from_wire_str(kind)
1885 .ok_or_else(|| anyhow::anyhow!("Unknown subscription channel: {kind}"))?;
1886
1887 match channel {
1888 HyperliquidWsChannel::AllMids => Ok(SubscriptionRequest::AllMids {
1889 dex: rest.map(|s| s.to_string()),
1890 }),
1891 HyperliquidWsChannel::AllDexsAssetCtxs => Ok(SubscriptionRequest::AllDexsAssetCtxs),
1892 HyperliquidWsChannel::Notification => Ok(SubscriptionRequest::Notification {
1893 user: rest.context("Missing user")?.to_string(),
1894 }),
1895 HyperliquidWsChannel::WebData2 => Ok(SubscriptionRequest::WebData2 {
1896 user: rest.context("Missing user")?.to_string(),
1897 }),
1898 HyperliquidWsChannel::Candle => {
1899 let rest = rest.context("Missing candle params")?;
1901 let (coin, interval_str) = rest.rsplit_once(':').context("Missing interval")?;
1902 let interval = HyperliquidBarInterval::from_str(interval_str)?;
1903 Ok(SubscriptionRequest::Candle {
1904 coin: Ustr::from(coin),
1905 interval,
1906 })
1907 }
1908 HyperliquidWsChannel::L2Book => Ok(SubscriptionRequest::L2Book {
1909 coin: Ustr::from(rest.context("Missing coin")?),
1910 mantissa: None,
1911 n_sig_figs: None,
1912 }),
1913 HyperliquidWsChannel::Trades => Ok(SubscriptionRequest::Trades {
1914 coin: Ustr::from(rest.context("Missing coin")?),
1915 }),
1916 HyperliquidWsChannel::OrderUpdates => Ok(SubscriptionRequest::OrderUpdates {
1917 user: rest.context("Missing user")?.to_string(),
1918 }),
1919 HyperliquidWsChannel::UserEvents => Ok(SubscriptionRequest::UserEvents {
1920 user: rest.context("Missing user")?.to_string(),
1921 }),
1922 HyperliquidWsChannel::UserFills => Ok(SubscriptionRequest::UserFills {
1923 user: rest.context("Missing user")?.to_string(),
1924 aggregate_by_time: None,
1925 }),
1926 HyperliquidWsChannel::UserFundings => Ok(SubscriptionRequest::UserFundings {
1927 user: rest.context("Missing user")?.to_string(),
1928 }),
1929 HyperliquidWsChannel::UserNonFundingLedgerUpdates => {
1930 Ok(SubscriptionRequest::UserNonFundingLedgerUpdates {
1931 user: rest.context("Missing user")?.to_string(),
1932 })
1933 }
1934 HyperliquidWsChannel::ActiveAssetCtx => Ok(SubscriptionRequest::ActiveAssetCtx {
1935 coin: Ustr::from(rest.context("Missing coin")?),
1936 }),
1937 HyperliquidWsChannel::ActiveSpotAssetCtx => Ok(SubscriptionRequest::ActiveSpotAssetCtx {
1938 coin: Ustr::from(rest.context("Missing coin")?),
1939 }),
1940 HyperliquidWsChannel::ActiveAssetData => {
1941 let rest = rest.context("Missing params")?;
1943 let (user, coin) = rest.split_once(':').context("Missing coin")?;
1944 Ok(SubscriptionRequest::ActiveAssetData {
1945 user: user.to_string(),
1946 coin: coin.to_string(),
1947 })
1948 }
1949 HyperliquidWsChannel::UserTwapSliceFills => Ok(SubscriptionRequest::UserTwapSliceFills {
1950 user: rest.context("Missing user")?.to_string(),
1951 }),
1952 HyperliquidWsChannel::UserTwapHistory => Ok(SubscriptionRequest::UserTwapHistory {
1953 user: rest.context("Missing user")?.to_string(),
1954 }),
1955 HyperliquidWsChannel::Bbo => Ok(SubscriptionRequest::Bbo {
1956 coin: Ustr::from(rest.context("Missing coin")?),
1957 }),
1958
1959 HyperliquidWsChannel::SubscriptionResponse
1961 | HyperliquidWsChannel::User
1962 | HyperliquidWsChannel::Post
1963 | HyperliquidWsChannel::Pong
1964 | HyperliquidWsChannel::Error => {
1965 anyhow::bail!("Not a subscription channel: {kind}")
1966 }
1967 }
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972 use rstest::rstest;
1973
1974 use super::*;
1975 use crate::{
1976 common::{consts::INFLIGHT_MAX, enums::HyperliquidBarInterval},
1977 websocket::handler::subscription_to_key,
1978 };
1979
1980 fn subscription_topic(sub: &SubscriptionRequest) -> String {
1982 subscription_to_key(sub)
1983 }
1984
1985 #[rstest]
1986 #[case(SubscriptionRequest::Trades { coin: "BTC".into() }, "trades:BTC")]
1987 #[case(SubscriptionRequest::Bbo { coin: "BTC".into() }, "bbo:BTC")]
1988 #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() }, "orderUpdates:0x123")]
1989 #[case(SubscriptionRequest::UserEvents { user: "0xabc".to_string() }, "userEvents:0xabc")]
1990 fn test_subscription_topic_generation(
1991 #[case] subscription: SubscriptionRequest,
1992 #[case] expected_topic: &str,
1993 ) {
1994 assert_eq!(subscription_topic(&subscription), expected_topic);
1995 }
1996
1997 #[rstest]
1998 fn test_subscription_topics_unique() {
1999 let sub1 = SubscriptionRequest::Trades { coin: "BTC".into() };
2000 let sub2 = SubscriptionRequest::Bbo { coin: "BTC".into() };
2001
2002 let topic1 = subscription_topic(&sub1);
2003 let topic2 = subscription_topic(&sub2);
2004
2005 assert_ne!(topic1, topic2);
2006 }
2007
2008 #[rstest]
2009 #[case(SubscriptionRequest::Trades { coin: "BTC".into() })]
2010 #[case(SubscriptionRequest::Bbo { coin: "ETH".into() })]
2011 #[case(SubscriptionRequest::Candle { coin: "SOL".into(), interval: HyperliquidBarInterval::OneHour })]
2012 #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() })]
2013 #[case(SubscriptionRequest::Trades { coin: "vntls:vCURSOR".into() })]
2014 #[case(SubscriptionRequest::L2Book { coin: "vntls:vCURSOR".into(), mantissa: None, n_sig_figs: None })]
2015 #[case(SubscriptionRequest::Candle { coin: "vntls:vCURSOR".into(), interval: HyperliquidBarInterval::OneHour })]
2016 fn test_subscription_reconstruction(#[case] subscription: SubscriptionRequest) {
2017 let topic = subscription_topic(&subscription);
2018 let reconstructed = subscription_from_topic(&topic).expect("Failed to reconstruct");
2019 assert_eq!(subscription_topic(&reconstructed), topic);
2020 }
2021
2022 #[rstest]
2023 fn test_subscription_topic_candle() {
2024 let sub = SubscriptionRequest::Candle {
2025 coin: "BTC".into(),
2026 interval: HyperliquidBarInterval::OneHour,
2027 };
2028
2029 let topic = subscription_topic(&sub);
2030 assert_eq!(topic, "candle:BTC:1h");
2031 }
2032
2033 #[rstest]
2034 fn set_post_timeout_updates_client_and_clone() {
2035 let mut client = HyperliquidWebSocketClient::new(
2036 None,
2037 HyperliquidEnvironment::Testnet,
2038 None,
2039 TransportBackend::default(),
2040 None,
2041 );
2042 let timeout = std::time::Duration::from_secs(7);
2043
2044 client.set_post_timeout(timeout);
2045
2046 assert_eq!(client.post_timeout, timeout);
2047 assert_eq!(client.clone().post_timeout, timeout);
2048 }
2049
2050 #[rstest]
2051 #[tokio::test(flavor = "multi_thread")]
2052 async fn send_post_request_times_out_while_waiting_for_inflight_slot() {
2053 let client = HyperliquidWebSocketClient::new(
2054 None,
2055 HyperliquidEnvironment::Testnet,
2056 None,
2057 TransportBackend::default(),
2058 None,
2059 );
2060 let mut receivers = Vec::with_capacity(INFLIGHT_MAX);
2061 for offset in 0..INFLIGHT_MAX {
2062 receivers.push(
2063 client
2064 .post_router
2065 .register(10_000 + offset as u64)
2066 .await
2067 .unwrap(),
2068 );
2069 }
2070
2071 let err = client
2072 .send_post_request(
2073 PostRequest::Info {
2074 payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
2075 },
2076 std::time::Duration::from_millis(25),
2077 )
2078 .await
2079 .expect_err("request should timeout before acquiring an inflight slot");
2080
2081 assert!(matches!(err, HyperliquidError::Timeout));
2082 assert_eq!(receivers.len(), INFLIGHT_MAX);
2083 }
2084
2085 #[rstest]
2086 fn cancel_errors_for_requests_accepts_empty_as_success() {
2087 let errors = cancel_errors_for_requests(Vec::new(), 2).unwrap();
2088
2089 assert_eq!(errors, vec![None, None]);
2090 }
2091
2092 #[rstest]
2093 fn cancel_errors_for_requests_rejects_status_count_mismatch() {
2094 let err = cancel_errors_for_requests(vec![None], 2).expect_err("mismatch should fail");
2095
2096 assert!(
2097 err.to_string()
2098 .contains("returned 1 statuses for 2 cancels")
2099 );
2100 }
2101
2102 #[rstest]
2103 fn test_post_payload_error_maps_rate_limit() {
2104 let err = map_post_payload_error("429 Too Many Requests".to_string(), 3);
2105
2106 assert!(matches!(
2107 err,
2108 HyperliquidError::RateLimit {
2109 scope: "exchange",
2110 weight: 3,
2111 retry_after_ms: None,
2112 }
2113 ));
2114 }
2115
2116 #[rstest]
2117 #[case("401 Unauthorized")]
2118 #[case("HTTP 403: forbidden")]
2119 #[case("invalid signature")]
2120 #[case("authentication failed")]
2121 fn test_post_payload_error_maps_auth(#[case] payload: &str) {
2122 let err = map_post_payload_error(payload.to_string(), 1);
2123
2124 assert!(matches!(err, HyperliquidError::Auth(_)));
2125 }
2126
2127 #[rstest]
2128 #[case("400 Bad Request")]
2129 #[case("HTTP 400: malformed payload")]
2130 #[case("bad request: missing action")]
2131 fn test_post_payload_error_maps_bad_request(#[case] payload: &str) {
2132 let err = map_post_payload_error(payload.to_string(), 1);
2133
2134 assert!(matches!(err, HyperliquidError::BadRequest(_)));
2135 }
2136
2137 #[rstest]
2138 #[case("500 Internal Server Error")]
2139 #[case("HTTP 503: service unavailable")]
2140 fn test_post_payload_error_maps_exchange_status(#[case] payload: &str) {
2141 let err = map_post_payload_error(payload.to_string(), 1);
2142
2143 assert!(matches!(err, HyperliquidError::Exchange(_)));
2144 }
2145
2146 #[rstest]
2147 #[case("order 429001 rejected")]
2148 #[case("asset 5001 is not tradable")]
2149 #[case("authoritative nonce window exceeded")]
2150 fn test_post_payload_error_does_not_match_embedded_codes_or_words(#[case] payload: &str) {
2151 let err = map_post_payload_error(payload.to_string(), 1);
2152
2153 assert!(matches!(err, HyperliquidError::Exchange(_)));
2154 }
2155}