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