1use std::{
17 str::FromStr,
18 sync::{
19 Arc, Mutex,
20 atomic::{AtomicBool, Ordering},
21 },
22 time::{Duration, Instant},
23};
24
25use ahash::{AHashMap, AHashSet};
26use anyhow::Context;
27use chrono::{DateTime, Utc};
28use nautilus_common::{
29 cache::InstrumentLookupError,
30 clients::DataClient,
31 live::{runner::get_data_event_sender, runtime::get_runtime, task::TaskHandles},
32 messages::{
33 DataEvent,
34 data::{
35 BarsResponse, BookResponse, CustomDataResponse, DataResponse, FundingRatesResponse,
36 InstrumentResponse, InstrumentsResponse, RequestBars, RequestBookSnapshot,
37 RequestCustomData, RequestFundingRates, RequestInstrument, RequestInstruments,
38 RequestTrades, SubscribeBars, SubscribeBookDeltas, SubscribeBookDepth10,
39 SubscribeCustomData, SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
40 SubscribeMarkPrices, SubscribeQuotes, SubscribeTrades, TradesResponse, UnsubscribeBars,
41 UnsubscribeBookDeltas, UnsubscribeBookDepth10, UnsubscribeCustomData,
42 UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeInstrument,
43 UnsubscribeInstruments, UnsubscribeMarkPrices, UnsubscribeQuotes, UnsubscribeTrades,
44 },
45 },
46};
47use nautilus_core::{
48 AtomicMap, MUTEX_POISONED, Params, UnixNanos,
49 datetime::{datetime_to_unix_nanos, unix_nanos_to_iso8601},
50 time::{AtomicTime, get_atomic_clock_realtime},
51};
52use nautilus_model::{
53 data::{
54 Bar, BarType, BookOrder, CustomData, Data, DataType, FundingRateUpdate,
55 OrderBookDeltas_API, TradeTick,
56 },
57 enums::{BarAggregation, BookType, OrderSide},
58 identifiers::{ClientId, InstrumentId, Venue},
59 instruments::{Instrument, InstrumentAny},
60 orderbook::OrderBook,
61 types::{Price, Quantity},
62};
63use rust_decimal::Decimal;
64use tokio::task::JoinHandle;
65use tokio_util::sync::CancellationToken;
66use ustr::Ustr;
67
68use crate::{
69 common::{
70 consts::HYPERLIQUID_VENUE,
71 credential::{Secrets, credential_env_vars},
72 parse::bar_type_to_interval,
73 },
74 config::HyperliquidDataClientConfig,
75 data_types::register_hyperliquid_custom_data,
76 http::{
77 client::HyperliquidHttpClient,
78 models::{HyperliquidCandle, HyperliquidFundingHistoryEntry, HyperliquidL2Book},
79 parse::parse_recent_trade,
80 },
81 websocket::{client::HyperliquidWebSocketClient, messages::NautilusWsMessage},
82};
83
84#[derive(Debug)]
85pub struct HyperliquidDataClient {
86 clock: &'static AtomicTime,
87 client_id: ClientId,
88 config: HyperliquidDataClientConfig,
89 http_client: HyperliquidHttpClient,
90 ws_client: HyperliquidWebSocketClient,
91 is_connected: AtomicBool,
92 cancellation_token: CancellationToken,
93 ws_stream_handle: Option<JoinHandle<()>>,
94 stream_health_handle: Option<JoinHandle<()>>,
95 pending_tasks: TaskHandles,
96 data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
97 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
98 coin_to_instrument_id: Arc<AtomicMap<Ustr, InstrumentId>>,
99 stream_health: Arc<Mutex<MarketDataStreamHealthMonitor>>,
100}
101
102impl HyperliquidDataClient {
103 pub fn new(client_id: ClientId, config: HyperliquidDataClientConfig) -> anyhow::Result<Self> {
109 let clock = get_atomic_clock_realtime();
110 let data_sender = get_data_event_sender();
111
112 let (pk_var, _) = credential_env_vars(config.environment);
115 let has_credentials = config.has_credentials() || std::env::var(pk_var).is_ok();
116
117 let mut http_client = if has_credentials {
118 let secrets =
119 Secrets::resolve(config.private_key.as_deref(), None, config.environment)?;
120 HyperliquidHttpClient::with_secrets(
121 &secrets,
122 config.http_timeout_secs,
123 config.proxy_url.clone(),
124 )?
125 } else {
126 HyperliquidHttpClient::new(
127 config.environment,
128 config.http_timeout_secs,
129 config.proxy_url.clone(),
130 )?
131 };
132
133 if let Some(url) = &config.base_url_http {
134 http_client.set_base_info_url(url.clone());
135 }
136
137 let ws_url = config.base_url_ws.clone();
138 let ws_client = HyperliquidWebSocketClient::new(
139 ws_url,
140 config.environment,
141 None,
142 config.transport_backend,
143 config.proxy_url.clone(),
144 );
145 let mut stream_health_monitor = MarketDataStreamHealthMonitor::new(
146 Duration::from_secs(config.stale_stream_receive_timeout_secs),
147 Duration::from_secs(config.stale_stream_warning_cooldown_secs),
148 );
149
150 if config.stale_stream_recovery_enabled {
151 if config.stale_stream_recovery_cooldown_secs > 0 {
152 stream_health_monitor = stream_health_monitor.with_recovery(
153 Duration::from_secs(config.stale_stream_recovery_cooldown_secs),
154 config.stale_stream_max_targeted_resubscribes,
155 );
156 } else {
157 log::warn!(
158 "Hyperliquid stale stream recovery disabled: \
159 stale_stream_recovery_cooldown_secs must be positive"
160 );
161 }
162 }
163
164 let stream_health = Arc::new(Mutex::new(stream_health_monitor));
165
166 Ok(Self {
167 clock,
168 client_id,
169 config,
170 http_client,
171 ws_client,
172 is_connected: AtomicBool::new(false),
173 cancellation_token: CancellationToken::new(),
174 ws_stream_handle: None,
175 stream_health_handle: None,
176 pending_tasks: TaskHandles::default(),
177 data_sender,
178 instruments: Arc::new(AtomicMap::new()),
179 coin_to_instrument_id: Arc::new(AtomicMap::new()),
180 stream_health,
181 })
182 }
183
184 fn spawn_task<F>(&self, description: &'static str, fut: F)
185 where
186 F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
187 {
188 let runtime = get_runtime();
189 let handle = runtime.spawn(async move {
190 if let Err(e) = fut.await {
191 log::warn!("{description} failed: {e:?}");
192 }
193 });
194
195 self.pending_tasks.push(handle);
196 }
197
198 fn abort_pending_tasks(&self) {
199 self.pending_tasks.abort_all();
200 }
201
202 fn abort_stream_health_monitor(&mut self) {
203 if let Some(handle) = self.stream_health_handle.take() {
204 handle.abort();
205 }
206 }
207
208 async fn stop_stream_health_monitor(&mut self) {
209 if let Some(handle) = self.stream_health_handle.take() {
210 match handle.await {
211 Ok(()) => {}
212 Err(e) if e.is_cancelled() => {}
213 Err(e) => log::warn!("Stream health monitor task failed: {e}"),
214 }
215 }
216 }
217
218 fn clear_stream_health(&self) {
219 self.stream_health.lock().expect(MUTEX_POISONED).clear();
220 }
221
222 fn register_stream_health(&self, channel: MarketDataChannel, instrument_id: InstrumentId) {
223 if !self.stream_health_monitor_enabled() {
224 return;
225 }
226
227 self.stream_health.lock().expect(MUTEX_POISONED).subscribe(
228 channel,
229 instrument_id,
230 Instant::now(),
231 );
232 }
233
234 fn remove_stream_health(&self, channel: MarketDataChannel, instrument_id: InstrumentId) {
235 self.stream_health
236 .lock()
237 .expect(MUTEX_POISONED)
238 .unsubscribe(channel, instrument_id);
239 }
240
241 fn stream_health_monitor_enabled(&self) -> bool {
242 self.config.stale_stream_receive_timeout_secs > 0
243 && self.config.stream_health_check_interval_secs > 0
244 }
245
246 fn spawn_stream_health_monitor(&mut self) {
247 if !self.stream_health_monitor_enabled() {
248 return;
249 }
250
251 if self
252 .stream_health_handle
253 .as_ref()
254 .is_some_and(|handle| !handle.is_finished())
255 {
256 return;
257 }
258
259 let stream_health = Arc::clone(&self.stream_health);
260 let cancellation_token = self.cancellation_token.clone();
261 let interval = Duration::from_secs(self.config.stream_health_check_interval_secs);
262 let clock = self.clock;
263 let ws_client = self.ws_client.clone();
264
265 let handle = get_runtime().spawn(async move {
266 log::debug!("Hyperliquid stream health monitor started");
267
268 loop {
269 tokio::select! {
270 () = cancellation_token.cancelled() => {
271 log::debug!("Hyperliquid stream health monitor cancelled");
272 break;
273 }
274 () = tokio::time::sleep(interval) => {
275 let events = stream_health
276 .lock()
277 .expect(MUTEX_POISONED)
278 .check_stale(Instant::now(), clock.get_time_ns());
279
280 handle_stream_health_events(&ws_client, &events).await;
281 }
282 }
283 }
284
285 log::debug!("Hyperliquid stream health monitor stopped");
286 });
287
288 self.stream_health_handle = Some(handle);
289 }
290
291 fn venue(&self) -> Venue {
292 *HYPERLIQUID_VENUE
293 }
294
295 fn custom_instrument_id(data_type: &DataType) -> anyhow::Result<Option<InstrumentId>> {
296 let Some(raw_instrument_id) = data_type
297 .metadata()
298 .and_then(|m| m.get("instrument_id"))
299 .and_then(|v| v.as_str())
300 .map(str::trim)
301 .filter(|value| !value.is_empty())
302 else {
303 return Ok(None);
304 };
305
306 let instrument_id = InstrumentId::from_str(raw_instrument_id)
307 .with_context(|| format!("invalid instrument_id metadata `{raw_instrument_id}`"))?;
308
309 Ok(Some(instrument_id))
310 }
311
312 async fn bootstrap_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
313 let instruments = self
314 .http_client
315 .request_instruments()
316 .await
317 .context("failed to fetch instruments during bootstrap")?;
318
319 self.instruments.rcu(|m| {
320 for instrument in &instruments {
321 m.insert(instrument.id(), instrument.clone());
322 }
323 });
324
325 self.coin_to_instrument_id.rcu(|m| {
326 for instrument in &instruments {
327 m.insert(instrument.raw_symbol().inner(), instrument.id());
328 }
329 });
330
331 for instrument in &instruments {
332 self.http_client.cache_instrument(instrument);
333 self.ws_client.cache_instrument(instrument.clone());
334 }
335
336 match self
337 .http_client
338 .build_all_dex_asset_ctxs_instrument_ids()
339 .await
340 {
341 Ok(mapping) => {
342 let mapping = mapping
343 .into_iter()
344 .map(|(dex, instrument_ids)| (Ustr::from(dex.as_str()), instrument_ids))
345 .collect();
346 self.ws_client
347 .cache_all_dex_asset_ctxs_instrument_ids(mapping);
348 }
349 Err(e) => {
350 log::warn!("Failed to build Hyperliquid allDexsAssetCtxs mapping: {e}");
351 }
352 }
353
354 log::debug!(
355 "Bootstrapped {} instruments with {} coin mappings",
356 self.instruments.len(),
357 self.coin_to_instrument_id.len()
358 );
359 Ok(instruments)
360 }
361
362 async fn spawn_ws(&mut self) -> anyhow::Result<()> {
363 let mut ws_client = self.ws_client.clone();
365
366 ws_client
367 .connect()
368 .await
369 .context("failed to connect to Hyperliquid WebSocket")?;
370
371 if let Some(handle) = ws_client.take_task_handle() {
373 self.ws_client.set_task_handle(handle);
374 }
375
376 let data_sender = self.data_sender.clone();
377 let cancellation_token = self.cancellation_token.clone();
378 let stream_health = Arc::clone(&self.stream_health);
379
380 let task = get_runtime().spawn(async move {
381 log::debug!("Hyperliquid WebSocket consumption loop started");
382
383 loop {
384 tokio::select! {
385 () = cancellation_token.cancelled() => {
386 log::debug!("WebSocket consumption loop cancelled");
387 break;
388 }
389 msg_opt = ws_client.next_event() => {
390 if let Some(msg) = msg_opt {
391 if let Some((channel, instrument_id, ts_event)) =
392 stream_health_update(&msg)
393 {
394 record_stream_receive(
395 &stream_health,
396 channel,
397 instrument_id,
398 ts_event,
399 );
400 }
401
402 match msg {
403 NautilusWsMessage::Trades(trades) => {
404 for trade in trades {
405 if let Err(e) = data_sender
406 .send(DataEvent::Data(Data::Trade(trade)))
407 {
408 log::error!("Failed to send trade tick: {e}");
409 }
410 }
411 }
412 NautilusWsMessage::Quote(quote) => {
413 if let Err(e) = data_sender
414 .send(DataEvent::Data(Data::Quote(quote)))
415 {
416 log::error!("Failed to send quote tick: {e}");
417 }
418 }
419 NautilusWsMessage::Deltas(deltas) => {
420 if let Err(e) = data_sender
421 .send(DataEvent::Data(Data::Deltas(
422 OrderBookDeltas_API::new(deltas),
423 )))
424 {
425 log::error!("Failed to send order book deltas: {e}");
426 }
427 }
428 NautilusWsMessage::Depth10(depth) => {
429 if let Err(e) =
430 data_sender.send(DataEvent::Data(Data::Depth10(depth)))
431 {
432 log::error!("Failed to send order book depth10: {e}");
433 }
434 }
435 NautilusWsMessage::Candle(bar) => {
436 if let Err(e) = data_sender
437 .send(DataEvent::Data(Data::Bar(bar)))
438 {
439 log::error!("Failed to send bar: {e}");
440 }
441 }
442 NautilusWsMessage::MarkPrice(update) => {
443 if let Err(e) = data_sender
444 .send(DataEvent::Data(Data::MarkPriceUpdate(update)))
445 {
446 log::error!("Failed to send mark price update: {e}");
447 }
448 }
449 NautilusWsMessage::IndexPrice(update) => {
450 if let Err(e) = data_sender
451 .send(DataEvent::Data(Data::IndexPriceUpdate(update)))
452 {
453 log::error!("Failed to send index price update: {e}");
454 }
455 }
456 NautilusWsMessage::FundingRate(update) => {
457 if let Err(e) = data_sender
458 .send(DataEvent::FundingRate(update))
459 {
460 log::error!("Failed to send funding rate update: {e}");
461 }
462 }
463 NautilusWsMessage::CustomData(data) => {
464 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
465 log::error!("Failed to send custom data: {e}");
466 }
467 }
468 NautilusWsMessage::Reconnected => {
469 log::info!("WebSocket reconnected");
470 }
471 NautilusWsMessage::Error(e) => {
472 log::warn!("WebSocket error: {e}");
473 }
474 NautilusWsMessage::ExecutionReports(_) => {
475 }
477 }
478 } else {
479 log::debug!("WebSocket next_event returned None, stream closed");
481 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
482 }
483 }
484 }
485 }
486
487 log::debug!("Hyperliquid WebSocket consumption loop finished");
488 });
489
490 self.ws_stream_handle = Some(task);
491 log::debug!("WebSocket consumption task spawned");
492
493 Ok(())
494 }
495}
496
497#[async_trait::async_trait(?Send)]
498impl DataClient for HyperliquidDataClient {
499 fn client_id(&self) -> ClientId {
500 self.client_id
501 }
502
503 fn venue(&self) -> Option<Venue> {
504 Some(self.venue())
505 }
506
507 fn start(&mut self) -> anyhow::Result<()> {
508 log::info!(
509 "Starting Hyperliquid data client: client_id={}, environment={:?}, proxy_url={:?}",
510 self.client_id,
511 self.config.environment,
512 self.config.proxy_url,
513 );
514 Ok(())
515 }
516
517 fn stop(&mut self) -> anyhow::Result<()> {
518 log::info!("Stopping Hyperliquid data client {}", self.client_id);
519 self.cancellation_token.cancel();
520 self.abort_stream_health_monitor();
521 self.clear_stream_health();
522 self.is_connected.store(false, Ordering::Relaxed);
523 Ok(())
524 }
525
526 fn reset(&mut self) -> anyhow::Result<()> {
527 log::debug!("Resetting Hyperliquid data client {}", self.client_id);
528 self.is_connected.store(false, Ordering::Relaxed);
529 self.cancellation_token.cancel();
533 self.abort_pending_tasks();
534 self.abort_stream_health_monitor();
535 self.clear_stream_health();
536
537 if let Some(handle) = self.ws_stream_handle.take() {
538 handle.abort();
539 }
540 self.instruments.store(AHashMap::new());
541 self.coin_to_instrument_id.store(AHashMap::new());
542 Ok(())
543 }
544
545 fn dispose(&mut self) -> anyhow::Result<()> {
546 log::debug!("Disposing Hyperliquid data client {}", self.client_id);
547 self.stop()
548 }
549
550 fn is_connected(&self) -> bool {
551 self.is_connected.load(Ordering::Acquire)
552 }
553
554 fn is_disconnected(&self) -> bool {
555 !self.is_connected()
556 }
557
558 async fn connect(&mut self) -> anyhow::Result<()> {
559 if self.is_connected() {
560 return Ok(());
561 }
562
563 if self.cancellation_token.is_cancelled() {
564 if let Err(e) = self.ws_client.disconnect().await {
569 log::debug!("Error tearing down Hyperliquid WebSocket after reset: {e}");
570 }
571 self.ws_client.reset_runtime_state();
572 self.abort_pending_tasks();
573 self.cancellation_token = CancellationToken::new();
574 }
575
576 register_hyperliquid_custom_data();
577
578 let instruments = self
579 .bootstrap_instruments()
580 .await
581 .context("failed to bootstrap instruments")?;
582
583 for instrument in instruments {
584 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
585 log::warn!("Failed to send instrument: {e}");
586 }
587 }
588
589 self.spawn_ws()
590 .await
591 .context("failed to spawn WebSocket client")?;
592 self.spawn_stream_health_monitor();
593
594 self.is_connected.store(true, Ordering::Relaxed);
595 log::info!("Connected: client_id={}", self.client_id);
596
597 Ok(())
598 }
599
600 async fn disconnect(&mut self) -> anyhow::Result<()> {
601 if !self.is_connected() {
602 return Ok(());
603 }
604
605 self.cancellation_token.cancel();
606
607 if let Some(handle) = self.ws_stream_handle.take()
608 && let Err(e) = handle.await
609 {
610 log::error!("Error waiting for WebSocket stream task: {e}");
611 }
612
613 self.abort_pending_tasks();
614
615 if let Err(e) = self.ws_client.disconnect().await {
616 log::warn!("Error disconnecting WebSocket client: {e}");
617 }
618
619 self.stop_stream_health_monitor().await;
620 self.clear_stream_health();
621 self.instruments.store(AHashMap::new());
622
623 self.is_connected.store(false, Ordering::Relaxed);
624 log::info!("Disconnected: client_id={}", self.client_id);
625
626 Ok(())
627 }
628
629 fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
630 let data_type = cmd.data_type.type_name();
631
632 if data_type == "HyperliquidAllMids" {
633 let ws = self.ws_client.clone();
634 let dex = cmd
635 .data_type
636 .metadata()
637 .as_ref()
638 .and_then(|m| m.get("dex"))
639 .and_then(|v| v.as_str())
640 .map(str::trim)
641 .filter(|value| !value.is_empty())
642 .map(ToString::to_string);
643
644 log::debug!("Subscribing to all mids (dex: {:?})", dex.as_deref());
645
646 self.spawn_task("subscribe_all_mids", async move {
647 ws.subscribe_all_mids_with_dex(dex.as_deref()).await
648 });
649
650 return Ok(());
651 }
652
653 if data_type == "HyperliquidAllDexsAssetCtxs" {
654 let ws = self.ws_client.clone();
655
656 self.spawn_task("subscribe_all_dexs_asset_ctxs", async move {
657 ws.subscribe_all_dexs_asset_ctxs().await
658 });
659
660 return Ok(());
661 }
662
663 if data_type == "HyperliquidOpenInterest" {
664 let ws = self.ws_client.clone();
665 let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
666 "HyperliquidOpenInterest subscriptions require metadata['instrument_id']",
667 )?;
668
669 self.spawn_task("subscribe_open_interest", async move {
670 ws.subscribe_open_interest(instrument_id).await
671 });
672
673 return Ok(());
674 }
675
676 if data_type == "HyperliquidPublicTrade" {
677 let ws = self.ws_client.clone();
678 let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
679 "HyperliquidPublicTrade subscriptions require metadata['instrument_id']",
680 )?;
681
682 self.spawn_task("subscribe_public_trades", async move {
683 ws.subscribe_public_trades(instrument_id).await
684 });
685
686 return Ok(());
687 }
688
689 log::warn!("Unsupported custom data subscription: {data_type}");
690 Ok(())
691 }
692
693 fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
694 let data_type = cmd.data_type.type_name();
695
696 if data_type == "HyperliquidAllMids" {
697 let ws = self.ws_client.clone();
698 let dex = cmd
699 .data_type
700 .metadata()
701 .as_ref()
702 .and_then(|m| m.get("dex"))
703 .and_then(|v| v.as_str())
704 .map(str::trim)
705 .filter(|value| !value.is_empty())
706 .map(ToString::to_string);
707
708 log::debug!("Unsubscribing from all mids (dex: {:?})", dex.as_deref());
709
710 self.spawn_task("unsubscribe_all_mids", async move {
711 ws.unsubscribe_all_mids_with_dex(dex.as_deref()).await
712 });
713
714 return Ok(());
715 }
716
717 if data_type == "HyperliquidAllDexsAssetCtxs" {
718 let ws = self.ws_client.clone();
719
720 self.spawn_task("unsubscribe_all_dexs_asset_ctxs", async move {
721 ws.unsubscribe_all_dexs_asset_ctxs().await
722 });
723
724 return Ok(());
725 }
726
727 if data_type == "HyperliquidOpenInterest" {
728 let ws = self.ws_client.clone();
729 let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
730 "HyperliquidOpenInterest unsubscriptions require metadata['instrument_id']",
731 )?;
732
733 self.spawn_task("unsubscribe_open_interest", async move {
734 ws.unsubscribe_open_interest(instrument_id).await
735 });
736
737 return Ok(());
738 }
739
740 if data_type == "HyperliquidPublicTrade" {
741 let ws = self.ws_client.clone();
742 let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
743 "HyperliquidPublicTrade unsubscriptions require metadata['instrument_id']",
744 )?;
745
746 self.spawn_task("unsubscribe_public_trades", async move {
747 ws.unsubscribe_public_trades(instrument_id).await
748 });
749
750 return Ok(());
751 }
752
753 log::warn!("Unsupported custom data unsubscription: {data_type}");
754 Ok(())
755 }
756
757 fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
758 let instruments = self.instruments.load();
759 if let Some(instrument) = instruments.get(&cmd.instrument_id) {
760 if let Err(e) = self
761 .data_sender
762 .send(DataEvent::Instrument(instrument.clone()))
763 {
764 log::error!("Failed to send instrument {}: {e}", cmd.instrument_id);
765 }
766 } else {
767 log::warn!("Instrument {} not found in cache", cmd.instrument_id);
768 }
769 Ok(())
770 }
771
772 fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
773 if subscription.book_type != BookType::L2_MBP {
774 anyhow::bail!("Hyperliquid only supports L2_MBP order book deltas");
775 }
776
777 let ws = self.ws_client.clone();
778 let instrument_id = subscription.instrument_id;
779 let (n_sig_figs, mantissa) = parse_book_precision_params(subscription.params.as_ref())?;
780 self.register_stream_health(MarketDataChannel::Deltas, instrument_id);
781
782 self.spawn_task("subscribe_book_deltas", async move {
783 ws.subscribe_book_with_options(instrument_id, n_sig_figs, mantissa)
784 .await
785 });
786
787 Ok(())
788 }
789
790 fn subscribe_book_depth10(&mut self, subscription: SubscribeBookDepth10) -> anyhow::Result<()> {
791 log::debug!(
792 "Subscribing to book depth10: {}",
793 subscription.instrument_id
794 );
795
796 if subscription.book_type != BookType::L2_MBP {
797 anyhow::bail!("Hyperliquid only supports L2_MBP order book depth10");
798 }
799
800 let ws = self.ws_client.clone();
801 let instrument_id = subscription.instrument_id;
802 let (n_sig_figs, mantissa) = parse_book_precision_params(subscription.params.as_ref())?;
803 self.register_stream_health(MarketDataChannel::Depth10, instrument_id);
804
805 self.spawn_task("subscribe_book_depth10", async move {
806 ws.subscribe_book_depth10_with_options(instrument_id, n_sig_figs, mantissa)
807 .await
808 });
809
810 Ok(())
811 }
812
813 fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
814 let ws = self.ws_client.clone();
815 let instrument_id = subscription.instrument_id;
816 self.register_stream_health(MarketDataChannel::Quote, instrument_id);
817
818 self.spawn_task("subscribe_quotes", async move {
819 ws.subscribe_quotes(instrument_id).await
820 });
821
822 Ok(())
823 }
824
825 fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
826 let ws = self.ws_client.clone();
827 let instrument_id = subscription.instrument_id;
828
829 self.spawn_task("subscribe_trades", async move {
830 ws.subscribe_trades(instrument_id).await
831 });
832
833 Ok(())
834 }
835
836 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
837 let ws = self.ws_client.clone();
838 let instrument_id = cmd.instrument_id;
839
840 self.spawn_task("subscribe_mark_prices", async move {
841 ws.subscribe_mark_prices(instrument_id).await
842 });
843
844 Ok(())
845 }
846
847 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
848 let ws = self.ws_client.clone();
849 let instrument_id = cmd.instrument_id;
850
851 self.spawn_task("subscribe_index_prices", async move {
852 ws.subscribe_index_prices(instrument_id).await
853 });
854
855 Ok(())
856 }
857
858 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
859 let ws = self.ws_client.clone();
860 let instrument_id = cmd.instrument_id;
861
862 self.spawn_task("subscribe_funding_rates", async move {
863 ws.subscribe_funding_rates(instrument_id).await
864 });
865
866 Ok(())
867 }
868
869 fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
870 let instrument_id = subscription.bar_type.instrument_id();
871 if !self.instruments.contains_key(&instrument_id) {
872 anyhow::bail!(InstrumentLookupError::not_found(instrument_id));
873 }
874
875 let bar_type = subscription.bar_type;
876 let ws = self.ws_client.clone();
877
878 self.spawn_task("subscribe_bars", async move {
879 ws.subscribe_bars(bar_type).await
880 });
881
882 Ok(())
883 }
884
885 fn unsubscribe_instrument(&mut self, _cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
886 Ok(())
889 }
890
891 fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
892 Ok(())
895 }
896
897 fn unsubscribe_book_deltas(
898 &mut self,
899 unsubscription: &UnsubscribeBookDeltas,
900 ) -> anyhow::Result<()> {
901 log::debug!(
902 "Unsubscribing from book deltas: {}",
903 unsubscription.instrument_id
904 );
905
906 let ws = self.ws_client.clone();
907 let instrument_id = unsubscription.instrument_id;
908 self.remove_stream_health(MarketDataChannel::Deltas, instrument_id);
909
910 self.spawn_task("unsubscribe_book_deltas", async move {
911 ws.unsubscribe_book(instrument_id).await
912 });
913
914 Ok(())
915 }
916
917 fn unsubscribe_book_depth10(
918 &mut self,
919 unsubscription: &UnsubscribeBookDepth10,
920 ) -> anyhow::Result<()> {
921 log::debug!(
922 "Unsubscribing from book depth10: {}",
923 unsubscription.instrument_id
924 );
925
926 let ws = self.ws_client.clone();
927 let instrument_id = unsubscription.instrument_id;
928 self.remove_stream_health(MarketDataChannel::Depth10, instrument_id);
929
930 self.spawn_task("unsubscribe_book_depth10", async move {
931 ws.unsubscribe_book_depth10(instrument_id).await
932 });
933
934 Ok(())
935 }
936
937 fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
938 log::debug!(
939 "Unsubscribing from quotes: {}",
940 unsubscription.instrument_id
941 );
942
943 let ws = self.ws_client.clone();
944 let instrument_id = unsubscription.instrument_id;
945 self.remove_stream_health(MarketDataChannel::Quote, instrument_id);
946
947 self.spawn_task("unsubscribe_quotes", async move {
948 ws.unsubscribe_quotes(instrument_id).await
949 });
950
951 Ok(())
952 }
953
954 fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
955 log::debug!(
956 "Unsubscribing from trades: {}",
957 unsubscription.instrument_id
958 );
959
960 let ws = self.ws_client.clone();
961 let instrument_id = unsubscription.instrument_id;
962
963 self.spawn_task("unsubscribe_trades", async move {
964 ws.unsubscribe_trades(instrument_id).await
965 });
966
967 Ok(())
968 }
969
970 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
971 let ws = self.ws_client.clone();
972 let instrument_id = cmd.instrument_id;
973
974 self.spawn_task("unsubscribe_mark_prices", async move {
975 ws.unsubscribe_mark_prices(instrument_id).await
976 });
977
978 Ok(())
979 }
980
981 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
982 let ws = self.ws_client.clone();
983 let instrument_id = cmd.instrument_id;
984
985 self.spawn_task("unsubscribe_index_prices", async move {
986 ws.unsubscribe_index_prices(instrument_id).await
987 });
988
989 Ok(())
990 }
991
992 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
993 let ws = self.ws_client.clone();
994 let instrument_id = cmd.instrument_id;
995
996 self.spawn_task("unsubscribe_funding_rates", async move {
997 ws.unsubscribe_funding_rates(instrument_id).await
998 });
999
1000 Ok(())
1001 }
1002
1003 fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
1004 let bar_type = unsubscription.bar_type;
1005 let ws = self.ws_client.clone();
1006
1007 self.spawn_task("unsubscribe_bars", async move {
1008 ws.unsubscribe_bars(bar_type).await
1009 });
1010
1011 Ok(())
1012 }
1013
1014 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1015 log::debug!("Requesting all instruments");
1016
1017 let http = self.http_client.clone();
1018 let sender = self.data_sender.clone();
1019 let instruments_cache = self.instruments.clone();
1020 let coin_map = self.coin_to_instrument_id.clone();
1021 let ws_instruments = self.ws_client.instruments_cache();
1022 let request_id = request.request_id;
1023 let client_id = request.client_id.unwrap_or(self.client_id);
1024 let venue = self.venue();
1025 let start_nanos = datetime_to_unix_nanos(request.start);
1026 let end_nanos = datetime_to_unix_nanos(request.end);
1027 let params = request.params;
1028 let clock = self.clock;
1029
1030 self.spawn_task("request_instruments", async move {
1031 let instruments = http
1032 .request_instruments()
1033 .await
1034 .context("failed to fetch instruments from Hyperliquid")?;
1035
1036 instruments_cache.rcu(|instruments_map| {
1037 coin_map.rcu(|coin_to_id| {
1038 for instrument in &instruments {
1039 let instrument_id = instrument.id();
1040 instruments_map.insert(instrument_id, instrument.clone());
1041 let coin = instrument.raw_symbol().inner();
1042 coin_to_id.insert(coin, instrument_id);
1043 ws_instruments.insert(coin, instrument.clone());
1044 }
1045 });
1046 });
1047
1048 let response = DataResponse::Instruments(InstrumentsResponse::new(
1049 request_id,
1050 client_id,
1051 venue,
1052 instruments,
1053 start_nanos,
1054 end_nanos,
1055 clock.get_time_ns(),
1056 params,
1057 ));
1058
1059 if let Err(e) = sender.send(DataEvent::Response(response)) {
1060 log::error!("Failed to send instruments response: {e}");
1061 }
1062 Ok(())
1063 });
1064
1065 Ok(())
1066 }
1067
1068 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1069 log::debug!("Requesting instrument: {}", request.instrument_id);
1070
1071 let http = self.http_client.clone();
1072 let sender = self.data_sender.clone();
1073 let instruments_cache = self.instruments.clone();
1074 let coin_map = self.coin_to_instrument_id.clone();
1075 let ws_instruments = self.ws_client.instruments_cache();
1076 let instrument_id = request.instrument_id;
1077 let request_id = request.request_id;
1078 let client_id = request.client_id.unwrap_or(self.client_id);
1079 let start_nanos = datetime_to_unix_nanos(request.start);
1080 let end_nanos = datetime_to_unix_nanos(request.end);
1081 let params = request.params;
1082 let clock = self.clock;
1083
1084 self.spawn_task("request_instrument", async move {
1085 let all_instruments = http
1086 .request_instruments()
1087 .await
1088 .context("failed to fetch instruments from Hyperliquid")?;
1089
1090 instruments_cache.rcu(|instruments_map| {
1091 coin_map.rcu(|coin_to_id| {
1092 for instrument in &all_instruments {
1093 let id = instrument.id();
1094 instruments_map.insert(id, instrument.clone());
1095 let coin = instrument.raw_symbol().inner();
1096 coin_to_id.insert(coin, id);
1097 ws_instruments.insert(coin, instrument.clone());
1098 }
1099 });
1100 });
1101
1102 if let Some(instrument) = all_instruments
1103 .into_iter()
1104 .find(|i| i.id() == instrument_id)
1105 {
1106 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1107 request_id,
1108 client_id,
1109 instrument.id(),
1110 instrument,
1111 start_nanos,
1112 end_nanos,
1113 clock.get_time_ns(),
1114 params,
1115 )));
1116
1117 if let Err(e) = sender.send(DataEvent::Response(response)) {
1118 log::error!("Failed to send instrument response: {e}");
1119 }
1120 } else {
1121 log::error!("Instrument not found: {instrument_id}");
1122 }
1123 Ok(())
1124 });
1125
1126 Ok(())
1127 }
1128
1129 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1130 log::debug!("Requesting bars for {}", request.bar_type);
1131
1132 let http = self.http_client.clone();
1133 let sender = self.data_sender.clone();
1134 let bar_type = request.bar_type;
1135 let start = request.start;
1136 let end = request.end;
1137 let limit = request.limit.map(|n| n.get() as u32);
1138 let request_id = request.request_id;
1139 let client_id = request.client_id.unwrap_or(self.client_id);
1140 let params = request.params;
1141 let clock = self.clock;
1142 let start_nanos = datetime_to_unix_nanos(start);
1143 let end_nanos = datetime_to_unix_nanos(end);
1144 let instruments = Arc::clone(&self.instruments);
1145
1146 self.spawn_task("request_bars", async move {
1147 let bars = request_bars_from_http(http, bar_type, start, end, limit, instruments)
1148 .await
1149 .context("bar request failed")?;
1150
1151 let response = DataResponse::Bars(BarsResponse::new(
1152 request_id,
1153 client_id,
1154 bar_type,
1155 bars,
1156 start_nanos,
1157 end_nanos,
1158 clock.get_time_ns(),
1159 params,
1160 ));
1161
1162 if let Err(e) = sender.send(DataEvent::Response(response)) {
1163 log::error!("Failed to send bars response: {e}");
1164 }
1165 Ok(())
1166 });
1167
1168 Ok(())
1169 }
1170
1171 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1172 let instrument_id = request.instrument_id;
1173 log::debug!("Requesting trades for {instrument_id}");
1174
1175 let instruments = self.instruments.load();
1176 let instrument = instruments
1177 .get(&instrument_id)
1178 .cloned()
1179 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1180
1181 let coin = instrument.raw_symbol().to_string();
1182 let http = self.http_client.clone();
1183 let sender = self.data_sender.clone();
1184 let client_id = request.client_id.unwrap_or(self.client_id);
1185 let request_id = request.request_id;
1186 let params = request.params;
1187 let clock = self.clock;
1188 let limit = request.limit.map(|n| n.get());
1189 let start_nanos = datetime_to_unix_nanos(request.start);
1190 let end_nanos = datetime_to_unix_nanos(request.end);
1191
1192 self.spawn_task("request_trades", async move {
1193 let raw_trades = match http.info_recent_trades(&coin).await {
1197 Ok(trades) => trades,
1198 Err(e) if e.is_unprocessable_entity() => {
1199 log::warn!(
1200 "Recent trades endpoint unavailable for {instrument_id} \
1201 (requires the Hyperliquid indexer); sending empty response"
1202 );
1203 Vec::new()
1204 }
1205 Err(e) => {
1206 return Err(anyhow::Error::new(e))
1207 .with_context(|| format!("trades request failed for {instrument_id}"));
1208 }
1209 };
1210
1211 let mut trades: Vec<TradeTick> = Vec::with_capacity(raw_trades.len());
1212 for raw in &raw_trades {
1213 match parse_recent_trade(raw, &instrument) {
1214 Ok(trade) => trades.push(trade),
1215 Err(e) => log::warn!("Skipping recent trade for {instrument_id}: {e}"),
1216 }
1217 }
1218 trades.sort_by_key(|trade| trade.ts_event);
1219
1220 let trades = filter_recent_trades(trades, start_nanos, end_nanos, limit, instrument_id);
1221
1222 log::debug!("Fetched {} trades for {instrument_id}", trades.len());
1223
1224 let response = DataResponse::Trades(TradesResponse::new(
1225 request_id,
1226 client_id,
1227 instrument_id,
1228 trades,
1229 start_nanos,
1230 end_nanos,
1231 clock.get_time_ns(),
1232 params,
1233 ));
1234
1235 if let Err(e) = sender.send(DataEvent::Response(response)) {
1236 log::error!("Failed to send trades response: {e}");
1237 }
1238 Ok(())
1239 });
1240
1241 Ok(())
1242 }
1243
1244 fn request_data(&self, request: RequestCustomData) -> anyhow::Result<()> {
1245 if request.data_type.type_name() != "HyperliquidPublicTrade" {
1246 log::warn!(
1247 "Unsupported custom data request: {}",
1248 request.data_type.type_name()
1249 );
1250 return Ok(());
1251 }
1252
1253 let instrument_id = Self::custom_instrument_id(&request.data_type)?
1254 .context("HyperliquidPublicTrade requests require metadata['instrument_id']")?;
1255 let data_type = DataType::new(
1256 request.data_type.type_name(),
1257 request.data_type.metadata().cloned(),
1258 Some(instrument_id.to_string()),
1259 );
1260 let http = self.http_client.clone();
1261 let sender = self.data_sender.clone();
1262 let request_id = request.request_id;
1263 let client_id = request.client_id;
1264 let params = request.params;
1265 let clock = self.clock;
1266 let limit = request.limit.map(|limit| limit.get());
1267 let start = request.start;
1268 let end = request.end;
1269 let start_nanos = datetime_to_unix_nanos(start);
1270 let end_nanos = datetime_to_unix_nanos(end);
1271 let venue = self.venue();
1272
1273 self.spawn_task("request_public_trades", async move {
1274 let trades = http
1275 .request_public_trades(instrument_id, start, end, limit)
1276 .await
1277 .map_err(anyhow::Error::new)
1278 .with_context(|| format!("public trades request failed for {instrument_id}"))?;
1279 let data: Vec<CustomData> = trades
1280 .into_iter()
1281 .map(|trade| CustomData::new(Arc::new(trade), data_type.clone()))
1282 .collect();
1283
1284 let response = DataResponse::Data(CustomDataResponse::new(
1285 request_id,
1286 client_id,
1287 Some(venue),
1288 data_type,
1289 data,
1290 start_nanos,
1291 end_nanos,
1292 clock.get_time_ns(),
1293 params,
1294 ));
1295
1296 if let Err(e) = sender.send(DataEvent::Response(response)) {
1297 log::error!("Failed to send public trades response: {e}");
1298 }
1299 Ok(())
1300 });
1301
1302 Ok(())
1303 }
1304
1305 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1306 let instrument_id = request.instrument_id;
1307 log::debug!("Requesting funding rates for {instrument_id}");
1308
1309 let instruments = self.instruments.load();
1310 let instrument = instruments
1311 .get(&instrument_id)
1312 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1313
1314 if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
1315 anyhow::bail!("Funding rates are only available for perpetual instruments");
1316 }
1317
1318 let coin = instrument.raw_symbol().to_string();
1319 let http = self.http_client.clone();
1320 let sender = self.data_sender.clone();
1321 let client_id = request.client_id.unwrap_or(self.client_id);
1322 let request_id = request.request_id;
1323 let params = request.params;
1324 let clock = self.clock;
1325 let limit = request.limit.map(|n| n.get());
1326 let start_dt = request.start;
1327 let end_dt = request.end;
1328 let start_nanos = datetime_to_unix_nanos(start_dt);
1329 let end_nanos = datetime_to_unix_nanos(end_dt);
1330
1331 let now_ms = Utc::now().timestamp_millis() as u64;
1332
1333 let default_lookback_ms: u64 = 7 * 86_400_000;
1335 let start_ms = match start_dt {
1336 Some(dt) => dt.timestamp_millis().max(0) as u64,
1337 None => now_ms.saturating_sub(default_lookback_ms),
1338 };
1339 let end_ms = end_dt.map(|dt| dt.timestamp_millis().max(0) as u64);
1340
1341 self.spawn_task("request_funding_rates", async move {
1342 let entries = http
1343 .info_funding_history(&coin, start_ms, end_ms)
1344 .await
1345 .with_context(|| format!("funding rates request failed for {instrument_id}"))?;
1346
1347 let mut funding_rates: Vec<FundingRateUpdate> = entries
1348 .iter()
1349 .map(|entry| funding_entry_to_update(entry, instrument_id))
1350 .collect();
1351
1352 if let Some(limit) = limit
1353 && funding_rates.len() > limit
1354 {
1355 funding_rates.truncate(limit);
1356 }
1357
1358 log::debug!(
1359 "Fetched {} funding rates for {instrument_id}",
1360 funding_rates.len(),
1361 );
1362
1363 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1364 request_id,
1365 client_id,
1366 instrument_id,
1367 funding_rates,
1368 start_nanos,
1369 end_nanos,
1370 clock.get_time_ns(),
1371 params,
1372 ));
1373
1374 if let Err(e) = sender.send(DataEvent::Response(response)) {
1375 log::error!("Failed to send funding rates response: {e}");
1376 }
1377 Ok(())
1378 });
1379
1380 Ok(())
1381 }
1382
1383 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1384 let instrument_id = request.instrument_id;
1385 let instruments = self.instruments.load();
1386 let instrument = instruments
1387 .get(&instrument_id)
1388 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1389
1390 let raw_symbol = instrument.raw_symbol().to_string();
1391 let price_precision = instrument.price_precision();
1392 let size_precision = instrument.size_precision();
1393 let depth = request.depth.map(|d| d.get());
1394
1395 let http = self.http_client.clone();
1396 let sender = self.data_sender.clone();
1397 let client_id = request.client_id.unwrap_or(self.client_id);
1398 let request_id = request.request_id;
1399 let params = request.params;
1400 let clock = self.clock;
1401
1402 self.spawn_task("request_book_snapshot", async move {
1403 let l2_book = http
1404 .info_l2_book(&raw_symbol)
1405 .await
1406 .with_context(|| format!("book snapshot request failed for {instrument_id}"))?;
1407
1408 let book = parse_l2_book_snapshot(
1409 &l2_book,
1410 instrument_id,
1411 price_precision,
1412 size_precision,
1413 depth,
1414 );
1415
1416 let response = DataResponse::Book(BookResponse::new(
1417 request_id,
1418 client_id,
1419 instrument_id,
1420 book,
1421 None,
1422 None,
1423 clock.get_time_ns(),
1424 params,
1425 ));
1426
1427 if let Err(e) = sender.send(DataEvent::Response(response)) {
1428 log::error!("Failed to send book snapshot response: {e}");
1429 }
1430 Ok(())
1431 });
1432
1433 Ok(())
1434 }
1435}
1436
1437#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1438enum MarketDataChannel {
1439 Deltas,
1440 Depth10,
1441 Quote,
1442}
1443
1444impl MarketDataChannel {
1445 const fn as_str(self) -> &'static str {
1446 match self {
1447 Self::Deltas => "deltas",
1448 Self::Depth10 => "depth10",
1449 Self::Quote => "quote",
1450 }
1451 }
1452}
1453
1454type MarketDataStreamKey = (MarketDataChannel, InstrumentId);
1455
1456#[derive(Debug, Clone)]
1457struct MarketDataStreamHealth {
1458 last_receive_at: Instant,
1459 last_venue_ts_event: Option<UnixNanos>,
1460 consecutive_stale_count: u32,
1461 last_warning_at: Option<Instant>,
1462 last_recovery_at: Option<Instant>,
1463 resubscribe_attempts: u32,
1464}
1465
1466impl MarketDataStreamHealth {
1467 fn new(receive_at: Instant) -> Self {
1468 Self {
1469 last_receive_at: receive_at,
1470 last_venue_ts_event: None,
1471 consecutive_stale_count: 0,
1472 last_warning_at: None,
1473 last_recovery_at: None,
1474 resubscribe_attempts: 0,
1475 }
1476 }
1477
1478 fn record_receive(&mut self, receive_at: Instant, venue_ts_event: UnixNanos) {
1479 self.last_receive_at = receive_at;
1480 self.last_venue_ts_event = Some(venue_ts_event);
1481 self.consecutive_stale_count = 0;
1482 self.last_warning_at = None;
1483 self.last_recovery_at = None;
1484 self.resubscribe_attempts = 0;
1485 }
1486}
1487
1488#[derive(Debug, Clone, Copy)]
1489struct StreamRecoveryConfig {
1490 cooldown: Duration,
1491 max_targeted_resubscribes: u32,
1492}
1493
1494#[derive(Debug)]
1495struct MarketDataStreamHealthMonitor {
1496 stale_receive_threshold: Duration,
1497 warning_cooldown: Duration,
1498 recovery: Option<StreamRecoveryConfig>,
1499 streams: AHashMap<MarketDataStreamKey, MarketDataStreamHealth>,
1500}
1501
1502impl MarketDataStreamHealthMonitor {
1503 fn new(stale_receive_threshold: Duration, warning_cooldown: Duration) -> Self {
1504 Self {
1505 stale_receive_threshold,
1506 warning_cooldown,
1507 recovery: None,
1508 streams: AHashMap::new(),
1509 }
1510 }
1511
1512 fn with_recovery(mut self, cooldown: Duration, max_targeted_resubscribes: u32) -> Self {
1513 self.recovery = Some(StreamRecoveryConfig {
1514 cooldown,
1515 max_targeted_resubscribes,
1516 });
1517 self
1518 }
1519
1520 fn subscribe(
1521 &mut self,
1522 channel: MarketDataChannel,
1523 instrument_id: InstrumentId,
1524 receive_at: Instant,
1525 ) {
1526 self.streams.insert(
1527 (channel, instrument_id),
1528 MarketDataStreamHealth::new(receive_at),
1529 );
1530 }
1531
1532 fn unsubscribe(&mut self, channel: MarketDataChannel, instrument_id: InstrumentId) {
1533 self.streams.remove(&(channel, instrument_id));
1534 }
1535
1536 fn clear(&mut self) {
1537 self.streams.clear();
1538 }
1539
1540 fn record_receive(
1541 &mut self,
1542 channel: MarketDataChannel,
1543 instrument_id: InstrumentId,
1544 receive_at: Instant,
1545 venue_ts_event: UnixNanos,
1546 ) {
1547 if let Some(stream) = self.streams.get_mut(&(channel, instrument_id)) {
1548 stream.record_receive(receive_at, venue_ts_event);
1549 }
1550 }
1551
1552 fn check_stale(
1553 &mut self,
1554 now: Instant,
1555 wall_clock_now: UnixNanos,
1556 ) -> Vec<MarketDataStaleEvent> {
1557 let fresh_quote_instruments: AHashSet<InstrumentId> = self
1559 .streams
1560 .iter()
1561 .filter(|((channel, _), stream)| {
1562 *channel == MarketDataChannel::Quote
1563 && now.saturating_duration_since(stream.last_receive_at)
1564 < self.stale_receive_threshold
1565 })
1566 .map(|((_, instrument_id), _)| *instrument_id)
1567 .collect();
1568
1569 let mut events = Vec::new();
1570
1571 for ((channel, instrument_id), stream) in &mut self.streams {
1572 let receive_age = now.saturating_duration_since(stream.last_receive_at);
1573 if receive_age < self.stale_receive_threshold {
1574 stream.consecutive_stale_count = 0;
1575 continue;
1576 }
1577
1578 stream.consecutive_stale_count = stream.consecutive_stale_count.saturating_add(1);
1579
1580 let quote_is_fresh = matches!(
1581 channel,
1582 MarketDataChannel::Deltas | MarketDataChannel::Depth10
1583 ) && fresh_quote_instruments.contains(instrument_id);
1584
1585 let venue_age = stream.last_venue_ts_event.map(|ts_event| {
1586 Duration::from_nanos(wall_clock_now.as_u64().saturating_sub(ts_event.as_u64()))
1587 });
1588
1589 if let Some(recovery) = self.recovery {
1590 let stale_since = stream.last_receive_at + self.stale_receive_threshold;
1592 let anchor = stream.last_recovery_at.unwrap_or(stale_since);
1593
1594 if stream.last_warning_at.is_some()
1595 && now.saturating_duration_since(anchor) >= recovery.cooldown
1596 {
1597 let action = if stream.resubscribe_attempts < recovery.max_targeted_resubscribes
1598 {
1599 stream.resubscribe_attempts += 1;
1600 StaleStreamAction::Resubscribe
1601 } else {
1602 stream.resubscribe_attempts = 0;
1604 StaleStreamAction::Reconnect
1605 };
1606 stream.last_recovery_at = Some(now);
1607 stream.last_warning_at = Some(now);
1608
1609 events.push(MarketDataStaleEvent {
1610 channel: *channel,
1611 instrument_id: *instrument_id,
1612 receive_age,
1613 venue_age,
1614 stale_count: stream.consecutive_stale_count,
1615 action,
1616 cooldown: recovery.cooldown,
1617 quote_is_fresh,
1618 });
1619 continue;
1620 }
1621 }
1622
1623 let should_warn = stream.last_warning_at.is_none_or(|last_warning_at| {
1624 now.saturating_duration_since(last_warning_at) >= self.warning_cooldown
1625 });
1626
1627 if !should_warn {
1628 continue;
1629 }
1630
1631 stream.last_warning_at = Some(now);
1632 events.push(MarketDataStaleEvent {
1633 channel: *channel,
1634 instrument_id: *instrument_id,
1635 receive_age,
1636 venue_age,
1637 stale_count: stream.consecutive_stale_count,
1638 action: StaleStreamAction::Warn,
1639 cooldown: self.warning_cooldown,
1640 quote_is_fresh,
1641 });
1642 }
1643
1644 events
1645 }
1646}
1647
1648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1649enum StaleStreamAction {
1650 Warn,
1651 Resubscribe,
1652 Reconnect,
1653}
1654
1655impl StaleStreamAction {
1656 const fn as_str(self) -> &'static str {
1657 match self {
1658 Self::Warn => "warn",
1659 Self::Resubscribe => "resubscribe",
1660 Self::Reconnect => "reconnect",
1661 }
1662 }
1663}
1664
1665#[derive(Debug, Clone, PartialEq, Eq)]
1666struct MarketDataStaleEvent {
1667 channel: MarketDataChannel,
1668 instrument_id: InstrumentId,
1669 receive_age: Duration,
1670 venue_age: Option<Duration>,
1671 stale_count: u32,
1672 action: StaleStreamAction,
1673 cooldown: Duration,
1674 quote_is_fresh: bool,
1675}
1676
1677fn stream_health_update(
1678 msg: &NautilusWsMessage,
1679) -> Option<(MarketDataChannel, InstrumentId, UnixNanos)> {
1680 match msg {
1681 NautilusWsMessage::Quote(quote) => Some((
1682 MarketDataChannel::Quote,
1683 quote.instrument_id,
1684 quote.ts_event,
1685 )),
1686 NautilusWsMessage::Deltas(deltas) => Some((
1687 MarketDataChannel::Deltas,
1688 deltas.instrument_id,
1689 deltas.ts_event,
1690 )),
1691 NautilusWsMessage::Depth10(depth) => Some((
1692 MarketDataChannel::Depth10,
1693 depth.instrument_id,
1694 depth.ts_event,
1695 )),
1696 _ => None,
1697 }
1698}
1699
1700fn record_stream_receive(
1701 stream_health: &Arc<Mutex<MarketDataStreamHealthMonitor>>,
1702 channel: MarketDataChannel,
1703 instrument_id: InstrumentId,
1704 venue_ts_event: UnixNanos,
1705) {
1706 stream_health.lock().expect(MUTEX_POISONED).record_receive(
1707 channel,
1708 instrument_id,
1709 Instant::now(),
1710 venue_ts_event,
1711 );
1712}
1713
1714fn log_stream_health_event(event: &MarketDataStaleEvent) {
1715 let venue_age_ms = event
1716 .venue_age
1717 .map_or_else(|| "n/a".to_string(), |age| age.as_millis().to_string());
1718 let prefix = if event.quote_is_fresh {
1719 "Hyperliquid book stream stale while bbo advances"
1720 } else {
1721 "Hyperliquid market data stream stale"
1722 };
1723
1724 log::warn!(
1725 "{prefix}: channel={}, instrument_id={}, receive_age_ms={}, venue_age_ms={}, \
1726 stale_count={}, action={}, cooldown_secs={}",
1727 event.channel.as_str(),
1728 event.instrument_id,
1729 event.receive_age.as_millis(),
1730 venue_age_ms,
1731 event.stale_count,
1732 event.action.as_str(),
1733 event.cooldown.as_secs(),
1734 );
1735}
1736
1737async fn handle_stream_health_events(
1738 ws_client: &HyperliquidWebSocketClient,
1739 events: &[MarketDataStaleEvent],
1740) {
1741 let mut resubscribed_books: AHashSet<InstrumentId> = AHashSet::new();
1743 let mut reconnect_requested = false;
1744
1745 for event in events {
1746 log_stream_health_event(event);
1747
1748 match event.action {
1749 StaleStreamAction::Warn => {}
1750 StaleStreamAction::Resubscribe => match event.channel {
1751 MarketDataChannel::Deltas | MarketDataChannel::Depth10 => {
1752 if resubscribed_books.insert(event.instrument_id)
1753 && let Err(e) = ws_client.resubscribe_book(event.instrument_id).await
1754 {
1755 log::warn!(
1756 "Failed targeted l2Book resubscribe for {}: {e}",
1757 event.instrument_id,
1758 );
1759 }
1760 }
1761 MarketDataChannel::Quote => {
1762 if let Err(e) = ws_client.resubscribe_quotes(event.instrument_id).await {
1763 log::warn!(
1764 "Failed targeted bbo resubscribe for {}: {e}",
1765 event.instrument_id,
1766 );
1767 }
1768 }
1769 },
1770 StaleStreamAction::Reconnect => reconnect_requested = true,
1771 }
1772 }
1773
1774 if reconnect_requested {
1775 if ws_client.request_reconnect() {
1776 log::warn!("Requested full WebSocket reconnect after failed targeted stream recovery");
1777 } else {
1778 log::debug!("Skipping reconnect request: connection not active");
1779 }
1780 }
1781}
1782
1783fn filter_recent_trades(
1790 trades: Vec<TradeTick>,
1791 start: Option<UnixNanos>,
1792 end: Option<UnixNanos>,
1793 limit: Option<usize>,
1794 instrument_id: InstrumentId,
1795) -> Vec<TradeTick> {
1796 let Some(floor) = trades.first().map(|trade| trade.ts_event) else {
1797 return Vec::new();
1798 };
1799
1800 if let Some(end) = end
1801 && end < floor
1802 {
1803 log::warn!(
1804 "Recent trades for {instrument_id} are entirely older than the requested window; \
1805 snapshot only covers back to {}",
1806 unix_nanos_to_iso8601(floor),
1807 );
1808 return Vec::new();
1809 }
1810
1811 if let Some(start) = start
1812 && start < floor
1813 {
1814 log::warn!(
1815 "Recent trades for {instrument_id} only cover back to {}; \
1816 the requested start is earlier and cannot be served",
1817 unix_nanos_to_iso8601(floor),
1818 );
1819 }
1820
1821 let mut filtered: Vec<TradeTick> = trades
1822 .into_iter()
1823 .filter(|trade| start.is_none_or(|s| trade.ts_event >= s))
1824 .filter(|trade| end.is_none_or(|e| trade.ts_event <= e))
1825 .collect();
1826
1827 if let Some(limit) = limit
1828 && filtered.len() > limit
1829 {
1830 filtered.drain(0..filtered.len() - limit);
1832 }
1833
1834 filtered
1835}
1836
1837pub(crate) fn parse_l2_book_snapshot(
1841 l2_book: &HyperliquidL2Book,
1842 instrument_id: InstrumentId,
1843 price_precision: u8,
1844 size_precision: u8,
1845 depth: Option<usize>,
1846) -> OrderBook {
1847 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1848 let ts_event = UnixNanos::from(l2_book.time * 1_000_000);
1849
1850 let all_bids = l2_book
1851 .levels
1852 .first()
1853 .map_or([].as_slice(), |v| v.as_slice());
1854 let all_asks = l2_book
1855 .levels
1856 .get(1)
1857 .map_or([].as_slice(), |v| v.as_slice());
1858
1859 let bids = match depth {
1860 Some(d) if d < all_bids.len() => &all_bids[..d],
1861 _ => all_bids,
1862 };
1863 let asks = match depth {
1864 Some(d) if d < all_asks.len() => &all_asks[..d],
1865 _ => all_asks,
1866 };
1867
1868 for (i, level) in bids.iter().enumerate() {
1869 if level.sz <= Decimal::ZERO {
1870 continue;
1871 }
1872 let Ok(price) = Price::from_decimal_dp(level.px, price_precision) else {
1873 continue;
1874 };
1875 let Ok(size) = Quantity::from_decimal_dp(level.sz, size_precision) else {
1876 continue;
1877 };
1878
1879 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1880 book.add(order, 0, i as u64, ts_event);
1881 }
1882
1883 let bids_len = bids.len();
1884
1885 for (i, level) in asks.iter().enumerate() {
1886 if level.sz <= Decimal::ZERO {
1887 continue;
1888 }
1889 let Ok(price) = Price::from_decimal_dp(level.px, price_precision) else {
1890 continue;
1891 };
1892 let Ok(size) = Quantity::from_decimal_dp(level.sz, size_precision) else {
1893 continue;
1894 };
1895
1896 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
1897 book.add(order, 0, (bids_len + i) as u64, ts_event);
1898 }
1899
1900 log::debug!(
1901 "Built order book for {instrument_id} with {} bids and {} asks",
1902 bids.len(),
1903 asks.len(),
1904 );
1905
1906 book
1907}
1908
1909pub(crate) fn parse_book_precision_params(
1912 params: Option<&Params>,
1913) -> anyhow::Result<(Option<u32>, Option<u32>)> {
1914 let Some(params) = params else {
1915 return Ok((None, None));
1916 };
1917
1918 let read_u32 = |key: &str| -> anyhow::Result<Option<u32>> {
1919 match params.get(key) {
1920 None => Ok(None),
1921 Some(v) => v
1922 .as_u64()
1923 .and_then(|n| u32::try_from(n).ok())
1924 .ok_or_else(|| anyhow::anyhow!("`{key}` must be a positive u32"))
1925 .map(Some),
1926 }
1927 };
1928
1929 Ok((read_u32("n_sig_figs")?, read_u32("mantissa")?))
1930}
1931
1932pub(crate) fn funding_entry_to_update(
1935 entry: &HyperliquidFundingHistoryEntry,
1936 instrument_id: InstrumentId,
1937) -> FundingRateUpdate {
1938 let rate = entry.funding_rate;
1939 let ts = UnixNanos::from(entry.time * 1_000_000);
1940 FundingRateUpdate::new(instrument_id, rate, Some(60), None, ts, ts)
1941}
1942
1943pub(crate) fn candle_to_bar(
1944 candle: &HyperliquidCandle,
1945 bar_type: BarType,
1946 price_precision: u8,
1947 size_precision: u8,
1948) -> anyhow::Result<Bar> {
1949 let ts_init = UnixNanos::from(candle.timestamp * 1_000_000);
1950 let ts_event = ts_init;
1951
1952 let open = Price::from_decimal_dp(candle.open, price_precision)
1953 .map_err(|e| anyhow::anyhow!("invalid open price: {e}"))?;
1954 let high = Price::from_decimal_dp(candle.high, price_precision)
1955 .map_err(|e| anyhow::anyhow!("invalid high price: {e}"))?;
1956 let low = Price::from_decimal_dp(candle.low, price_precision)
1957 .map_err(|e| anyhow::anyhow!("invalid low price: {e}"))?;
1958 let close = Price::from_decimal_dp(candle.close, price_precision)
1959 .map_err(|e| anyhow::anyhow!("invalid close price: {e}"))?;
1960 let volume = Quantity::from_decimal_dp(candle.volume, size_precision)
1961 .map_err(|e| anyhow::anyhow!("invalid volume: {e}"))?;
1962
1963 Ok(Bar::new(
1964 bar_type, open, high, low, close, volume, ts_event, ts_init,
1965 ))
1966}
1967
1968async fn request_bars_from_http(
1970 http_client: HyperliquidHttpClient,
1971 bar_type: BarType,
1972 start: Option<DateTime<Utc>>,
1973 end: Option<DateTime<Utc>>,
1974 limit: Option<u32>,
1975 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
1976) -> anyhow::Result<Vec<Bar>> {
1977 let instrument_id = bar_type.instrument_id();
1979 let instrument = instruments
1980 .load()
1981 .get(&instrument_id)
1982 .cloned()
1983 .context("instrument not found in cache")?;
1984
1985 let price_precision = instrument.price_precision();
1986 let size_precision = instrument.size_precision();
1987 let raw_symbol = instrument.raw_symbol();
1988 let coin = raw_symbol.as_str();
1989
1990 let interval = bar_type_to_interval(&bar_type)?;
1991
1992 let now = Utc::now();
1994 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1995 let start_time = if let Some(start) = start {
1996 start.timestamp_millis() as u64
1997 } else {
1998 let spec = bar_type.spec();
2000 let step_ms = match spec.aggregation {
2001 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2002 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2003 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2004 _ => 60_000,
2005 };
2006 end_time.saturating_sub(1000 * step_ms)
2007 };
2008
2009 let candles = http_client
2010 .info_candle_snapshot(coin, interval, start_time, end_time)
2011 .await
2012 .context("failed to fetch candle snapshot from Hyperliquid")?;
2013
2014 let mut bars: Vec<Bar> = candles
2015 .iter()
2016 .filter_map(|candle| {
2017 candle_to_bar(candle, bar_type, price_precision, size_precision)
2018 .map_err(|e| {
2019 log::warn!("Failed to convert candle to bar: {e}");
2020 e
2021 })
2022 .ok()
2023 })
2024 .collect();
2025
2026 if let Some(limit) = limit
2027 && bars.len() > limit as usize
2028 {
2029 bars = bars.into_iter().take(limit as usize).collect();
2030 }
2031
2032 log::debug!("Fetched {} bars for {}", bars.len(), bar_type);
2033 Ok(bars)
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038 use nautilus_common::live::runner::set_data_event_sender;
2039 use nautilus_model::{
2040 data::{
2041 QuoteTick,
2042 stubs::{stub_deltas, stub_depth10},
2043 },
2044 enums::AggressorSide,
2045 identifiers::TradeId,
2046 };
2047 use rstest::rstest;
2048 use rust_decimal_macros::dec;
2049 use ustr::Ustr;
2050
2051 use super::*;
2052 use crate::common::testing::load_test_data;
2053
2054 fn btc_perp_id() -> InstrumentId {
2055 InstrumentId::from("BTC-PERP.HYPERLIQUID")
2056 }
2057
2058 #[rstest]
2059 fn test_stream_health_monitor_fresh_stream_does_not_warn() {
2060 let mut monitor =
2061 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2062 let instrument_id = btc_perp_id();
2063 let start = Instant::now();
2064
2065 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2066
2067 let warnings = monitor.check_stale(
2068 start + Duration::from_secs(4),
2069 UnixNanos::from(4_000_000_000),
2070 );
2071 assert!(warnings.is_empty());
2072 }
2073
2074 #[rstest]
2075 fn test_stream_health_monitor_warns_once_after_threshold() {
2076 let mut monitor =
2077 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2078 let instrument_id = btc_perp_id();
2079 let start = Instant::now();
2080
2081 monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
2082 monitor.record_receive(
2083 MarketDataChannel::Quote,
2084 instrument_id,
2085 start + Duration::from_secs(1),
2086 UnixNanos::from(1_000_000_000),
2087 );
2088
2089 let warnings = monitor.check_stale(
2090 start + Duration::from_secs(7),
2091 UnixNanos::from(9_000_000_000),
2092 );
2093
2094 assert_eq!(
2095 warnings,
2096 vec![MarketDataStaleEvent {
2097 channel: MarketDataChannel::Quote,
2098 instrument_id,
2099 receive_age: Duration::from_secs(6),
2100 venue_age: Some(Duration::from_secs(8)),
2101 stale_count: 1,
2102 action: StaleStreamAction::Warn,
2103 cooldown: Duration::from_secs(30),
2104 quote_is_fresh: false,
2105 }]
2106 );
2107 }
2108
2109 #[rstest]
2110 fn test_stream_health_monitor_warns_at_receive_threshold() {
2111 let mut monitor =
2112 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2113 let instrument_id = btc_perp_id();
2114 let start = Instant::now();
2115
2116 monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
2117
2118 let warnings = monitor.check_stale(
2119 start + Duration::from_secs(5),
2120 UnixNanos::from(5_000_000_000),
2121 );
2122
2123 assert_eq!(warnings.len(), 1);
2124 assert_eq!(warnings[0].receive_age, Duration::from_secs(5));
2125 assert_eq!(warnings[0].stale_count, 1);
2126 }
2127
2128 #[rstest]
2129 fn test_stream_health_monitor_new_update_resets_age_and_stale_count() {
2130 let mut monitor =
2131 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2132 let instrument_id = btc_perp_id();
2133 let start = Instant::now();
2134
2135 monitor.subscribe(MarketDataChannel::Depth10, instrument_id, start);
2136 assert_eq!(
2137 monitor
2138 .check_stale(
2139 start + Duration::from_secs(6),
2140 UnixNanos::from(6_000_000_000),
2141 )
2142 .len(),
2143 1,
2144 );
2145
2146 monitor.record_receive(
2147 MarketDataChannel::Depth10,
2148 instrument_id,
2149 start + Duration::from_secs(7),
2150 UnixNanos::from(7_000_000_000),
2151 );
2152
2153 assert!(
2154 monitor
2155 .check_stale(
2156 start + Duration::from_secs(11),
2157 UnixNanos::from(11_000_000_000),
2158 )
2159 .is_empty()
2160 );
2161
2162 let warnings = monitor.check_stale(
2163 start + Duration::from_secs(13),
2164 UnixNanos::from(13_000_000_000),
2165 );
2166 assert_eq!(warnings.len(), 1);
2167 assert_eq!(warnings[0].stale_count, 1);
2168 assert_eq!(warnings[0].receive_age, Duration::from_secs(6));
2169 }
2170
2171 #[rstest]
2172 fn test_stream_health_monitor_unsubscribe_removes_stream() {
2173 let mut monitor =
2174 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2175 let instrument_id = btc_perp_id();
2176 let start = Instant::now();
2177
2178 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2179 monitor.unsubscribe(MarketDataChannel::Deltas, instrument_id);
2180
2181 let warnings = monitor.check_stale(
2182 start + Duration::from_secs(6),
2183 UnixNanos::from(6_000_000_000),
2184 );
2185
2186 assert!(warnings.is_empty());
2187 }
2188
2189 #[rstest]
2190 #[case(0, 15)]
2191 #[case(120, 0)]
2192 fn test_data_client_stream_health_config_zero_disables_monitor(
2193 #[case] stale_receive_timeout_secs: u64,
2194 #[case] check_interval_secs: u64,
2195 ) {
2196 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2197 set_data_event_sender(tx);
2198 let client = HyperliquidDataClient::new(
2199 *crate::common::consts::HYPERLIQUID_CLIENT_ID,
2200 HyperliquidDataClientConfig {
2201 stale_stream_receive_timeout_secs: stale_receive_timeout_secs,
2202 stream_health_check_interval_secs: check_interval_secs,
2203 ..HyperliquidDataClientConfig::default()
2204 },
2205 )
2206 .unwrap();
2207 let instrument_id = btc_perp_id();
2208 let start = Instant::now();
2209
2210 assert!(!client.stream_health_monitor_enabled());
2211 client.register_stream_health(MarketDataChannel::Deltas, instrument_id);
2212
2213 let warnings = client
2214 .stream_health
2215 .lock()
2216 .expect(MUTEX_POISONED)
2217 .check_stale(
2218 start + Duration::from_secs(121),
2219 UnixNanos::from(121_000_000_000),
2220 );
2221
2222 assert!(warnings.is_empty());
2223 }
2224
2225 #[rstest]
2226 fn test_data_client_recovery_requires_positive_cooldown() {
2227 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2228 set_data_event_sender(tx);
2229 let client = HyperliquidDataClient::new(
2230 *crate::common::consts::HYPERLIQUID_CLIENT_ID,
2231 HyperliquidDataClientConfig {
2232 stale_stream_recovery_enabled: true,
2233 stale_stream_recovery_cooldown_secs: 0,
2234 ..HyperliquidDataClientConfig::default()
2235 },
2236 )
2237 .unwrap();
2238
2239 assert!(
2240 client
2241 .stream_health
2242 .lock()
2243 .expect(MUTEX_POISONED)
2244 .recovery
2245 .is_none(),
2246 "a zero recovery cooldown must leave the monitor observability-only",
2247 );
2248 }
2249
2250 #[rstest]
2251 fn test_stream_health_monitor_warning_cooldown_prevents_repeated_logs() {
2252 let mut monitor =
2253 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10));
2254 let instrument_id = btc_perp_id();
2255 let start = Instant::now();
2256
2257 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2258
2259 let first = monitor.check_stale(
2260 start + Duration::from_secs(6),
2261 UnixNanos::from(6_000_000_000),
2262 );
2263 let inside_cooldown = monitor.check_stale(
2264 start + Duration::from_secs(7),
2265 UnixNanos::from(7_000_000_000),
2266 );
2267 let second = monitor.check_stale(
2268 start + Duration::from_secs(16),
2269 UnixNanos::from(16_000_000_000),
2270 );
2271
2272 assert_eq!(first.len(), 1);
2273 assert!(inside_cooldown.is_empty());
2274 assert_eq!(second.len(), 1);
2275 assert_eq!(second[0].stale_count, 3);
2276 }
2277
2278 fn check_at(
2279 monitor: &mut MarketDataStreamHealthMonitor,
2280 start: Instant,
2281 secs: u64,
2282 ) -> Vec<MarketDataStaleEvent> {
2283 monitor.check_stale(
2284 start + Duration::from_secs(secs),
2285 UnixNanos::from(secs * 1_000_000_000),
2286 )
2287 }
2288
2289 #[rstest]
2290 fn test_stream_health_recovery_ladder_escalates_and_resets() {
2291 let mut monitor =
2292 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10))
2293 .with_recovery(Duration::from_secs(30), 2);
2294 let instrument_id = btc_perp_id();
2295 let start = Instant::now();
2296
2297 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2298
2299 let events = check_at(&mut monitor, start, 5);
2300 assert_eq!(events.len(), 1);
2301 assert_eq!(events[0].action, StaleStreamAction::Warn);
2302
2303 let events = check_at(&mut monitor, start, 20);
2304 assert_eq!(events[0].action, StaleStreamAction::Warn);
2305
2306 let events = check_at(&mut monitor, start, 35);
2307 assert_eq!(
2308 events,
2309 vec![MarketDataStaleEvent {
2310 channel: MarketDataChannel::Deltas,
2311 instrument_id,
2312 receive_age: Duration::from_secs(35),
2313 venue_age: None,
2314 stale_count: 3,
2315 action: StaleStreamAction::Resubscribe,
2316 cooldown: Duration::from_secs(30),
2317 quote_is_fresh: false,
2318 }],
2319 );
2320
2321 let events = check_at(&mut monitor, start, 50);
2322 assert_eq!(events[0].action, StaleStreamAction::Warn);
2323
2324 let events = check_at(&mut monitor, start, 65);
2325 assert_eq!(events[0].action, StaleStreamAction::Resubscribe);
2326
2327 let events = check_at(&mut monitor, start, 95);
2328 assert_eq!(events[0].action, StaleStreamAction::Reconnect);
2329
2330 let events = check_at(&mut monitor, start, 125);
2331 assert_eq!(events[0].action, StaleStreamAction::Resubscribe);
2332 }
2333
2334 #[rstest]
2335 fn test_stream_health_recovery_first_breach_warns_even_past_cooldown() {
2336 let mut monitor =
2337 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10))
2338 .with_recovery(Duration::from_secs(1), 1);
2339 let instrument_id = btc_perp_id();
2340 let start = Instant::now();
2341
2342 monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
2343
2344 let events = check_at(&mut monitor, start, 40);
2346 assert_eq!(events.len(), 1);
2347 assert_eq!(events[0].action, StaleStreamAction::Warn);
2348
2349 let events = check_at(&mut monitor, start, 41);
2350 assert_eq!(events.len(), 1);
2351 assert_eq!(events[0].action, StaleStreamAction::Resubscribe);
2352 }
2353
2354 #[rstest]
2355 fn test_stream_health_receive_resets_recovery_state() {
2356 let mut monitor =
2357 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10))
2358 .with_recovery(Duration::from_secs(10), 1);
2359 let instrument_id = btc_perp_id();
2360 let start = Instant::now();
2361
2362 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2363 assert_eq!(
2364 check_at(&mut monitor, start, 5)[0].action,
2365 StaleStreamAction::Warn
2366 );
2367 assert_eq!(
2368 check_at(&mut monitor, start, 15)[0].action,
2369 StaleStreamAction::Resubscribe,
2370 );
2371
2372 monitor.record_receive(
2373 MarketDataChannel::Deltas,
2374 instrument_id,
2375 start + Duration::from_secs(16),
2376 UnixNanos::from(16_000_000_000),
2377 );
2378
2379 assert!(check_at(&mut monitor, start, 20).is_empty());
2380
2381 let events = check_at(&mut monitor, start, 21);
2382 assert_eq!(events[0].action, StaleStreamAction::Warn);
2383 assert_eq!(events[0].stale_count, 1);
2384
2385 assert_eq!(
2386 check_at(&mut monitor, start, 31)[0].action,
2387 StaleStreamAction::Resubscribe,
2388 );
2389 assert_eq!(
2390 check_at(&mut monitor, start, 41)[0].action,
2391 StaleStreamAction::Reconnect,
2392 );
2393 }
2394
2395 #[rstest]
2396 fn test_check_stale_book_with_fresh_quote_flags_relative_staleness() {
2397 let mut monitor =
2398 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2399 let instrument_id = btc_perp_id();
2400 let start = Instant::now();
2401
2402 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2403 monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
2404 monitor.record_receive(
2405 MarketDataChannel::Quote,
2406 instrument_id,
2407 start + Duration::from_secs(8),
2408 UnixNanos::from(8_000_000_000),
2409 );
2410
2411 let events = check_at(&mut monitor, start, 10);
2412
2413 assert_eq!(events.len(), 1, "fresh quote stream must not be reported");
2414 assert_eq!(events[0].channel, MarketDataChannel::Deltas);
2415 assert!(events[0].quote_is_fresh);
2416 }
2417
2418 #[rstest]
2419 #[case(true)]
2420 #[case(false)]
2421 fn test_check_stale_book_without_fresh_quote_is_not_flagged(#[case] quote_subscribed: bool) {
2422 let mut monitor =
2423 MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
2424 let instrument_id = btc_perp_id();
2425 let start = Instant::now();
2426
2427 monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
2428 if quote_subscribed {
2429 monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
2430 }
2431
2432 let events = check_at(&mut monitor, start, 10);
2433
2434 let deltas_event = events
2435 .iter()
2436 .find(|event| event.channel == MarketDataChannel::Deltas)
2437 .expect("deltas event");
2438 assert!(
2439 !deltas_event.quote_is_fresh,
2440 "a stale or absent quote stream must not flag relative staleness",
2441 );
2442
2443 if quote_subscribed {
2444 let quote_event = events
2445 .iter()
2446 .find(|event| event.channel == MarketDataChannel::Quote)
2447 .expect("quote event");
2448 assert!(!quote_event.quote_is_fresh);
2449 }
2450 }
2451
2452 #[rstest]
2453 fn test_stream_health_update_extracts_tracked_market_data_messages() {
2454 let quote = QuoteTick {
2455 instrument_id: btc_perp_id(),
2456 ts_event: UnixNanos::from(1),
2457 ..QuoteTick::default()
2458 };
2459 let deltas = stub_deltas();
2460 let depth = stub_depth10();
2461
2462 assert_eq!(
2463 stream_health_update(&NautilusWsMessage::Quote(quote)),
2464 Some((
2465 MarketDataChannel::Quote,
2466 quote.instrument_id,
2467 quote.ts_event
2468 )),
2469 );
2470 assert_eq!(
2471 stream_health_update(&NautilusWsMessage::Deltas(deltas.clone())),
2472 Some((
2473 MarketDataChannel::Deltas,
2474 deltas.instrument_id,
2475 deltas.ts_event
2476 )),
2477 );
2478 assert_eq!(
2479 stream_health_update(&NautilusWsMessage::Depth10(Box::new(depth))),
2480 Some((
2481 MarketDataChannel::Depth10,
2482 depth.instrument_id,
2483 depth.ts_event
2484 )),
2485 );
2486 assert_eq!(stream_health_update(&NautilusWsMessage::Reconnected), None,);
2487 }
2488
2489 #[rstest]
2490 fn test_funding_entry_to_update_parses_positive_rate() {
2491 let entry = HyperliquidFundingHistoryEntry {
2492 coin: Ustr::from("BTC"),
2493 funding_rate: dec!(0.0000125),
2494 premium: Some(dec!(0.00029005)),
2495 time: 1769908800000,
2496 };
2497 let instrument_id = btc_perp_id();
2498
2499 let update = funding_entry_to_update(&entry, instrument_id);
2500
2501 assert_eq!(update.instrument_id, instrument_id);
2502 assert_eq!(update.rate, dec!(0.0000125));
2503 assert_eq!(update.interval, Some(60));
2504 assert!(update.next_funding_ns.is_none());
2505 assert_eq!(update.ts_event, UnixNanos::from(1769908800000 * 1_000_000));
2506 assert_eq!(update.ts_init, update.ts_event);
2507 }
2508
2509 #[rstest]
2510 fn test_funding_entry_to_update_handles_negative_rate() {
2511 let entry = HyperliquidFundingHistoryEntry {
2512 coin: Ustr::from("BTC"),
2513 funding_rate: dec!(-0.0000081),
2514 premium: None,
2515 time: 1769912400000,
2516 };
2517 let update = funding_entry_to_update(&entry, btc_perp_id());
2518 assert_eq!(update.rate, dec!(-0.0000081));
2519 }
2520
2521 #[rstest]
2522 fn test_funding_history_entry_rejects_invalid_rate() {
2523 let json = r#"{"coin":"BTC","fundingRate":"not-a-number","time":1769912400000}"#;
2526 assert!(serde_json::from_str::<HyperliquidFundingHistoryEntry>(json).is_err());
2527 }
2528
2529 #[rstest]
2530 fn test_parse_book_precision_params_none() {
2531 let (n, m) = parse_book_precision_params(None).unwrap();
2532 assert_eq!(n, None);
2533 assert_eq!(m, None);
2534 }
2535
2536 fn make_params(json: serde_json::Value) -> Params {
2537 serde_json::from_value(json).expect("valid params payload")
2538 }
2539
2540 #[rstest]
2541 fn test_parse_book_precision_params_only_n_sig_figs() {
2542 let params = make_params(serde_json::json!({"n_sig_figs": 4}));
2543 let (n, m) = parse_book_precision_params(Some(¶ms)).unwrap();
2544 assert_eq!(n, Some(4));
2545 assert_eq!(m, None);
2546 }
2547
2548 #[rstest]
2549 fn test_parse_book_precision_params_both() {
2550 let params = make_params(serde_json::json!({"n_sig_figs": 5, "mantissa": 2}));
2551 let (n, m) = parse_book_precision_params(Some(¶ms)).unwrap();
2552 assert_eq!(n, Some(5));
2553 assert_eq!(m, Some(2));
2554 }
2555
2556 #[rstest]
2557 fn test_parse_book_precision_params_rejects_negative() {
2558 let params = make_params(serde_json::json!({"n_sig_figs": -1}));
2559 let err = parse_book_precision_params(Some(¶ms)).unwrap_err();
2560 assert!(err.to_string().contains("n_sig_figs"));
2561 }
2562
2563 #[rstest]
2564 fn test_funding_history_fixture_parses() {
2565 let entries: Vec<HyperliquidFundingHistoryEntry> =
2566 load_test_data("http_funding_history.json");
2567 assert_eq!(entries.len(), 3);
2568 assert_eq!(entries[0].coin.as_str(), "BTC");
2569 assert_eq!(entries[0].funding_rate, dec!(0.0000125));
2570 assert_eq!(entries[0].premium, Some(dec!(0.00029005)));
2571 assert!(entries[2].premium.is_none());
2572
2573 let updates: Vec<FundingRateUpdate> = entries
2574 .iter()
2575 .map(|e| funding_entry_to_update(e, btc_perp_id()))
2576 .collect();
2577 assert_eq!(updates.len(), 3);
2578 assert_eq!(updates[0].rate, dec!(0.0000125));
2579 assert_eq!(updates[1].rate, dec!(-0.0000081));
2580 assert_eq!(updates[2].rate, dec!(0.0000033));
2581 }
2582
2583 fn level(px: &str, sz: &str) -> crate::http::models::HyperliquidLevel {
2584 crate::http::models::HyperliquidLevel {
2585 px: px.parse().unwrap(),
2586 sz: sz.parse().unwrap(),
2587 }
2588 }
2589
2590 fn sample_l2_book() -> HyperliquidL2Book {
2591 HyperliquidL2Book {
2592 coin: Ustr::from("BTC"),
2593 levels: vec![
2594 vec![
2595 level("98450.50", "2.5"),
2596 level("98449.00", "1.2"),
2597 level("98448.00", "0.8"),
2598 ],
2599 vec![
2600 level("98451.00", "1.5"),
2601 level("98452.00", "2.0"),
2602 level("98453.00", "0.5"),
2603 ],
2604 ],
2605 time: 1769908800000,
2606 }
2607 }
2608
2609 #[rstest]
2610 fn test_parse_l2_book_snapshot_populates_both_sides() {
2611 let book_data = sample_l2_book();
2612 let instrument_id = btc_perp_id();
2613 let book = parse_l2_book_snapshot(&book_data, instrument_id, 2, 4, None);
2614
2615 assert_eq!(book.instrument_id, instrument_id);
2616 assert_eq!(book.book_type, BookType::L2_MBP);
2617 assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
2618 assert_eq!(book.best_ask_price(), Some(Price::new(98451.00, 2)));
2619 assert_eq!(book.best_bid_size(), Some(Quantity::new(2.5, 4)));
2620 assert_eq!(book.best_ask_size(), Some(Quantity::new(1.5, 4)));
2621 assert_eq!(book.update_count, 6);
2622 }
2623
2624 #[rstest]
2625 fn test_parse_l2_book_snapshot_truncates_to_depth() {
2626 let book_data = sample_l2_book();
2627 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, Some(1));
2628
2629 assert_eq!(book.update_count, 2);
2631 assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
2632 assert_eq!(book.best_ask_price(), Some(Price::new(98451.00, 2)));
2633 }
2634
2635 #[rstest]
2636 fn test_parse_l2_book_snapshot_uses_venue_time_as_ts_event() {
2637 let book_data = sample_l2_book();
2638 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
2639 let expected_ts = UnixNanos::from(1769908800000_u64 * 1_000_000);
2640
2641 assert_eq!(book.ts_last, expected_ts);
2644 }
2645
2646 #[rstest]
2647 fn test_parse_l2_book_snapshot_skips_non_positive_size() {
2648 let book_data = HyperliquidL2Book {
2649 coin: Ustr::from("BTC"),
2650 levels: vec![
2651 vec![level("98450.50", "2.5"), level("98449.00", "0")],
2652 vec![level("98451.00", "0"), level("98452.00", "1.5")],
2653 ],
2654 time: 1769908800000,
2655 };
2656 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
2657
2658 assert_eq!(book.update_count, 2, "zero-sized levels must be skipped");
2659 assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
2660 assert_eq!(book.best_ask_price(), Some(Price::new(98452.00, 2)));
2661 }
2662
2663 #[rstest]
2664 fn test_parse_l2_book_snapshot_skips_zero_size_levels() {
2665 let book_data = HyperliquidL2Book {
2666 coin: Ustr::from("BTC"),
2667 levels: vec![
2668 vec![level("98448.00", "0.0"), level("98449.00", "1.2")],
2669 vec![level("98451.00", "0.0"), level("98452.00", "1.5")],
2670 ],
2671 time: 1769908800000,
2672 };
2673 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
2674
2675 assert_eq!(book.update_count, 2);
2677 assert_eq!(book.best_bid_price(), Some(Price::new(98449.00, 2)));
2678 assert_eq!(book.best_ask_price(), Some(Price::new(98452.00, 2)));
2679 }
2680
2681 #[rstest]
2682 fn test_parse_l2_book_snapshot_empty_levels_yields_empty_book() {
2683 let book_data = HyperliquidL2Book {
2684 coin: Ustr::from("BTC"),
2685 levels: vec![],
2686 time: 1769908800000,
2687 };
2688 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
2689
2690 assert_eq!(book.update_count, 0);
2691 assert!(book.best_bid_price().is_none());
2692 assert!(book.best_ask_price().is_none());
2693 }
2694
2695 fn trade_at(ts_ns: u64, tid: u64) -> TradeTick {
2696 TradeTick::new(
2697 btc_perp_id(),
2698 Price::from("104300.0"),
2699 Quantity::from("0.01000"),
2700 AggressorSide::Buyer,
2701 TradeId::new(tid.to_string()),
2702 UnixNanos::from(ts_ns),
2703 UnixNanos::from(ts_ns),
2704 )
2705 }
2706
2707 fn sample_trades() -> Vec<TradeTick> {
2710 vec![trade_at(1000, 1), trade_at(2000, 2), trade_at(3000, 3)]
2711 }
2712
2713 #[rstest]
2714 fn test_recent_trades_fixture_parses_and_sorts() {
2715 let raw: Vec<crate::http::models::HyperliquidRecentTrade> =
2716 load_test_data("http_recent_trades_btc.json");
2717 assert_eq!(raw.len(), 3);
2718 assert_eq!(raw[0].tid, 300003);
2720
2721 let meta: crate::http::models::PerpMeta = load_test_data("http_meta_perp_sample.json");
2722 let defs = crate::http::parse::parse_perp_instruments(&meta, 0).unwrap();
2723 let instrument =
2724 crate::http::parse::create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
2725
2726 let mut trades: Vec<TradeTick> = raw
2727 .iter()
2728 .map(|t| parse_recent_trade(t, &instrument).unwrap())
2729 .collect();
2730 trades.sort_by_key(|trade| trade.ts_event);
2731
2732 assert_eq!(trades[0].trade_id.to_string(), "300001");
2734 assert_eq!(trades[2].trade_id.to_string(), "300003");
2735 assert!(trades[0].ts_event <= trades[2].ts_event);
2736 assert_eq!(trades[0].ts_init, trades[0].ts_event);
2738 }
2739
2740 #[rstest]
2741 fn test_filter_recent_trades_full_window_returns_all() {
2742 let filtered = filter_recent_trades(sample_trades(), None, None, None, btc_perp_id());
2743
2744 assert_eq!(filtered.len(), 3);
2745 }
2746
2747 #[rstest]
2748 fn test_filter_recent_trades_empty_snapshot_returns_empty() {
2749 let filtered = filter_recent_trades(
2750 Vec::new(),
2751 Some(UnixNanos::from(500)),
2752 Some(UnixNanos::from(2500)),
2753 None,
2754 btc_perp_id(),
2755 );
2756
2757 assert!(filtered.is_empty());
2758 }
2759
2760 #[rstest]
2761 fn test_filter_recent_trades_entirely_older_returns_empty() {
2762 let filtered = filter_recent_trades(
2764 sample_trades(),
2765 Some(UnixNanos::from(100)),
2766 Some(UnixNanos::from(500)),
2767 None,
2768 btc_perp_id(),
2769 );
2770
2771 assert!(filtered.is_empty());
2772 }
2773
2774 #[rstest]
2775 fn test_filter_recent_trades_partial_keeps_in_range_subset() {
2776 let filtered = filter_recent_trades(
2778 sample_trades(),
2779 Some(UnixNanos::from(500)),
2780 Some(UnixNanos::from(2500)),
2781 None,
2782 btc_perp_id(),
2783 );
2784
2785 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
2786 assert_eq!(ts, vec![1000, 2000]);
2787 }
2788
2789 #[rstest]
2790 fn test_filter_recent_trades_within_window_filters_bounds() {
2791 let filtered = filter_recent_trades(
2792 sample_trades(),
2793 Some(UnixNanos::from(1500)),
2794 Some(UnixNanos::from(3000)),
2795 None,
2796 btc_perp_id(),
2797 );
2798
2799 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
2800 assert_eq!(ts, vec![2000, 3000]);
2801 }
2802
2803 #[rstest]
2804 fn test_filter_recent_trades_limit_keeps_most_recent() {
2805 let filtered = filter_recent_trades(sample_trades(), None, None, Some(2), btc_perp_id());
2806
2807 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
2808 assert_eq!(ts, vec![2000, 3000]);
2809 }
2810
2811 #[rstest]
2812 fn test_filter_recent_trades_end_equal_to_floor_keeps_floor_trade() {
2813 let filtered = filter_recent_trades(
2816 sample_trades(),
2817 None,
2818 Some(UnixNanos::from(1000)),
2819 None,
2820 btc_perp_id(),
2821 );
2822
2823 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
2824 assert_eq!(ts, vec![1000]);
2825 }
2826
2827 #[rstest]
2828 fn test_filter_recent_trades_bounds_are_inclusive() {
2829 let filtered = filter_recent_trades(
2832 sample_trades(),
2833 Some(UnixNanos::from(2000)),
2834 Some(UnixNanos::from(3000)),
2835 None,
2836 btc_perp_id(),
2837 );
2838
2839 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
2840 assert_eq!(ts, vec![2000, 3000]);
2841 }
2842}