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