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