1use std::{
19 collections::{BTreeSet, VecDeque},
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24};
25
26use ahash::{AHashMap, AHashSet};
27use nautilus_common::cache::fifo::FifoCache;
28use nautilus_core::{
29 AtomicTime, MUTEX_POISONED, Params, nanos::UnixNanos, time::get_atomic_clock_realtime,
30};
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 retry::{RetryManager, create_websocket_retry_manager},
40 websocket::{SubscriptionState, WebSocketClient},
41};
42use rust_decimal::Decimal;
43use tokio_tungstenite::tungstenite::Message;
44use ustr::Ustr;
45
46use super::{
47 client::{AssetContextDataType, CloidCache},
48 enums::HyperliquidWsChannel,
49 error::HyperliquidWsError,
50 messages::{
51 CandleData, ExecutionReport, HyperliquidWsMessage, HyperliquidWsRequest, NautilusWsMessage,
52 PostRequest, SubscriptionRequest, WsActiveAssetCtxData, WsAllDexsAssetCtxsData,
53 WsUserEventData,
54 },
55 parse::{
56 parse_ws_asset_context, parse_ws_candle, parse_ws_fill_report, parse_ws_open_interest,
57 parse_ws_order_book_deltas, parse_ws_order_book_depth10, parse_ws_order_status_report,
58 parse_ws_public_trade, parse_ws_quote_tick, parse_ws_trade_tick, parse_ws_twap_history_row,
59 parse_ws_twap_slice_fill,
60 },
61 post::PostRouter,
62 trades::TradeStreamUses,
63};
64use crate::data_types::{
65 HyperliquidAllDexsAssetCtxs, HyperliquidAllMids, HyperliquidDexAssetCtx,
66 HyperliquidImpactPrices,
67};
68
69#[derive(Debug)]
71#[expect(
72 clippy::large_enum_variant,
73 reason = "Commands are ephemeral and immediately consumed"
74)]
75#[allow(private_interfaces)]
76pub enum HandlerCommand {
77 SetClient(WebSocketClient),
79 Disconnect,
81 Subscribe {
83 subscriptions: Vec<SubscriptionRequest>,
84 },
85 Unsubscribe {
87 subscriptions: Vec<SubscriptionRequest>,
88 },
89 Post { id: u64, request: PostRequest },
91 InitializeInstruments(Vec<InstrumentAny>),
93 UpdateInstrument(InstrumentAny),
95 AddBarType { key: String, bar_type: BarType },
97 RemoveBarType { key: String },
99 UpdateAssetContextSubs {
101 coin: Ustr,
102 data_types: AHashSet<AssetContextDataType>,
103 },
104 UpdateTradeSubs { coin: Ustr, uses: TradeStreamUses },
106 CacheAllDexAssetCtxsInstrumentIds(AHashMap<Ustr, Vec<Option<InstrumentId>>>),
108 CacheSpotFillCoins(AHashMap<Ustr, Ustr>),
110 SetDepth10Sub { coin: Ustr, subscribed: bool },
113}
114
115#[derive(Default)]
116struct AssetContextCaches {
117 mark_price: AHashMap<Ustr, Decimal>,
118 index_price: AHashMap<Ustr, Decimal>,
119 funding_rate: AHashMap<Ustr, Decimal>,
120 open_interest: AHashMap<Ustr, Decimal>,
121}
122
123impl AssetContextCaches {
124 fn clear(&mut self, coin: Ustr, data_type: AssetContextDataType) {
125 match data_type {
126 AssetContextDataType::MarkPrice => {
127 self.mark_price.remove(&coin);
128 }
129 AssetContextDataType::IndexPrice => {
130 self.index_price.remove(&coin);
131 }
132 AssetContextDataType::FundingRate => {
133 self.funding_rate.remove(&coin);
134 }
135 AssetContextDataType::OpenInterest => {
136 self.open_interest.remove(&coin);
137 }
138 }
139 }
140
141 fn clear_removed(
142 &mut self,
143 coin: Ustr,
144 previous_data_types: Option<&AHashSet<AssetContextDataType>>,
145 next_data_types: &AHashSet<AssetContextDataType>,
146 ) {
147 let Some(previous_data_types) = previous_data_types else {
148 return;
149 };
150
151 for data_type in previous_data_types {
152 if !next_data_types.contains(data_type) {
153 self.clear(coin, *data_type);
154 }
155 }
156 }
157}
158
159#[derive(Debug)]
160struct AllMidsDataTypeCache {
161 dexes: BTreeSet<Option<String>>,
162 projected: Vec<DataType>,
163}
164
165impl Default for AllMidsDataTypeCache {
166 fn default() -> Self {
167 let mut cache = Self {
168 dexes: BTreeSet::new(),
169 projected: Vec::new(),
170 };
171 cache.rebuild();
172 cache
173 }
174}
175
176impl AllMidsDataTypeCache {
177 fn apply(&mut self, subscription: &SubscriptionRequest, subscribed: bool) {
178 let SubscriptionRequest::AllMids { dex } = subscription else {
179 return;
180 };
181 let changed = if subscribed {
182 self.dexes.insert(dex.clone())
183 } else {
184 self.dexes.remove(dex)
185 };
186
187 if changed {
188 self.rebuild();
189 }
190 }
191
192 fn as_slice(&self) -> &[DataType] {
193 &self.projected
194 }
195
196 fn rebuild(&mut self) {
197 self.projected.clear();
198 if self.dexes.is_empty() {
199 self.projected
200 .push(DataType::new("HyperliquidAllMids", None, None));
201 return;
202 }
203
204 self.projected.extend(self.dexes.iter().map(|dex| {
205 let metadata = dex.as_ref().map(|dex| {
206 let mut metadata = Params::new();
207 metadata.insert("dex".to_owned(), serde_json::Value::String(dex.clone()));
208 metadata
209 });
210 DataType::new("HyperliquidAllMids", metadata, None)
211 }));
212 }
213}
214
215pub(super) struct FeedHandler {
216 clock: &'static AtomicTime,
217 signal: Arc<AtomicBool>,
218 client: Option<WebSocketClient>,
219 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
220 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
221 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
222 account_id: Option<AccountId>,
223 subscriptions: SubscriptionState,
224 all_mids_data_types: AllMidsDataTypeCache,
225 post_router: Arc<PostRouter>,
226 retry_manager: RetryManager<HyperliquidWsError>,
227 message_buffer: VecDeque<NautilusWsMessage>,
228 instruments: AHashMap<Ustr, InstrumentAny>,
229 cloid_cache: CloidCache,
230 bar_types_cache: AHashMap<String, BarType>,
231 bar_cache: AHashMap<String, CandleData>,
232 asset_context_subs: AHashMap<Ustr, AHashSet<AssetContextDataType>>,
233 trade_subs: AHashMap<Ustr, TradeStreamUses>,
234 all_dex_asset_ctxs_instrument_ids: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
235 depth10_subs: AHashSet<Ustr>,
236 processed_trade_ids: FifoCache<u64, 10_000>,
237 processed_public_trade_ids: FifoCache<(Ustr, u64), 10_000>,
238 asset_context_caches: AssetContextCaches,
239}
240
241impl FeedHandler {
242 #[allow(
244 clippy::too_many_arguments,
245 reason = "constructs the handler from independent runtime channels and caches"
246 )]
247 pub(super) fn new(
248 signal: Arc<AtomicBool>,
249 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
250 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
251 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
252 account_id: Option<AccountId>,
253 subscriptions: SubscriptionState,
254 cloid_cache: CloidCache,
255 post_router: Arc<PostRouter>,
256 ) -> Self {
257 Self {
258 clock: get_atomic_clock_realtime(),
259 signal,
260 client: None,
261 cmd_rx,
262 raw_rx,
263 out_tx,
264 account_id,
265 subscriptions,
266 all_mids_data_types: AllMidsDataTypeCache::default(),
267 post_router,
268 retry_manager: create_websocket_retry_manager(),
269 message_buffer: VecDeque::new(),
270 instruments: AHashMap::new(),
271 cloid_cache,
272 bar_types_cache: AHashMap::new(),
273 bar_cache: AHashMap::new(),
274 asset_context_subs: AHashMap::new(),
275 trade_subs: AHashMap::new(),
276 all_dex_asset_ctxs_instrument_ids: AHashMap::new(),
277 depth10_subs: AHashSet::new(),
278 processed_trade_ids: FifoCache::new(),
279 processed_public_trade_ids: FifoCache::new(),
280 asset_context_caches: AssetContextCaches::default(),
281 }
282 }
283
284 pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
286 self.out_tx
287 .send(msg)
288 .map_err(|e| format!("Failed to send message: {e}"))
289 }
290
291 pub(super) fn is_stopped(&self) -> bool {
293 self.signal.load(Ordering::Relaxed)
294 }
295
296 async fn send_with_retry(&self, payload: String) -> anyhow::Result<()> {
297 if let Some(client) = &self.client {
298 self.retry_manager
299 .execute_with_retry(
300 "websocket_send",
301 || {
302 let payload = payload.clone();
303 async move {
304 client.send_text(payload, None).await.map_err(|e| {
305 HyperliquidWsError::ClientError(format!("Send failed: {e}"))
306 })
307 }
308 },
309 should_retry_hyperliquid_error,
310 |e| create_hyperliquid_timeout_error(e.to_string()),
311 )
312 .await
313 .map_err(|e| anyhow::anyhow!("{e}"))
314 } else {
315 Err(anyhow::anyhow!("No WebSocket client available"))
316 }
317 }
318
319 pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
320 if let Some(msg) = self.message_buffer.pop_front() {
321 return Some(msg);
322 }
323
324 loop {
325 tokio::select! {
326 Some(cmd) = self.cmd_rx.recv() => {
327 match cmd {
328 HandlerCommand::SetClient(client) => {
329 log::debug!("Setting WebSocket client in handler");
330 self.client = Some(client);
331 }
332 HandlerCommand::Disconnect => {
333 log::debug!("Handler received disconnect command");
334
335 if let Some(ref client) = self.client {
336 client.disconnect().await;
337 }
338 self.signal.store(true, Ordering::SeqCst);
339 return None;
340 }
341 HandlerCommand::Subscribe { subscriptions } => {
342 for subscription in subscriptions {
343 let key = subscription_to_key(&subscription);
344 self.subscriptions.mark_subscribe(&key);
345 self.all_mids_data_types.apply(&subscription, true);
346
347 let request = HyperliquidWsRequest::Subscribe { subscription };
348 match serde_json::to_string(&request) {
349 Ok(payload) => {
350 log::debug!("Sending subscribe payload ({} bytes)", payload.len());
351 if let Err(e) = self.send_with_retry(payload).await {
352 log::error!("Error subscribing to {key}: {e}");
353 self.subscriptions.mark_failure(&key);
354 }
355 }
356 Err(e) => {
357 log::error!("Error serializing subscription for {key}: {e}");
358 self.subscriptions.mark_failure(&key);
359 }
360 }
361 }
362 }
363 HandlerCommand::Unsubscribe { subscriptions } => {
364 for subscription in subscriptions {
365 let key = subscription_to_key(&subscription);
366 self.subscriptions.mark_unsubscribe(&key);
367 self.all_mids_data_types.apply(&subscription, false);
368
369 let request = HyperliquidWsRequest::Unsubscribe { subscription };
370 match serde_json::to_string(&request) {
371 Ok(payload) => {
372 log::debug!("Sending unsubscribe payload ({} bytes)", payload.len());
373 if let Err(e) = self.send_with_retry(payload).await {
374 log::error!("Error unsubscribing from {key}: {e}");
375 }
376 }
377 Err(e) => {
378 log::error!("Error serializing unsubscription for {key}: {e}");
379 }
380 }
381 }
382 }
383 HandlerCommand::Post { id, request } => {
384 let request = HyperliquidWsRequest::Post { id, request };
385 match serde_json::to_string(&request) {
386 Ok(payload) => {
387 log::debug!("Sending post payload: id={id}");
388 if let Err(e) = self.send_with_retry(payload).await {
389 log::error!("Error sending post request id={id}: {e}");
390 self.post_router.cancel(id).await;
391 }
392 }
393 Err(e) => {
394 log::error!("Error serializing post request id={id}: {e}");
395 self.post_router.cancel(id).await;
396 }
397 }
398 }
399 HandlerCommand::InitializeInstruments(instruments) => {
400 for inst in instruments {
401 let coin = inst.raw_symbol().inner();
402 self.instruments.insert(coin, inst);
403 }
404 }
405 HandlerCommand::UpdateInstrument(inst) => {
406 let coin = inst.raw_symbol().inner();
407 self.instruments.insert(coin, inst);
408 }
409 HandlerCommand::AddBarType { key, bar_type } => {
410 self.bar_types_cache.insert(key, bar_type);
411 }
412 HandlerCommand::RemoveBarType { key } => {
413 self.bar_types_cache.remove(&key);
414 self.bar_cache.remove(&key);
415 }
416 HandlerCommand::UpdateAssetContextSubs { coin, data_types } => {
417 let previous_data_types = self.asset_context_subs.get(&coin).cloned();
418 self.asset_context_caches.clear_removed(
419 coin,
420 previous_data_types.as_ref(),
421 &data_types,
422 );
423
424 if data_types.is_empty() {
425 self.asset_context_subs.remove(&coin);
426 } else {
427 self.asset_context_subs.insert(coin, data_types);
428 }
429 }
430 HandlerCommand::UpdateTradeSubs { coin, uses } => {
431 if uses.is_empty() {
432 self.trade_subs.remove(&coin);
433 } else {
434 self.trade_subs.insert(coin, uses);
435 }
436 }
437 HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mappings) => {
438 self.all_dex_asset_ctxs_instrument_ids = mappings;
439 }
440 HandlerCommand::CacheSpotFillCoins(_) => {
441 }
443 HandlerCommand::SetDepth10Sub { coin, subscribed } => {
444 if subscribed {
445 self.depth10_subs.insert(coin);
446 } else {
447 self.depth10_subs.remove(&coin);
448 }
449 }
450 }
451 }
452
453 Some(raw_msg) = self.raw_rx.recv() => {
454 match raw_msg {
455 Message::Text(text) => {
456 if text == RECONNECTED {
457 log::info!("Received RECONNECTED sentinel");
458 return Some(NautilusWsMessage::Reconnected);
459 }
460
461 match serde_json::from_str::<HyperliquidWsMessage>(&text) {
462 Ok(msg) => {
463 if let HyperliquidWsMessage::Post { data } = msg {
464 self.post_router.complete(data).await;
465 continue;
466 }
467
468 let ts_init = self.clock.get_time_ns();
469
470 let nautilus_msgs = Self::parse_to_nautilus_messages(
471 msg,
472 &self.instruments,
473 &self.cloid_cache,
474 &self.bar_types_cache,
475 self.account_id,
476 ts_init,
477 &self.asset_context_subs,
478 &self.trade_subs,
479 &self.depth10_subs,
480 &mut self.processed_trade_ids,
481 &mut self.processed_public_trade_ids,
482 &mut self.asset_context_caches,
483 &mut self.bar_cache,
484 &self.all_dex_asset_ctxs_instrument_ids,
485 self.all_mids_data_types.as_slice(),
486 );
487
488 if !nautilus_msgs.is_empty() {
489 let mut iter = nautilus_msgs.into_iter();
490 let first = iter.next().unwrap();
491 self.message_buffer.extend(iter);
492 return Some(first);
493 }
494 }
495 Err(e) => {
496 log::error!("Error parsing WebSocket message: {e}, text: {text}");
497 }
498 }
499 }
500 Message::Ping(data) => {
501 if let Some(ref client) = self.client
502 && let Err(e) = client.send_pong(data.to_vec()).await {
503 log::error!("Error sending pong: {e}");
504 }
505 }
506 Message::Close(_) => {
507 log::debug!("Received WebSocket close frame");
508 return None;
509 }
510 _ => {}
511 }
512 }
513
514 else => {
515 log::debug!("Handler shutting down: stream ended or command channel closed");
516 return None;
517 }
518 }
519 }
520 }
521
522 #[expect(clippy::too_many_arguments)]
523 fn parse_to_nautilus_messages(
524 msg: HyperliquidWsMessage,
525 instruments: &AHashMap<Ustr, InstrumentAny>,
526 cloid_cache: &CloidCache,
527 bar_types: &AHashMap<String, BarType>,
528 account_id: Option<AccountId>,
529 ts_init: UnixNanos,
530 asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
531 trade_subs: &AHashMap<Ustr, TradeStreamUses>,
532 depth10_subs: &AHashSet<Ustr>,
533 processed_trade_ids: &mut FifoCache<u64, 10_000>,
534 processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
535 asset_context_caches: &mut AssetContextCaches,
536 bar_cache: &mut AHashMap<String, CandleData>,
537 all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
538 all_mids_data_types: &[DataType],
539 ) -> Vec<NautilusWsMessage> {
540 let mut result = Vec::new();
541
542 match msg {
543 HyperliquidWsMessage::OrderUpdates { data } => {
544 if let Some(account_id) = account_id
545 && let Some(msg) = Self::handle_order_updates(
546 &data,
547 instruments,
548 cloid_cache,
549 account_id,
550 ts_init,
551 )
552 {
553 result.push(msg);
554 }
555 }
556 HyperliquidWsMessage::UserEvents { data } | HyperliquidWsMessage::User { data } => {
557 match data {
559 WsUserEventData::Fills { fills } => {
560 log::debug!("Received {} fill(s) from userEvents channel", fills.len());
561 for fill in &fills {
562 log::debug!(
563 "Fill: oid={}, coin={}, side={:?}, sz={}, px={}",
564 fill.oid,
565 fill.coin,
566 fill.side,
567 fill.sz,
568 fill.px
569 );
570 }
571
572 if let Some(account_id) = account_id {
573 log::debug!("Processing fills with account_id={account_id}");
574
575 if let Some(msg) = Self::handle_user_fills(
576 &fills,
577 instruments,
578 cloid_cache,
579 account_id,
580 ts_init,
581 processed_trade_ids,
582 ) {
583 log::debug!("Successfully created fill message");
584 result.push(msg);
585 } else {
586 log::debug!("handle_user_fills returned None (no new fills)");
587 }
588 } else {
589 log::warn!("Cannot process fills: account_id is None");
590 }
591 }
592 WsUserEventData::Liquidation { liquidation } => {
593 log::warn!(
594 "Liquidation event: lid={}, liquidator={}, liquidated_user={}, ntl_pos={}, account_value={}",
595 liquidation.lid,
596 liquidation.liquidator,
597 liquidation.liquidated_user,
598 liquidation.liquidated_ntl_pos,
599 liquidation.liquidated_account_value,
600 );
601 }
602 _ => {
603 log::debug!("Received non-fill user event: {data:?}");
604 }
605 }
606 }
607 HyperliquidWsMessage::UserFills { data } => {
608 if let Some(account_id) = account_id
611 && let Some(msg) = Self::handle_user_fills(
612 &data.fills,
613 instruments,
614 cloid_cache,
615 account_id,
616 ts_init,
617 processed_trade_ids,
618 )
619 {
620 result.push(msg);
621 }
622 }
623 HyperliquidWsMessage::Trades { data } => {
624 result.extend(Self::handle_trades(
625 &data,
626 instruments,
627 trade_subs,
628 processed_public_trade_ids,
629 ts_init,
630 ));
631 }
632 HyperliquidWsMessage::AllMids { data } => {
633 let mut mids = std::collections::HashMap::with_capacity(
634 data.mids.len().min(instruments.len()),
635 );
636
637 for (coin, mid_str) in &data.mids {
638 if let Some(instrument) = instruments.get(coin) {
639 match mid_str.parse::<Price>() {
640 Ok(price) => {
641 mids.insert(instrument.id(), price);
642 }
643 Err(e) => {
644 log::warn!("Failed to parse mid price for {coin}: {e}");
645 }
646 }
647 } else {
648 log::debug!("No instrument found for coin: {coin}");
649 }
650 }
651
652 if !mids.is_empty() {
653 let last_idx = all_mids_data_types.len().saturating_sub(1);
655 for (i, data_type) in all_mids_data_types.iter().enumerate() {
656 let mids_for_this = if i == last_idx {
657 std::mem::take(&mut mids)
658 } else {
659 mids.clone()
660 };
661 let all_mids = HyperliquidAllMids::new(mids_for_this, ts_init, ts_init);
662 result.push(NautilusWsMessage::CustomData(Data::Custom(
663 CustomData::new(Arc::new(all_mids), data_type.clone()),
664 )));
665 }
666 }
667 }
668 HyperliquidWsMessage::AllDexsAssetCtxs { data } => {
669 if let Some(msg) = Self::handle_all_dexs_asset_ctxs(
670 data,
671 all_dex_asset_ctxs_instrument_ids,
672 ts_init,
673 ) {
674 result.push(msg);
675 }
676 }
677 HyperliquidWsMessage::Bbo { data } => {
678 if let Some(msg) = Self::handle_bbo(&data, instruments, ts_init) {
679 result.push(msg);
680 }
681 }
682 HyperliquidWsMessage::L2Book { data } => {
683 result.extend(Self::handle_l2_book(
684 &data,
685 instruments,
686 depth10_subs,
687 ts_init,
688 ));
689 }
690 HyperliquidWsMessage::Candle { data } => {
691 if let Some(msg) =
692 Self::handle_candle(&data, instruments, bar_types, bar_cache, ts_init)
693 {
694 result.push(msg);
695 }
696 }
697 HyperliquidWsMessage::ActiveAssetCtx { data }
698 | HyperliquidWsMessage::ActiveSpotAssetCtx { data } => {
699 result.extend(Self::handle_asset_context(
700 &data,
701 instruments,
702 asset_context_subs,
703 asset_context_caches,
704 ts_init,
705 ));
706 }
707 HyperliquidWsMessage::UserTwapHistory { data } => {
708 result.extend(Self::handle_user_twap_history(&data, instruments, ts_init));
709 }
710 HyperliquidWsMessage::UserTwapSliceFills { data } => {
711 result.extend(Self::handle_user_twap_slice_fills(
712 &data,
713 instruments,
714 ts_init,
715 ));
716 }
717 HyperliquidWsMessage::Error { data } => {
718 log::warn!("Received error from Hyperliquid WebSocket: {data}");
719 }
720 _ => {}
722 }
723
724 result
725 }
726
727 fn handle_order_updates(
728 data: &[super::messages::WsOrderData],
729 instruments: &AHashMap<Ustr, InstrumentAny>,
730 cloid_cache: &CloidCache,
731 account_id: AccountId,
732 ts_init: UnixNanos,
733 ) -> Option<NautilusWsMessage> {
734 let mut exec_reports = Vec::new();
735
736 for order_update in data {
737 let instrument = instruments.get(&order_update.order.coin);
738
739 if let Some(instrument) = instrument {
740 match parse_ws_order_status_report(order_update, instrument, account_id, ts_init) {
741 Ok(mut report) => {
742 if let Some(cloid) = &order_update.order.cloid {
744 let cloid_ustr = Ustr::from(cloid.as_str());
745 let resolved = cloid_cache
746 .lock()
747 .expect(MUTEX_POISONED)
748 .get(&cloid_ustr)
749 .copied();
750
751 if let Some(real_client_order_id) = resolved {
752 log::debug!("Resolved cloid {cloid} -> {real_client_order_id}");
753 report.client_order_id = Some(real_client_order_id);
754 }
755 }
756 exec_reports.push(ExecutionReport::Order(report));
757 }
758 Err(e) => {
759 log::error!("Error parsing order update: {e}");
760 }
761 }
762 } else {
763 log::debug!("No instrument found for coin: {}", order_update.order.coin);
764 }
765 }
766
767 if exec_reports.is_empty() {
768 None
769 } else {
770 Some(NautilusWsMessage::ExecutionReports(exec_reports))
771 }
772 }
773
774 fn handle_user_fills(
775 fills: &[super::messages::WsFillData],
776 instruments: &AHashMap<Ustr, InstrumentAny>,
777 cloid_cache: &CloidCache,
778 account_id: AccountId,
779 ts_init: UnixNanos,
780 processed_trade_ids: &mut FifoCache<u64, 10_000>,
781 ) -> Option<NautilusWsMessage> {
782 let mut exec_reports = Vec::new();
783
784 for fill in fills {
785 if processed_trade_ids.contains(&fill.tid) {
786 log::debug!("Skipping duplicate fill: tid={}", fill.tid);
787 continue;
788 }
789
790 let instrument = instruments.get(&fill.coin);
791
792 if let Some(instrument) = instrument {
793 log::debug!("Found instrument for fill coin={}", fill.coin);
794 match parse_ws_fill_report(fill, instrument, account_id, ts_init) {
795 Ok(mut report) => {
796 processed_trade_ids.add(fill.tid);
798
799 if let Some(cloid) = &fill.cloid {
800 let cloid_ustr = Ustr::from(cloid.as_str());
801 let resolved = cloid_cache
802 .lock()
803 .expect(MUTEX_POISONED)
804 .get(&cloid_ustr)
805 .copied();
806
807 if let Some(real_client_order_id) = resolved {
808 log::debug!(
809 "Resolved fill cloid {cloid} -> {real_client_order_id}"
810 );
811 report.client_order_id = Some(real_client_order_id);
812 }
813 }
814 log::debug!(
815 "Parsed fill report: venue_order_id={:?}, trade_id={:?}",
816 report.venue_order_id,
817 report.trade_id
818 );
819 exec_reports.push(ExecutionReport::Fill(report));
820 }
821 Err(e) => {
822 log::error!("Error parsing fill: {e}");
823 }
824 }
825 } else {
826 log::warn!("No instrument found for fill coin={}", fill.coin);
828 }
829 }
830
831 if exec_reports.is_empty() {
832 None
833 } else {
834 Some(NautilusWsMessage::ExecutionReports(exec_reports))
835 }
836 }
837
838 fn handle_trades(
839 data: &[super::messages::WsTradeData],
840 instruments: &AHashMap<Ustr, InstrumentAny>,
841 trade_subs: &AHashMap<Ustr, TradeStreamUses>,
842 processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
843 ts_init: UnixNanos,
844 ) -> Vec<NautilusWsMessage> {
845 let mut trade_ticks = Vec::new();
846 let mut public_trades = Vec::new();
847
848 for trade in data {
849 if let Some(instrument) = instruments.get(&trade.coin) {
850 let uses = trade_subs.get(&trade.coin).copied().unwrap_or_default();
851
852 if uses.ticks {
853 match parse_ws_trade_tick(trade, instrument, ts_init) {
854 Ok(tick) => trade_ticks.push(tick),
855 Err(e) => {
856 log::error!("Error parsing trade tick: {e}");
857 }
858 }
859 }
860
861 if uses.public_trades {
862 let trade_key = (trade.coin, trade.tid);
863 if processed_public_trade_ids.contains(&trade_key) {
864 log::debug!(
865 "Skipping replayed public trade: coin={}, tid={}",
866 trade.coin,
867 trade.tid
868 );
869 continue;
870 }
871
872 match parse_ws_public_trade(trade, instrument, ts_init) {
873 Ok(trade) => {
874 processed_public_trade_ids.add(trade_key);
875 public_trades.push(trade);
876 }
877 Err(e) => {
878 log::error!("Error parsing public trade: {e}");
879 }
880 }
881 }
882 } else {
883 log::debug!("No instrument found for coin: {}", trade.coin);
884 }
885 }
886
887 let mut result = Vec::with_capacity(1 + public_trades.len());
888 if !trade_ticks.is_empty() {
889 result.push(NautilusWsMessage::Trades(trade_ticks));
890 }
891 result.extend(public_trades.into_iter().map(|trade| {
892 let instrument_id = trade.instrument_id;
893 NautilusWsMessage::CustomData(Data::Custom(CustomData::new(
894 Arc::new(trade),
895 Self::public_trade_data_type(instrument_id),
896 )))
897 }));
898 result
899 }
900
901 fn handle_bbo(
902 data: &super::messages::WsBboData,
903 instruments: &AHashMap<Ustr, InstrumentAny>,
904 ts_init: UnixNanos,
905 ) -> Option<NautilusWsMessage> {
906 if let Some(instrument) = instruments.get(&data.coin) {
907 match parse_ws_quote_tick(data, instrument, ts_init) {
908 Ok(quote_tick) => Some(NautilusWsMessage::Quote(quote_tick)),
909 Err(e) => {
910 log::error!("Error parsing quote tick: {e}");
911 None
912 }
913 }
914 } else {
915 log::debug!("No instrument found for coin: {}", data.coin);
916 None
917 }
918 }
919
920 fn handle_l2_book(
921 data: &super::messages::WsBookData,
922 instruments: &AHashMap<Ustr, InstrumentAny>,
923 depth10_subs: &AHashSet<Ustr>,
924 ts_init: UnixNanos,
925 ) -> Vec<NautilusWsMessage> {
926 let mut out = Vec::new();
927
928 let Some(instrument) = instruments.get(&data.coin) else {
929 log::debug!("No instrument found for coin: {}", data.coin);
930 return out;
931 };
932
933 match parse_ws_order_book_deltas(data, instrument, ts_init) {
934 Ok(deltas) => out.push(NautilusWsMessage::Deltas(deltas)),
935 Err(e) => log::error!("Error parsing order book deltas: {e}"),
936 }
937
938 if depth10_subs.contains(&data.coin) {
939 match parse_ws_order_book_depth10(data, instrument, ts_init) {
940 Ok(depth) => out.push(NautilusWsMessage::Depth10(Box::new(depth))),
941 Err(e) => log::error!("Error parsing order book depth10: {e}"),
942 }
943 }
944
945 out
946 }
947
948 fn handle_candle(
949 data: &CandleData,
950 instruments: &AHashMap<Ustr, InstrumentAny>,
951 bar_types: &AHashMap<String, BarType>,
952 bar_cache: &mut AHashMap<String, CandleData>,
953 ts_init: UnixNanos,
954 ) -> Option<NautilusWsMessage> {
955 let key = format!("candle:{}:{}", data.s, data.i);
956
957 let mut closed_bar = None;
958
959 if let Some(cached) = bar_cache.get(&key) {
960 if cached.close_time != data.close_time {
962 log::debug!(
963 "Bar period changed for {}: prev_close_time={}, new_close_time={}",
964 data.s,
965 cached.close_time,
966 data.close_time
967 );
968 closed_bar = Some(cached.clone());
969 }
970 }
971
972 bar_cache.insert(key.clone(), data.clone());
973
974 if let Some(closed_data) = closed_bar {
975 if let Some(bar_type) = bar_types.get(&key) {
976 if let Some(instrument) = instruments.get(&data.s) {
977 match parse_ws_candle(&closed_data, instrument, bar_type, ts_init) {
978 Ok(bar) => return Some(NautilusWsMessage::Candle(bar)),
979 Err(e) => {
980 log::error!("Error parsing closed candle: {e}");
981 }
982 }
983 } else {
984 log::debug!("No instrument found for coin: {}", data.s);
985 }
986 } else {
987 log::debug!("No bar type found for key: {key}");
988 }
989 }
990
991 None
992 }
993
994 fn handle_asset_context(
995 data: &WsActiveAssetCtxData,
996 instruments: &AHashMap<Ustr, InstrumentAny>,
997 asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
998 asset_context_caches: &mut AssetContextCaches,
999 ts_init: UnixNanos,
1000 ) -> Vec<NautilusWsMessage> {
1001 let mut result = Vec::new();
1002
1003 let coin = match data {
1004 WsActiveAssetCtxData::Perp { coin, .. } => coin,
1005 WsActiveAssetCtxData::Spot { coin, .. } => coin,
1006 };
1007
1008 if let Some(instrument) = instruments.get(coin) {
1009 let (mark_px, oracle_px, funding, open_interest) = match data {
1010 WsActiveAssetCtxData::Perp { ctx, .. } => (
1011 &ctx.shared.mark_px,
1012 Some(&ctx.oracle_px),
1013 Some(&ctx.funding),
1014 Some(&ctx.open_interest),
1015 ),
1016 WsActiveAssetCtxData::Spot { ctx, .. } => (&ctx.shared.mark_px, None, None, None),
1017 };
1018
1019 let mark_changed = asset_context_caches.mark_price.get(coin) != Some(mark_px);
1020 let index_changed =
1021 oracle_px.is_some_and(|px| asset_context_caches.index_price.get(coin) != Some(px));
1022 let funding_changed = funding
1023 .is_some_and(|rate| asset_context_caches.funding_rate.get(coin) != Some(rate));
1024 let open_interest_changed = open_interest
1025 .is_some_and(|value| asset_context_caches.open_interest.get(coin) != Some(value));
1026
1027 let subscribed_types = asset_context_subs.get(coin);
1028
1029 if mark_changed || index_changed || funding_changed {
1030 match parse_ws_asset_context(data, instrument, ts_init) {
1031 Ok((mark_price, index_price, funding_rate)) => {
1032 if mark_changed
1033 && subscribed_types
1034 .is_some_and(|s| s.contains(&AssetContextDataType::MarkPrice))
1035 {
1036 asset_context_caches.mark_price.insert(*coin, *mark_px);
1037 result.push(NautilusWsMessage::MarkPrice(mark_price));
1038 }
1039
1040 if index_changed
1041 && subscribed_types
1042 .is_some_and(|s| s.contains(&AssetContextDataType::IndexPrice))
1043 {
1044 if let Some(px) = oracle_px {
1045 asset_context_caches.index_price.insert(*coin, *px);
1046 }
1047
1048 if let Some(index) = index_price {
1049 result.push(NautilusWsMessage::IndexPrice(index));
1050 }
1051 }
1052
1053 if funding_changed
1054 && subscribed_types
1055 .is_some_and(|s| s.contains(&AssetContextDataType::FundingRate))
1056 {
1057 if let Some(rate) = funding {
1058 asset_context_caches.funding_rate.insert(*coin, *rate);
1059 }
1060
1061 if let Some(funding) = funding_rate {
1062 result.push(NautilusWsMessage::FundingRate(funding));
1063 }
1064 }
1065 }
1066 Err(e) => {
1067 log::error!("Error parsing asset context: {e}");
1068 }
1069 }
1070 }
1071
1072 if let Some(value) = open_interest
1073 && open_interest_changed
1074 && subscribed_types.is_some_and(|s| s.contains(&AssetContextDataType::OpenInterest))
1075 {
1076 match parse_ws_open_interest(*value, instrument, ts_init) {
1077 Ok(open_interest_data) => {
1078 asset_context_caches.open_interest.insert(*coin, *value);
1079
1080 let data_type =
1081 Self::open_interest_data_type(open_interest_data.instrument_id);
1082 result.push(NautilusWsMessage::CustomData(Data::Custom(
1083 CustomData::new(Arc::new(open_interest_data), data_type),
1084 )));
1085 }
1086 Err(e) => {
1087 log::error!("Error parsing open interest: {e}");
1088 }
1089 }
1090 }
1091 } else {
1092 log::debug!("No instrument found for coin: {coin}");
1093 }
1094
1095 result
1096 }
1097
1098 fn handle_all_dexs_asset_ctxs(
1099 data: WsAllDexsAssetCtxsData,
1100 all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1101 ts_init: UnixNanos,
1102 ) -> Option<NautilusWsMessage> {
1103 let mut entries = Vec::new();
1104
1105 for (dex, ctxs) in data.ctxs {
1106 let dex_key = Ustr::from(dex.as_str());
1107 let Some(instrument_ids) = all_dex_asset_ctxs_instrument_ids.get(&dex_key) else {
1108 log::warn!("Missing Hyperliquid allDexsAssetCtxs mapping for dex='{dex}'");
1109 continue;
1110 };
1111
1112 if ctxs.len() != instrument_ids.len() {
1113 log::warn!(
1116 "Hyperliquid allDexsAssetCtxs count mismatch for dex='{dex}': received {} contexts but cached {} instrument IDs (reconnect to refresh)",
1117 ctxs.len(),
1118 instrument_ids.len()
1119 );
1120 }
1121
1122 for (index, ctx) in ctxs.into_iter().enumerate() {
1123 let Some(Some(instrument_id)) = instrument_ids.get(index).copied() else {
1124 log::warn!(
1125 "Missing Hyperliquid allDexsAssetCtxs instrument mapping for dex='{dex}' index={index}"
1126 );
1127 continue;
1128 };
1129
1130 match Self::normalize_all_dex_asset_ctx_entry(&dex, instrument_id, ctx) {
1131 Ok(entry) => entries.push(entry),
1132 Err(e) => {
1133 log::warn!(
1134 "Failed to normalize Hyperliquid allDexsAssetCtxs entry dex='{dex}' index={index}: {e}"
1135 );
1136 }
1137 }
1138 }
1139 }
1140
1141 if entries.is_empty() {
1142 return None;
1143 }
1144
1145 let payload = HyperliquidAllDexsAssetCtxs::new(entries, ts_init, ts_init);
1146 let data_type = DataType::new("HyperliquidAllDexsAssetCtxs", None, None);
1147 Some(NautilusWsMessage::CustomData(Data::Custom(
1148 CustomData::new(Arc::new(payload), data_type),
1149 )))
1150 }
1151
1152 fn normalize_all_dex_asset_ctx_entry(
1153 dex: &str,
1154 instrument_id: InstrumentId,
1155 ctx: super::messages::PerpsAssetCtx,
1156 ) -> anyhow::Result<HyperliquidDexAssetCtx> {
1157 let mark_price = Price::from_decimal(ctx.shared.mark_px).map_err(anyhow::Error::msg)?;
1158 let oracle_price = Price::from_decimal(ctx.oracle_px).map_err(anyhow::Error::msg)?;
1159 let prev_day_price =
1160 Price::from_decimal(ctx.shared.prev_day_px).map_err(anyhow::Error::msg)?;
1161 let mid_price = ctx
1162 .shared
1163 .mid_px
1164 .map(|value| Price::from_decimal(value).map_err(anyhow::Error::msg))
1165 .transpose()?;
1166 let funding_rate = ctx.funding;
1167 let open_interest = ctx.open_interest;
1168 let premium = ctx.premium;
1169 let day_ntl_volume = ctx.shared.day_ntl_vlm;
1170 let day_base_volume = ctx
1171 .shared
1172 .day_base_vlm
1173 .ok_or_else(|| anyhow::anyhow!("missing dayBaseVlm"))?;
1174 let impact_prices = match ctx.shared.impact_pxs {
1175 Some(values) => match values.as_slice() {
1176 [bid, ask] => Some(HyperliquidImpactPrices {
1177 bid: bid.parse::<Price>().map_err(anyhow::Error::msg)?,
1178 ask: ask.parse::<Price>().map_err(anyhow::Error::msg)?,
1179 }),
1180 other => {
1181 anyhow::bail!("expected 2 impact prices, received {}", other.len());
1182 }
1183 },
1184 None => None,
1185 };
1186
1187 Ok(HyperliquidDexAssetCtx {
1188 dex: dex.to_string(),
1189 instrument_id,
1190 mark_price,
1191 oracle_price,
1192 prev_day_price,
1193 mid_price,
1194 impact_prices,
1195 funding_rate,
1196 open_interest,
1197 premium,
1198 day_ntl_volume,
1199 day_base_volume,
1200 })
1201 }
1202
1203 fn open_interest_data_type(instrument_id: InstrumentId) -> DataType {
1204 let mut metadata = Params::new();
1205 metadata.insert(
1206 "instrument_id".to_string(),
1207 serde_json::Value::String(instrument_id.to_string()),
1208 );
1209 DataType::new(
1210 "HyperliquidOpenInterest",
1211 Some(metadata),
1212 Some(instrument_id.to_string()),
1213 )
1214 }
1215
1216 fn public_trade_data_type(instrument_id: InstrumentId) -> DataType {
1217 let mut metadata = Params::new();
1218 metadata.insert(
1219 "instrument_id".to_string(),
1220 serde_json::Value::String(instrument_id.to_string()),
1221 );
1222 DataType::new(
1223 "HyperliquidPublicTrade",
1224 Some(metadata),
1225 Some(instrument_id.to_string()),
1226 )
1227 }
1228
1229 fn handle_user_twap_history(
1230 data: &super::messages::WsUserTwapHistoryData,
1231 instruments: &AHashMap<Ustr, InstrumentAny>,
1232 ts_init: UnixNanos,
1233 ) -> Vec<NautilusWsMessage> {
1234 let is_snapshot = data.is_snapshot.unwrap_or(false);
1235 let mut result = Vec::with_capacity(data.history.len());
1236
1237 for row in &data.history {
1238 let instrument = instruments.get(&row.state.coin);
1239 match parse_ws_twap_history_row(row, &data.user, is_snapshot, instrument, ts_init) {
1240 Ok(payload) => {
1241 let user = payload.user.clone();
1242 result.push(NautilusWsMessage::CustomData(Data::Custom(
1243 CustomData::new(Arc::new(payload), Self::twap_history_data_type(&user)),
1244 )));
1245 }
1246 Err(e) => {
1247 log::error!("Error parsing TWAP history row: {e}");
1248 }
1249 }
1250 }
1251
1252 result
1253 }
1254
1255 fn handle_user_twap_slice_fills(
1256 data: &super::messages::WsUserTwapSliceFillsData,
1257 instruments: &AHashMap<Ustr, InstrumentAny>,
1258 ts_init: UnixNanos,
1259 ) -> Vec<NautilusWsMessage> {
1260 let is_snapshot = data.is_snapshot.unwrap_or(false);
1261 let mut result = Vec::with_capacity(data.twap_slice_fills.len());
1262
1263 for item in &data.twap_slice_fills {
1264 let instrument = instruments.get(&item.fill.coin);
1265 match parse_ws_twap_slice_fill(item, &data.user, is_snapshot, instrument, ts_init) {
1266 Ok(payload) => {
1267 let user = payload.user.clone();
1268 result.push(NautilusWsMessage::CustomData(Data::Custom(
1269 CustomData::new(Arc::new(payload), Self::twap_slice_fill_data_type(&user)),
1270 )));
1271 }
1272 Err(e) => {
1273 log::error!("Error parsing TWAP slice fill: {e}");
1274 }
1275 }
1276 }
1277
1278 result
1279 }
1280
1281 fn twap_history_data_type(user: &str) -> DataType {
1282 let mut metadata = Params::new();
1283 metadata.insert(
1284 "user".to_string(),
1285 serde_json::Value::String(user.to_string()),
1286 );
1287 DataType::new(
1288 "HyperliquidTwapHistory",
1289 Some(metadata),
1290 Some(user.to_string()),
1291 )
1292 }
1293
1294 fn twap_slice_fill_data_type(user: &str) -> DataType {
1295 let mut metadata = Params::new();
1296 metadata.insert(
1297 "user".to_string(),
1298 serde_json::Value::String(user.to_string()),
1299 );
1300 DataType::new(
1301 "HyperliquidTwapSliceFill",
1302 Some(metadata),
1303 Some(user.to_string()),
1304 )
1305 }
1306}
1307
1308pub(crate) fn subscription_to_key(sub: &SubscriptionRequest) -> String {
1309 match sub {
1310 SubscriptionRequest::AllMids { dex } => {
1311 if let Some(dex_name) = dex {
1312 format!("{}:{dex_name}", HyperliquidWsChannel::AllMids.as_str())
1313 } else {
1314 HyperliquidWsChannel::AllMids.as_str().to_string()
1315 }
1316 }
1317 SubscriptionRequest::AllDexsAssetCtxs => {
1318 HyperliquidWsChannel::AllDexsAssetCtxs.as_str().to_string()
1319 }
1320 SubscriptionRequest::Notification { user } => {
1321 format!("{}:{user}", HyperliquidWsChannel::Notification.as_str())
1322 }
1323 SubscriptionRequest::WebData2 { user } => {
1324 format!("{}:{user}", HyperliquidWsChannel::WebData2.as_str())
1325 }
1326 SubscriptionRequest::Candle { coin, interval } => {
1327 format!(
1328 "{}:{coin}:{}",
1329 HyperliquidWsChannel::Candle.as_str(),
1330 interval.as_str()
1331 )
1332 }
1333 SubscriptionRequest::L2Book { coin, .. } => {
1334 format!("{}:{coin}", HyperliquidWsChannel::L2Book.as_str())
1335 }
1336 SubscriptionRequest::Trades { coin } => {
1337 format!("{}:{coin}", HyperliquidWsChannel::Trades.as_str())
1338 }
1339 SubscriptionRequest::OrderUpdates { user } => {
1340 format!("{}:{user}", HyperliquidWsChannel::OrderUpdates.as_str())
1341 }
1342 SubscriptionRequest::UserEvents { user } => {
1343 format!("{}:{user}", HyperliquidWsChannel::UserEvents.as_str())
1344 }
1345 SubscriptionRequest::UserFills { user, .. } => {
1346 format!("{}:{user}", HyperliquidWsChannel::UserFills.as_str())
1347 }
1348 SubscriptionRequest::UserFundings { user } => {
1349 format!("{}:{user}", HyperliquidWsChannel::UserFundings.as_str())
1350 }
1351 SubscriptionRequest::UserNonFundingLedgerUpdates { user } => {
1352 format!(
1353 "{}:{user}",
1354 HyperliquidWsChannel::UserNonFundingLedgerUpdates.as_str()
1355 )
1356 }
1357 SubscriptionRequest::ActiveAssetCtx { coin } => {
1358 format!("{}:{coin}", HyperliquidWsChannel::ActiveAssetCtx.as_str())
1359 }
1360 SubscriptionRequest::ActiveSpotAssetCtx { coin } => {
1361 format!(
1362 "{}:{coin}",
1363 HyperliquidWsChannel::ActiveSpotAssetCtx.as_str()
1364 )
1365 }
1366 SubscriptionRequest::ActiveAssetData { user, coin } => {
1367 format!(
1368 "{}:{user}:{coin}",
1369 HyperliquidWsChannel::ActiveAssetData.as_str()
1370 )
1371 }
1372 SubscriptionRequest::UserTwapSliceFills { user } => {
1373 format!(
1374 "{}:{user}",
1375 HyperliquidWsChannel::UserTwapSliceFills.as_str()
1376 )
1377 }
1378 SubscriptionRequest::UserTwapHistory { user } => {
1379 format!("{}:{user}", HyperliquidWsChannel::UserTwapHistory.as_str())
1380 }
1381 SubscriptionRequest::Bbo { coin } => {
1382 format!("{}:{coin}", HyperliquidWsChannel::Bbo.as_str())
1383 }
1384 }
1385}
1386
1387pub(crate) fn should_retry_hyperliquid_error(error: &HyperliquidWsError) -> bool {
1389 match error {
1390 HyperliquidWsError::TungsteniteError(_) => true,
1391 HyperliquidWsError::ClientError(msg) => {
1392 let msg_lower = msg.to_lowercase();
1393 msg_lower.contains("timeout")
1394 || msg_lower.contains("timed out")
1395 || msg_lower.contains("connection")
1396 || msg_lower.contains("network")
1397 }
1398 _ => false,
1399 }
1400}
1401
1402pub(crate) fn create_hyperliquid_timeout_error(msg: String) -> HyperliquidWsError {
1404 HyperliquidWsError::ClientError(msg)
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use std::{
1410 sync::{Arc, Mutex, atomic::AtomicBool},
1411 time::Duration,
1412 };
1413
1414 use ahash::{AHashMap, AHashSet};
1415 use log::{Level, LevelFilter, Log, Metadata, Record};
1416 use nautilus_common::cache::fifo::FifoCacheMap;
1417 use nautilus_core::nanos::UnixNanos;
1418 use nautilus_model::{
1419 data::Data,
1420 identifiers::{ClientOrderId, InstrumentId, Symbol},
1421 instruments::{CryptoPerpetual, Instrument, InstrumentAny},
1422 types::{Currency, Price, Quantity},
1423 };
1424 use nautilus_network::websocket::SubscriptionState;
1425 use rstest::rstest;
1426 use rust_decimal::Decimal;
1427 use rust_decimal_macros::dec;
1428 use serde_json::json;
1429 use ustr::Ustr;
1430
1431 use super::{
1432 super::{
1433 client::{AssetContextDataType, CLOID_CACHE_CAPACITY, CloidCache},
1434 messages::{
1435 HyperliquidWsRequest, NautilusWsMessage, PerpsAssetCtx, PostRequest,
1436 SharedAssetCtx, SpotAssetCtx, SubscriptionRequest, WsActiveAssetCtxData,
1437 WsAllDexsAssetCtxsData, WsBookData, WsLevelData,
1438 },
1439 post::PostRouter,
1440 },
1441 AllMidsDataTypeCache, AssetContextCaches, FeedHandler, HandlerCommand,
1442 };
1443 use crate::{
1444 common::consts::HYPERLIQUID_VENUE,
1445 data_types::{HyperliquidAllDexsAssetCtxs, HyperliquidOpenInterest},
1446 };
1447
1448 const SECRET_MARKER: &str = "OUTBOUND_SECRET_MARKER";
1449
1450 struct OutboundLogCapture {
1451 messages: Mutex<Vec<String>>,
1452 }
1453
1454 static OUTBOUND_LOG_CAPTURE: OutboundLogCapture = OutboundLogCapture {
1455 messages: Mutex::new(Vec::new()),
1456 };
1457
1458 impl OutboundLogCapture {
1459 fn clear(&self) {
1460 self.messages.lock().unwrap().clear();
1461 }
1462
1463 fn messages(&self) -> Vec<String> {
1464 self.messages.lock().unwrap().clone()
1465 }
1466 }
1467
1468 impl Log for OutboundLogCapture {
1469 fn enabled(&self, metadata: &Metadata<'_>) -> bool {
1470 metadata.level() == Level::Debug
1471 && metadata.target() == "nautilus_hyperliquid::websocket::handler"
1472 }
1473
1474 fn log(&self, record: &Record<'_>) {
1475 if self.enabled(record.metadata()) {
1476 let message = record.args().to_string();
1477 if message.starts_with("Sending ") {
1478 self.messages.lock().unwrap().push(message);
1479 }
1480 }
1481 }
1482
1483 fn flush(&self) {}
1484 }
1485
1486 #[rstest]
1487 fn all_mids_cache_projects_subscriptions_without_scanning_every_websocket_message() {
1488 let mut cache = AllMidsDataTypeCache::default();
1489
1490 assert_eq!(cache.as_slice().len(), 1);
1491 assert!(cache.as_slice()[0].metadata().is_none());
1492
1493 cache.apply(
1494 &SubscriptionRequest::AllMids {
1495 dex: Some("xyz".to_owned()),
1496 },
1497 true,
1498 );
1499 assert_eq!(cache.as_slice().len(), 1);
1500 assert_eq!(
1501 cache.as_slice()[0]
1502 .metadata()
1503 .and_then(|metadata| metadata.get_str("dex")),
1504 Some("xyz"),
1505 );
1506
1507 cache.apply(&SubscriptionRequest::AllMids { dex: None }, true);
1508 assert_eq!(cache.as_slice().len(), 2);
1509
1510 cache.apply(
1511 &SubscriptionRequest::AllMids {
1512 dex: Some("xyz".to_owned()),
1513 },
1514 false,
1515 );
1516 assert_eq!(cache.as_slice().len(), 1);
1517 assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1518 assert!(cache.as_slice()[0].metadata().is_none());
1519
1520 cache.apply(&SubscriptionRequest::AllMids { dex: None }, false);
1521 assert_eq!(cache.as_slice().len(), 1);
1522 assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1523 assert!(cache.as_slice()[0].metadata().is_none());
1524 }
1525
1526 fn btc_perp() -> InstrumentAny {
1527 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1528 InstrumentId::new(Symbol::new("BTC-PERP"), *HYPERLIQUID_VENUE),
1529 Symbol::new("BTC-PERP"),
1530 Currency::from("BTC"),
1531 Currency::from("USDC"),
1532 Currency::from("USDC"),
1533 false,
1534 2,
1535 3,
1536 Price::from("0.01"),
1537 Quantity::from("0.001"),
1538 None,
1539 None,
1540 None,
1541 None,
1542 None,
1543 None,
1544 None,
1545 None,
1546 None,
1547 None,
1548 None,
1549 None,
1550 None,
1551 None,
1552 UnixNanos::default(),
1553 UnixNanos::default(),
1554 ))
1555 }
1556
1557 fn one_level_book() -> WsBookData {
1558 WsBookData {
1559 coin: Ustr::from("BTC"),
1560 levels: [
1561 vec![WsLevelData {
1562 px: dec!(100.00),
1563 sz: dec!(1.0),
1564 n: 1,
1565 }],
1566 vec![WsLevelData {
1567 px: dec!(100.01),
1568 sz: dec!(1.0),
1569 n: 1,
1570 }],
1571 ],
1572 time: 1_700_000_000_000,
1573 }
1574 }
1575
1576 fn btc_active_spot_asset_ctx() -> WsActiveAssetCtxData {
1577 WsActiveAssetCtxData::Spot {
1578 coin: Ustr::from("BTC"),
1579 ctx: SpotAssetCtx {
1580 shared: SharedAssetCtx {
1581 day_ntl_vlm: dec!(1000000.0),
1582 prev_day_px: dec!(49000.0),
1583 mark_px: dec!(50000.0),
1584 mid_px: Some(dec!(50001.0)),
1585 impact_pxs: None,
1586 day_base_vlm: Some(dec!(100.0)),
1587 },
1588 circulating_supply: dec!(19000000.0),
1589 },
1590 }
1591 }
1592
1593 fn btc_active_asset_ctx(open_interest: Decimal) -> WsActiveAssetCtxData {
1594 WsActiveAssetCtxData::Perp {
1595 coin: Ustr::from("BTC"),
1596 ctx: PerpsAssetCtx {
1597 shared: SharedAssetCtx {
1598 day_ntl_vlm: dec!(1000000.0),
1599 prev_day_px: dec!(49000.0),
1600 mark_px: dec!(50000.0),
1601 mid_px: Some(dec!(50001.0)),
1602 impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
1603 day_base_vlm: Some(dec!(100.0)),
1604 },
1605 funding: dec!(0.0001),
1606 open_interest,
1607 oracle_px: dec!(50005.0),
1608 premium: Some(dec!(-0.0001)),
1609 },
1610 }
1611 }
1612
1613 fn sample_all_dexs_asset_ctxs() -> WsAllDexsAssetCtxsData {
1614 let raw = include_str!("../../test_data/ws_all_dexs_asset_ctxs.json");
1615 let msg: super::super::messages::HyperliquidWsMessage =
1616 serde_json::from_str(raw).expect("expected valid allDexsAssetCtxs fixture");
1617
1618 let super::super::messages::HyperliquidWsMessage::AllDexsAssetCtxs { data } = msg else {
1619 panic!("expected allDexsAssetCtxs fixture message");
1620 };
1621
1622 let default_entry = data
1623 .ctxs
1624 .iter()
1625 .find(|(dex, _)| dex.is_empty())
1626 .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1627 .expect("expected default dex sample");
1628 let xyz_entry = data
1629 .ctxs
1630 .iter()
1631 .find(|(dex, _)| dex == "xyz")
1632 .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1633 .expect("expected xyz dex sample");
1634
1635 WsAllDexsAssetCtxsData {
1636 ctxs: vec![default_entry, xyz_entry],
1637 }
1638 }
1639
1640 #[tokio::test]
1641 async fn post_send_failure_cancels_router_waiter() {
1642 let signal = Arc::new(AtomicBool::new(false));
1643 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1644 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1645 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1646 let post_router = PostRouter::new();
1647 let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
1648 Ustr,
1649 ClientOrderId,
1650 CLOID_CACHE_CAPACITY,
1651 >::new()));
1652 let mut handler = FeedHandler::new(
1653 signal,
1654 cmd_rx,
1655 raw_rx,
1656 out_tx,
1657 None,
1658 SubscriptionState::new(':'),
1659 cloid_cache,
1660 Arc::clone(&post_router),
1661 );
1662
1663 let id = 99;
1664 let rx = post_router.register(id).await.unwrap();
1665
1666 let task = tokio::spawn(async move { handler.next().await });
1667
1668 cmd_tx
1669 .send(HandlerCommand::Post {
1670 id,
1671 request: PostRequest::Info {
1672 payload: json!({"type": "userRateLimit", "user": "0x123"}),
1673 },
1674 })
1675 .unwrap();
1676 drop(cmd_tx);
1677 drop(raw_tx);
1678
1679 let closed = tokio::time::timeout(Duration::from_millis(100), rx)
1680 .await
1681 .expect("post waiter should close without waiting for post timeout");
1682 assert!(closed.is_err(), "post router cancel must close the waiter");
1683 let _rx = post_router
1684 .register(id)
1685 .await
1686 .expect("post id should be reusable after cancellation");
1687 assert!(task.await.unwrap().is_none());
1688 }
1689
1690 #[rstest]
1691 #[tokio::test]
1692 async fn outbound_subscription_logs_omit_payload_bodies() {
1693 log::set_logger(&OUTBOUND_LOG_CAPTURE).expect("test logger already installed");
1694 log::set_max_level(LevelFilter::Debug);
1695
1696 let signal = Arc::new(AtomicBool::new(false));
1697 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1698 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1699 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1700 let post_router = PostRouter::new();
1701 let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
1702 Ustr,
1703 ClientOrderId,
1704 CLOID_CACHE_CAPACITY,
1705 >::new()));
1706 let mut handler = FeedHandler::new(
1707 signal,
1708 cmd_rx,
1709 raw_rx,
1710 out_tx,
1711 None,
1712 SubscriptionState::new(':'),
1713 cloid_cache,
1714 post_router,
1715 );
1716 let subscription = SubscriptionRequest::Notification {
1717 user: SECRET_MARKER.to_string(),
1718 };
1719 let subscribe_len = serde_json::to_string(&HyperliquidWsRequest::Subscribe {
1720 subscription: subscription.clone(),
1721 })
1722 .unwrap()
1723 .len();
1724 let unsubscribe_len = serde_json::to_string(&HyperliquidWsRequest::Unsubscribe {
1725 subscription: subscription.clone(),
1726 })
1727 .unwrap()
1728 .len();
1729 OUTBOUND_LOG_CAPTURE.clear();
1730
1731 cmd_tx
1732 .send(HandlerCommand::Subscribe {
1733 subscriptions: vec![subscription.clone()],
1734 })
1735 .unwrap();
1736 cmd_tx
1737 .send(HandlerCommand::Unsubscribe {
1738 subscriptions: vec![subscription],
1739 })
1740 .unwrap();
1741 drop(cmd_tx);
1742 drop(raw_tx);
1743
1744 assert!(handler.next().await.is_none());
1745
1746 let messages = OUTBOUND_LOG_CAPTURE.messages();
1747
1748 assert!(
1749 messages
1750 .iter()
1751 .all(|message| !message.contains(SECRET_MARKER)),
1752 "outbound logs exposed the secret marker: {messages:?}"
1753 );
1754 assert!(
1755 messages
1756 .iter()
1757 .any(|message| message
1758 == &format!("Sending subscribe payload ({subscribe_len} bytes)")),
1759 "subscribe metadata missing or inaccurate: {messages:?}"
1760 );
1761 assert!(
1762 messages.iter().any(|message| {
1763 message == &format!("Sending unsubscribe payload ({unsubscribe_len} bytes)")
1764 }),
1765 "unsubscribe metadata missing or inaccurate: {messages:?}"
1766 );
1767 }
1768
1769 #[rstest]
1770 fn handle_l2_book_emits_deltas_only_when_not_in_depth10_subs() {
1771 let mut instruments = AHashMap::new();
1772 instruments.insert(Ustr::from("BTC"), btc_perp());
1773 let depth10_subs = AHashSet::<Ustr>::new();
1774
1775 let msgs = FeedHandler::handle_l2_book(
1776 &one_level_book(),
1777 &instruments,
1778 &depth10_subs,
1779 UnixNanos::default(),
1780 );
1781
1782 assert_eq!(msgs.len(), 1);
1783 assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
1784 }
1785
1786 #[rstest]
1787 fn handle_l2_book_emits_deltas_and_depth10_when_coin_in_subs() {
1788 let mut instruments = AHashMap::new();
1789 instruments.insert(Ustr::from("BTC"), btc_perp());
1790 let mut depth10_subs = AHashSet::<Ustr>::new();
1791 depth10_subs.insert(Ustr::from("BTC"));
1792
1793 let msgs = FeedHandler::handle_l2_book(
1794 &one_level_book(),
1795 &instruments,
1796 &depth10_subs,
1797 UnixNanos::default(),
1798 );
1799
1800 assert_eq!(msgs.len(), 2);
1801 assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
1802 assert!(matches!(msgs[1], NautilusWsMessage::Depth10(_)));
1803 }
1804
1805 #[rstest]
1806 fn handle_l2_book_returns_empty_when_instrument_unknown() {
1807 let instruments = AHashMap::<Ustr, InstrumentAny>::new();
1808 let depth10_subs = AHashSet::<Ustr>::new();
1809
1810 let msgs = FeedHandler::handle_l2_book(
1811 &one_level_book(),
1812 &instruments,
1813 &depth10_subs,
1814 UnixNanos::default(),
1815 );
1816
1817 assert!(msgs.is_empty());
1818 }
1819
1820 #[rstest]
1821 fn handle_asset_context_emits_open_interest_custom_data_when_subscribed() {
1822 let instrument = btc_perp();
1823 let instrument_id = instrument.id();
1824 let mut instruments = AHashMap::new();
1825 instruments.insert(Ustr::from("BTC"), instrument);
1826
1827 let mut asset_context_subs = AHashMap::new();
1828 asset_context_subs.insert(
1829 Ustr::from("BTC"),
1830 AHashSet::from_iter([AssetContextDataType::OpenInterest]),
1831 );
1832
1833 let mut asset_context_caches = AssetContextCaches::default();
1834
1835 let msgs = FeedHandler::handle_asset_context(
1836 &btc_active_asset_ctx(dec!(100000.0)),
1837 &instruments,
1838 &asset_context_subs,
1839 &mut asset_context_caches,
1840 UnixNanos::default(),
1841 );
1842
1843 assert_eq!(msgs.len(), 1);
1844
1845 match &msgs[0] {
1846 NautilusWsMessage::CustomData(Data::Custom(custom)) => {
1847 let open_interest = custom
1848 .data
1849 .as_any()
1850 .downcast_ref::<HyperliquidOpenInterest>()
1851 .expect("expected HyperliquidOpenInterest");
1852 assert_eq!(open_interest.instrument_id, instrument_id);
1853 assert_eq!(open_interest.open_interest.to_string(), "100000.0");
1854 assert_eq!(
1855 custom
1856 .data_type
1857 .metadata()
1858 .and_then(|metadata| metadata.get_str("instrument_id"))
1859 .map(ToString::to_string),
1860 Some(instrument_id.to_string()),
1861 );
1862 }
1863 other => panic!("unexpected message type: {other:?}"),
1864 }
1865 }
1866
1867 #[rstest]
1868 fn handle_all_dexs_asset_ctxs_emits_normalized_custom_data() {
1869 let mapping = AHashMap::from_iter([
1870 (
1871 Ustr::from(""),
1872 vec![Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"))],
1873 ),
1874 (
1875 Ustr::from("xyz"),
1876 vec![Some(InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID"))],
1877 ),
1878 ]);
1879
1880 let msg = FeedHandler::handle_all_dexs_asset_ctxs(
1881 sample_all_dexs_asset_ctxs(),
1882 &mapping,
1883 UnixNanos::default(),
1884 )
1885 .expect("expected custom data");
1886
1887 match msg {
1888 NautilusWsMessage::CustomData(Data::Custom(custom)) => {
1889 let payload = custom
1890 .data
1891 .as_any()
1892 .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
1893 .expect("expected HyperliquidAllDexsAssetCtxs");
1894 assert_eq!(payload.entries.len(), 2);
1895 assert_eq!(
1896 payload.entries[0].instrument_id,
1897 InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")
1898 );
1899 assert_eq!(payload.entries[1].dex, "xyz");
1900 assert_eq!(
1901 payload.entries[1].instrument_id,
1902 InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID")
1903 );
1904 assert_eq!(payload.entries[0].mark_price.to_string(), "77562.0");
1905 assert_eq!(payload.entries[1].day_base_volume.to_string(), "5135.2458");
1906 }
1907 other => panic!("expected custom data, found {other:?}"),
1908 }
1909 }
1910
1911 #[rstest]
1912 fn handle_all_dexs_asset_ctxs_preserves_index_alignment_when_mappings_are_missing() {
1913 let data = WsAllDexsAssetCtxsData {
1914 ctxs: vec![(
1915 String::new(),
1916 vec![
1917 PerpsAssetCtx {
1918 shared: SharedAssetCtx {
1919 day_ntl_vlm: dec!(1516669192.1953897476),
1920 prev_day_px: dec!(76317.0),
1921 mark_px: dec!(77562.0),
1922 mid_px: Some(dec!(77558.5)),
1923 impact_pxs: Some(vec!["77558.0".to_string(), "77559.0".to_string()]),
1924 day_base_vlm: Some(dec!(19707.77457)),
1925 },
1926 funding: dec!(-0.0000015186),
1927 open_interest: dec!(27353.17682),
1928 oracle_px: dec!(77605.0),
1929 premium: Some(dec!(-0.0005927453)),
1930 },
1931 PerpsAssetCtx {
1932 shared: SharedAssetCtx {
1933 day_ntl_vlm: dec!(591989409.9392402172),
1934 prev_day_px: dec!(2094.6),
1935 mark_px: dec!(2123.7),
1936 mid_px: Some(dec!(2123.95)),
1937 impact_pxs: Some(vec!["2123.65".to_string(), "2124.0".to_string()]),
1938 day_base_vlm: Some(dec!(281686.8234999999)),
1939 },
1940 funding: dec!(0.0000125),
1941 open_interest: dec!(605822.2557999999),
1942 oracle_px: dec!(2124.6),
1943 premium: Some(dec!(-0.0002824061)),
1944 },
1945 ],
1946 )],
1947 };
1948
1949 let mapping = AHashMap::from_iter([(
1950 Ustr::from(""),
1951 vec![None, Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"))],
1952 )]);
1953
1954 let msg = FeedHandler::handle_all_dexs_asset_ctxs(data, &mapping, UnixNanos::default())
1955 .expect("expected custom data");
1956
1957 match msg {
1958 NautilusWsMessage::CustomData(Data::Custom(custom)) => {
1959 let payload = custom
1960 .data
1961 .as_any()
1962 .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
1963 .expect("expected HyperliquidAllDexsAssetCtxs");
1964 assert_eq!(payload.entries.len(), 1);
1965 assert_eq!(
1966 payload.entries[0].instrument_id,
1967 InstrumentId::from("ETH-USD-PERP.HYPERLIQUID")
1968 );
1969 assert_eq!(payload.entries[0].mark_price.to_string(), "2123.7");
1970 }
1971 other => panic!("expected custom data, found {other:?}"),
1972 }
1973 }
1974
1975 #[rstest]
1976 fn handle_asset_context_skips_open_interest_for_spot_payload() {
1977 let instrument = btc_perp();
1978 let mut instruments = AHashMap::new();
1979 instruments.insert(Ustr::from("BTC"), instrument);
1980
1981 let mut asset_context_subs = AHashMap::new();
1982 asset_context_subs.insert(
1983 Ustr::from("BTC"),
1984 AHashSet::from_iter([AssetContextDataType::OpenInterest]),
1985 );
1986
1987 let mut asset_context_caches = AssetContextCaches::default();
1988
1989 let msgs = FeedHandler::handle_asset_context(
1990 &btc_active_spot_asset_ctx(),
1991 &instruments,
1992 &asset_context_subs,
1993 &mut asset_context_caches,
1994 UnixNanos::default(),
1995 );
1996
1997 assert!(msgs.is_empty());
1998 assert!(asset_context_caches.open_interest.is_empty());
1999 }
2000
2001 #[rstest]
2002 fn handle_asset_context_suppresses_unchanged_open_interest() {
2003 let instrument = btc_perp();
2004 let mut instruments = AHashMap::new();
2005 instruments.insert(Ustr::from("BTC"), instrument);
2006
2007 let mut asset_context_subs = AHashMap::new();
2008 asset_context_subs.insert(
2009 Ustr::from("BTC"),
2010 AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2011 );
2012
2013 let mut asset_context_caches = AssetContextCaches::default();
2014
2015 let first = FeedHandler::handle_asset_context(
2016 &btc_active_asset_ctx(dec!(100000.0)),
2017 &instruments,
2018 &asset_context_subs,
2019 &mut asset_context_caches,
2020 UnixNanos::default(),
2021 );
2022 let second = FeedHandler::handle_asset_context(
2023 &btc_active_asset_ctx(dec!(100000.0)),
2024 &instruments,
2025 &asset_context_subs,
2026 &mut asset_context_caches,
2027 UnixNanos::default(),
2028 );
2029
2030 assert_eq!(first.len(), 1);
2031 assert!(second.is_empty());
2032 }
2033
2034 #[rstest]
2035 fn asset_context_caches_clear_removed_data_types() {
2036 let coin = Ustr::from("BTC");
2037 let mut caches = AssetContextCaches::default();
2038 caches.mark_price.insert(coin, dec!(98455.5));
2039 caches.index_price.insert(coin, dec!(98460.0));
2040 caches.funding_rate.insert(coin, dec!(0.0001));
2041 caches.open_interest.insert(coin, dec!(1500.0));
2042
2043 let previous_data_types = AHashSet::from_iter([
2044 AssetContextDataType::MarkPrice,
2045 AssetContextDataType::IndexPrice,
2046 AssetContextDataType::FundingRate,
2047 AssetContextDataType::OpenInterest,
2048 ]);
2049 let next_data_types = AHashSet::from_iter([
2050 AssetContextDataType::MarkPrice,
2051 AssetContextDataType::FundingRate,
2052 ]);
2053
2054 caches.clear_removed(coin, Some(&previous_data_types), &next_data_types);
2055
2056 assert_eq!(caches.mark_price.get(&coin).copied(), Some(dec!(98455.5)));
2057 assert!(caches.index_price.get(&coin).is_none());
2058 assert_eq!(caches.funding_rate.get(&coin).copied(), Some(dec!(0.0001)));
2059 assert!(caches.open_interest.get(&coin).is_none());
2060 }
2061}