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