1use std::{
17 str::FromStr,
18 sync::{
19 Arc, Mutex,
20 atomic::{AtomicBool, Ordering},
21 },
22};
23
24use ahash::AHashMap;
25use anyhow::Context;
26use chrono::{DateTime, Utc};
27use nautilus_common::{
28 cache::InstrumentLookupError,
29 clients::DataClient,
30 live::{runner::get_data_event_sender, runtime::get_runtime},
31 messages::{
32 DataEvent,
33 data::{
34 BarsResponse, BookResponse, DataResponse, FundingRatesResponse, InstrumentResponse,
35 InstrumentsResponse, RequestBars, RequestBookSnapshot, RequestFundingRates,
36 RequestInstrument, RequestInstruments, RequestTrades, SubscribeBars,
37 SubscribeBookDeltas, SubscribeBookDepth10, SubscribeCustomData, SubscribeFundingRates,
38 SubscribeIndexPrices, SubscribeInstrument, SubscribeMarkPrices, SubscribeQuotes,
39 SubscribeTrades, TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas,
40 UnsubscribeBookDepth10, UnsubscribeCustomData, UnsubscribeFundingRates,
41 UnsubscribeIndexPrices, UnsubscribeInstrument, UnsubscribeInstruments,
42 UnsubscribeMarkPrices, UnsubscribeQuotes, UnsubscribeTrades,
43 },
44 },
45};
46use nautilus_core::{
47 AtomicMap, MUTEX_POISONED, Params, UnixNanos,
48 datetime::{datetime_to_unix_nanos, unix_nanos_to_iso8601},
49 time::{AtomicTime, get_atomic_clock_realtime},
50};
51use nautilus_model::{
52 data::{
53 Bar, BarType, BookOrder, Data, DataType, FundingRateUpdate, OrderBookDeltas_API, TradeTick,
54 },
55 enums::{BarAggregation, BookType, OrderSide},
56 identifiers::{ClientId, InstrumentId, Venue},
57 instruments::{Instrument, InstrumentAny},
58 orderbook::OrderBook,
59 types::{Price, Quantity},
60};
61use rust_decimal::Decimal;
62use tokio::task::JoinHandle;
63use tokio_util::sync::CancellationToken;
64use ustr::Ustr;
65
66use crate::{
67 common::{
68 consts::HYPERLIQUID_VENUE,
69 credential::{Secrets, credential_env_vars},
70 parse::bar_type_to_interval,
71 },
72 config::HyperliquidDataClientConfig,
73 data_types::register_hyperliquid_custom_data,
74 http::{
75 client::HyperliquidHttpClient,
76 models::{HyperliquidCandle, HyperliquidFundingHistoryEntry, HyperliquidL2Book},
77 parse::parse_recent_trade,
78 },
79 websocket::{client::HyperliquidWebSocketClient, messages::NautilusWsMessage},
80};
81
82#[derive(Debug)]
83pub struct HyperliquidDataClient {
84 clock: &'static AtomicTime,
85 client_id: ClientId,
86 config: HyperliquidDataClientConfig,
87 http_client: HyperliquidHttpClient,
88 ws_client: HyperliquidWebSocketClient,
89 is_connected: AtomicBool,
90 cancellation_token: CancellationToken,
91 ws_stream_handle: Mutex<Option<JoinHandle<()>>>,
92 pending_tasks: Mutex<Vec<JoinHandle<()>>>,
93 data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
94 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
95 coin_to_instrument_id: Arc<AtomicMap<Ustr, InstrumentId>>,
96}
97
98impl HyperliquidDataClient {
99 pub fn new(client_id: ClientId, config: HyperliquidDataClientConfig) -> anyhow::Result<Self> {
105 let clock = get_atomic_clock_realtime();
106 let data_sender = get_data_event_sender();
107
108 let (pk_var, _) = credential_env_vars(config.environment);
111 let has_credentials = config.has_credentials() || std::env::var(pk_var).is_ok();
112
113 let mut http_client = if has_credentials {
114 let secrets =
115 Secrets::resolve(config.private_key.as_deref(), None, config.environment)?;
116 HyperliquidHttpClient::with_secrets(
117 &secrets,
118 config.http_timeout_secs,
119 config.proxy_url.clone(),
120 )?
121 } else {
122 HyperliquidHttpClient::new(
123 config.environment,
124 config.http_timeout_secs,
125 config.proxy_url.clone(),
126 )?
127 };
128
129 if let Some(url) = &config.base_url_http {
130 http_client.set_base_info_url(url.clone());
131 }
132
133 let ws_url = config.base_url_ws.clone();
134 let ws_client = HyperliquidWebSocketClient::new(
135 ws_url,
136 config.environment,
137 None,
138 config.transport_backend,
139 config.proxy_url.clone(),
140 );
141
142 Ok(Self {
143 clock,
144 client_id,
145 config,
146 http_client,
147 ws_client,
148 is_connected: AtomicBool::new(false),
149 cancellation_token: CancellationToken::new(),
150 ws_stream_handle: Mutex::new(None),
151 pending_tasks: Mutex::new(Vec::new()),
152 data_sender,
153 instruments: Arc::new(AtomicMap::new()),
154 coin_to_instrument_id: Arc::new(AtomicMap::new()),
155 })
156 }
157
158 fn spawn_task<F>(&self, description: &'static str, fut: F)
159 where
160 F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
161 {
162 let runtime = get_runtime();
163 let handle = runtime.spawn(async move {
164 if let Err(e) = fut.await {
165 log::warn!("{description} failed: {e:?}");
166 }
167 });
168
169 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
170 tasks.retain(|handle| !handle.is_finished());
171 tasks.push(handle);
172 }
173
174 fn abort_pending_tasks(&self) {
175 let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
176 for handle in tasks.drain(..) {
177 handle.abort();
178 }
179 }
180
181 fn venue(&self) -> Venue {
182 *HYPERLIQUID_VENUE
183 }
184
185 fn custom_instrument_id(data_type: &DataType) -> anyhow::Result<Option<InstrumentId>> {
186 let Some(raw_instrument_id) = data_type
187 .metadata()
188 .and_then(|m| m.get("instrument_id"))
189 .and_then(|v| v.as_str())
190 .map(str::trim)
191 .filter(|value| !value.is_empty())
192 else {
193 return Ok(None);
194 };
195
196 let instrument_id = InstrumentId::from_str(raw_instrument_id)
197 .with_context(|| format!("invalid instrument_id metadata `{raw_instrument_id}`"))?;
198
199 Ok(Some(instrument_id))
200 }
201
202 async fn bootstrap_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
203 let instruments = self
204 .http_client
205 .request_instruments()
206 .await
207 .context("failed to fetch instruments during bootstrap")?;
208
209 self.instruments.rcu(|m| {
210 for instrument in &instruments {
211 m.insert(instrument.id(), instrument.clone());
212 }
213 });
214
215 self.coin_to_instrument_id.rcu(|m| {
216 for instrument in &instruments {
217 m.insert(instrument.raw_symbol().inner(), instrument.id());
218 }
219 });
220
221 for instrument in &instruments {
222 self.http_client.cache_instrument(instrument);
223 self.ws_client.cache_instrument(instrument.clone());
224 }
225
226 match self
227 .http_client
228 .build_all_dex_asset_ctxs_instrument_ids()
229 .await
230 {
231 Ok(mapping) => {
232 let mapping = mapping
233 .into_iter()
234 .map(|(dex, instrument_ids)| (Ustr::from(dex.as_str()), instrument_ids))
235 .collect();
236 self.ws_client
237 .cache_all_dex_asset_ctxs_instrument_ids(mapping);
238 }
239 Err(e) => {
240 log::warn!("Failed to build Hyperliquid allDexsAssetCtxs mapping: {e}");
241 }
242 }
243
244 log::debug!(
245 "Bootstrapped {} instruments with {} coin mappings",
246 self.instruments.len(),
247 self.coin_to_instrument_id.len()
248 );
249 Ok(instruments)
250 }
251
252 async fn spawn_ws(&mut self) -> anyhow::Result<()> {
253 let mut ws_client = self.ws_client.clone();
255
256 ws_client
257 .connect()
258 .await
259 .context("failed to connect to Hyperliquid WebSocket")?;
260
261 if let Some(handle) = ws_client.take_task_handle() {
263 self.ws_client.set_task_handle(handle);
264 }
265
266 let data_sender = self.data_sender.clone();
267 let cancellation_token = self.cancellation_token.clone();
268
269 let task = get_runtime().spawn(async move {
270 log::debug!("Hyperliquid WebSocket consumption loop started");
271
272 loop {
273 tokio::select! {
274 () = cancellation_token.cancelled() => {
275 log::debug!("WebSocket consumption loop cancelled");
276 break;
277 }
278 msg_opt = ws_client.next_event() => {
279 if let Some(msg) = msg_opt {
280 match msg {
281 NautilusWsMessage::Trades(trades) => {
282 for trade in trades {
283 if let Err(e) = data_sender
284 .send(DataEvent::Data(Data::Trade(trade)))
285 {
286 log::error!("Failed to send trade tick: {e}");
287 }
288 }
289 }
290 NautilusWsMessage::Quote(quote) => {
291 if let Err(e) = data_sender
292 .send(DataEvent::Data(Data::Quote(quote)))
293 {
294 log::error!("Failed to send quote tick: {e}");
295 }
296 }
297 NautilusWsMessage::Deltas(deltas) => {
298 if let Err(e) = data_sender
299 .send(DataEvent::Data(Data::Deltas(
300 OrderBookDeltas_API::new(deltas),
301 )))
302 {
303 log::error!("Failed to send order book deltas: {e}");
304 }
305 }
306 NautilusWsMessage::Depth10(depth) => {
307 if let Err(e) =
308 data_sender.send(DataEvent::Data(Data::Depth10(depth)))
309 {
310 log::error!("Failed to send order book depth10: {e}");
311 }
312 }
313 NautilusWsMessage::Candle(bar) => {
314 if let Err(e) = data_sender
315 .send(DataEvent::Data(Data::Bar(bar)))
316 {
317 log::error!("Failed to send bar: {e}");
318 }
319 }
320 NautilusWsMessage::MarkPrice(update) => {
321 if let Err(e) = data_sender
322 .send(DataEvent::Data(Data::MarkPriceUpdate(update)))
323 {
324 log::error!("Failed to send mark price update: {e}");
325 }
326 }
327 NautilusWsMessage::IndexPrice(update) => {
328 if let Err(e) = data_sender
329 .send(DataEvent::Data(Data::IndexPriceUpdate(update)))
330 {
331 log::error!("Failed to send index price update: {e}");
332 }
333 }
334 NautilusWsMessage::FundingRate(update) => {
335 if let Err(e) = data_sender
336 .send(DataEvent::FundingRate(update))
337 {
338 log::error!("Failed to send funding rate update: {e}");
339 }
340 }
341 NautilusWsMessage::CustomData(data) => {
342 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
343 log::error!("Failed to send custom data: {e}");
344 }
345 }
346 NautilusWsMessage::Reconnected => {
347 log::info!("WebSocket reconnected");
348 }
349 NautilusWsMessage::Error(e) => {
350 log::warn!("WebSocket error: {e}");
351 }
352 NautilusWsMessage::ExecutionReports(_) => {
353 }
355 }
356 } else {
357 log::debug!("WebSocket next_event returned None, stream closed");
359 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
360 }
361 }
362 }
363 }
364
365 log::debug!("Hyperliquid WebSocket consumption loop finished");
366 });
367
368 let mut slot = self.ws_stream_handle.lock().expect(MUTEX_POISONED);
369 *slot = Some(task);
370 log::debug!("WebSocket consumption task spawned");
371
372 Ok(())
373 }
374}
375
376#[async_trait::async_trait(?Send)]
377impl DataClient for HyperliquidDataClient {
378 fn client_id(&self) -> ClientId {
379 self.client_id
380 }
381
382 fn venue(&self) -> Option<Venue> {
383 Some(self.venue())
384 }
385
386 fn start(&mut self) -> anyhow::Result<()> {
387 log::info!(
388 "Starting Hyperliquid data client: client_id={}, environment={:?}, proxy_url={:?}",
389 self.client_id,
390 self.config.environment,
391 self.config.proxy_url,
392 );
393 Ok(())
394 }
395
396 fn stop(&mut self) -> anyhow::Result<()> {
397 log::info!("Stopping Hyperliquid data client {}", self.client_id);
398 self.cancellation_token.cancel();
399 self.is_connected.store(false, Ordering::Relaxed);
400 Ok(())
401 }
402
403 fn reset(&mut self) -> anyhow::Result<()> {
404 log::debug!("Resetting Hyperliquid data client {}", self.client_id);
405 self.is_connected.store(false, Ordering::Relaxed);
406 self.cancellation_token = CancellationToken::new();
407 self.abort_pending_tasks();
408
409 if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
410 handle.abort();
411 }
412 Ok(())
413 }
414
415 fn dispose(&mut self) -> anyhow::Result<()> {
416 log::debug!("Disposing Hyperliquid data client {}", self.client_id);
417 self.stop()
418 }
419
420 fn is_connected(&self) -> bool {
421 self.is_connected.load(Ordering::Acquire)
422 }
423
424 fn is_disconnected(&self) -> bool {
425 !self.is_connected()
426 }
427
428 async fn connect(&mut self) -> anyhow::Result<()> {
429 if self.is_connected() {
430 return Ok(());
431 }
432
433 if self.cancellation_token.is_cancelled() {
434 self.cancellation_token = CancellationToken::new();
435 }
436
437 register_hyperliquid_custom_data();
438
439 let instruments = self
440 .bootstrap_instruments()
441 .await
442 .context("failed to bootstrap instruments")?;
443
444 for instrument in instruments {
445 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
446 log::warn!("Failed to send instrument: {e}");
447 }
448 }
449
450 self.spawn_ws()
451 .await
452 .context("failed to spawn WebSocket client")?;
453
454 self.is_connected.store(true, Ordering::Relaxed);
455 log::info!("Connected: client_id={}", self.client_id);
456
457 Ok(())
458 }
459
460 async fn disconnect(&mut self) -> anyhow::Result<()> {
461 if !self.is_connected() {
462 return Ok(());
463 }
464
465 self.cancellation_token.cancel();
466
467 let ws_stream_handle = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take();
468 if let Some(handle) = ws_stream_handle
469 && let Err(e) = handle.await
470 {
471 log::error!("Error waiting for WebSocket stream task: {e}");
472 }
473
474 self.abort_pending_tasks();
475
476 if let Err(e) = self.ws_client.disconnect().await {
477 log::warn!("Error disconnecting WebSocket client: {e}");
478 }
479
480 self.instruments.store(AHashMap::new());
481
482 self.is_connected.store(false, Ordering::Relaxed);
483 log::info!("Disconnected: client_id={}", self.client_id);
484
485 Ok(())
486 }
487
488 fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
489 let data_type = cmd.data_type.type_name();
490
491 if data_type == "HyperliquidAllMids" {
492 let ws = self.ws_client.clone();
493 let dex = cmd
494 .data_type
495 .metadata()
496 .as_ref()
497 .and_then(|m| m.get("dex"))
498 .and_then(|v| v.as_str())
499 .map(str::trim)
500 .filter(|value| !value.is_empty())
501 .map(ToString::to_string);
502
503 log::debug!("Subscribing to all mids (dex: {:?})", dex.as_deref());
504
505 self.spawn_task("subscribe_all_mids", async move {
506 ws.subscribe_all_mids_with_dex(dex.as_deref()).await
507 });
508
509 return Ok(());
510 }
511
512 if data_type == "HyperliquidAllDexsAssetCtxs" {
513 let ws = self.ws_client.clone();
514
515 self.spawn_task("subscribe_all_dexs_asset_ctxs", async move {
516 ws.subscribe_all_dexs_asset_ctxs().await
517 });
518
519 return Ok(());
520 }
521
522 if data_type == "HyperliquidOpenInterest" {
523 let ws = self.ws_client.clone();
524 let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
525 "HyperliquidOpenInterest subscriptions require metadata['instrument_id']",
526 )?;
527
528 self.spawn_task("subscribe_open_interest", async move {
529 ws.subscribe_open_interest(instrument_id).await
530 });
531
532 return Ok(());
533 }
534
535 log::warn!("Unsupported custom data subscription: {data_type}");
536 Ok(())
537 }
538
539 fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
540 let data_type = cmd.data_type.type_name();
541
542 if data_type == "HyperliquidAllMids" {
543 let ws = self.ws_client.clone();
544 let dex = cmd
545 .data_type
546 .metadata()
547 .as_ref()
548 .and_then(|m| m.get("dex"))
549 .and_then(|v| v.as_str())
550 .map(str::trim)
551 .filter(|value| !value.is_empty())
552 .map(ToString::to_string);
553
554 log::debug!("Unsubscribing from all mids (dex: {:?})", dex.as_deref());
555
556 self.spawn_task("unsubscribe_all_mids", async move {
557 ws.unsubscribe_all_mids_with_dex(dex.as_deref()).await
558 });
559
560 return Ok(());
561 }
562
563 if data_type == "HyperliquidAllDexsAssetCtxs" {
564 let ws = self.ws_client.clone();
565
566 self.spawn_task("unsubscribe_all_dexs_asset_ctxs", async move {
567 ws.unsubscribe_all_dexs_asset_ctxs().await
568 });
569
570 return Ok(());
571 }
572
573 if data_type == "HyperliquidOpenInterest" {
574 let ws = self.ws_client.clone();
575 let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
576 "HyperliquidOpenInterest unsubscriptions require metadata['instrument_id']",
577 )?;
578
579 self.spawn_task("unsubscribe_open_interest", async move {
580 ws.unsubscribe_open_interest(instrument_id).await
581 });
582
583 return Ok(());
584 }
585
586 log::warn!("Unsupported custom data unsubscription: {data_type}");
587 Ok(())
588 }
589
590 fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
591 let instruments = self.instruments.load();
592 if let Some(instrument) = instruments.get(&cmd.instrument_id) {
593 if let Err(e) = self
594 .data_sender
595 .send(DataEvent::Instrument(instrument.clone()))
596 {
597 log::error!("Failed to send instrument {}: {e}", cmd.instrument_id);
598 }
599 } else {
600 log::warn!("Instrument {} not found in cache", cmd.instrument_id);
601 }
602 Ok(())
603 }
604
605 fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
606 if subscription.book_type != BookType::L2_MBP {
607 anyhow::bail!("Hyperliquid only supports L2_MBP order book deltas");
608 }
609
610 let ws = self.ws_client.clone();
611 let instrument_id = subscription.instrument_id;
612 let (n_sig_figs, mantissa) = parse_book_precision_params(subscription.params.as_ref())?;
613
614 self.spawn_task("subscribe_book_deltas", async move {
615 ws.subscribe_book_with_options(instrument_id, n_sig_figs, mantissa)
616 .await
617 });
618
619 Ok(())
620 }
621
622 fn subscribe_book_depth10(&mut self, subscription: SubscribeBookDepth10) -> anyhow::Result<()> {
623 log::debug!(
624 "Subscribing to book depth10: {}",
625 subscription.instrument_id
626 );
627
628 if subscription.book_type != BookType::L2_MBP {
629 anyhow::bail!("Hyperliquid only supports L2_MBP order book depth10");
630 }
631
632 let ws = self.ws_client.clone();
633 let instrument_id = subscription.instrument_id;
634 let (n_sig_figs, mantissa) = parse_book_precision_params(subscription.params.as_ref())?;
635
636 self.spawn_task("subscribe_book_depth10", async move {
637 ws.subscribe_book_depth10_with_options(instrument_id, n_sig_figs, mantissa)
638 .await
639 });
640
641 Ok(())
642 }
643
644 fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
645 let ws = self.ws_client.clone();
646 let instrument_id = subscription.instrument_id;
647
648 self.spawn_task("subscribe_quotes", async move {
649 ws.subscribe_quotes(instrument_id).await
650 });
651
652 Ok(())
653 }
654
655 fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
656 let ws = self.ws_client.clone();
657 let instrument_id = subscription.instrument_id;
658
659 self.spawn_task("subscribe_trades", async move {
660 ws.subscribe_trades(instrument_id).await
661 });
662
663 Ok(())
664 }
665
666 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
667 let ws = self.ws_client.clone();
668 let instrument_id = cmd.instrument_id;
669
670 self.spawn_task("subscribe_mark_prices", async move {
671 ws.subscribe_mark_prices(instrument_id).await
672 });
673
674 Ok(())
675 }
676
677 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
678 let ws = self.ws_client.clone();
679 let instrument_id = cmd.instrument_id;
680
681 self.spawn_task("subscribe_index_prices", async move {
682 ws.subscribe_index_prices(instrument_id).await
683 });
684
685 Ok(())
686 }
687
688 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
689 let ws = self.ws_client.clone();
690 let instrument_id = cmd.instrument_id;
691
692 self.spawn_task("subscribe_funding_rates", async move {
693 ws.subscribe_funding_rates(instrument_id).await
694 });
695
696 Ok(())
697 }
698
699 fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
700 let instrument_id = subscription.bar_type.instrument_id();
701 if !self.instruments.contains_key(&instrument_id) {
702 anyhow::bail!(InstrumentLookupError::not_found(instrument_id));
703 }
704
705 let bar_type = subscription.bar_type;
706 let ws = self.ws_client.clone();
707
708 self.spawn_task("subscribe_bars", async move {
709 ws.subscribe_bars(bar_type).await
710 });
711
712 Ok(())
713 }
714
715 fn unsubscribe_instrument(&mut self, _cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
716 Ok(())
719 }
720
721 fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
722 Ok(())
725 }
726
727 fn unsubscribe_book_deltas(
728 &mut self,
729 unsubscription: &UnsubscribeBookDeltas,
730 ) -> anyhow::Result<()> {
731 log::debug!(
732 "Unsubscribing from book deltas: {}",
733 unsubscription.instrument_id
734 );
735
736 let ws = self.ws_client.clone();
737 let instrument_id = unsubscription.instrument_id;
738
739 self.spawn_task("unsubscribe_book_deltas", async move {
740 ws.unsubscribe_book(instrument_id).await
741 });
742
743 Ok(())
744 }
745
746 fn unsubscribe_book_depth10(
747 &mut self,
748 unsubscription: &UnsubscribeBookDepth10,
749 ) -> anyhow::Result<()> {
750 log::debug!(
751 "Unsubscribing from book depth10: {}",
752 unsubscription.instrument_id
753 );
754
755 let ws = self.ws_client.clone();
756 let instrument_id = unsubscription.instrument_id;
757
758 self.spawn_task("unsubscribe_book_depth10", async move {
759 ws.unsubscribe_book_depth10(instrument_id).await
760 });
761
762 Ok(())
763 }
764
765 fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
766 log::debug!(
767 "Unsubscribing from quotes: {}",
768 unsubscription.instrument_id
769 );
770
771 let ws = self.ws_client.clone();
772 let instrument_id = unsubscription.instrument_id;
773
774 self.spawn_task("unsubscribe_quotes", async move {
775 ws.unsubscribe_quotes(instrument_id).await
776 });
777
778 Ok(())
779 }
780
781 fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
782 log::debug!(
783 "Unsubscribing from trades: {}",
784 unsubscription.instrument_id
785 );
786
787 let ws = self.ws_client.clone();
788 let instrument_id = unsubscription.instrument_id;
789
790 self.spawn_task("unsubscribe_trades", async move {
791 ws.unsubscribe_trades(instrument_id).await
792 });
793
794 Ok(())
795 }
796
797 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
798 let ws = self.ws_client.clone();
799 let instrument_id = cmd.instrument_id;
800
801 self.spawn_task("unsubscribe_mark_prices", async move {
802 ws.unsubscribe_mark_prices(instrument_id).await
803 });
804
805 Ok(())
806 }
807
808 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
809 let ws = self.ws_client.clone();
810 let instrument_id = cmd.instrument_id;
811
812 self.spawn_task("unsubscribe_index_prices", async move {
813 ws.unsubscribe_index_prices(instrument_id).await
814 });
815
816 Ok(())
817 }
818
819 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
820 let ws = self.ws_client.clone();
821 let instrument_id = cmd.instrument_id;
822
823 self.spawn_task("unsubscribe_funding_rates", async move {
824 ws.unsubscribe_funding_rates(instrument_id).await
825 });
826
827 Ok(())
828 }
829
830 fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
831 let bar_type = unsubscription.bar_type;
832 let ws = self.ws_client.clone();
833
834 self.spawn_task("unsubscribe_bars", async move {
835 ws.unsubscribe_bars(bar_type).await
836 });
837
838 Ok(())
839 }
840
841 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
842 log::debug!("Requesting all instruments");
843
844 let http = self.http_client.clone();
845 let sender = self.data_sender.clone();
846 let instruments_cache = self.instruments.clone();
847 let coin_map = self.coin_to_instrument_id.clone();
848 let ws_instruments = self.ws_client.instruments_cache();
849 let request_id = request.request_id;
850 let client_id = request.client_id.unwrap_or(self.client_id);
851 let venue = self.venue();
852 let start_nanos = datetime_to_unix_nanos(request.start);
853 let end_nanos = datetime_to_unix_nanos(request.end);
854 let params = request.params;
855 let clock = self.clock;
856
857 self.spawn_task("request_instruments", async move {
858 let instruments = http
859 .request_instruments()
860 .await
861 .context("failed to fetch instruments from Hyperliquid")?;
862
863 instruments_cache.rcu(|instruments_map| {
864 coin_map.rcu(|coin_to_id| {
865 for instrument in &instruments {
866 let instrument_id = instrument.id();
867 instruments_map.insert(instrument_id, instrument.clone());
868 let coin = instrument.raw_symbol().inner();
869 coin_to_id.insert(coin, instrument_id);
870 ws_instruments.insert(coin, instrument.clone());
871 }
872 });
873 });
874
875 let response = DataResponse::Instruments(InstrumentsResponse::new(
876 request_id,
877 client_id,
878 venue,
879 instruments,
880 start_nanos,
881 end_nanos,
882 clock.get_time_ns(),
883 params,
884 ));
885
886 if let Err(e) = sender.send(DataEvent::Response(response)) {
887 log::error!("Failed to send instruments response: {e}");
888 }
889 Ok(())
890 });
891
892 Ok(())
893 }
894
895 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
896 log::debug!("Requesting instrument: {}", request.instrument_id);
897
898 let http = self.http_client.clone();
899 let sender = self.data_sender.clone();
900 let instruments_cache = self.instruments.clone();
901 let coin_map = self.coin_to_instrument_id.clone();
902 let ws_instruments = self.ws_client.instruments_cache();
903 let instrument_id = request.instrument_id;
904 let request_id = request.request_id;
905 let client_id = request.client_id.unwrap_or(self.client_id);
906 let start_nanos = datetime_to_unix_nanos(request.start);
907 let end_nanos = datetime_to_unix_nanos(request.end);
908 let params = request.params;
909 let clock = self.clock;
910
911 self.spawn_task("request_instrument", async move {
912 let all_instruments = http
913 .request_instruments()
914 .await
915 .context("failed to fetch instruments from Hyperliquid")?;
916
917 instruments_cache.rcu(|instruments_map| {
918 coin_map.rcu(|coin_to_id| {
919 for instrument in &all_instruments {
920 let id = instrument.id();
921 instruments_map.insert(id, instrument.clone());
922 let coin = instrument.raw_symbol().inner();
923 coin_to_id.insert(coin, id);
924 ws_instruments.insert(coin, instrument.clone());
925 }
926 });
927 });
928
929 if let Some(instrument) = all_instruments
930 .into_iter()
931 .find(|i| i.id() == instrument_id)
932 {
933 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
934 request_id,
935 client_id,
936 instrument.id(),
937 instrument,
938 start_nanos,
939 end_nanos,
940 clock.get_time_ns(),
941 params,
942 )));
943
944 if let Err(e) = sender.send(DataEvent::Response(response)) {
945 log::error!("Failed to send instrument response: {e}");
946 }
947 } else {
948 log::error!("Instrument not found: {instrument_id}");
949 }
950 Ok(())
951 });
952
953 Ok(())
954 }
955
956 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
957 log::debug!("Requesting bars for {}", request.bar_type);
958
959 let http = self.http_client.clone();
960 let sender = self.data_sender.clone();
961 let bar_type = request.bar_type;
962 let start = request.start;
963 let end = request.end;
964 let limit = request.limit.map(|n| n.get() as u32);
965 let request_id = request.request_id;
966 let client_id = request.client_id.unwrap_or(self.client_id);
967 let params = request.params;
968 let clock = self.clock;
969 let start_nanos = datetime_to_unix_nanos(start);
970 let end_nanos = datetime_to_unix_nanos(end);
971 let instruments = Arc::clone(&self.instruments);
972
973 self.spawn_task("request_bars", async move {
974 let bars = request_bars_from_http(http, bar_type, start, end, limit, instruments)
975 .await
976 .context("bar request failed")?;
977
978 let response = DataResponse::Bars(BarsResponse::new(
979 request_id,
980 client_id,
981 bar_type,
982 bars,
983 start_nanos,
984 end_nanos,
985 clock.get_time_ns(),
986 params,
987 ));
988
989 if let Err(e) = sender.send(DataEvent::Response(response)) {
990 log::error!("Failed to send bars response: {e}");
991 }
992 Ok(())
993 });
994
995 Ok(())
996 }
997
998 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
999 let instrument_id = request.instrument_id;
1000 log::debug!("Requesting trades for {instrument_id}");
1001
1002 let instruments = self.instruments.load();
1003 let instrument = instruments
1004 .get(&instrument_id)
1005 .cloned()
1006 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1007
1008 let coin = instrument.raw_symbol().to_string();
1009 let http = self.http_client.clone();
1010 let sender = self.data_sender.clone();
1011 let client_id = request.client_id.unwrap_or(self.client_id);
1012 let request_id = request.request_id;
1013 let params = request.params;
1014 let clock = self.clock;
1015 let limit = request.limit.map(|n| n.get());
1016 let start_nanos = datetime_to_unix_nanos(request.start);
1017 let end_nanos = datetime_to_unix_nanos(request.end);
1018
1019 self.spawn_task("request_trades", async move {
1020 let raw_trades = match http.info_recent_trades(&coin).await {
1024 Ok(trades) => trades,
1025 Err(e) if e.is_unprocessable_entity() => {
1026 log::warn!(
1027 "Recent trades endpoint unavailable for {instrument_id} \
1028 (requires the Hyperliquid indexer); sending empty response"
1029 );
1030 Vec::new()
1031 }
1032 Err(e) => {
1033 return Err(anyhow::Error::new(e))
1034 .with_context(|| format!("trades request failed for {instrument_id}"));
1035 }
1036 };
1037
1038 let mut trades: Vec<TradeTick> = Vec::with_capacity(raw_trades.len());
1039 for raw in &raw_trades {
1040 match parse_recent_trade(raw, &instrument) {
1041 Ok(trade) => trades.push(trade),
1042 Err(e) => log::warn!("Skipping recent trade for {instrument_id}: {e}"),
1043 }
1044 }
1045 trades.sort_by_key(|trade| trade.ts_event);
1046
1047 let trades = filter_recent_trades(trades, start_nanos, end_nanos, limit, instrument_id);
1048
1049 log::debug!("Fetched {} trades for {instrument_id}", trades.len());
1050
1051 let response = DataResponse::Trades(TradesResponse::new(
1052 request_id,
1053 client_id,
1054 instrument_id,
1055 trades,
1056 start_nanos,
1057 end_nanos,
1058 clock.get_time_ns(),
1059 params,
1060 ));
1061
1062 if let Err(e) = sender.send(DataEvent::Response(response)) {
1063 log::error!("Failed to send trades response: {e}");
1064 }
1065 Ok(())
1066 });
1067
1068 Ok(())
1069 }
1070
1071 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1072 let instrument_id = request.instrument_id;
1073 log::debug!("Requesting funding rates for {instrument_id}");
1074
1075 let instruments = self.instruments.load();
1076 let instrument = instruments
1077 .get(&instrument_id)
1078 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1079
1080 if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
1081 anyhow::bail!("Funding rates are only available for perpetual instruments");
1082 }
1083
1084 let coin = instrument.raw_symbol().to_string();
1085 let http = self.http_client.clone();
1086 let sender = self.data_sender.clone();
1087 let client_id = request.client_id.unwrap_or(self.client_id);
1088 let request_id = request.request_id;
1089 let params = request.params;
1090 let clock = self.clock;
1091 let limit = request.limit.map(|n| n.get());
1092 let start_dt = request.start;
1093 let end_dt = request.end;
1094 let start_nanos = datetime_to_unix_nanos(start_dt);
1095 let end_nanos = datetime_to_unix_nanos(end_dt);
1096
1097 let now_ms = Utc::now().timestamp_millis() as u64;
1098
1099 let default_lookback_ms: u64 = 7 * 86_400_000;
1101 let start_ms = match start_dt {
1102 Some(dt) => dt.timestamp_millis().max(0) as u64,
1103 None => now_ms.saturating_sub(default_lookback_ms),
1104 };
1105 let end_ms = end_dt.map(|dt| dt.timestamp_millis().max(0) as u64);
1106
1107 self.spawn_task("request_funding_rates", async move {
1108 let entries = http
1109 .info_funding_history(&coin, start_ms, end_ms)
1110 .await
1111 .with_context(|| format!("funding rates request failed for {instrument_id}"))?;
1112
1113 let mut funding_rates: Vec<FundingRateUpdate> = entries
1114 .iter()
1115 .map(|entry| funding_entry_to_update(entry, instrument_id))
1116 .collect();
1117
1118 if let Some(limit) = limit
1119 && funding_rates.len() > limit
1120 {
1121 funding_rates.truncate(limit);
1122 }
1123
1124 log::debug!(
1125 "Fetched {} funding rates for {instrument_id}",
1126 funding_rates.len(),
1127 );
1128
1129 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1130 request_id,
1131 client_id,
1132 instrument_id,
1133 funding_rates,
1134 start_nanos,
1135 end_nanos,
1136 clock.get_time_ns(),
1137 params,
1138 ));
1139
1140 if let Err(e) = sender.send(DataEvent::Response(response)) {
1141 log::error!("Failed to send funding rates response: {e}");
1142 }
1143 Ok(())
1144 });
1145
1146 Ok(())
1147 }
1148
1149 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1150 let instrument_id = request.instrument_id;
1151 let instruments = self.instruments.load();
1152 let instrument = instruments
1153 .get(&instrument_id)
1154 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1155
1156 let raw_symbol = instrument.raw_symbol().to_string();
1157 let price_precision = instrument.price_precision();
1158 let size_precision = instrument.size_precision();
1159 let depth = request.depth.map(|d| d.get());
1160
1161 let http = self.http_client.clone();
1162 let sender = self.data_sender.clone();
1163 let client_id = request.client_id.unwrap_or(self.client_id);
1164 let request_id = request.request_id;
1165 let params = request.params;
1166 let clock = self.clock;
1167
1168 self.spawn_task("request_book_snapshot", async move {
1169 let l2_book = http
1170 .info_l2_book(&raw_symbol)
1171 .await
1172 .with_context(|| format!("book snapshot request failed for {instrument_id}"))?;
1173
1174 let book = parse_l2_book_snapshot(
1175 &l2_book,
1176 instrument_id,
1177 price_precision,
1178 size_precision,
1179 depth,
1180 );
1181
1182 let response = DataResponse::Book(BookResponse::new(
1183 request_id,
1184 client_id,
1185 instrument_id,
1186 book,
1187 None,
1188 None,
1189 clock.get_time_ns(),
1190 params,
1191 ));
1192
1193 if let Err(e) = sender.send(DataEvent::Response(response)) {
1194 log::error!("Failed to send book snapshot response: {e}");
1195 }
1196 Ok(())
1197 });
1198
1199 Ok(())
1200 }
1201}
1202
1203fn filter_recent_trades(
1210 trades: Vec<TradeTick>,
1211 start: Option<UnixNanos>,
1212 end: Option<UnixNanos>,
1213 limit: Option<usize>,
1214 instrument_id: InstrumentId,
1215) -> Vec<TradeTick> {
1216 let Some(floor) = trades.first().map(|trade| trade.ts_event) else {
1217 return Vec::new();
1218 };
1219
1220 if let Some(end) = end
1221 && end < floor
1222 {
1223 log::warn!(
1224 "Recent trades for {instrument_id} are entirely older than the requested window; \
1225 snapshot only covers back to {}",
1226 unix_nanos_to_iso8601(floor),
1227 );
1228 return Vec::new();
1229 }
1230
1231 if let Some(start) = start
1232 && start < floor
1233 {
1234 log::warn!(
1235 "Recent trades for {instrument_id} only cover back to {}; \
1236 the requested start is earlier and cannot be served",
1237 unix_nanos_to_iso8601(floor),
1238 );
1239 }
1240
1241 let mut filtered: Vec<TradeTick> = trades
1242 .into_iter()
1243 .filter(|trade| start.is_none_or(|s| trade.ts_event >= s))
1244 .filter(|trade| end.is_none_or(|e| trade.ts_event <= e))
1245 .collect();
1246
1247 if let Some(limit) = limit
1248 && filtered.len() > limit
1249 {
1250 filtered.drain(0..filtered.len() - limit);
1252 }
1253
1254 filtered
1255}
1256
1257pub(crate) fn parse_l2_book_snapshot(
1261 l2_book: &HyperliquidL2Book,
1262 instrument_id: InstrumentId,
1263 price_precision: u8,
1264 size_precision: u8,
1265 depth: Option<usize>,
1266) -> OrderBook {
1267 let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
1268 let ts_event = UnixNanos::from(l2_book.time * 1_000_000);
1269
1270 let all_bids = l2_book
1271 .levels
1272 .first()
1273 .map_or([].as_slice(), |v| v.as_slice());
1274 let all_asks = l2_book
1275 .levels
1276 .get(1)
1277 .map_or([].as_slice(), |v| v.as_slice());
1278
1279 let bids = match depth {
1280 Some(d) if d < all_bids.len() => &all_bids[..d],
1281 _ => all_bids,
1282 };
1283 let asks = match depth {
1284 Some(d) if d < all_asks.len() => &all_asks[..d],
1285 _ => all_asks,
1286 };
1287
1288 for (i, level) in bids.iter().enumerate() {
1289 if level.sz <= Decimal::ZERO {
1290 continue;
1291 }
1292 let Ok(price) = Price::from_decimal_dp(level.px, price_precision) else {
1293 continue;
1294 };
1295 let Ok(size) = Quantity::from_decimal_dp(level.sz, size_precision) else {
1296 continue;
1297 };
1298
1299 let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
1300 book.add(order, 0, i as u64, ts_event);
1301 }
1302
1303 let bids_len = bids.len();
1304
1305 for (i, level) in asks.iter().enumerate() {
1306 if level.sz <= Decimal::ZERO {
1307 continue;
1308 }
1309 let Ok(price) = Price::from_decimal_dp(level.px, price_precision) else {
1310 continue;
1311 };
1312 let Ok(size) = Quantity::from_decimal_dp(level.sz, size_precision) else {
1313 continue;
1314 };
1315
1316 let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
1317 book.add(order, 0, (bids_len + i) as u64, ts_event);
1318 }
1319
1320 log::debug!(
1321 "Built order book for {instrument_id} with {} bids and {} asks",
1322 bids.len(),
1323 asks.len(),
1324 );
1325
1326 book
1327}
1328
1329pub(crate) fn parse_book_precision_params(
1332 params: Option<&Params>,
1333) -> anyhow::Result<(Option<u32>, Option<u32>)> {
1334 let Some(params) = params else {
1335 return Ok((None, None));
1336 };
1337
1338 let read_u32 = |key: &str| -> anyhow::Result<Option<u32>> {
1339 match params.get(key) {
1340 None => Ok(None),
1341 Some(v) => v
1342 .as_u64()
1343 .and_then(|n| u32::try_from(n).ok())
1344 .ok_or_else(|| anyhow::anyhow!("`{key}` must be a positive u32"))
1345 .map(Some),
1346 }
1347 };
1348
1349 Ok((read_u32("n_sig_figs")?, read_u32("mantissa")?))
1350}
1351
1352pub(crate) fn funding_entry_to_update(
1355 entry: &HyperliquidFundingHistoryEntry,
1356 instrument_id: InstrumentId,
1357) -> FundingRateUpdate {
1358 let rate = entry.funding_rate;
1359 let ts = UnixNanos::from(entry.time * 1_000_000);
1360 FundingRateUpdate::new(instrument_id, rate, Some(60), None, ts, ts)
1361}
1362
1363pub(crate) fn candle_to_bar(
1364 candle: &HyperliquidCandle,
1365 bar_type: BarType,
1366 price_precision: u8,
1367 size_precision: u8,
1368) -> anyhow::Result<Bar> {
1369 let ts_init = UnixNanos::from(candle.timestamp * 1_000_000);
1370 let ts_event = ts_init;
1371
1372 let open = Price::from_decimal_dp(candle.open, price_precision)
1373 .map_err(|e| anyhow::anyhow!("invalid open price: {e}"))?;
1374 let high = Price::from_decimal_dp(candle.high, price_precision)
1375 .map_err(|e| anyhow::anyhow!("invalid high price: {e}"))?;
1376 let low = Price::from_decimal_dp(candle.low, price_precision)
1377 .map_err(|e| anyhow::anyhow!("invalid low price: {e}"))?;
1378 let close = Price::from_decimal_dp(candle.close, price_precision)
1379 .map_err(|e| anyhow::anyhow!("invalid close price: {e}"))?;
1380 let volume = Quantity::from_decimal_dp(candle.volume, size_precision)
1381 .map_err(|e| anyhow::anyhow!("invalid volume: {e}"))?;
1382
1383 Ok(Bar::new(
1384 bar_type, open, high, low, close, volume, ts_event, ts_init,
1385 ))
1386}
1387
1388async fn request_bars_from_http(
1390 http_client: HyperliquidHttpClient,
1391 bar_type: BarType,
1392 start: Option<DateTime<Utc>>,
1393 end: Option<DateTime<Utc>>,
1394 limit: Option<u32>,
1395 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
1396) -> anyhow::Result<Vec<Bar>> {
1397 let instrument_id = bar_type.instrument_id();
1399 let instrument = instruments
1400 .load()
1401 .get(&instrument_id)
1402 .cloned()
1403 .context("instrument not found in cache")?;
1404
1405 let price_precision = instrument.price_precision();
1406 let size_precision = instrument.size_precision();
1407 let raw_symbol = instrument.raw_symbol();
1408 let coin = raw_symbol.as_str();
1409
1410 let interval = bar_type_to_interval(&bar_type)?;
1411
1412 let now = Utc::now();
1414 let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1415 let start_time = if let Some(start) = start {
1416 start.timestamp_millis() as u64
1417 } else {
1418 let spec = bar_type.spec();
1420 let step_ms = match spec.aggregation {
1421 BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1422 BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1423 BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1424 _ => 60_000,
1425 };
1426 end_time.saturating_sub(1000 * step_ms)
1427 };
1428
1429 let candles = http_client
1430 .info_candle_snapshot(coin, interval, start_time, end_time)
1431 .await
1432 .context("failed to fetch candle snapshot from Hyperliquid")?;
1433
1434 let mut bars: Vec<Bar> = candles
1435 .iter()
1436 .filter_map(|candle| {
1437 candle_to_bar(candle, bar_type, price_precision, size_precision)
1438 .map_err(|e| {
1439 log::warn!("Failed to convert candle to bar: {e}");
1440 e
1441 })
1442 .ok()
1443 })
1444 .collect();
1445
1446 if let Some(limit) = limit
1447 && bars.len() > limit as usize
1448 {
1449 bars = bars.into_iter().take(limit as usize).collect();
1450 }
1451
1452 log::debug!("Fetched {} bars for {}", bars.len(), bar_type);
1453 Ok(bars)
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use nautilus_model::{enums::AggressorSide, identifiers::TradeId};
1459 use rstest::rstest;
1460 use rust_decimal_macros::dec;
1461 use ustr::Ustr;
1462
1463 use super::*;
1464 use crate::common::testing::load_test_data;
1465
1466 fn btc_perp_id() -> InstrumentId {
1467 InstrumentId::from("BTC-PERP.HYPERLIQUID")
1468 }
1469
1470 #[rstest]
1471 fn test_funding_entry_to_update_parses_positive_rate() {
1472 let entry = HyperliquidFundingHistoryEntry {
1473 coin: Ustr::from("BTC"),
1474 funding_rate: dec!(0.0000125),
1475 premium: Some(dec!(0.00029005)),
1476 time: 1769908800000,
1477 };
1478 let instrument_id = btc_perp_id();
1479
1480 let update = funding_entry_to_update(&entry, instrument_id);
1481
1482 assert_eq!(update.instrument_id, instrument_id);
1483 assert_eq!(update.rate, dec!(0.0000125));
1484 assert_eq!(update.interval, Some(60));
1485 assert!(update.next_funding_ns.is_none());
1486 assert_eq!(update.ts_event, UnixNanos::from(1769908800000 * 1_000_000));
1487 assert_eq!(update.ts_init, update.ts_event);
1488 }
1489
1490 #[rstest]
1491 fn test_funding_entry_to_update_handles_negative_rate() {
1492 let entry = HyperliquidFundingHistoryEntry {
1493 coin: Ustr::from("BTC"),
1494 funding_rate: dec!(-0.0000081),
1495 premium: None,
1496 time: 1769912400000,
1497 };
1498 let update = funding_entry_to_update(&entry, btc_perp_id());
1499 assert_eq!(update.rate, dec!(-0.0000081));
1500 }
1501
1502 #[rstest]
1503 fn test_funding_history_entry_rejects_invalid_rate() {
1504 let json = r#"{"coin":"BTC","fundingRate":"not-a-number","time":1769912400000}"#;
1507 assert!(serde_json::from_str::<HyperliquidFundingHistoryEntry>(json).is_err());
1508 }
1509
1510 #[rstest]
1511 fn test_parse_book_precision_params_none() {
1512 let (n, m) = parse_book_precision_params(None).unwrap();
1513 assert_eq!(n, None);
1514 assert_eq!(m, None);
1515 }
1516
1517 fn make_params(json: serde_json::Value) -> Params {
1518 serde_json::from_value(json).expect("valid params payload")
1519 }
1520
1521 #[rstest]
1522 fn test_parse_book_precision_params_only_n_sig_figs() {
1523 let params = make_params(serde_json::json!({"n_sig_figs": 4}));
1524 let (n, m) = parse_book_precision_params(Some(¶ms)).unwrap();
1525 assert_eq!(n, Some(4));
1526 assert_eq!(m, None);
1527 }
1528
1529 #[rstest]
1530 fn test_parse_book_precision_params_both() {
1531 let params = make_params(serde_json::json!({"n_sig_figs": 5, "mantissa": 2}));
1532 let (n, m) = parse_book_precision_params(Some(¶ms)).unwrap();
1533 assert_eq!(n, Some(5));
1534 assert_eq!(m, Some(2));
1535 }
1536
1537 #[rstest]
1538 fn test_parse_book_precision_params_rejects_negative() {
1539 let params = make_params(serde_json::json!({"n_sig_figs": -1}));
1540 let err = parse_book_precision_params(Some(¶ms)).unwrap_err();
1541 assert!(err.to_string().contains("n_sig_figs"));
1542 }
1543
1544 #[rstest]
1545 fn test_funding_history_fixture_parses() {
1546 let entries: Vec<HyperliquidFundingHistoryEntry> =
1547 load_test_data("http_funding_history.json");
1548 assert_eq!(entries.len(), 3);
1549 assert_eq!(entries[0].coin.as_str(), "BTC");
1550 assert_eq!(entries[0].funding_rate, dec!(0.0000125));
1551 assert_eq!(entries[0].premium, Some(dec!(0.00029005)));
1552 assert!(entries[2].premium.is_none());
1553
1554 let updates: Vec<FundingRateUpdate> = entries
1555 .iter()
1556 .map(|e| funding_entry_to_update(e, btc_perp_id()))
1557 .collect();
1558 assert_eq!(updates.len(), 3);
1559 assert_eq!(updates[0].rate, dec!(0.0000125));
1560 assert_eq!(updates[1].rate, dec!(-0.0000081));
1561 assert_eq!(updates[2].rate, dec!(0.0000033));
1562 }
1563
1564 fn level(px: &str, sz: &str) -> crate::http::models::HyperliquidLevel {
1565 crate::http::models::HyperliquidLevel {
1566 px: px.parse().unwrap(),
1567 sz: sz.parse().unwrap(),
1568 }
1569 }
1570
1571 fn sample_l2_book() -> HyperliquidL2Book {
1572 HyperliquidL2Book {
1573 coin: Ustr::from("BTC"),
1574 levels: vec![
1575 vec![
1576 level("98450.50", "2.5"),
1577 level("98449.00", "1.2"),
1578 level("98448.00", "0.8"),
1579 ],
1580 vec![
1581 level("98451.00", "1.5"),
1582 level("98452.00", "2.0"),
1583 level("98453.00", "0.5"),
1584 ],
1585 ],
1586 time: 1769908800000,
1587 }
1588 }
1589
1590 #[rstest]
1591 fn test_parse_l2_book_snapshot_populates_both_sides() {
1592 let book_data = sample_l2_book();
1593 let instrument_id = btc_perp_id();
1594 let book = parse_l2_book_snapshot(&book_data, instrument_id, 2, 4, None);
1595
1596 assert_eq!(book.instrument_id, instrument_id);
1597 assert_eq!(book.book_type, BookType::L2_MBP);
1598 assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
1599 assert_eq!(book.best_ask_price(), Some(Price::new(98451.00, 2)));
1600 assert_eq!(book.best_bid_size(), Some(Quantity::new(2.5, 4)));
1601 assert_eq!(book.best_ask_size(), Some(Quantity::new(1.5, 4)));
1602 assert_eq!(book.update_count, 6);
1603 }
1604
1605 #[rstest]
1606 fn test_parse_l2_book_snapshot_truncates_to_depth() {
1607 let book_data = sample_l2_book();
1608 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, Some(1));
1609
1610 assert_eq!(book.update_count, 2);
1612 assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
1613 assert_eq!(book.best_ask_price(), Some(Price::new(98451.00, 2)));
1614 }
1615
1616 #[rstest]
1617 fn test_parse_l2_book_snapshot_uses_venue_time_as_ts_event() {
1618 let book_data = sample_l2_book();
1619 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
1620 let expected_ts = UnixNanos::from(1769908800000_u64 * 1_000_000);
1621
1622 assert_eq!(book.ts_last, expected_ts);
1625 }
1626
1627 #[rstest]
1628 fn test_parse_l2_book_snapshot_skips_non_positive_size() {
1629 let book_data = HyperliquidL2Book {
1630 coin: Ustr::from("BTC"),
1631 levels: vec![
1632 vec![level("98450.50", "2.5"), level("98449.00", "0")],
1633 vec![level("98451.00", "0"), level("98452.00", "1.5")],
1634 ],
1635 time: 1769908800000,
1636 };
1637 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
1638
1639 assert_eq!(book.update_count, 2, "zero-sized levels must be skipped");
1640 assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
1641 assert_eq!(book.best_ask_price(), Some(Price::new(98452.00, 2)));
1642 }
1643
1644 #[rstest]
1645 fn test_parse_l2_book_snapshot_skips_zero_size_levels() {
1646 let book_data = HyperliquidL2Book {
1647 coin: Ustr::from("BTC"),
1648 levels: vec![
1649 vec![level("98448.00", "0.0"), level("98449.00", "1.2")],
1650 vec![level("98451.00", "0.0"), level("98452.00", "1.5")],
1651 ],
1652 time: 1769908800000,
1653 };
1654 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
1655
1656 assert_eq!(book.update_count, 2);
1658 assert_eq!(book.best_bid_price(), Some(Price::new(98449.00, 2)));
1659 assert_eq!(book.best_ask_price(), Some(Price::new(98452.00, 2)));
1660 }
1661
1662 #[rstest]
1663 fn test_parse_l2_book_snapshot_empty_levels_yields_empty_book() {
1664 let book_data = HyperliquidL2Book {
1665 coin: Ustr::from("BTC"),
1666 levels: vec![],
1667 time: 1769908800000,
1668 };
1669 let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
1670
1671 assert_eq!(book.update_count, 0);
1672 assert!(book.best_bid_price().is_none());
1673 assert!(book.best_ask_price().is_none());
1674 }
1675
1676 fn trade_at(ts_ns: u64, tid: u64) -> TradeTick {
1677 TradeTick::new(
1678 btc_perp_id(),
1679 Price::from("104300.0"),
1680 Quantity::from("0.01000"),
1681 AggressorSide::Buyer,
1682 TradeId::new(tid.to_string()),
1683 UnixNanos::from(ts_ns),
1684 UnixNanos::from(ts_ns),
1685 )
1686 }
1687
1688 fn sample_trades() -> Vec<TradeTick> {
1691 vec![trade_at(1000, 1), trade_at(2000, 2), trade_at(3000, 3)]
1692 }
1693
1694 #[rstest]
1695 fn test_recent_trades_fixture_parses_and_sorts() {
1696 let raw: Vec<crate::http::models::HyperliquidRecentTrade> =
1697 load_test_data("http_recent_trades_btc.json");
1698 assert_eq!(raw.len(), 3);
1699 assert_eq!(raw[0].tid, 300003);
1701
1702 let meta: crate::http::models::PerpMeta = load_test_data("http_meta_perp_sample.json");
1703 let defs = crate::http::parse::parse_perp_instruments(&meta, 0).unwrap();
1704 let instrument =
1705 crate::http::parse::create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
1706
1707 let mut trades: Vec<TradeTick> = raw
1708 .iter()
1709 .map(|t| parse_recent_trade(t, &instrument).unwrap())
1710 .collect();
1711 trades.sort_by_key(|trade| trade.ts_event);
1712
1713 assert_eq!(trades[0].trade_id.to_string(), "300001");
1715 assert_eq!(trades[2].trade_id.to_string(), "300003");
1716 assert!(trades[0].ts_event <= trades[2].ts_event);
1717 assert_eq!(trades[0].ts_init, trades[0].ts_event);
1719 }
1720
1721 #[rstest]
1722 fn test_filter_recent_trades_full_window_returns_all() {
1723 let filtered = filter_recent_trades(sample_trades(), None, None, None, btc_perp_id());
1724
1725 assert_eq!(filtered.len(), 3);
1726 }
1727
1728 #[rstest]
1729 fn test_filter_recent_trades_empty_snapshot_returns_empty() {
1730 let filtered = filter_recent_trades(
1731 Vec::new(),
1732 Some(UnixNanos::from(500)),
1733 Some(UnixNanos::from(2500)),
1734 None,
1735 btc_perp_id(),
1736 );
1737
1738 assert!(filtered.is_empty());
1739 }
1740
1741 #[rstest]
1742 fn test_filter_recent_trades_entirely_older_returns_empty() {
1743 let filtered = filter_recent_trades(
1745 sample_trades(),
1746 Some(UnixNanos::from(100)),
1747 Some(UnixNanos::from(500)),
1748 None,
1749 btc_perp_id(),
1750 );
1751
1752 assert!(filtered.is_empty());
1753 }
1754
1755 #[rstest]
1756 fn test_filter_recent_trades_partial_keeps_in_range_subset() {
1757 let filtered = filter_recent_trades(
1759 sample_trades(),
1760 Some(UnixNanos::from(500)),
1761 Some(UnixNanos::from(2500)),
1762 None,
1763 btc_perp_id(),
1764 );
1765
1766 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
1767 assert_eq!(ts, vec![1000, 2000]);
1768 }
1769
1770 #[rstest]
1771 fn test_filter_recent_trades_within_window_filters_bounds() {
1772 let filtered = filter_recent_trades(
1773 sample_trades(),
1774 Some(UnixNanos::from(1500)),
1775 Some(UnixNanos::from(3000)),
1776 None,
1777 btc_perp_id(),
1778 );
1779
1780 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
1781 assert_eq!(ts, vec![2000, 3000]);
1782 }
1783
1784 #[rstest]
1785 fn test_filter_recent_trades_limit_keeps_most_recent() {
1786 let filtered = filter_recent_trades(sample_trades(), None, None, Some(2), btc_perp_id());
1787
1788 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
1789 assert_eq!(ts, vec![2000, 3000]);
1790 }
1791
1792 #[rstest]
1793 fn test_filter_recent_trades_end_equal_to_floor_keeps_floor_trade() {
1794 let filtered = filter_recent_trades(
1797 sample_trades(),
1798 None,
1799 Some(UnixNanos::from(1000)),
1800 None,
1801 btc_perp_id(),
1802 );
1803
1804 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
1805 assert_eq!(ts, vec![1000]);
1806 }
1807
1808 #[rstest]
1809 fn test_filter_recent_trades_bounds_are_inclusive() {
1810 let filtered = filter_recent_trades(
1813 sample_trades(),
1814 Some(UnixNanos::from(2000)),
1815 Some(UnixNanos::from(3000)),
1816 None,
1817 btc_perp_id(),
1818 );
1819
1820 let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
1821 assert_eq!(ts, vec![2000, 3000]);
1822 }
1823}