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