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