Skip to main content

nautilus_hyperliquid/
data.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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    /// Creates a new [`HyperliquidDataClient`] instance.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error if the HTTP client fails to initialize.
104    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        // Only fall back to unauthenticated when credentials are absent,
109        // not when they're invalid (fail fast on malformed keys)
110        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        // Clone client before connecting so the clone can have out_rx set
254        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        // Transfer task handle to original so disconnect() can await it
262        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                                    // Handled by execution client
354                                }
355                            }
356                        } else {
357                            // Connection closed or error
358                            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        // `subscribe_instrument` only emits the cached instrument; it opens no
717        // venue channel, so there is nothing to tear down here.
718        Ok(())
719    }
720
721    fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
722        // See `unsubscribe_instrument`: instrument subscriptions carry no
723        // venue-side state to unsubscribe from.
724        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            // `recentTrades` depends on the Hyperliquid indexer; nodes without it
1021            // return HTTP 422. Treat that as "no coverage" and serve an empty
1022            // response so the awaiting caller still completes.
1023            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        // Hyperliquid requires a startTime; default to a 7-day lookback when none given
1100        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
1203// Applies the request window and limit to a snapshot of recent trades. `trades`
1204// must be sorted ascending by `ts_event`. Returns the subset within `[start, end]`
1205// (each bound unbounded when `None`), keeping at most the most recent `limit`
1206// trades. Because `recentTrades` exposes only a recent snapshot with no historical
1207// depth, a warning is logged when the request reaches below the snapshot's
1208// coverage floor (its oldest trade).
1209fn 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        // Keep the most recent `limit` trades; ascending order is preserved
1251        filtered.drain(0..filtered.len() - limit);
1252    }
1253
1254    filtered
1255}
1256
1257// Levels with unparsable px/sz or non-positive size are skipped rather than
1258// erroring; the snapshot's `time` field (ms) becomes `ts_event` after the
1259// ms->ns conversion.
1260pub(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
1329// Reads optional `nSigFigs` / `mantissa` L2 precision controls from
1330// `subscribe_params`; bails on non-positive integer values.
1331pub(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
1352// Hyperliquid funds perpetuals hourly, so `interval` is fixed at 60 mins;
1353// `time` from the venue marks the end of the funding interval in ms.
1354pub(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
1388/// Request bars from HTTP API.
1389async 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    // Get instrument details for precision
1398    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    // Hyperliquid uses millisecond timestamps
1413    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        // Default to 1000 bars before end_time
1419        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        // The funding rate is now a Decimal field, so an invalid value is
1505        // rejected at deserialization rather than by funding_entry_to_update.
1506        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(&params)).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(&params)).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(&params)).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        // depth=1 keeps the top of book on each side, drops the rest.
1611        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        // ts_last reflects the last applied delta; every added order
1623        // carries the venue time after the ms->ns conversion.
1624        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        // Zero-size levels are skipped; one priced level remains per side.
1657        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    // A snapshot of three trades at 1000/2000/3000ns, sorted ascending. The
1689    // coverage floor (oldest) is 1000ns.
1690    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        // Fixture is newest-first as the venue returns it.
1700        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        // Ascending after sort: oldest tid first.
1714        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        // Historical ticks carry ts_init == ts_event.
1718        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        // Requested window ends before the snapshot floor (1000ns).
1744        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        // Start (500ns) is below the floor; end (2500ns) drops the 3000ns trade.
1758        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        // `end` exactly on the floor (1000ns) is inclusive: not "entirely
1795        // older". Distinguishes `end < floor` from `end <= floor`.
1796        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        // `start`/`end` landing exactly on a trade's ts_event keep that trade.
1811        // Distinguishes `>=`/`<=` from strict `>`/`<`.
1812        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}