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