1use std::{
19 collections::{BTreeSet, VecDeque},
20 future::Future,
21 sync::{
22 Arc,
23 atomic::{AtomicBool, Ordering},
24 },
25};
26
27use ahash::{AHashMap, AHashSet};
28use nautilus_common::cache::fifo::FifoCache;
29use nautilus_core::{AtomicTime, Params, nanos::UnixNanos, time::get_atomic_clock_realtime};
30use nautilus_model::{
31 data::{BarType, CustomData, Data, DataType},
32 identifiers::{AccountId, InstrumentId},
33 instruments::{Instrument, InstrumentAny},
34 types::Price,
35};
36use nautilus_network::{
37 RECONNECTED,
38 error::SendError,
39 retry::{RetryError, RetryManager, create_websocket_retry_manager},
40 websocket::{SubscriptionState, WebSocketClient},
41};
42use rust_decimal::Decimal;
43use tokio_tungstenite::tungstenite::Message;
44use tokio_util::sync::CancellationToken;
45use ustr::Ustr;
46
47use super::{
48 client::{AssetContextDataType, CloidCache},
49 enums::HyperliquidWsChannel,
50 error::HyperliquidWsError,
51 messages::{
52 CandleData, ExecutionReport, HyperliquidWsMessage, HyperliquidWsRequest, NautilusWsMessage,
53 PostRequest, SubscriptionRequest, WsActiveAssetCtxData, WsAllDexsAssetCtxsData,
54 WsUserEventData,
55 },
56 parse::{
57 parse_ws_asset_context, parse_ws_candle, parse_ws_fill_report, parse_ws_open_interest,
58 parse_ws_order_book_deltas, parse_ws_order_book_depth10, parse_ws_order_status_report,
59 parse_ws_public_trade, parse_ws_quote_tick, parse_ws_trade_tick, parse_ws_twap_history_row,
60 parse_ws_twap_slice_fill,
61 },
62 post::PostRouter,
63 rate_limits::WebSocketRateLimits,
64 trades::TradeStreamUses,
65};
66use crate::{
67 common::consts::HEARTBEAT_INTERVAL,
68 data_types::{
69 HyperliquidAllDexsAssetCtxs, HyperliquidAllMids, HyperliquidDexAssetCtx,
70 HyperliquidImpactPrices,
71 },
72};
73
74const HEARTBEAT_MESSAGE: &str = r#"{"method":"ping"}"#;
75
76#[derive(Debug)]
78#[expect(
79 clippy::large_enum_variant,
80 reason = "Commands are ephemeral and immediately consumed"
81)]
82#[allow(private_interfaces)]
83pub enum HandlerCommand {
84 SetClient(WebSocketClient),
86 Disconnect,
88 Subscribe {
90 subscriptions: Vec<SubscriptionRequest>,
91 },
92 Unsubscribe {
94 subscriptions: Vec<SubscriptionRequest>,
95 },
96 Resubscribe { subscription: SubscriptionRequest },
98 Post {
100 id: u64,
101 request: PostRequest,
102 deadline: tokio::time::Instant,
103 cancellation_token: CancellationToken,
104 },
105 InitializeInstruments(Vec<InstrumentAny>),
107 UpdateInstrument(InstrumentAny),
109 AddBarType { key: String, bar_type: BarType },
111 RemoveBarType { key: String },
113 UpdateAssetContextSubs {
115 coin: Ustr,
116 data_types: AHashSet<AssetContextDataType>,
117 },
118 UpdateTradeSubs { coin: Ustr, uses: TradeStreamUses },
120 CacheAllDexAssetCtxsInstrumentIds(AHashMap<Ustr, Vec<Option<InstrumentId>>>),
122 CacheSpotFillCoins(AHashMap<Ustr, Ustr>),
124 SetDepth10Sub { coin: Ustr, subscribed: bool },
127}
128
129#[derive(Default)]
130struct AssetContextCaches {
131 mark_price: AHashMap<Ustr, Decimal>,
132 index_price: AHashMap<Ustr, Decimal>,
133 funding_rate: AHashMap<Ustr, Decimal>,
134 open_interest: AHashMap<Ustr, Decimal>,
135}
136
137impl AssetContextCaches {
138 fn clear(&mut self, coin: Ustr, data_type: AssetContextDataType) {
139 match data_type {
140 AssetContextDataType::MarkPrice => {
141 self.mark_price.remove(&coin);
142 }
143 AssetContextDataType::IndexPrice => {
144 self.index_price.remove(&coin);
145 }
146 AssetContextDataType::FundingRate => {
147 self.funding_rate.remove(&coin);
148 }
149 AssetContextDataType::OpenInterest => {
150 self.open_interest.remove(&coin);
151 }
152 }
153 }
154
155 fn clear_removed(
156 &mut self,
157 coin: Ustr,
158 previous_data_types: Option<&AHashSet<AssetContextDataType>>,
159 next_data_types: &AHashSet<AssetContextDataType>,
160 ) {
161 let Some(previous_data_types) = previous_data_types else {
162 return;
163 };
164
165 for data_type in previous_data_types {
166 if !next_data_types.contains(data_type) {
167 self.clear(coin, *data_type);
168 }
169 }
170 }
171}
172
173#[derive(Debug)]
174struct AllMidsDataTypeCache {
175 dexes: BTreeSet<Option<String>>,
176 projected: Vec<DataType>,
177}
178
179impl Default for AllMidsDataTypeCache {
180 fn default() -> Self {
181 let mut cache = Self {
182 dexes: BTreeSet::new(),
183 projected: Vec::new(),
184 };
185 cache.rebuild();
186 cache
187 }
188}
189
190impl AllMidsDataTypeCache {
191 fn apply(&mut self, subscription: &SubscriptionRequest, subscribed: bool) {
192 let SubscriptionRequest::AllMids { dex } = subscription else {
193 return;
194 };
195 let changed = if subscribed {
196 self.dexes.insert(dex.clone())
197 } else {
198 self.dexes.remove(dex)
199 };
200
201 if changed {
202 self.rebuild();
203 }
204 }
205
206 fn as_slice(&self) -> &[DataType] {
207 &self.projected
208 }
209
210 fn rebuild(&mut self) {
211 self.projected.clear();
212 if self.dexes.is_empty() {
213 self.projected
214 .push(DataType::new("HyperliquidAllMids", None, None));
215 return;
216 }
217
218 self.projected.extend(self.dexes.iter().map(|dex| {
219 let metadata = dex.as_ref().map(|dex| {
220 let mut metadata = Params::new();
221 metadata.insert("dex".to_owned(), serde_json::Value::String(dex.clone()));
222 metadata
223 });
224 DataType::new("HyperliquidAllMids", metadata, None)
225 }));
226 }
227}
228
229pub(super) struct FeedHandler {
230 clock: &'static AtomicTime,
231 signal: Arc<AtomicBool>,
232 client: Option<WebSocketClient>,
233 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
234 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
235 cmd_closed: bool,
236 raw_closed: bool,
237 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
238 account_id: Option<AccountId>,
239 subscriptions: SubscriptionState,
240 all_mids_data_types: AllMidsDataTypeCache,
241 post_router: Arc<PostRouter>,
242 rate_limits: Arc<WebSocketRateLimits>,
243 client_id: u64,
244 heartbeat: tokio::time::Interval,
245 retry_manager: RetryManager<HyperliquidWsError>,
246 retry_manager_post: RetryManager<PostSendError>,
247 message_buffer: VecDeque<NautilusWsMessage>,
248 instruments: AHashMap<Ustr, InstrumentAny>,
249 cloid_cache: CloidCache,
250 bar_types_cache: AHashMap<String, BarType>,
251 bar_cache: AHashMap<String, CandleData>,
252 asset_context_subs: AHashMap<Ustr, AHashSet<AssetContextDataType>>,
253 trade_subs: AHashMap<Ustr, TradeStreamUses>,
254 all_dex_asset_ctxs_instrument_ids: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
255 depth10_subs: AHashSet<Ustr>,
256 processed_trade_ids: FifoCache<u64, 10_000>,
257 processed_public_trade_ids: FifoCache<(Ustr, u64), 10_000>,
258 asset_context_caches: AssetContextCaches,
259}
260
261impl FeedHandler {
262 #[allow(
264 clippy::too_many_arguments,
265 reason = "constructs the handler from independent runtime channels and caches"
266 )]
267 pub(super) fn new(
268 signal: Arc<AtomicBool>,
269 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
270 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
271 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
272 account_id: Option<AccountId>,
273 subscriptions: SubscriptionState,
274 cloid_cache: CloidCache,
275 post_router: Arc<PostRouter>,
276 rate_limits: Arc<WebSocketRateLimits>,
277 client_id: u64,
278 ) -> Self {
279 let heartbeat = tokio::time::interval_at(
280 tokio::time::Instant::now() + HEARTBEAT_INTERVAL,
281 HEARTBEAT_INTERVAL,
282 );
283 Self {
284 clock: get_atomic_clock_realtime(),
285 signal,
286 client: None,
287 cmd_rx,
288 raw_rx,
289 cmd_closed: false,
290 raw_closed: false,
291 out_tx,
292 account_id,
293 subscriptions,
294 all_mids_data_types: AllMidsDataTypeCache::default(),
295 post_router,
296 rate_limits,
297 client_id,
298 heartbeat,
299 retry_manager: create_websocket_retry_manager(),
300 retry_manager_post: create_websocket_retry_manager(),
301 message_buffer: VecDeque::new(),
302 instruments: AHashMap::new(),
303 cloid_cache,
304 bar_types_cache: AHashMap::new(),
305 bar_cache: AHashMap::new(),
306 asset_context_subs: AHashMap::new(),
307 trade_subs: AHashMap::new(),
308 all_dex_asset_ctxs_instrument_ids: AHashMap::new(),
309 depth10_subs: AHashSet::new(),
310 processed_trade_ids: FifoCache::new(),
311 processed_public_trade_ids: FifoCache::new(),
312 asset_context_caches: AssetContextCaches::default(),
313 }
314 }
315
316 pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
318 self.out_tx
319 .send(msg)
320 .map_err(|e| format!("Failed to send message: {e}"))
321 }
322
323 pub(super) fn is_stopped(&self) -> bool {
325 self.signal.load(Ordering::Relaxed)
326 }
327
328 async fn send_with_retry(&self, payload: String) -> anyhow::Result<()> {
329 if let Some(client) = &self.client {
330 let rate_key = self.rate_limits.message_key();
331 self.retry_manager
332 .invocation(
333 "websocket_send",
334 || {
335 let payload = payload.clone();
336 async move {
337 client
338 .send_text(payload, Some(std::slice::from_ref(&rate_key)))
339 .await
340 .map_err(|e| {
341 HyperliquidWsError::ClientError(format!("Send failed: {e}"))
342 })
343 }
344 },
345 should_retry_hyperliquid_error,
346 |e| create_hyperliquid_timeout_error(e.to_string()),
347 )
348 .execute()
349 .await
350 .map_err(|e| anyhow::anyhow!("{e}"))
351 } else {
352 Err(anyhow::anyhow!("No WebSocket client available"))
353 }
354 }
355
356 pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
357 if let Some(msg) = self.message_buffer.pop_front() {
358 return Some(msg);
359 }
360
361 loop {
362 if self.raw_closed && self.cmd_rx.is_empty() {
363 log::debug!("Handler shutting down: input stream closed");
364 return None;
365 }
366
367 tokio::select! {
368 cmd = self.cmd_rx.recv(), if !self.cmd_closed => {
369 let Some(cmd) = cmd else {
370 self.cmd_closed = true;
371 continue;
372 };
373
374 match cmd {
375 HandlerCommand::SetClient(client) => {
376 log::debug!("Setting WebSocket client in handler");
377 self.client = Some(client);
378 }
379 HandlerCommand::Disconnect => {
380 log::debug!("Handler received disconnect command");
381
382 if let Some(ref client) = self.client {
383 self.rate_limits.acquire_message().await;
384 client.disconnect().await;
385 }
386 self.signal.store(true, Ordering::SeqCst);
387 return None;
388 }
389 HandlerCommand::Subscribe { subscriptions } => {
390 self.subscribe(subscriptions).await;
391 }
392 HandlerCommand::Unsubscribe { subscriptions } => {
393 self.unsubscribe(subscriptions).await;
394 }
395 HandlerCommand::Resubscribe { subscription } => {
396 self.unsubscribe(vec![subscription.clone()]).await;
397 self.subscribe(vec![subscription]).await;
398 }
399 HandlerCommand::Post {
400 id,
401 request,
402 deadline,
403 cancellation_token,
404 } => {
405 if cancellation_token.is_cancelled()
406 || tokio::time::Instant::now() >= deadline
407 {
408 self.post_router
409 .cancel_registration(id, &cancellation_token)
410 .await;
411 continue;
412 }
413
414 let request = HyperliquidWsRequest::Post { id, request };
415 match serde_json::to_string(&request) {
416 Ok(payload) => {
417 log::debug!("Sending post payload: id={id}");
418 let result = if let Some(client) = &self.client {
419 let rate_key = self.rate_limits.message_key();
420 send_post_with_retry(
421 &self.retry_manager_post,
422 deadline,
423 &cancellation_token,
424 || {
425 let payload = payload.clone();
426 async move {
427 let connection_epoch =
428 client.connection_epoch();
429 client
430 .send_text_on_connection(
431 payload,
432 Some(std::slice::from_ref(&rate_key)),
433 connection_epoch,
434 )
435 .await
436 .map_err(PostSendError::Transport)
437 }
438 },
439 )
440 .await
441 .map_err(|e| anyhow::anyhow!("{e}"))
442 } else {
443 Err(anyhow::anyhow!("No WebSocket client available"))
444 };
445
446 if let Err(e) = result {
447 log::error!("Error sending post request id={id}: {e}");
448 self.post_router
449 .cancel_registration(id, &cancellation_token)
450 .await;
451 }
452 }
453 Err(e) => {
454 log::error!("Error serializing post request id={id}: {e}");
455 self.post_router
456 .cancel_registration(id, &cancellation_token)
457 .await;
458 }
459 }
460 }
461 HandlerCommand::InitializeInstruments(instruments) => {
462 for inst in instruments {
463 let coin = inst.raw_symbol().inner();
464 self.instruments.insert(coin, inst);
465 }
466 }
467 HandlerCommand::UpdateInstrument(inst) => {
468 let coin = inst.raw_symbol().inner();
469 self.instruments.insert(coin, inst);
470 }
471 HandlerCommand::AddBarType { key, bar_type } => {
472 self.bar_types_cache.insert(key, bar_type);
473 }
474 HandlerCommand::RemoveBarType { key } => {
475 self.bar_types_cache.remove(&key);
476 self.bar_cache.remove(&key);
477 }
478 HandlerCommand::UpdateAssetContextSubs { coin, data_types } => {
479 let previous_data_types = self.asset_context_subs.get(&coin).cloned();
480 self.asset_context_caches.clear_removed(
481 coin,
482 previous_data_types.as_ref(),
483 &data_types,
484 );
485
486 if data_types.is_empty() {
487 self.asset_context_subs.remove(&coin);
488 } else {
489 self.asset_context_subs.insert(coin, data_types);
490 }
491 }
492 HandlerCommand::UpdateTradeSubs { coin, uses } => {
493 if uses.is_empty() {
494 self.trade_subs.remove(&coin);
495 } else {
496 self.trade_subs.insert(coin, uses);
497 }
498 }
499 HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mappings) => {
500 self.all_dex_asset_ctxs_instrument_ids.extend(mappings);
503 }
504 HandlerCommand::CacheSpotFillCoins(_) => {
505 }
507 HandlerCommand::SetDepth10Sub { coin, subscribed } => {
508 if subscribed {
509 self.depth10_subs.insert(coin);
510 } else {
511 self.depth10_subs.remove(&coin);
512 }
513 }
514 }
515 }
516
517 raw_msg = self.raw_rx.recv(), if !self.raw_closed => {
518 let Some(raw_msg) = raw_msg else {
519 self.raw_closed = true;
520 continue;
521 };
522
523 match raw_msg {
524 Message::Text(text) => {
525 if text == RECONNECTED {
526 log::info!("Received RECONNECTED sentinel");
527 return Some(NautilusWsMessage::Reconnected);
528 }
529
530 match serde_json::from_str::<HyperliquidWsMessage>(&text) {
531 Ok(msg) => {
532 if let HyperliquidWsMessage::Post { data } = msg {
533 self.post_router.complete(data).await;
534 continue;
535 }
536
537 if let HyperliquidWsMessage::SubscriptionResponse { data } = &msg {
538 let key = subscription_to_key(&data.subscription);
539 match data.method.as_str() {
540 "subscribe" => self.subscriptions.confirm_subscribe(&key),
541 "unsubscribe" => {
542 let was_pending = self
543 .subscriptions
544 .pending_unsubscribe_topics()
545 .iter()
546 .any(|topic| topic == &key);
547 self.subscriptions.confirm_unsubscribe(&key);
548
549 if was_pending {
550 self.rate_limits.release_subscription(
551 self.client_id,
552 &key,
553 );
554 }
555 }
556 method => {
557 log::warn!(
558 "Unknown subscription response method: {method}"
559 );
560 }
561 }
562 continue;
563 }
564
565 let ts_init = self.clock.get_time_ns();
566
567 let nautilus_msgs = Self::parse_to_nautilus_messages(
568 msg,
569 &self.instruments,
570 &self.cloid_cache,
571 &self.bar_types_cache,
572 self.account_id,
573 ts_init,
574 &self.asset_context_subs,
575 &self.trade_subs,
576 &self.depth10_subs,
577 &mut self.processed_trade_ids,
578 &mut self.processed_public_trade_ids,
579 &mut self.asset_context_caches,
580 &mut self.bar_cache,
581 &self.all_dex_asset_ctxs_instrument_ids,
582 self.all_mids_data_types.as_slice(),
583 );
584
585 if !nautilus_msgs.is_empty() {
586 let mut iter = nautilus_msgs.into_iter();
587 let first = iter.next().unwrap();
588 self.message_buffer.extend(iter);
589 return Some(first);
590 }
591 }
592 Err(e) => {
593 log::error!("Error parsing WebSocket message: {e}, text: {text}");
594 }
595 }
596 }
597 Message::Ping(data) => {
598 if let Some(ref client) = self.client {
599 self.rate_limits.acquire_message().await;
600
601 if let Err(e) = client.send_pong(data.to_vec()).await {
602 log::error!("Error sending pong: {e}");
603 }
604 }
605 }
606 Message::Close(_) => {
607 log::debug!("Received WebSocket close frame");
608 return None;
609 }
610 _ => {}
611 }
612 }
613
614 _ = self.heartbeat.tick() => {
615 if self.client.as_ref().is_some_and(WebSocketClient::is_active)
616 && let Err(e) = self.send_with_retry(
617 HEARTBEAT_MESSAGE.to_string(),
618 ).await
619 {
620 log::error!("Error sending WebSocket heartbeat: {e}");
621 }
622 }
623
624 }
625 }
626 }
627
628 async fn subscribe(&mut self, subscriptions: Vec<SubscriptionRequest>) {
629 for subscription in subscriptions {
630 let key = subscription_to_key(&subscription);
631 self.subscriptions.mark_subscribe(&key);
632
633 if let Err(e) = self
634 .rate_limits
635 .reserve_subscription(self.client_id, &subscription)
636 {
637 log::error!("Cannot subscribe to {key}: {e}");
638 self.subscriptions.mark_unsubscribe(&key);
639 self.subscriptions.confirm_unsubscribe(&key);
640 continue;
641 }
642
643 self.all_mids_data_types.apply(&subscription, true);
644 let request = HyperliquidWsRequest::Subscribe { subscription };
645 match serde_json::to_string(&request) {
646 Ok(payload) => {
647 log::debug!("Sending subscribe payload ({} bytes)", payload.len());
648 if let Err(e) = self.send_with_retry(payload).await {
649 log::error!("Error subscribing to {key}: {e}");
650 self.subscriptions.mark_failure(&key);
651 }
652 }
653 Err(e) => {
654 log::error!("Error serializing subscription for {key}: {e}");
655 self.subscriptions.mark_failure(&key);
656 }
657 }
658 }
659 }
660
661 async fn unsubscribe(&mut self, subscriptions: Vec<SubscriptionRequest>) {
662 for subscription in subscriptions {
663 let key = subscription_to_key(&subscription);
664 self.subscriptions.mark_unsubscribe(&key);
665 self.all_mids_data_types.apply(&subscription, false);
666
667 let request = HyperliquidWsRequest::Unsubscribe { subscription };
668 match serde_json::to_string(&request) {
669 Ok(payload) => {
670 log::debug!("Sending unsubscribe payload ({} bytes)", payload.len());
671 if let Err(e) = self.send_with_retry(payload).await {
672 log::error!("Error unsubscribing from {key}: {e}");
673 }
674 }
675 Err(e) => {
676 log::error!("Error serializing unsubscription for {key}: {e}");
677 }
678 }
679 }
680 }
681
682 #[expect(clippy::too_many_arguments)]
683 fn parse_to_nautilus_messages(
684 msg: HyperliquidWsMessage,
685 instruments: &AHashMap<Ustr, InstrumentAny>,
686 cloid_cache: &CloidCache,
687 bar_types: &AHashMap<String, BarType>,
688 account_id: Option<AccountId>,
689 ts_init: UnixNanos,
690 asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
691 trade_subs: &AHashMap<Ustr, TradeStreamUses>,
692 depth10_subs: &AHashSet<Ustr>,
693 processed_trade_ids: &mut FifoCache<u64, 10_000>,
694 processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
695 asset_context_caches: &mut AssetContextCaches,
696 bar_cache: &mut AHashMap<String, CandleData>,
697 all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
698 all_mids_data_types: &[DataType],
699 ) -> Vec<NautilusWsMessage> {
700 let mut result = Vec::new();
701
702 match msg {
703 HyperliquidWsMessage::OrderUpdates { data } => {
704 if let Some(account_id) = account_id
705 && let Some(msg) = Self::handle_order_updates(
706 &data,
707 instruments,
708 cloid_cache,
709 account_id,
710 ts_init,
711 )
712 {
713 result.push(msg);
714 }
715 }
716 HyperliquidWsMessage::UserEvents { data } | HyperliquidWsMessage::User { data } => {
717 match data {
719 WsUserEventData::Fills { fills } => {
720 log::debug!("Received {} fill(s) from userEvents channel", fills.len());
721 for fill in &fills {
722 log::debug!(
723 "Fill: oid={}, coin={}, side={:?}, sz={}, px={}",
724 fill.oid,
725 fill.coin,
726 fill.side,
727 fill.sz,
728 fill.px
729 );
730 }
731
732 if let Some(account_id) = account_id {
733 log::debug!("Processing fills with account_id={account_id}");
734
735 if let Some(msg) = Self::handle_user_fills(
736 &fills,
737 instruments,
738 cloid_cache,
739 account_id,
740 ts_init,
741 processed_trade_ids,
742 ) {
743 log::debug!("Successfully created fill message");
744 result.push(msg);
745 } else {
746 log::debug!("handle_user_fills returned None (no new fills)");
747 }
748 } else {
749 log::warn!("Cannot process fills: account_id is None");
750 }
751 }
752 WsUserEventData::Liquidation { liquidation } => {
753 log::warn!(
754 "Liquidation event: lid={}, liquidator={}, liquidated_user={}, ntl_pos={}, account_value={}",
755 liquidation.lid,
756 liquidation.liquidator,
757 liquidation.liquidated_user,
758 liquidation.liquidated_ntl_pos,
759 liquidation.liquidated_account_value,
760 );
761 }
762 _ => {
763 log::debug!("Received non-fill user event: {data:?}");
764 }
765 }
766 }
767 HyperliquidWsMessage::UserFills { data } => {
768 if let Some(account_id) = account_id
771 && let Some(msg) = Self::handle_user_fills(
772 &data.fills,
773 instruments,
774 cloid_cache,
775 account_id,
776 ts_init,
777 processed_trade_ids,
778 )
779 {
780 result.push(msg);
781 }
782 }
783 HyperliquidWsMessage::Trades { data } => {
784 result.extend(Self::handle_trades(
785 &data,
786 instruments,
787 trade_subs,
788 processed_public_trade_ids,
789 ts_init,
790 ));
791 }
792 HyperliquidWsMessage::AllMids { data } => {
793 let mut mids = std::collections::HashMap::with_capacity(
794 data.mids.len().min(instruments.len()),
795 );
796
797 for (coin, mid_str) in &data.mids {
798 if let Some(instrument) = instruments.get(coin) {
799 match mid_str.parse::<Price>() {
800 Ok(price) => {
801 mids.insert(instrument.id(), price);
802 }
803 Err(e) => {
804 log::warn!("Failed to parse mid price for {coin}: {e}");
805 }
806 }
807 } else {
808 log::debug!("No instrument found for coin: {coin}");
809 }
810 }
811
812 if !mids.is_empty() {
813 let last_idx = all_mids_data_types.len().saturating_sub(1);
815 for (i, data_type) in all_mids_data_types.iter().enumerate() {
816 let mids_for_this = if i == last_idx {
817 std::mem::take(&mut mids)
818 } else {
819 mids.clone()
820 };
821 let all_mids = HyperliquidAllMids::new(mids_for_this, ts_init, ts_init);
822 result.push(NautilusWsMessage::CustomData(Data::Custom(
823 CustomData::new(Arc::new(all_mids), data_type.clone()),
824 )));
825 }
826 }
827 }
828 HyperliquidWsMessage::AllDexsAssetCtxs { data } => {
829 if let Some(msg) = Self::handle_all_dexs_asset_ctxs(
830 data,
831 all_dex_asset_ctxs_instrument_ids,
832 ts_init,
833 ) {
834 result.push(msg);
835 }
836 }
837 HyperliquidWsMessage::Bbo { data } => {
838 if let Some(msg) = Self::handle_bbo(&data, instruments, ts_init) {
839 result.push(msg);
840 }
841 }
842 HyperliquidWsMessage::L2Book { data } => {
843 result.extend(Self::handle_l2_book(
844 &data,
845 instruments,
846 depth10_subs,
847 ts_init,
848 ));
849 }
850 HyperliquidWsMessage::Candle { data } => {
851 if let Some(msg) =
852 Self::handle_candle(&data, instruments, bar_types, bar_cache, ts_init)
853 {
854 result.push(msg);
855 }
856 }
857 HyperliquidWsMessage::ActiveAssetCtx { data }
858 | HyperliquidWsMessage::ActiveSpotAssetCtx { data } => {
859 result.extend(Self::handle_asset_context(
860 &data,
861 instruments,
862 asset_context_subs,
863 asset_context_caches,
864 ts_init,
865 ));
866 }
867 HyperliquidWsMessage::UserTwapHistory { data } => {
868 result.extend(Self::handle_user_twap_history(&data, instruments, ts_init));
869 }
870 HyperliquidWsMessage::UserTwapSliceFills { data } => {
871 result.extend(Self::handle_user_twap_slice_fills(
872 &data,
873 instruments,
874 ts_init,
875 ));
876 }
877 HyperliquidWsMessage::Error { data } => {
878 log::warn!("Received error from Hyperliquid WebSocket: {data}");
879 }
880 _ => {}
882 }
883
884 result
885 }
886
887 fn handle_order_updates(
888 data: &[super::messages::WsOrderData],
889 instruments: &AHashMap<Ustr, InstrumentAny>,
890 cloid_cache: &CloidCache,
891 account_id: AccountId,
892 ts_init: UnixNanos,
893 ) -> Option<NautilusWsMessage> {
894 let mut exec_reports = Vec::new();
895
896 for order_update in data {
897 let instrument = instruments.get(&order_update.order.coin);
898
899 if let Some(instrument) = instrument {
900 match parse_ws_order_status_report(order_update, instrument, account_id, ts_init) {
901 Ok(mut report) => {
902 if let Some(cloid) = &order_update.order.cloid {
904 let cloid_ustr = Ustr::from(cloid.as_str());
905 let resolved = cloid_cache.lock().get(&cloid_ustr).copied();
906
907 if let Some(real_client_order_id) = resolved {
908 log::debug!("Resolved cloid {cloid} -> {real_client_order_id}");
909 report.client_order_id = Some(real_client_order_id);
910 }
911 }
912 exec_reports.push(ExecutionReport::Order(report));
913 }
914 Err(e) => {
915 log::error!("Error parsing order update: {e}");
916 }
917 }
918 } else {
919 log::debug!("No instrument found for coin: {}", order_update.order.coin);
920 }
921 }
922
923 if exec_reports.is_empty() {
924 None
925 } else {
926 Some(NautilusWsMessage::ExecutionReports(exec_reports))
927 }
928 }
929
930 fn handle_user_fills(
931 fills: &[super::messages::WsFillData],
932 instruments: &AHashMap<Ustr, InstrumentAny>,
933 cloid_cache: &CloidCache,
934 account_id: AccountId,
935 ts_init: UnixNanos,
936 processed_trade_ids: &mut FifoCache<u64, 10_000>,
937 ) -> Option<NautilusWsMessage> {
938 let mut exec_reports = Vec::new();
939
940 for fill in fills {
941 if processed_trade_ids.contains(&fill.tid) {
942 log::debug!("Skipping duplicate fill: tid={}", fill.tid);
943 continue;
944 }
945
946 let instrument = instruments.get(&fill.coin);
947
948 if let Some(instrument) = instrument {
949 log::debug!("Found instrument for fill coin={}", fill.coin);
950 match parse_ws_fill_report(fill, instrument, account_id, ts_init) {
951 Ok(mut report) => {
952 processed_trade_ids.add(fill.tid);
954
955 if let Some(cloid) = &fill.cloid {
956 let cloid_ustr = Ustr::from(cloid.as_str());
957 let resolved = cloid_cache.lock().get(&cloid_ustr).copied();
958
959 if let Some(real_client_order_id) = resolved {
960 log::debug!(
961 "Resolved fill cloid {cloid} -> {real_client_order_id}"
962 );
963 report.client_order_id = Some(real_client_order_id);
964 }
965 }
966 log::debug!(
967 "Parsed fill report: venue_order_id={:?}, trade_id={:?}",
968 report.venue_order_id,
969 report.trade_id
970 );
971 exec_reports.push(ExecutionReport::Fill(report));
972 }
973 Err(e) => {
974 log::error!("Error parsing fill: {e}");
975 }
976 }
977 } else {
978 log::warn!("No instrument found for fill coin={}", fill.coin);
980 }
981 }
982
983 if exec_reports.is_empty() {
984 None
985 } else {
986 Some(NautilusWsMessage::ExecutionReports(exec_reports))
987 }
988 }
989
990 fn handle_trades(
991 data: &[super::messages::WsTradeData],
992 instruments: &AHashMap<Ustr, InstrumentAny>,
993 trade_subs: &AHashMap<Ustr, TradeStreamUses>,
994 processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
995 ts_init: UnixNanos,
996 ) -> Vec<NautilusWsMessage> {
997 let mut trade_ticks = Vec::new();
998 let mut public_trades = Vec::new();
999
1000 for trade in data {
1001 if let Some(instrument) = instruments.get(&trade.coin) {
1002 let uses = trade_subs.get(&trade.coin).copied().unwrap_or_default();
1003
1004 if uses.ticks {
1005 match parse_ws_trade_tick(trade, instrument, ts_init) {
1006 Ok(tick) => trade_ticks.push(tick),
1007 Err(e) => {
1008 log::error!("Error parsing trade tick: {e}");
1009 }
1010 }
1011 }
1012
1013 if uses.public_trades {
1014 let trade_key = (trade.coin, trade.tid);
1015 if processed_public_trade_ids.contains(&trade_key) {
1016 log::debug!(
1017 "Skipping replayed public trade: coin={}, tid={}",
1018 trade.coin,
1019 trade.tid
1020 );
1021 continue;
1022 }
1023
1024 match parse_ws_public_trade(trade, instrument, ts_init) {
1025 Ok(trade) => {
1026 processed_public_trade_ids.add(trade_key);
1027 public_trades.push(trade);
1028 }
1029 Err(e) => {
1030 log::error!("Error parsing public trade: {e}");
1031 }
1032 }
1033 }
1034 } else {
1035 log::debug!("No instrument found for coin: {}", trade.coin);
1036 }
1037 }
1038
1039 let mut result = Vec::with_capacity(1 + public_trades.len());
1040 if !trade_ticks.is_empty() {
1041 result.push(NautilusWsMessage::Trades(trade_ticks));
1042 }
1043 result.extend(public_trades.into_iter().map(|trade| {
1044 let instrument_id = trade.instrument_id;
1045 NautilusWsMessage::CustomData(Data::Custom(CustomData::new(
1046 Arc::new(trade),
1047 Self::public_trade_data_type(instrument_id),
1048 )))
1049 }));
1050 result
1051 }
1052
1053 fn handle_bbo(
1054 data: &super::messages::WsBboData,
1055 instruments: &AHashMap<Ustr, InstrumentAny>,
1056 ts_init: UnixNanos,
1057 ) -> Option<NautilusWsMessage> {
1058 if let Some(instrument) = instruments.get(&data.coin) {
1059 match parse_ws_quote_tick(data, instrument, ts_init) {
1060 Ok(quote_tick) => Some(NautilusWsMessage::Quote(quote_tick)),
1061 Err(e) => {
1062 log::error!("Error parsing quote tick: {e}");
1063 None
1064 }
1065 }
1066 } else {
1067 log::debug!("No instrument found for coin: {}", data.coin);
1068 None
1069 }
1070 }
1071
1072 fn handle_l2_book(
1073 data: &super::messages::WsBookData,
1074 instruments: &AHashMap<Ustr, InstrumentAny>,
1075 depth10_subs: &AHashSet<Ustr>,
1076 ts_init: UnixNanos,
1077 ) -> Vec<NautilusWsMessage> {
1078 let mut out = Vec::new();
1079
1080 let Some(instrument) = instruments.get(&data.coin) else {
1081 log::debug!("No instrument found for coin: {}", data.coin);
1082 return out;
1083 };
1084
1085 match parse_ws_order_book_deltas(data, instrument, ts_init) {
1086 Ok(deltas) => out.push(NautilusWsMessage::Deltas(deltas)),
1087 Err(e) => log::error!("Error parsing order book deltas: {e}"),
1088 }
1089
1090 if depth10_subs.contains(&data.coin) {
1091 match parse_ws_order_book_depth10(data, instrument, ts_init) {
1092 Ok(depth) => out.push(NautilusWsMessage::Depth10(Box::new(depth))),
1093 Err(e) => log::error!("Error parsing order book depth10: {e}"),
1094 }
1095 }
1096
1097 out
1098 }
1099
1100 fn handle_candle(
1101 data: &CandleData,
1102 instruments: &AHashMap<Ustr, InstrumentAny>,
1103 bar_types: &AHashMap<String, BarType>,
1104 bar_cache: &mut AHashMap<String, CandleData>,
1105 ts_init: UnixNanos,
1106 ) -> Option<NautilusWsMessage> {
1107 let key = format!("candle:{}:{}", data.s, data.i);
1108
1109 let mut closed_bar = None;
1110
1111 if let Some(cached) = bar_cache.get(&key) {
1112 if cached.close_time != data.close_time {
1114 log::debug!(
1115 "Bar period changed for {}: prev_close_time={}, new_close_time={}",
1116 data.s,
1117 cached.close_time,
1118 data.close_time
1119 );
1120 closed_bar = Some(cached.clone());
1121 }
1122 }
1123
1124 bar_cache.insert(key.clone(), data.clone());
1125
1126 if let Some(closed_data) = closed_bar {
1127 if let Some(bar_type) = bar_types.get(&key) {
1128 if let Some(instrument) = instruments.get(&data.s) {
1129 match parse_ws_candle(&closed_data, instrument, bar_type, ts_init) {
1130 Ok(bar) => return Some(NautilusWsMessage::Candle(bar)),
1131 Err(e) => {
1132 log::error!("Error parsing closed candle: {e}");
1133 }
1134 }
1135 } else {
1136 log::debug!("No instrument found for coin: {}", data.s);
1137 }
1138 } else {
1139 log::debug!("No bar type found for key: {key}");
1140 }
1141 }
1142
1143 None
1144 }
1145
1146 fn handle_asset_context(
1147 data: &WsActiveAssetCtxData,
1148 instruments: &AHashMap<Ustr, InstrumentAny>,
1149 asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
1150 asset_context_caches: &mut AssetContextCaches,
1151 ts_init: UnixNanos,
1152 ) -> Vec<NautilusWsMessage> {
1153 let mut result = Vec::new();
1154
1155 let coin = match data {
1156 WsActiveAssetCtxData::Perp { coin, .. } => coin,
1157 WsActiveAssetCtxData::Spot { coin, .. } => coin,
1158 };
1159
1160 if let Some(instrument) = instruments.get(coin) {
1161 let (mark_px, oracle_px, funding, open_interest) = match data {
1162 WsActiveAssetCtxData::Perp { ctx, .. } => (
1163 &ctx.shared.mark_px,
1164 Some(&ctx.oracle_px),
1165 Some(&ctx.funding),
1166 Some(&ctx.open_interest),
1167 ),
1168 WsActiveAssetCtxData::Spot { ctx, .. } => (&ctx.shared.mark_px, None, None, None),
1169 };
1170
1171 let mark_changed = asset_context_caches.mark_price.get(coin) != Some(mark_px);
1172 let index_changed =
1173 oracle_px.is_some_and(|px| asset_context_caches.index_price.get(coin) != Some(px));
1174 let funding_changed = funding
1175 .is_some_and(|rate| asset_context_caches.funding_rate.get(coin) != Some(rate));
1176 let open_interest_changed = open_interest
1177 .is_some_and(|value| asset_context_caches.open_interest.get(coin) != Some(value));
1178
1179 let subscribed_types = asset_context_subs.get(coin);
1180
1181 if mark_changed || index_changed || funding_changed {
1182 match parse_ws_asset_context(data, instrument, ts_init) {
1183 Ok((mark_price, index_price, funding_rate)) => {
1184 if mark_changed
1185 && subscribed_types
1186 .is_some_and(|s| s.contains(&AssetContextDataType::MarkPrice))
1187 {
1188 asset_context_caches.mark_price.insert(*coin, *mark_px);
1189 result.push(NautilusWsMessage::MarkPrice(mark_price));
1190 }
1191
1192 if index_changed
1193 && subscribed_types
1194 .is_some_and(|s| s.contains(&AssetContextDataType::IndexPrice))
1195 {
1196 if let Some(px) = oracle_px {
1197 asset_context_caches.index_price.insert(*coin, *px);
1198 }
1199
1200 if let Some(index) = index_price {
1201 result.push(NautilusWsMessage::IndexPrice(index));
1202 }
1203 }
1204
1205 if funding_changed
1206 && subscribed_types
1207 .is_some_and(|s| s.contains(&AssetContextDataType::FundingRate))
1208 {
1209 if let Some(rate) = funding {
1210 asset_context_caches.funding_rate.insert(*coin, *rate);
1211 }
1212
1213 if let Some(funding) = funding_rate {
1214 result.push(NautilusWsMessage::FundingRate(funding));
1215 }
1216 }
1217 }
1218 Err(e) => {
1219 log::error!("Error parsing asset context: {e}");
1220 }
1221 }
1222 }
1223
1224 if let Some(value) = open_interest
1225 && open_interest_changed
1226 && subscribed_types.is_some_and(|s| s.contains(&AssetContextDataType::OpenInterest))
1227 {
1228 match parse_ws_open_interest(*value, instrument, ts_init) {
1229 Ok(open_interest_data) => {
1230 asset_context_caches.open_interest.insert(*coin, *value);
1231
1232 let data_type =
1233 Self::open_interest_data_type(open_interest_data.instrument_id);
1234 result.push(NautilusWsMessage::CustomData(Data::Custom(
1235 CustomData::new(Arc::new(open_interest_data), data_type),
1236 )));
1237 }
1238 Err(e) => {
1239 log::error!("Error parsing open interest: {e}");
1240 }
1241 }
1242 }
1243 } else {
1244 log::debug!("No instrument found for coin: {coin}");
1245 }
1246
1247 result
1248 }
1249
1250 fn handle_all_dexs_asset_ctxs(
1251 data: WsAllDexsAssetCtxsData,
1252 all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1253 ts_init: UnixNanos,
1254 ) -> Option<NautilusWsMessage> {
1255 let mut entries = Vec::new();
1256
1257 for (dex, ctxs) in data.ctxs {
1258 let dex_key = Ustr::from(dex.as_str());
1259 let Some(instrument_ids) = all_dex_asset_ctxs_instrument_ids.get(&dex_key) else {
1260 log::warn!("Missing Hyperliquid allDexsAssetCtxs mapping for dex='{dex}'");
1261 continue;
1262 };
1263
1264 if ctxs.len() != instrument_ids.len() {
1265 log::warn!(
1269 "Hyperliquid allDexsAssetCtxs count mismatch for dex='{dex}': received {} contexts but cached {} instrument IDs (the next instrument refresh, instrument request, or data client connect rebuilds the mapping)",
1270 ctxs.len(),
1271 instrument_ids.len()
1272 );
1273 }
1274
1275 for (index, ctx) in ctxs.into_iter().enumerate() {
1276 let Some(Some(instrument_id)) = instrument_ids.get(index).copied() else {
1277 log::warn!(
1278 "Missing Hyperliquid allDexsAssetCtxs instrument mapping for dex='{dex}' index={index}"
1279 );
1280 continue;
1281 };
1282
1283 match Self::normalize_all_dex_asset_ctx_entry(&dex, instrument_id, ctx) {
1284 Ok(entry) => entries.push(entry),
1285 Err(e) => {
1286 log::warn!(
1287 "Failed to normalize Hyperliquid allDexsAssetCtxs entry dex='{dex}' index={index}: {e}"
1288 );
1289 }
1290 }
1291 }
1292 }
1293
1294 if entries.is_empty() {
1295 return None;
1296 }
1297
1298 let payload = HyperliquidAllDexsAssetCtxs::new(entries, ts_init, ts_init);
1299 let data_type = DataType::new("HyperliquidAllDexsAssetCtxs", None, None);
1300 Some(NautilusWsMessage::CustomData(Data::Custom(
1301 CustomData::new(Arc::new(payload), data_type),
1302 )))
1303 }
1304
1305 fn normalize_all_dex_asset_ctx_entry(
1306 dex: &str,
1307 instrument_id: InstrumentId,
1308 ctx: super::messages::PerpsAssetCtx,
1309 ) -> anyhow::Result<HyperliquidDexAssetCtx> {
1310 let mark_price = Price::from_decimal(ctx.shared.mark_px).map_err(anyhow::Error::msg)?;
1311 let oracle_price = Price::from_decimal(ctx.oracle_px).map_err(anyhow::Error::msg)?;
1312 let prev_day_price =
1313 Price::from_decimal(ctx.shared.prev_day_px).map_err(anyhow::Error::msg)?;
1314 let mid_price = ctx
1315 .shared
1316 .mid_px
1317 .map(|value| Price::from_decimal(value).map_err(anyhow::Error::msg))
1318 .transpose()?;
1319 let funding_rate = ctx.funding;
1320 let open_interest = ctx.open_interest;
1321 let premium = ctx.premium;
1322 let day_ntl_volume = ctx.shared.day_ntl_vlm;
1323 let day_base_volume = ctx
1324 .shared
1325 .day_base_vlm
1326 .ok_or_else(|| anyhow::anyhow!("missing dayBaseVlm"))?;
1327 let impact_prices = match ctx.shared.impact_pxs {
1328 Some(values) => match values.as_slice() {
1329 [bid, ask] => Some(HyperliquidImpactPrices {
1330 bid: bid.parse::<Price>().map_err(anyhow::Error::msg)?,
1331 ask: ask.parse::<Price>().map_err(anyhow::Error::msg)?,
1332 }),
1333 other => {
1334 anyhow::bail!("expected 2 impact prices, received {}", other.len());
1335 }
1336 },
1337 None => None,
1338 };
1339
1340 Ok(HyperliquidDexAssetCtx {
1341 dex: dex.to_string(),
1342 instrument_id,
1343 mark_price,
1344 oracle_price,
1345 prev_day_price,
1346 mid_price,
1347 impact_prices,
1348 funding_rate,
1349 open_interest,
1350 premium,
1351 day_ntl_volume,
1352 day_base_volume,
1353 })
1354 }
1355
1356 fn open_interest_data_type(instrument_id: InstrumentId) -> DataType {
1357 let mut metadata = Params::new();
1358 metadata.insert(
1359 "instrument_id".to_string(),
1360 serde_json::Value::String(instrument_id.to_string()),
1361 );
1362 DataType::new(
1363 "HyperliquidOpenInterest",
1364 Some(metadata),
1365 Some(instrument_id.to_string()),
1366 )
1367 }
1368
1369 fn public_trade_data_type(instrument_id: InstrumentId) -> DataType {
1370 let mut metadata = Params::new();
1371 metadata.insert(
1372 "instrument_id".to_string(),
1373 serde_json::Value::String(instrument_id.to_string()),
1374 );
1375 DataType::new(
1376 "HyperliquidPublicTrade",
1377 Some(metadata),
1378 Some(instrument_id.to_string()),
1379 )
1380 }
1381
1382 fn handle_user_twap_history(
1383 data: &super::messages::WsUserTwapHistoryData,
1384 instruments: &AHashMap<Ustr, InstrumentAny>,
1385 ts_init: UnixNanos,
1386 ) -> Vec<NautilusWsMessage> {
1387 let is_snapshot = data.is_snapshot.unwrap_or(false);
1388 let mut result = Vec::with_capacity(data.history.len());
1389
1390 for row in &data.history {
1391 let instrument = instruments.get(&row.state.coin);
1392 match parse_ws_twap_history_row(row, &data.user, is_snapshot, instrument, ts_init) {
1393 Ok(payload) => {
1394 let user = payload.user.clone();
1395 result.push(NautilusWsMessage::CustomData(Data::Custom(
1396 CustomData::new(Arc::new(payload), Self::twap_history_data_type(&user)),
1397 )));
1398 }
1399 Err(e) => {
1400 log::error!("Error parsing TWAP history row: {e}");
1401 }
1402 }
1403 }
1404
1405 result
1406 }
1407
1408 fn handle_user_twap_slice_fills(
1409 data: &super::messages::WsUserTwapSliceFillsData,
1410 instruments: &AHashMap<Ustr, InstrumentAny>,
1411 ts_init: UnixNanos,
1412 ) -> Vec<NautilusWsMessage> {
1413 let is_snapshot = data.is_snapshot.unwrap_or(false);
1414 let mut result = Vec::with_capacity(data.twap_slice_fills.len());
1415
1416 for item in &data.twap_slice_fills {
1417 let instrument = instruments.get(&item.fill.coin);
1418 match parse_ws_twap_slice_fill(item, &data.user, is_snapshot, instrument, ts_init) {
1419 Ok(payload) => {
1420 let user = payload.user.clone();
1421 result.push(NautilusWsMessage::CustomData(Data::Custom(
1422 CustomData::new(Arc::new(payload), Self::twap_slice_fill_data_type(&user)),
1423 )));
1424 }
1425 Err(e) => {
1426 log::error!("Error parsing TWAP slice fill: {e}");
1427 }
1428 }
1429 }
1430
1431 result
1432 }
1433
1434 fn twap_history_data_type(user: &str) -> DataType {
1435 let mut metadata = Params::new();
1436 metadata.insert(
1437 "user".to_string(),
1438 serde_json::Value::String(user.to_string()),
1439 );
1440 DataType::new(
1441 "HyperliquidTwapHistory",
1442 Some(metadata),
1443 Some(user.to_string()),
1444 )
1445 }
1446
1447 fn twap_slice_fill_data_type(user: &str) -> DataType {
1448 let mut metadata = Params::new();
1449 metadata.insert(
1450 "user".to_string(),
1451 serde_json::Value::String(user.to_string()),
1452 );
1453 DataType::new(
1454 "HyperliquidTwapSliceFill",
1455 Some(metadata),
1456 Some(user.to_string()),
1457 )
1458 }
1459}
1460
1461#[derive(Debug, thiserror::Error)]
1462enum PostSendError {
1463 #[error(transparent)]
1464 Transport(SendError),
1465 #[error(transparent)]
1466 Retry(RetryError),
1467 #[error("Post deadline expired")]
1468 Deadline,
1469}
1470
1471async fn send_post_with_retry<F, Fut>(
1472 retry_manager: &RetryManager<PostSendError>,
1473 deadline: tokio::time::Instant,
1474 cancellation_token: &CancellationToken,
1475 send: F,
1476) -> Result<(), PostSendError>
1477where
1478 F: Fn() -> Fut + Clone,
1479 Fut: Future<Output = Result<(), PostSendError>>,
1480{
1481 let invocation = retry_manager
1482 .invocation(
1483 "websocket_post_send",
1484 || {
1485 let send = send.clone();
1486 async move { send_post_before_deadline(deadline, cancellation_token, send).await }
1487 },
1488 should_retry_post_send,
1489 PostSendError::Retry,
1490 )
1491 .cancellation_token(cancellation_token)
1492 .execute();
1493 tokio::pin!(invocation);
1494
1495 tokio::select! {
1496 biased;
1497 () = tokio::time::sleep_until(deadline) => {
1498 Err(PostSendError::Deadline)
1499 }
1500 result = &mut invocation => result,
1501 }
1502}
1503
1504async fn send_post_before_deadline<F, Fut>(
1505 deadline: tokio::time::Instant,
1506 cancellation_token: &CancellationToken,
1507 send: F,
1508) -> Result<(), PostSendError>
1509where
1510 F: FnOnce() -> Fut,
1511 Fut: Future<Output = Result<(), PostSendError>>,
1512{
1513 if cancellation_token.is_cancelled() {
1514 return Err(PostSendError::Retry(RetryError::Canceled));
1515 }
1516
1517 if tokio::time::Instant::now() >= deadline {
1518 return Err(PostSendError::Deadline);
1519 }
1520
1521 send().await
1522}
1523
1524fn should_retry_post_send(error: &PostSendError) -> bool {
1525 matches!(
1526 error,
1527 PostSendError::Transport(SendError::Timeout | SendError::ConnectionChanged)
1528 )
1529}
1530
1531pub(super) fn subscription_to_key(sub: &SubscriptionRequest) -> String {
1532 match sub {
1533 SubscriptionRequest::AllMids { dex } => {
1534 if let Some(dex_name) = dex {
1535 format!("{}:{dex_name}", HyperliquidWsChannel::AllMids.as_str())
1536 } else {
1537 HyperliquidWsChannel::AllMids.as_str().to_string()
1538 }
1539 }
1540 SubscriptionRequest::AllDexsAssetCtxs => {
1541 HyperliquidWsChannel::AllDexsAssetCtxs.as_str().to_string()
1542 }
1543 SubscriptionRequest::Notification { user } => {
1544 format!("{}:{user}", HyperliquidWsChannel::Notification.as_str())
1545 }
1546 SubscriptionRequest::WebData2 { user } => {
1547 format!("{}:{user}", HyperliquidWsChannel::WebData2.as_str())
1548 }
1549 SubscriptionRequest::Candle { coin, interval } => {
1550 format!(
1551 "{}:{coin}:{}",
1552 HyperliquidWsChannel::Candle.as_str(),
1553 interval.as_str()
1554 )
1555 }
1556 SubscriptionRequest::L2Book { coin, .. } => {
1557 format!("{}:{coin}", HyperliquidWsChannel::L2Book.as_str())
1558 }
1559 SubscriptionRequest::Trades { coin } => {
1560 format!("{}:{coin}", HyperliquidWsChannel::Trades.as_str())
1561 }
1562 SubscriptionRequest::OrderUpdates { user } => {
1563 format!("{}:{user}", HyperliquidWsChannel::OrderUpdates.as_str())
1564 }
1565 SubscriptionRequest::UserEvents { user } => {
1566 format!("{}:{user}", HyperliquidWsChannel::UserEvents.as_str())
1567 }
1568 SubscriptionRequest::UserFills { user, .. } => {
1569 format!("{}:{user}", HyperliquidWsChannel::UserFills.as_str())
1570 }
1571 SubscriptionRequest::UserFundings { user } => {
1572 format!("{}:{user}", HyperliquidWsChannel::UserFundings.as_str())
1573 }
1574 SubscriptionRequest::UserNonFundingLedgerUpdates { user } => {
1575 format!(
1576 "{}:{user}",
1577 HyperliquidWsChannel::UserNonFundingLedgerUpdates.as_str()
1578 )
1579 }
1580 SubscriptionRequest::ActiveAssetCtx { coin } => {
1581 format!("{}:{coin}", HyperliquidWsChannel::ActiveAssetCtx.as_str())
1582 }
1583 SubscriptionRequest::ActiveSpotAssetCtx { coin } => {
1584 format!(
1585 "{}:{coin}",
1586 HyperliquidWsChannel::ActiveSpotAssetCtx.as_str()
1587 )
1588 }
1589 SubscriptionRequest::ActiveAssetData { user, coin } => {
1590 format!(
1591 "{}:{user}:{coin}",
1592 HyperliquidWsChannel::ActiveAssetData.as_str()
1593 )
1594 }
1595 SubscriptionRequest::UserTwapSliceFills { user } => {
1596 format!(
1597 "{}:{user}",
1598 HyperliquidWsChannel::UserTwapSliceFills.as_str()
1599 )
1600 }
1601 SubscriptionRequest::UserTwapHistory { user } => {
1602 format!("{}:{user}", HyperliquidWsChannel::UserTwapHistory.as_str())
1603 }
1604 SubscriptionRequest::Bbo { coin } => {
1605 format!("{}:{coin}", HyperliquidWsChannel::Bbo.as_str())
1606 }
1607 }
1608}
1609
1610pub(crate) fn should_retry_hyperliquid_error(error: &HyperliquidWsError) -> bool {
1612 match error {
1613 HyperliquidWsError::TungsteniteError(_) => true,
1614 HyperliquidWsError::ClientError(msg) => {
1615 let msg_lower = msg.to_lowercase();
1616 msg_lower.contains("timeout")
1617 || msg_lower.contains("timed out")
1618 || msg_lower.contains("connection")
1619 || msg_lower.contains("network")
1620 }
1621 _ => false,
1622 }
1623}
1624
1625pub(crate) fn create_hyperliquid_timeout_error(msg: String) -> HyperliquidWsError {
1627 HyperliquidWsError::ClientError(msg)
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use std::{
1633 sync::{
1634 Arc,
1635 atomic::{AtomicBool, AtomicUsize, Ordering},
1636 },
1637 time::Duration,
1638 };
1639
1640 use ahash::{AHashMap, AHashSet};
1641 use log::{Level, LevelFilter, Log, Metadata, Record};
1642 use nautilus_common::cache::fifo::FifoCacheMap;
1643 use nautilus_core::nanos::UnixNanos;
1644 use nautilus_model::{
1645 data::Data,
1646 identifiers::{ClientOrderId, InstrumentId, Symbol},
1647 instruments::{CryptoPerpetual, Instrument, InstrumentAny},
1648 types::{Currency, Price, Quantity},
1649 };
1650 use nautilus_network::{
1651 error::SendError,
1652 retry::{RetryConfig, RetryError, RetryManager},
1653 websocket::SubscriptionState,
1654 };
1655 use parking_lot::Mutex;
1656 use rstest::rstest;
1657 use rust_decimal::Decimal;
1658 use rust_decimal_macros::dec;
1659 use serde_json::json;
1660 use tokio_tungstenite::tungstenite::Message;
1661 use tokio_util::sync::CancellationToken;
1662 use ustr::Ustr;
1663
1664 use super::{
1665 super::{
1666 client::{AssetContextDataType, CLOID_CACHE_CAPACITY, CloidCache},
1667 messages::{
1668 HyperliquidWsRequest, NautilusWsMessage, PerpsAssetCtx, PostRequest,
1669 SharedAssetCtx, SpotAssetCtx, SubscriptionRequest, WsActiveAssetCtxData,
1670 WsAllDexsAssetCtxsData, WsBookData, WsLevelData,
1671 },
1672 post::PostRouter,
1673 rate_limits::WebSocketRateLimits,
1674 },
1675 AllMidsDataTypeCache, AssetContextCaches, FeedHandler, HandlerCommand, PostSendError,
1676 send_post_before_deadline, send_post_with_retry, should_retry_post_send,
1677 };
1678 use crate::{
1679 common::consts::{
1680 HYPERLIQUID_VENUE, HYPERLIQUID_WS_MESSAGES_PER_MINUTE, HYPERLIQUID_WS_SUBSCRIPTIONS_MAX,
1681 },
1682 data_types::{HyperliquidAllDexsAssetCtxs, HyperliquidOpenInterest},
1683 };
1684
1685 const SECRET_MARKER: &str = "OUTBOUND_SECRET_MARKER";
1686
1687 struct OutboundLogCapture {
1688 messages: Mutex<Vec<String>>,
1689 }
1690
1691 static OUTBOUND_LOG_CAPTURE: OutboundLogCapture = OutboundLogCapture {
1692 messages: Mutex::new(Vec::new()),
1693 };
1694
1695 impl OutboundLogCapture {
1696 fn clear(&self) {
1697 self.messages.lock().clear();
1698 }
1699
1700 fn messages(&self) -> Vec<String> {
1701 self.messages.lock().clone()
1702 }
1703 }
1704
1705 impl Log for OutboundLogCapture {
1706 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
1707 metadata.level() == Level::Debug
1708 && metadata.target() == "nautilus_hyperliquid::websocket::handler"
1709 }
1710
1711 fn log(&self, record: &Record<'_>) {
1712 if self.enabled(record.metadata()) {
1713 let message = record.args().to_string();
1714 if message.starts_with("Sending ") {
1715 self.messages.lock().push(message);
1716 }
1717 }
1718 }
1719
1720 fn flush(&self) {}
1721 }
1722
1723 #[rstest]
1724 fn all_mids_cache_projects_subscriptions_without_scanning_every_websocket_message() {
1725 let mut cache = AllMidsDataTypeCache::default();
1726
1727 assert_eq!(cache.as_slice().len(), 1);
1728 assert!(cache.as_slice()[0].metadata().is_none());
1729
1730 cache.apply(
1731 &SubscriptionRequest::AllMids {
1732 dex: Some("xyz".to_owned()),
1733 },
1734 true,
1735 );
1736 assert_eq!(cache.as_slice().len(), 1);
1737 assert_eq!(
1738 cache.as_slice()[0]
1739 .metadata()
1740 .and_then(|metadata| metadata.get_str("dex")),
1741 Some("xyz"),
1742 );
1743
1744 cache.apply(&SubscriptionRequest::AllMids { dex: None }, true);
1745 assert_eq!(cache.as_slice().len(), 2);
1746
1747 cache.apply(
1748 &SubscriptionRequest::AllMids {
1749 dex: Some("xyz".to_owned()),
1750 },
1751 false,
1752 );
1753 assert_eq!(cache.as_slice().len(), 1);
1754 assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1755 assert!(cache.as_slice()[0].metadata().is_none());
1756
1757 cache.apply(&SubscriptionRequest::AllMids { dex: None }, false);
1758 assert_eq!(cache.as_slice().len(), 1);
1759 assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1760 assert!(cache.as_slice()[0].metadata().is_none());
1761 }
1762
1763 fn btc_perp() -> InstrumentAny {
1764 InstrumentAny::CryptoPerpetual(
1765 CryptoPerpetual::builder()
1766 .instrument_id(InstrumentId::new(
1767 Symbol::new("BTC-PERP"),
1768 *HYPERLIQUID_VENUE,
1769 ))
1770 .raw_symbol(Symbol::new("BTC-PERP"))
1771 .base_currency(Currency::from("BTC"))
1772 .quote_currency(Currency::from("USDC"))
1773 .settlement_currency(Currency::from("USDC"))
1774 .is_inverse(false)
1775 .price_precision(2)
1776 .size_precision(3)
1777 .price_increment(Price::from("0.01"))
1778 .size_increment(Quantity::from("0.001"))
1779 .ts_event(UnixNanos::default())
1780 .ts_init(UnixNanos::default())
1781 .build()
1782 .unwrap(),
1783 )
1784 }
1785
1786 fn one_level_book() -> WsBookData {
1787 WsBookData {
1788 coin: Ustr::from("BTC"),
1789 levels: [
1790 vec![WsLevelData {
1791 px: dec!(100.00),
1792 sz: dec!(1.0),
1793 n: 1,
1794 }],
1795 vec![WsLevelData {
1796 px: dec!(100.01),
1797 sz: dec!(1.0),
1798 n: 1,
1799 }],
1800 ],
1801 time: 1_700_000_000_000,
1802 }
1803 }
1804
1805 fn btc_active_spot_asset_ctx() -> WsActiveAssetCtxData {
1806 WsActiveAssetCtxData::Spot {
1807 coin: Ustr::from("BTC"),
1808 ctx: SpotAssetCtx {
1809 shared: SharedAssetCtx {
1810 day_ntl_vlm: dec!(1000000.0),
1811 prev_day_px: dec!(49000.0),
1812 mark_px: dec!(50000.0),
1813 mid_px: Some(dec!(50001.0)),
1814 impact_pxs: None,
1815 day_base_vlm: Some(dec!(100.0)),
1816 },
1817 circulating_supply: dec!(19000000.0),
1818 },
1819 }
1820 }
1821
1822 fn btc_active_asset_ctx(open_interest: Decimal) -> WsActiveAssetCtxData {
1823 WsActiveAssetCtxData::Perp {
1824 coin: Ustr::from("BTC"),
1825 ctx: PerpsAssetCtx {
1826 shared: SharedAssetCtx {
1827 day_ntl_vlm: dec!(1000000.0),
1828 prev_day_px: dec!(49000.0),
1829 mark_px: dec!(50000.0),
1830 mid_px: Some(dec!(50001.0)),
1831 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
1832 day_base_vlm: Some(dec!(100.0)),
1833 },
1834 funding: dec!(0.0001),
1835 open_interest,
1836 oracle_px: dec!(50005.0),
1837 premium: Some(dec!(-0.0001)),
1838 },
1839 }
1840 }
1841
1842 fn sample_all_dexs_asset_ctxs() -> WsAllDexsAssetCtxsData {
1843 let raw = include_str!("../../test_data/ws_all_dexs_asset_ctxs.json");
1844 let msg: super::super::messages::HyperliquidWsMessage =
1845 serde_json::from_str(raw).expect("expected valid allDexsAssetCtxs fixture");
1846
1847 let super::super::messages::HyperliquidWsMessage::AllDexsAssetCtxs { data } = msg else {
1848 panic!("expected allDexsAssetCtxs fixture message");
1849 };
1850
1851 let default_entry = data
1852 .ctxs
1853 .iter()
1854 .find(|(dex, _)| dex.is_empty())
1855 .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1856 .expect("expected default dex sample");
1857 let xyz_entry = data
1858 .ctxs
1859 .iter()
1860 .find(|(dex, _)| dex == "xyz")
1861 .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1862 .expect("expected xyz dex sample");
1863
1864 WsAllDexsAssetCtxsData {
1865 ctxs: vec![default_entry, xyz_entry],
1866 }
1867 }
1868
1869 #[tokio::test]
1870 async fn post_send_failure_cancels_router_waiter() {
1871 let signal = Arc::new(AtomicBool::new(false));
1872 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1873 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1874 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1875 let post_router = PostRouter::new();
1876 let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
1877 Ustr,
1878 ClientOrderId,
1879 CLOID_CACHE_CAPACITY,
1880 >::new()));
1881 let mut handler = FeedHandler::new(
1882 signal,
1883 cmd_rx,
1884 raw_rx,
1885 out_tx,
1886 None,
1887 SubscriptionState::new(':'),
1888 cloid_cache,
1889 Arc::clone(&post_router),
1890 Arc::new(WebSocketRateLimits::new()),
1891 1,
1892 );
1893
1894 let id = 99;
1895 let cancellation_token = CancellationToken::new();
1896 let rx = post_router
1897 .register_with_cancellation(id, &cancellation_token)
1898 .await
1899 .unwrap();
1900
1901 let task = tokio::spawn(async move { handler.next().await });
1902
1903 cmd_tx
1904 .send(HandlerCommand::Post {
1905 id,
1906 request: PostRequest::Info {
1907 payload: json!({"type": "userRateLimit", "user": "0x123"}),
1908 },
1909 deadline: tokio::time::Instant::now() + Duration::from_secs(1),
1910 cancellation_token,
1911 })
1912 .unwrap();
1913 drop(cmd_tx);
1914 drop(raw_tx);
1915
1916 let closed = tokio::time::timeout(Duration::from_millis(100), rx)
1917 .await
1918 .expect("post waiter should close without waiting for post timeout");
1919 assert!(closed.is_err(), "post router cancel must close the waiter");
1920 let _rx = post_router
1921 .register(id)
1922 .await
1923 .expect("post id should be reusable after cancellation");
1924 assert!(task.await.unwrap().is_none());
1925 }
1926
1927 fn retry_manager_with_backoff() -> RetryManager<PostSendError> {
1928 RetryManager::new(RetryConfig {
1929 max_retries: 1,
1930 initial_delay_ms: 1_000,
1931 max_delay_ms: 1_000,
1932 backoff_factor: 1.0,
1933 jitter_ms: 0,
1934 operation_timeout_ms: None,
1935 immediate_first: false,
1936 max_elapsed_ms: None,
1937 })
1938 }
1939
1940 #[rstest]
1941 #[tokio::test(start_paused = true)]
1942 async fn expired_post_deadline_prevents_first_send() {
1943 let cancellation_token = CancellationToken::new();
1944 let sends = Arc::new(AtomicUsize::new(0));
1945 let send_count = Arc::clone(&sends);
1946
1947 let error = send_post_before_deadline(
1948 tokio::time::Instant::now(),
1949 &cancellation_token,
1950 move || {
1951 let send_count = Arc::clone(&send_count);
1952 async move {
1953 send_count.fetch_add(1, Ordering::SeqCst);
1954 Ok(())
1955 }
1956 },
1957 )
1958 .await
1959 .unwrap_err();
1960
1961 assert!(matches!(error, PostSendError::Deadline));
1962 assert_eq!(sends.load(Ordering::SeqCst), 0);
1963 }
1964
1965 #[rstest]
1966 #[tokio::test(start_paused = true)]
1967 async fn post_deadline_while_waiting_for_message_quota_prevents_send() {
1968 let manager = retry_manager_with_backoff();
1969 let cancellation_token = CancellationToken::new();
1970 let limits = Arc::new(WebSocketRateLimits::new());
1971 let rate_key = limits.message_key();
1972 for _ in 0..HYPERLIQUID_WS_MESSAGES_PER_MINUTE {
1973 assert!(limits.messages.check_key(&rate_key).is_ok());
1974 }
1975 let sends = Arc::new(AtomicUsize::new(0));
1976 let send_count = Arc::clone(&sends);
1977 let send_limits = Arc::clone(&limits);
1978 let deadline = tokio::time::Instant::now() + Duration::from_millis(10);
1979
1980 let error = send_post_with_retry(&manager, deadline, &cancellation_token, move || {
1981 let send_count = Arc::clone(&send_count);
1982 let send_limits = Arc::clone(&send_limits);
1983 async move {
1984 send_limits.acquire_message().await;
1985 send_count.fetch_add(1, Ordering::SeqCst);
1986 Ok(())
1987 }
1988 })
1989 .await
1990 .unwrap_err();
1991
1992 assert!(matches!(error, PostSendError::Deadline));
1993 assert_eq!(sends.load(Ordering::SeqCst), 0);
1994 }
1995
1996 #[rstest]
1997 #[tokio::test(start_paused = true)]
1998 async fn post_deadline_during_backoff_prevents_retry() {
1999 let manager = retry_manager_with_backoff();
2000 let cancellation_token = CancellationToken::new();
2001 let sends = Arc::new(AtomicUsize::new(0));
2002 let send_count = Arc::clone(&sends);
2003 let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
2004
2005 let error = send_post_with_retry(&manager, deadline, &cancellation_token, move || {
2006 let send_count = Arc::clone(&send_count);
2007 async move {
2008 send_count.fetch_add(1, Ordering::SeqCst);
2009 Err(PostSendError::Transport(SendError::Timeout))
2010 }
2011 })
2012 .await
2013 .unwrap_err();
2014
2015 assert!(matches!(error, PostSendError::Deadline));
2016 assert_eq!(sends.load(Ordering::SeqCst), 1);
2017 }
2018
2019 #[rstest]
2020 #[tokio::test(start_paused = true)]
2021 async fn post_deadline_after_send_starts_preserves_unknown_outcome() {
2022 let manager = retry_manager_with_backoff();
2023 let cancellation_token = CancellationToken::new();
2024 let sends = Arc::new(AtomicUsize::new(0));
2025 let send_count = Arc::clone(&sends);
2026 let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
2027
2028 let error = send_post_with_retry(&manager, deadline, &cancellation_token, move || {
2029 let send_count = Arc::clone(&send_count);
2030 async move {
2031 send_count.fetch_add(1, Ordering::SeqCst);
2032 std::future::pending::<Result<(), PostSendError>>().await
2033 }
2034 })
2035 .await
2036 .unwrap_err();
2037
2038 assert!(matches!(error, PostSendError::Deadline));
2039 assert_eq!(sends.load(Ordering::SeqCst), 1);
2040 }
2041
2042 #[rstest]
2043 #[tokio::test(start_paused = true)]
2044 async fn post_cancellation_stops_started_send() {
2045 let manager = retry_manager_with_backoff();
2046 let cancellation_token = CancellationToken::new();
2047 let task_cancellation_token = cancellation_token.clone();
2048 let started = Arc::new(tokio::sync::Notify::new());
2049 let task_started = Arc::clone(&started);
2050 let sends = Arc::new(AtomicUsize::new(0));
2051 let task_sends = Arc::clone(&sends);
2052 let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
2053 let task = tokio::spawn(async move {
2054 send_post_with_retry(&manager, deadline, &task_cancellation_token, move || {
2055 let task_started = Arc::clone(&task_started);
2056 let task_sends = Arc::clone(&task_sends);
2057 async move {
2058 task_sends.fetch_add(1, Ordering::SeqCst);
2059 task_started.notify_one();
2060 std::future::pending::<Result<(), PostSendError>>().await
2061 }
2062 })
2063 .await
2064 });
2065
2066 started.notified().await;
2067 cancellation_token.cancel();
2068 let error = task.await.unwrap().unwrap_err();
2069
2070 assert!(matches!(error, PostSendError::Retry(RetryError::Canceled)));
2071 assert_eq!(sends.load(Ordering::SeqCst), 1);
2072 }
2073
2074 #[rstest]
2075 #[case(SendError::Timeout, true)]
2076 #[case(SendError::ConnectionChanged, true)]
2077 #[case(SendError::WriteTimeout, false)]
2078 #[case(SendError::BrokenPipe("transport failed".to_string()), false)]
2079 #[case(SendError::Closed, false)]
2080 #[case(SendError::InvalidInput("invalid payload".to_string()), false)]
2081 fn post_send_retries_only_before_writing(#[case] error: SendError, #[case] expected: bool) {
2082 assert_eq!(
2083 should_retry_post_send(&PostSendError::Transport(error)),
2084 expected
2085 );
2086 }
2087
2088 #[rstest]
2089 #[tokio::test]
2090 async fn outbound_subscription_logs_omit_payload_bodies() {
2091 log::set_logger(&OUTBOUND_LOG_CAPTURE).expect("test logger already installed");
2092 log::set_max_level(LevelFilter::Debug);
2093
2094 let signal = Arc::new(AtomicBool::new(false));
2095 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2096 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
2097 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
2098 let post_router = PostRouter::new();
2099 let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
2100 Ustr,
2101 ClientOrderId,
2102 CLOID_CACHE_CAPACITY,
2103 >::new()));
2104 let mut handler = FeedHandler::new(
2105 signal,
2106 cmd_rx,
2107 raw_rx,
2108 out_tx,
2109 None,
2110 SubscriptionState::new(':'),
2111 cloid_cache,
2112 post_router,
2113 Arc::new(WebSocketRateLimits::new()),
2114 1,
2115 );
2116 let subscription = SubscriptionRequest::Notification {
2117 user: SECRET_MARKER.to_string(),
2118 };
2119 let subscribe_len = serde_json::to_string(&HyperliquidWsRequest::Subscribe {
2120 subscription: subscription.clone(),
2121 })
2122 .unwrap()
2123 .len();
2124 let unsubscribe_len = serde_json::to_string(&HyperliquidWsRequest::Unsubscribe {
2125 subscription: subscription.clone(),
2126 })
2127 .unwrap()
2128 .len();
2129 OUTBOUND_LOG_CAPTURE.clear();
2130
2131 cmd_tx
2132 .send(HandlerCommand::Subscribe {
2133 subscriptions: vec![subscription.clone()],
2134 })
2135 .unwrap();
2136 cmd_tx
2137 .send(HandlerCommand::Unsubscribe {
2138 subscriptions: vec![subscription],
2139 })
2140 .unwrap();
2141 drop(cmd_tx);
2142 drop(raw_tx);
2143
2144 assert!(handler.next().await.is_none());
2145
2146 let messages = OUTBOUND_LOG_CAPTURE.messages();
2147
2148 assert!(
2149 messages
2150 .iter()
2151 .all(|message| !message.contains(SECRET_MARKER)),
2152 "outbound logs exposed the secret marker: {messages:?}"
2153 );
2154 assert!(
2155 messages
2156 .iter()
2157 .any(|message| message
2158 == &format!("Sending subscribe payload ({subscribe_len} bytes)")),
2159 "subscribe metadata missing or inaccurate: {messages:?}"
2160 );
2161 assert!(
2162 messages.iter().any(|message| {
2163 message == &format!("Sending unsubscribe payload ({unsubscribe_len} bytes)")
2164 }),
2165 "unsubscribe metadata missing or inaccurate: {messages:?}"
2166 );
2167 }
2168
2169 #[tokio::test]
2170 async fn unsubscribe_confirmation_releases_shared_reservation() {
2171 let signal = Arc::new(AtomicBool::new(false));
2172 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2173 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
2174 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
2175 let subscriptions = SubscriptionState::new(':');
2176 let limits = Arc::new(WebSocketRateLimits::new());
2177 let released = SubscriptionRequest::Trades {
2178 coin: Ustr::from("RELEASED"),
2179 };
2180 let released_key = super::subscription_to_key(&released);
2181 subscriptions.mark_subscribe(&released_key);
2182 subscriptions.confirm_subscribe(&released_key);
2183 subscriptions.mark_unsubscribe(&released_key);
2184 assert!(limits.reserve_subscription(1, &released).unwrap());
2185
2186 for index in 1..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
2187 assert!(
2188 limits
2189 .reserve_subscription(
2190 1,
2191 &SubscriptionRequest::Trades {
2192 coin: Ustr::from(&format!("COIN-{index}")),
2193 },
2194 )
2195 .unwrap()
2196 );
2197 }
2198 assert!(
2199 limits
2200 .reserve_subscription(
2201 2,
2202 &SubscriptionRequest::Trades {
2203 coin: Ustr::from("BEFORE-ACK"),
2204 },
2205 )
2206 .is_err()
2207 );
2208
2209 let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
2210 Ustr,
2211 ClientOrderId,
2212 CLOID_CACHE_CAPACITY,
2213 >::new()));
2214 let mut handler = FeedHandler::new(
2215 signal,
2216 cmd_rx,
2217 raw_rx,
2218 out_tx,
2219 None,
2220 subscriptions,
2221 cloid_cache,
2222 PostRouter::new(),
2223 Arc::clone(&limits),
2224 1,
2225 );
2226 raw_tx
2227 .send(Message::Text(
2228 json!({
2229 "channel": "subscriptionResponse",
2230 "data": {
2231 "method": "unsubscribe",
2232 "subscription": {
2233 "type": "trades",
2234 "coin": "RELEASED",
2235 },
2236 },
2237 })
2238 .to_string()
2239 .into(),
2240 ))
2241 .unwrap();
2242 drop(raw_tx);
2243 drop(cmd_tx);
2244
2245 assert!(handler.next().await.is_none());
2246 assert!(
2247 limits
2248 .reserve_subscription(
2249 2,
2250 &SubscriptionRequest::Trades {
2251 coin: Ustr::from("AFTER-ACK"),
2252 },
2253 )
2254 .unwrap()
2255 );
2256 }
2257
2258 #[rstest]
2259 fn handle_l2_book_emits_deltas_only_when_not_in_depth10_subs() {
2260 let mut instruments = AHashMap::new();
2261 instruments.insert(Ustr::from("BTC"), btc_perp());
2262 let depth10_subs = AHashSet::<Ustr>::new();
2263
2264 let msgs = FeedHandler::handle_l2_book(
2265 &one_level_book(),
2266 &instruments,
2267 &depth10_subs,
2268 UnixNanos::default(),
2269 );
2270
2271 assert_eq!(msgs.len(), 1);
2272 assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
2273 }
2274
2275 #[rstest]
2276 fn handle_l2_book_emits_deltas_and_depth10_when_coin_in_subs() {
2277 let mut instruments = AHashMap::new();
2278 instruments.insert(Ustr::from("BTC"), btc_perp());
2279 let mut depth10_subs = AHashSet::<Ustr>::new();
2280 depth10_subs.insert(Ustr::from("BTC"));
2281
2282 let msgs = FeedHandler::handle_l2_book(
2283 &one_level_book(),
2284 &instruments,
2285 &depth10_subs,
2286 UnixNanos::default(),
2287 );
2288
2289 assert_eq!(msgs.len(), 2);
2290 assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
2291 assert!(matches!(msgs[1], NautilusWsMessage::Depth10(_)));
2292 }
2293
2294 #[rstest]
2295 fn handle_l2_book_returns_empty_when_instrument_unknown() {
2296 let instruments = AHashMap::<Ustr, InstrumentAny>::new();
2297 let depth10_subs = AHashSet::<Ustr>::new();
2298
2299 let msgs = FeedHandler::handle_l2_book(
2300 &one_level_book(),
2301 &instruments,
2302 &depth10_subs,
2303 UnixNanos::default(),
2304 );
2305
2306 assert!(msgs.is_empty());
2307 }
2308
2309 #[rstest]
2310 fn handle_asset_context_emits_open_interest_custom_data_when_subscribed() {
2311 let instrument = btc_perp();
2312 let instrument_id = instrument.id();
2313 let mut instruments = AHashMap::new();
2314 instruments.insert(Ustr::from("BTC"), instrument);
2315
2316 let mut asset_context_subs = AHashMap::new();
2317 asset_context_subs.insert(
2318 Ustr::from("BTC"),
2319 AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2320 );
2321
2322 let mut asset_context_caches = AssetContextCaches::default();
2323
2324 let msgs = FeedHandler::handle_asset_context(
2325 &btc_active_asset_ctx(dec!(100000.0)),
2326 &instruments,
2327 &asset_context_subs,
2328 &mut asset_context_caches,
2329 UnixNanos::default(),
2330 );
2331
2332 assert_eq!(msgs.len(), 1);
2333
2334 match &msgs[0] {
2335 NautilusWsMessage::CustomData(Data::Custom(custom)) => {
2336 let open_interest = custom
2337 .data
2338 .as_any()
2339 .downcast_ref::<HyperliquidOpenInterest>()
2340 .expect("expected HyperliquidOpenInterest");
2341 assert_eq!(open_interest.instrument_id, instrument_id);
2342 assert_eq!(open_interest.open_interest.to_string(), "100000.0");
2343 assert_eq!(
2344 custom
2345 .data_type
2346 .metadata()
2347 .and_then(|metadata| metadata.get_str("instrument_id"))
2348 .map(ToString::to_string),
2349 Some(instrument_id.to_string()),
2350 );
2351 }
2352 other => panic!("unexpected message type: {other:?}"),
2353 }
2354 }
2355
2356 #[rstest]
2357 fn handle_all_dexs_asset_ctxs_emits_normalized_custom_data() {
2358 let mapping = AHashMap::from_iter([
2359 (
2360 Ustr::from(""),
2361 vec![Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"))],
2362 ),
2363 (
2364 Ustr::from("xyz"),
2365 vec![Some(InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID"))],
2366 ),
2367 ]);
2368
2369 let msg = FeedHandler::handle_all_dexs_asset_ctxs(
2370 sample_all_dexs_asset_ctxs(),
2371 &mapping,
2372 UnixNanos::default(),
2373 )
2374 .expect("expected custom data");
2375
2376 match msg {
2377 NautilusWsMessage::CustomData(Data::Custom(custom)) => {
2378 let payload = custom
2379 .data
2380 .as_any()
2381 .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
2382 .expect("expected HyperliquidAllDexsAssetCtxs");
2383 assert_eq!(payload.entries.len(), 2);
2384 assert_eq!(
2385 payload.entries[0].instrument_id,
2386 InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")
2387 );
2388 assert_eq!(payload.entries[1].dex, "xyz");
2389 assert_eq!(
2390 payload.entries[1].instrument_id,
2391 InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID")
2392 );
2393 assert_eq!(payload.entries[0].mark_price.to_string(), "77562.0");
2394 assert_eq!(payload.entries[1].day_base_volume.to_string(), "5135.2458");
2395 }
2396 other => panic!("expected custom data, found {other:?}"),
2397 }
2398 }
2399
2400 #[rstest]
2401 fn handle_all_dexs_asset_ctxs_preserves_index_alignment_when_mappings_are_missing() {
2402 let data = WsAllDexsAssetCtxsData {
2403 ctxs: vec![(
2404 String::new(),
2405 vec![
2406 PerpsAssetCtx {
2407 shared: SharedAssetCtx {
2408 day_ntl_vlm: dec!(1516669192.1953897476),
2409 prev_day_px: dec!(76317.0),
2410 mark_px: dec!(77562.0),
2411 mid_px: Some(dec!(77558.5)),
2412 impact_pxs: Some(vec!["77558.0".to_string(), "77559.0".to_string()]),
2413 day_base_vlm: Some(dec!(19707.77457)),
2414 },
2415 funding: dec!(-0.0000015186),
2416 open_interest: dec!(27353.17682),
2417 oracle_px: dec!(77605.0),
2418 premium: Some(dec!(-0.0005927453)),
2419 },
2420 PerpsAssetCtx {
2421 shared: SharedAssetCtx {
2422 day_ntl_vlm: dec!(591989409.9392402172),
2423 prev_day_px: dec!(2094.6),
2424 mark_px: dec!(2123.7),
2425 mid_px: Some(dec!(2123.95)),
2426 impact_pxs: Some(vec!["2123.65".to_string(), "2124.0".to_string()]),
2427 day_base_vlm: Some(dec!(281686.8234999999)),
2428 },
2429 funding: dec!(0.0000125),
2430 open_interest: dec!(605822.2557999999),
2431 oracle_px: dec!(2124.6),
2432 premium: Some(dec!(-0.0002824061)),
2433 },
2434 ],
2435 )],
2436 };
2437
2438 let mapping = AHashMap::from_iter([(
2439 Ustr::from(""),
2440 vec![None, Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"))],
2441 )]);
2442
2443 let msg = FeedHandler::handle_all_dexs_asset_ctxs(data, &mapping, UnixNanos::default())
2444 .expect("expected custom data");
2445
2446 match msg {
2447 NautilusWsMessage::CustomData(Data::Custom(custom)) => {
2448 let payload = custom
2449 .data
2450 .as_any()
2451 .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
2452 .expect("expected HyperliquidAllDexsAssetCtxs");
2453 assert_eq!(payload.entries.len(), 1);
2454 assert_eq!(
2455 payload.entries[0].instrument_id,
2456 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID")
2457 );
2458 assert_eq!(payload.entries[0].mark_price.to_string(), "2123.7");
2459 }
2460 other => panic!("expected custom data, found {other:?}"),
2461 }
2462 }
2463
2464 #[rstest]
2465 fn handle_asset_context_skips_open_interest_for_spot_payload() {
2466 let instrument = btc_perp();
2467 let mut instruments = AHashMap::new();
2468 instruments.insert(Ustr::from("BTC"), instrument);
2469
2470 let mut asset_context_subs = AHashMap::new();
2471 asset_context_subs.insert(
2472 Ustr::from("BTC"),
2473 AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2474 );
2475
2476 let mut asset_context_caches = AssetContextCaches::default();
2477
2478 let msgs = FeedHandler::handle_asset_context(
2479 &btc_active_spot_asset_ctx(),
2480 &instruments,
2481 &asset_context_subs,
2482 &mut asset_context_caches,
2483 UnixNanos::default(),
2484 );
2485
2486 assert!(msgs.is_empty());
2487 assert!(asset_context_caches.open_interest.is_empty());
2488 }
2489
2490 #[rstest]
2491 fn handle_asset_context_suppresses_unchanged_open_interest() {
2492 let instrument = btc_perp();
2493 let mut instruments = AHashMap::new();
2494 instruments.insert(Ustr::from("BTC"), instrument);
2495
2496 let mut asset_context_subs = AHashMap::new();
2497 asset_context_subs.insert(
2498 Ustr::from("BTC"),
2499 AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2500 );
2501
2502 let mut asset_context_caches = AssetContextCaches::default();
2503
2504 let first = FeedHandler::handle_asset_context(
2505 &btc_active_asset_ctx(dec!(100000.0)),
2506 &instruments,
2507 &asset_context_subs,
2508 &mut asset_context_caches,
2509 UnixNanos::default(),
2510 );
2511 let second = FeedHandler::handle_asset_context(
2512 &btc_active_asset_ctx(dec!(100000.0)),
2513 &instruments,
2514 &asset_context_subs,
2515 &mut asset_context_caches,
2516 UnixNanos::default(),
2517 );
2518
2519 assert_eq!(first.len(), 1);
2520 assert!(second.is_empty());
2521 }
2522
2523 #[rstest]
2524 fn asset_context_caches_clear_removed_data_types() {
2525 let coin = Ustr::from("BTC");
2526 let mut caches = AssetContextCaches::default();
2527 caches.mark_price.insert(coin, dec!(98455.5));
2528 caches.index_price.insert(coin, dec!(98460.0));
2529 caches.funding_rate.insert(coin, dec!(0.0001));
2530 caches.open_interest.insert(coin, dec!(1500.0));
2531
2532 let previous_data_types = AHashSet::from_iter([
2533 AssetContextDataType::MarkPrice,
2534 AssetContextDataType::IndexPrice,
2535 AssetContextDataType::FundingRate,
2536 AssetContextDataType::OpenInterest,
2537 ]);
2538 let next_data_types = AHashSet::from_iter([
2539 AssetContextDataType::MarkPrice,
2540 AssetContextDataType::FundingRate,
2541 ]);
2542
2543 caches.clear_removed(coin, Some(&previous_data_types), &next_data_types);
2544
2545 assert_eq!(caches.mark_price.get(&coin).copied(), Some(dec!(98455.5)));
2546 assert!(caches.index_price.get(&coin).is_none());
2547 assert_eq!(caches.funding_rate.get(&coin).copied(), Some(dec!(0.0001)));
2548 assert!(caches.open_interest.get(&coin).is_none());
2549 }
2550}