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