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