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