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