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