Skip to main content

nautilus_hyperliquid/
data.rs

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