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