Skip to main content

nautilus_hyperliquid/
data.rs

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