use std::{
str::FromStr,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use ahash::{AHashMap, AHashSet};
use anyhow::Context;
use chrono::{DateTime, Utc};
use nautilus_common::{
cache::InstrumentLookupError,
clients::DataClient,
live::{runner::get_data_event_sender, runtime::get_runtime, task::TaskHandles},
messages::{
DataEvent,
data::{
BarsResponse, BookResponse, CustomDataResponse, DataResponse, FundingRatesResponse,
InstrumentResponse, InstrumentsResponse, RequestBars, RequestBookSnapshot,
RequestCustomData, RequestFundingRates, RequestInstrument, RequestInstruments,
RequestTrades, SubscribeBars, SubscribeBookDeltas, SubscribeBookDepth10,
SubscribeCustomData, SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
SubscribeMarkPrices, SubscribeQuotes, SubscribeTrades, TradesResponse, UnsubscribeBars,
UnsubscribeBookDeltas, UnsubscribeBookDepth10, UnsubscribeCustomData,
UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeInstrument,
UnsubscribeInstruments, UnsubscribeMarkPrices, UnsubscribeQuotes, UnsubscribeTrades,
},
},
};
use nautilus_core::{
AtomicMap, MUTEX_POISONED, Params, UnixNanos,
datetime::{datetime_to_unix_nanos, unix_nanos_to_iso8601},
time::{AtomicTime, get_atomic_clock_realtime},
};
use nautilus_model::{
data::{
Bar, BarType, BookOrder, CustomData, Data, DataType, FundingRateUpdate,
OrderBookDeltas_API, TradeTick,
},
enums::{BarAggregation, BookType, OrderSide},
identifiers::{ClientId, InstrumentId, Venue},
instruments::{Instrument, InstrumentAny},
orderbook::OrderBook,
types::{Price, Quantity},
};
use rust_decimal::Decimal;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use ustr::Ustr;
use crate::{
common::{
consts::HYPERLIQUID_VENUE,
credential::{Secrets, credential_env_vars},
parse::bar_type_to_interval,
},
config::HyperliquidDataClientConfig,
data_types::register_hyperliquid_custom_data,
http::{
client::HyperliquidHttpClient,
models::{HyperliquidCandle, HyperliquidFundingHistoryEntry, HyperliquidL2Book},
parse::parse_recent_trade,
},
websocket::{client::HyperliquidWebSocketClient, messages::NautilusWsMessage},
};
#[derive(Debug)]
pub struct HyperliquidDataClient {
clock: &'static AtomicTime,
client_id: ClientId,
config: HyperliquidDataClientConfig,
http_client: HyperliquidHttpClient,
ws_client: HyperliquidWebSocketClient,
is_connected: AtomicBool,
cancellation_token: CancellationToken,
ws_stream_handle: Option<JoinHandle<()>>,
stream_health_handle: Option<JoinHandle<()>>,
pending_tasks: TaskHandles,
data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
coin_to_instrument_id: Arc<AtomicMap<Ustr, InstrumentId>>,
stream_health: Arc<Mutex<MarketDataStreamHealthMonitor>>,
}
impl HyperliquidDataClient {
pub fn new(client_id: ClientId, config: HyperliquidDataClientConfig) -> anyhow::Result<Self> {
let clock = get_atomic_clock_realtime();
let data_sender = get_data_event_sender();
let (pk_var, _) = credential_env_vars(config.environment);
let has_credentials = config.has_credentials() || std::env::var(pk_var).is_ok();
let mut http_client = if has_credentials {
let secrets =
Secrets::resolve(config.private_key.as_deref(), None, config.environment)?;
HyperliquidHttpClient::with_secrets(
&secrets,
config.http_timeout_secs,
config.proxy_url.clone(),
)?
} else {
HyperliquidHttpClient::new(
config.environment,
config.http_timeout_secs,
config.proxy_url.clone(),
)?
};
if let Some(url) = &config.base_url_http {
http_client.set_base_info_url(url.clone());
}
let ws_url = config.base_url_ws.clone();
let ws_client = HyperliquidWebSocketClient::new(
ws_url,
config.environment,
None,
config.transport_backend,
config.proxy_url.clone(),
);
let mut stream_health_monitor = MarketDataStreamHealthMonitor::new(
Duration::from_secs(config.stale_stream_receive_timeout_secs),
Duration::from_secs(config.stale_stream_warning_cooldown_secs),
);
if config.stale_stream_recovery_enabled {
if config.stale_stream_recovery_cooldown_secs > 0 {
stream_health_monitor = stream_health_monitor.with_recovery(
Duration::from_secs(config.stale_stream_recovery_cooldown_secs),
config.stale_stream_max_targeted_resubscribes,
);
} else {
log::warn!(
"Hyperliquid stale stream recovery disabled: \
stale_stream_recovery_cooldown_secs must be positive"
);
}
}
let stream_health = Arc::new(Mutex::new(stream_health_monitor));
Ok(Self {
clock,
client_id,
config,
http_client,
ws_client,
is_connected: AtomicBool::new(false),
cancellation_token: CancellationToken::new(),
ws_stream_handle: None,
stream_health_handle: None,
pending_tasks: TaskHandles::default(),
data_sender,
instruments: Arc::new(AtomicMap::new()),
coin_to_instrument_id: Arc::new(AtomicMap::new()),
stream_health,
})
}
fn spawn_task<F>(&self, description: &'static str, fut: F)
where
F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
{
let runtime = get_runtime();
let handle = runtime.spawn(async move {
if let Err(e) = fut.await {
log::warn!("{description} failed: {e:?}");
}
});
self.pending_tasks.push(handle);
}
fn abort_pending_tasks(&self) {
self.pending_tasks.abort_all();
}
fn abort_stream_health_monitor(&mut self) {
if let Some(handle) = self.stream_health_handle.take() {
handle.abort();
}
}
async fn stop_stream_health_monitor(&mut self) {
if let Some(handle) = self.stream_health_handle.take() {
match handle.await {
Ok(()) => {}
Err(e) if e.is_cancelled() => {}
Err(e) => log::warn!("Stream health monitor task failed: {e}"),
}
}
}
fn clear_stream_health(&self) {
self.stream_health.lock().expect(MUTEX_POISONED).clear();
}
fn register_stream_health(&self, channel: MarketDataChannel, instrument_id: InstrumentId) {
if !self.stream_health_monitor_enabled() {
return;
}
self.stream_health.lock().expect(MUTEX_POISONED).subscribe(
channel,
instrument_id,
Instant::now(),
);
}
fn remove_stream_health(&self, channel: MarketDataChannel, instrument_id: InstrumentId) {
self.stream_health
.lock()
.expect(MUTEX_POISONED)
.unsubscribe(channel, instrument_id);
}
fn stream_health_monitor_enabled(&self) -> bool {
self.config.stale_stream_receive_timeout_secs > 0
&& self.config.stream_health_check_interval_secs > 0
}
fn spawn_stream_health_monitor(&mut self) {
if !self.stream_health_monitor_enabled() {
return;
}
if self
.stream_health_handle
.as_ref()
.is_some_and(|handle| !handle.is_finished())
{
return;
}
let stream_health = Arc::clone(&self.stream_health);
let cancellation_token = self.cancellation_token.clone();
let interval = Duration::from_secs(self.config.stream_health_check_interval_secs);
let clock = self.clock;
let ws_client = self.ws_client.clone();
let handle = get_runtime().spawn(async move {
log::debug!("Hyperliquid stream health monitor started");
loop {
tokio::select! {
() = cancellation_token.cancelled() => {
log::debug!("Hyperliquid stream health monitor cancelled");
break;
}
() = tokio::time::sleep(interval) => {
let events = stream_health
.lock()
.expect(MUTEX_POISONED)
.check_stale(Instant::now(), clock.get_time_ns());
handle_stream_health_events(&ws_client, &events).await;
}
}
}
log::debug!("Hyperliquid stream health monitor stopped");
});
self.stream_health_handle = Some(handle);
}
fn venue(&self) -> Venue {
*HYPERLIQUID_VENUE
}
fn custom_instrument_id(data_type: &DataType) -> anyhow::Result<Option<InstrumentId>> {
let Some(raw_instrument_id) = data_type
.metadata()
.and_then(|m| m.get("instrument_id"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let instrument_id = InstrumentId::from_str(raw_instrument_id)
.with_context(|| format!("invalid instrument_id metadata `{raw_instrument_id}`"))?;
Ok(Some(instrument_id))
}
async fn bootstrap_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
let instruments = self
.http_client
.request_instruments()
.await
.context("failed to fetch instruments during bootstrap")?;
self.instruments.rcu(|m| {
for instrument in &instruments {
m.insert(instrument.id(), instrument.clone());
}
});
self.coin_to_instrument_id.rcu(|m| {
for instrument in &instruments {
m.insert(instrument.raw_symbol().inner(), instrument.id());
}
});
for instrument in &instruments {
self.http_client.cache_instrument(instrument);
self.ws_client.cache_instrument(instrument.clone());
}
match self
.http_client
.build_all_dex_asset_ctxs_instrument_ids()
.await
{
Ok(mapping) => {
let mapping = mapping
.into_iter()
.map(|(dex, instrument_ids)| (Ustr::from(dex.as_str()), instrument_ids))
.collect();
self.ws_client
.cache_all_dex_asset_ctxs_instrument_ids(mapping);
}
Err(e) => {
log::warn!("Failed to build Hyperliquid allDexsAssetCtxs mapping: {e}");
}
}
log::debug!(
"Bootstrapped {} instruments with {} coin mappings",
self.instruments.len(),
self.coin_to_instrument_id.len()
);
Ok(instruments)
}
async fn spawn_ws(&mut self) -> anyhow::Result<()> {
let mut ws_client = self.ws_client.clone();
ws_client
.connect()
.await
.context("failed to connect to Hyperliquid WebSocket")?;
if let Some(handle) = ws_client.take_task_handle() {
self.ws_client.set_task_handle(handle);
}
let data_sender = self.data_sender.clone();
let cancellation_token = self.cancellation_token.clone();
let stream_health = Arc::clone(&self.stream_health);
let task = get_runtime().spawn(async move {
log::debug!("Hyperliquid WebSocket consumption loop started");
loop {
tokio::select! {
() = cancellation_token.cancelled() => {
log::debug!("WebSocket consumption loop cancelled");
break;
}
msg_opt = ws_client.next_event() => {
if let Some(msg) = msg_opt {
if let Some((channel, instrument_id, ts_event)) =
stream_health_update(&msg)
{
record_stream_receive(
&stream_health,
channel,
instrument_id,
ts_event,
);
}
match msg {
NautilusWsMessage::Trades(trades) => {
for trade in trades {
if let Err(e) = data_sender
.send(DataEvent::Data(Data::Trade(trade)))
{
log::error!("Failed to send trade tick: {e}");
}
}
}
NautilusWsMessage::Quote(quote) => {
if let Err(e) = data_sender
.send(DataEvent::Data(Data::Quote(quote)))
{
log::error!("Failed to send quote tick: {e}");
}
}
NautilusWsMessage::Deltas(deltas) => {
if let Err(e) = data_sender
.send(DataEvent::Data(Data::Deltas(
OrderBookDeltas_API::new(deltas),
)))
{
log::error!("Failed to send order book deltas: {e}");
}
}
NautilusWsMessage::Depth10(depth) => {
if let Err(e) =
data_sender.send(DataEvent::Data(Data::Depth10(depth)))
{
log::error!("Failed to send order book depth10: {e}");
}
}
NautilusWsMessage::Candle(bar) => {
if let Err(e) = data_sender
.send(DataEvent::Data(Data::Bar(bar)))
{
log::error!("Failed to send bar: {e}");
}
}
NautilusWsMessage::MarkPrice(update) => {
if let Err(e) = data_sender
.send(DataEvent::Data(Data::MarkPriceUpdate(update)))
{
log::error!("Failed to send mark price update: {e}");
}
}
NautilusWsMessage::IndexPrice(update) => {
if let Err(e) = data_sender
.send(DataEvent::Data(Data::IndexPriceUpdate(update)))
{
log::error!("Failed to send index price update: {e}");
}
}
NautilusWsMessage::FundingRate(update) => {
if let Err(e) = data_sender
.send(DataEvent::FundingRate(update))
{
log::error!("Failed to send funding rate update: {e}");
}
}
NautilusWsMessage::CustomData(data) => {
if let Err(e) = data_sender.send(DataEvent::Data(data)) {
log::error!("Failed to send custom data: {e}");
}
}
NautilusWsMessage::Reconnected => {
log::info!("WebSocket reconnected");
}
NautilusWsMessage::Error(e) => {
log::warn!("WebSocket error: {e}");
}
NautilusWsMessage::ExecutionReports(_) => {
}
}
} else {
log::debug!("WebSocket next_event returned None, stream closed");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
}
}
}
log::debug!("Hyperliquid WebSocket consumption loop finished");
});
self.ws_stream_handle = Some(task);
log::debug!("WebSocket consumption task spawned");
Ok(())
}
}
#[async_trait::async_trait(?Send)]
impl DataClient for HyperliquidDataClient {
fn client_id(&self) -> ClientId {
self.client_id
}
fn venue(&self) -> Option<Venue> {
Some(self.venue())
}
fn start(&mut self) -> anyhow::Result<()> {
log::info!(
"Starting Hyperliquid data client: client_id={}, environment={:?}, proxy_url={:?}",
self.client_id,
self.config.environment,
self.config.proxy_url,
);
Ok(())
}
fn stop(&mut self) -> anyhow::Result<()> {
log::info!("Stopping Hyperliquid data client {}", self.client_id);
self.cancellation_token.cancel();
self.abort_stream_health_monitor();
self.clear_stream_health();
self.is_connected.store(false, Ordering::Relaxed);
Ok(())
}
fn reset(&mut self) -> anyhow::Result<()> {
log::debug!("Resetting Hyperliquid data client {}", self.client_id);
self.is_connected.store(false, Ordering::Relaxed);
self.cancellation_token.cancel();
self.abort_pending_tasks();
self.abort_stream_health_monitor();
self.clear_stream_health();
if let Some(handle) = self.ws_stream_handle.take() {
handle.abort();
}
self.instruments.store(AHashMap::new());
self.coin_to_instrument_id.store(AHashMap::new());
Ok(())
}
fn dispose(&mut self) -> anyhow::Result<()> {
log::debug!("Disposing Hyperliquid data client {}", self.client_id);
self.stop()
}
fn is_connected(&self) -> bool {
self.is_connected.load(Ordering::Acquire)
}
fn is_disconnected(&self) -> bool {
!self.is_connected()
}
async fn connect(&mut self) -> anyhow::Result<()> {
if self.is_connected() {
return Ok(());
}
if self.cancellation_token.is_cancelled() {
if let Err(e) = self.ws_client.disconnect().await {
log::debug!("Error tearing down Hyperliquid WebSocket after reset: {e}");
}
self.ws_client.reset_runtime_state();
self.abort_pending_tasks();
self.cancellation_token = CancellationToken::new();
}
register_hyperliquid_custom_data();
let instruments = self
.bootstrap_instruments()
.await
.context("failed to bootstrap instruments")?;
for instrument in instruments {
if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
log::warn!("Failed to send instrument: {e}");
}
}
self.spawn_ws()
.await
.context("failed to spawn WebSocket client")?;
self.spawn_stream_health_monitor();
self.is_connected.store(true, Ordering::Relaxed);
log::info!("Connected: client_id={}", self.client_id);
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
if !self.is_connected() {
return Ok(());
}
self.cancellation_token.cancel();
if let Some(handle) = self.ws_stream_handle.take()
&& let Err(e) = handle.await
{
log::error!("Error waiting for WebSocket stream task: {e}");
}
self.abort_pending_tasks();
if let Err(e) = self.ws_client.disconnect().await {
log::warn!("Error disconnecting WebSocket client: {e}");
}
self.stop_stream_health_monitor().await;
self.clear_stream_health();
self.instruments.store(AHashMap::new());
self.is_connected.store(false, Ordering::Relaxed);
log::info!("Disconnected: client_id={}", self.client_id);
Ok(())
}
fn subscribe(&mut self, cmd: SubscribeCustomData) -> anyhow::Result<()> {
let data_type = cmd.data_type.type_name();
if data_type == "HyperliquidAllMids" {
let ws = self.ws_client.clone();
let dex = cmd
.data_type
.metadata()
.as_ref()
.and_then(|m| m.get("dex"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
log::debug!("Subscribing to all mids (dex: {:?})", dex.as_deref());
self.spawn_task("subscribe_all_mids", async move {
ws.subscribe_all_mids_with_dex(dex.as_deref()).await
});
return Ok(());
}
if data_type == "HyperliquidAllDexsAssetCtxs" {
let ws = self.ws_client.clone();
self.spawn_task("subscribe_all_dexs_asset_ctxs", async move {
ws.subscribe_all_dexs_asset_ctxs().await
});
return Ok(());
}
if data_type == "HyperliquidOpenInterest" {
let ws = self.ws_client.clone();
let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
"HyperliquidOpenInterest subscriptions require metadata['instrument_id']",
)?;
self.spawn_task("subscribe_open_interest", async move {
ws.subscribe_open_interest(instrument_id).await
});
return Ok(());
}
if data_type == "HyperliquidPublicTrade" {
let ws = self.ws_client.clone();
let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
"HyperliquidPublicTrade subscriptions require metadata['instrument_id']",
)?;
self.spawn_task("subscribe_public_trades", async move {
ws.subscribe_public_trades(instrument_id).await
});
return Ok(());
}
log::warn!("Unsupported custom data subscription: {data_type}");
Ok(())
}
fn unsubscribe(&mut self, cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
let data_type = cmd.data_type.type_name();
if data_type == "HyperliquidAllMids" {
let ws = self.ws_client.clone();
let dex = cmd
.data_type
.metadata()
.as_ref()
.and_then(|m| m.get("dex"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
log::debug!("Unsubscribing from all mids (dex: {:?})", dex.as_deref());
self.spawn_task("unsubscribe_all_mids", async move {
ws.unsubscribe_all_mids_with_dex(dex.as_deref()).await
});
return Ok(());
}
if data_type == "HyperliquidAllDexsAssetCtxs" {
let ws = self.ws_client.clone();
self.spawn_task("unsubscribe_all_dexs_asset_ctxs", async move {
ws.unsubscribe_all_dexs_asset_ctxs().await
});
return Ok(());
}
if data_type == "HyperliquidOpenInterest" {
let ws = self.ws_client.clone();
let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
"HyperliquidOpenInterest unsubscriptions require metadata['instrument_id']",
)?;
self.spawn_task("unsubscribe_open_interest", async move {
ws.unsubscribe_open_interest(instrument_id).await
});
return Ok(());
}
if data_type == "HyperliquidPublicTrade" {
let ws = self.ws_client.clone();
let instrument_id = Self::custom_instrument_id(&cmd.data_type)?.context(
"HyperliquidPublicTrade unsubscriptions require metadata['instrument_id']",
)?;
self.spawn_task("unsubscribe_public_trades", async move {
ws.unsubscribe_public_trades(instrument_id).await
});
return Ok(());
}
log::warn!("Unsupported custom data unsubscription: {data_type}");
Ok(())
}
fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
let instruments = self.instruments.load();
if let Some(instrument) = instruments.get(&cmd.instrument_id) {
if let Err(e) = self
.data_sender
.send(DataEvent::Instrument(instrument.clone()))
{
log::error!("Failed to send instrument {}: {e}", cmd.instrument_id);
}
} else {
log::warn!("Instrument {} not found in cache", cmd.instrument_id);
}
Ok(())
}
fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
if subscription.book_type != BookType::L2_MBP {
anyhow::bail!("Hyperliquid only supports L2_MBP order book deltas");
}
let ws = self.ws_client.clone();
let instrument_id = subscription.instrument_id;
let (n_sig_figs, mantissa) = parse_book_precision_params(subscription.params.as_ref())?;
self.register_stream_health(MarketDataChannel::Deltas, instrument_id);
self.spawn_task("subscribe_book_deltas", async move {
ws.subscribe_book_with_options(instrument_id, n_sig_figs, mantissa)
.await
});
Ok(())
}
fn subscribe_book_depth10(&mut self, subscription: SubscribeBookDepth10) -> anyhow::Result<()> {
log::debug!(
"Subscribing to book depth10: {}",
subscription.instrument_id
);
if subscription.book_type != BookType::L2_MBP {
anyhow::bail!("Hyperliquid only supports L2_MBP order book depth10");
}
let ws = self.ws_client.clone();
let instrument_id = subscription.instrument_id;
let (n_sig_figs, mantissa) = parse_book_precision_params(subscription.params.as_ref())?;
self.register_stream_health(MarketDataChannel::Depth10, instrument_id);
self.spawn_task("subscribe_book_depth10", async move {
ws.subscribe_book_depth10_with_options(instrument_id, n_sig_figs, mantissa)
.await
});
Ok(())
}
fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = subscription.instrument_id;
self.register_stream_health(MarketDataChannel::Quote, instrument_id);
self.spawn_task("subscribe_quotes", async move {
ws.subscribe_quotes(instrument_id).await
});
Ok(())
}
fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = subscription.instrument_id;
self.spawn_task("subscribe_trades", async move {
ws.subscribe_trades(instrument_id).await
});
Ok(())
}
fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = cmd.instrument_id;
self.spawn_task("subscribe_mark_prices", async move {
ws.subscribe_mark_prices(instrument_id).await
});
Ok(())
}
fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = cmd.instrument_id;
self.spawn_task("subscribe_index_prices", async move {
ws.subscribe_index_prices(instrument_id).await
});
Ok(())
}
fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = cmd.instrument_id;
self.spawn_task("subscribe_funding_rates", async move {
ws.subscribe_funding_rates(instrument_id).await
});
Ok(())
}
fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
let instrument_id = subscription.bar_type.instrument_id();
if !self.instruments.contains_key(&instrument_id) {
anyhow::bail!(InstrumentLookupError::not_found(instrument_id));
}
let bar_type = subscription.bar_type;
let ws = self.ws_client.clone();
self.spawn_task("subscribe_bars", async move {
ws.subscribe_bars(bar_type).await
});
Ok(())
}
fn unsubscribe_instrument(&mut self, _cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
Ok(())
}
fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
Ok(())
}
fn unsubscribe_book_deltas(
&mut self,
unsubscription: &UnsubscribeBookDeltas,
) -> anyhow::Result<()> {
log::debug!(
"Unsubscribing from book deltas: {}",
unsubscription.instrument_id
);
let ws = self.ws_client.clone();
let instrument_id = unsubscription.instrument_id;
self.remove_stream_health(MarketDataChannel::Deltas, instrument_id);
self.spawn_task("unsubscribe_book_deltas", async move {
ws.unsubscribe_book(instrument_id).await
});
Ok(())
}
fn unsubscribe_book_depth10(
&mut self,
unsubscription: &UnsubscribeBookDepth10,
) -> anyhow::Result<()> {
log::debug!(
"Unsubscribing from book depth10: {}",
unsubscription.instrument_id
);
let ws = self.ws_client.clone();
let instrument_id = unsubscription.instrument_id;
self.remove_stream_health(MarketDataChannel::Depth10, instrument_id);
self.spawn_task("unsubscribe_book_depth10", async move {
ws.unsubscribe_book_depth10(instrument_id).await
});
Ok(())
}
fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
log::debug!(
"Unsubscribing from quotes: {}",
unsubscription.instrument_id
);
let ws = self.ws_client.clone();
let instrument_id = unsubscription.instrument_id;
self.remove_stream_health(MarketDataChannel::Quote, instrument_id);
self.spawn_task("unsubscribe_quotes", async move {
ws.unsubscribe_quotes(instrument_id).await
});
Ok(())
}
fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
log::debug!(
"Unsubscribing from trades: {}",
unsubscription.instrument_id
);
let ws = self.ws_client.clone();
let instrument_id = unsubscription.instrument_id;
self.spawn_task("unsubscribe_trades", async move {
ws.unsubscribe_trades(instrument_id).await
});
Ok(())
}
fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = cmd.instrument_id;
self.spawn_task("unsubscribe_mark_prices", async move {
ws.unsubscribe_mark_prices(instrument_id).await
});
Ok(())
}
fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = cmd.instrument_id;
self.spawn_task("unsubscribe_index_prices", async move {
ws.unsubscribe_index_prices(instrument_id).await
});
Ok(())
}
fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
let ws = self.ws_client.clone();
let instrument_id = cmd.instrument_id;
self.spawn_task("unsubscribe_funding_rates", async move {
ws.unsubscribe_funding_rates(instrument_id).await
});
Ok(())
}
fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
let bar_type = unsubscription.bar_type;
let ws = self.ws_client.clone();
self.spawn_task("unsubscribe_bars", async move {
ws.unsubscribe_bars(bar_type).await
});
Ok(())
}
fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
log::debug!("Requesting all instruments");
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let instruments_cache = self.instruments.clone();
let coin_map = self.coin_to_instrument_id.clone();
let ws_instruments = self.ws_client.instruments_cache();
let request_id = request.request_id;
let client_id = request.client_id.unwrap_or(self.client_id);
let venue = self.venue();
let start_nanos = datetime_to_unix_nanos(request.start);
let end_nanos = datetime_to_unix_nanos(request.end);
let params = request.params;
let clock = self.clock;
self.spawn_task("request_instruments", async move {
let instruments = http
.request_instruments()
.await
.context("failed to fetch instruments from Hyperliquid")?;
instruments_cache.rcu(|instruments_map| {
coin_map.rcu(|coin_to_id| {
for instrument in &instruments {
let instrument_id = instrument.id();
instruments_map.insert(instrument_id, instrument.clone());
let coin = instrument.raw_symbol().inner();
coin_to_id.insert(coin, instrument_id);
ws_instruments.insert(coin, instrument.clone());
}
});
});
let response = DataResponse::Instruments(InstrumentsResponse::new(
request_id,
client_id,
venue,
instruments,
start_nanos,
end_nanos,
clock.get_time_ns(),
params,
));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send instruments response: {e}");
}
Ok(())
});
Ok(())
}
fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
log::debug!("Requesting instrument: {}", request.instrument_id);
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let instruments_cache = self.instruments.clone();
let coin_map = self.coin_to_instrument_id.clone();
let ws_instruments = self.ws_client.instruments_cache();
let instrument_id = request.instrument_id;
let request_id = request.request_id;
let client_id = request.client_id.unwrap_or(self.client_id);
let start_nanos = datetime_to_unix_nanos(request.start);
let end_nanos = datetime_to_unix_nanos(request.end);
let params = request.params;
let clock = self.clock;
self.spawn_task("request_instrument", async move {
let all_instruments = http
.request_instruments()
.await
.context("failed to fetch instruments from Hyperliquid")?;
instruments_cache.rcu(|instruments_map| {
coin_map.rcu(|coin_to_id| {
for instrument in &all_instruments {
let id = instrument.id();
instruments_map.insert(id, instrument.clone());
let coin = instrument.raw_symbol().inner();
coin_to_id.insert(coin, id);
ws_instruments.insert(coin, instrument.clone());
}
});
});
if let Some(instrument) = all_instruments
.into_iter()
.find(|i| i.id() == instrument_id)
{
let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
request_id,
client_id,
instrument.id(),
instrument,
start_nanos,
end_nanos,
clock.get_time_ns(),
params,
)));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send instrument response: {e}");
}
} else {
log::error!("Instrument not found: {instrument_id}");
}
Ok(())
});
Ok(())
}
fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
log::debug!("Requesting bars for {}", request.bar_type);
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let bar_type = request.bar_type;
let start = request.start;
let end = request.end;
let limit = request.limit.map(|n| n.get() as u32);
let request_id = request.request_id;
let client_id = request.client_id.unwrap_or(self.client_id);
let params = request.params;
let clock = self.clock;
let start_nanos = datetime_to_unix_nanos(start);
let end_nanos = datetime_to_unix_nanos(end);
let instruments = Arc::clone(&self.instruments);
self.spawn_task("request_bars", async move {
let bars = request_bars_from_http(http, bar_type, start, end, limit, instruments)
.await
.context("bar request failed")?;
let response = DataResponse::Bars(BarsResponse::new(
request_id,
client_id,
bar_type,
bars,
start_nanos,
end_nanos,
clock.get_time_ns(),
params,
));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send bars response: {e}");
}
Ok(())
});
Ok(())
}
fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
let instrument_id = request.instrument_id;
log::debug!("Requesting trades for {instrument_id}");
let instruments = self.instruments.load();
let instrument = instruments
.get(&instrument_id)
.cloned()
.ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
let coin = instrument.raw_symbol().to_string();
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let client_id = request.client_id.unwrap_or(self.client_id);
let request_id = request.request_id;
let params = request.params;
let clock = self.clock;
let limit = request.limit.map(|n| n.get());
let start_nanos = datetime_to_unix_nanos(request.start);
let end_nanos = datetime_to_unix_nanos(request.end);
self.spawn_task("request_trades", async move {
let raw_trades = match http.info_recent_trades(&coin).await {
Ok(trades) => trades,
Err(e) if e.is_unprocessable_entity() => {
log::warn!(
"Recent trades endpoint unavailable for {instrument_id} \
(requires the Hyperliquid indexer); sending empty response"
);
Vec::new()
}
Err(e) => {
return Err(anyhow::Error::new(e))
.with_context(|| format!("trades request failed for {instrument_id}"));
}
};
let mut trades: Vec<TradeTick> = Vec::with_capacity(raw_trades.len());
for raw in &raw_trades {
match parse_recent_trade(raw, &instrument) {
Ok(trade) => trades.push(trade),
Err(e) => log::warn!("Skipping recent trade for {instrument_id}: {e}"),
}
}
trades.sort_by_key(|trade| trade.ts_event);
let trades = filter_recent_trades(trades, start_nanos, end_nanos, limit, instrument_id);
log::debug!("Fetched {} trades for {instrument_id}", trades.len());
let response = DataResponse::Trades(TradesResponse::new(
request_id,
client_id,
instrument_id,
trades,
start_nanos,
end_nanos,
clock.get_time_ns(),
params,
));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send trades response: {e}");
}
Ok(())
});
Ok(())
}
fn request_data(&self, request: RequestCustomData) -> anyhow::Result<()> {
if request.data_type.type_name() != "HyperliquidPublicTrade" {
log::warn!(
"Unsupported custom data request: {}",
request.data_type.type_name()
);
return Ok(());
}
let instrument_id = Self::custom_instrument_id(&request.data_type)?
.context("HyperliquidPublicTrade requests require metadata['instrument_id']")?;
let data_type = DataType::new(
request.data_type.type_name(),
request.data_type.metadata().cloned(),
Some(instrument_id.to_string()),
);
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let request_id = request.request_id;
let client_id = request.client_id;
let params = request.params;
let clock = self.clock;
let limit = request.limit.map(|limit| limit.get());
let start = request.start;
let end = request.end;
let start_nanos = datetime_to_unix_nanos(start);
let end_nanos = datetime_to_unix_nanos(end);
let venue = self.venue();
self.spawn_task("request_public_trades", async move {
let trades = http
.request_public_trades(instrument_id, start, end, limit)
.await
.map_err(anyhow::Error::new)
.with_context(|| format!("public trades request failed for {instrument_id}"))?;
let data: Vec<CustomData> = trades
.into_iter()
.map(|trade| CustomData::new(Arc::new(trade), data_type.clone()))
.collect();
let response = DataResponse::Data(CustomDataResponse::new(
request_id,
client_id,
Some(venue),
data_type,
data,
start_nanos,
end_nanos,
clock.get_time_ns(),
params,
));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send public trades response: {e}");
}
Ok(())
});
Ok(())
}
fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
let instrument_id = request.instrument_id;
log::debug!("Requesting funding rates for {instrument_id}");
let instruments = self.instruments.load();
let instrument = instruments
.get(&instrument_id)
.ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
anyhow::bail!("Funding rates are only available for perpetual instruments");
}
let coin = instrument.raw_symbol().to_string();
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let client_id = request.client_id.unwrap_or(self.client_id);
let request_id = request.request_id;
let params = request.params;
let clock = self.clock;
let limit = request.limit.map(|n| n.get());
let start_dt = request.start;
let end_dt = request.end;
let start_nanos = datetime_to_unix_nanos(start_dt);
let end_nanos = datetime_to_unix_nanos(end_dt);
let now_ms = Utc::now().timestamp_millis() as u64;
let default_lookback_ms: u64 = 7 * 86_400_000;
let start_ms = match start_dt {
Some(dt) => dt.timestamp_millis().max(0) as u64,
None => now_ms.saturating_sub(default_lookback_ms),
};
let end_ms = end_dt.map(|dt| dt.timestamp_millis().max(0) as u64);
self.spawn_task("request_funding_rates", async move {
let entries = http
.info_funding_history(&coin, start_ms, end_ms)
.await
.with_context(|| format!("funding rates request failed for {instrument_id}"))?;
let mut funding_rates: Vec<FundingRateUpdate> = entries
.iter()
.map(|entry| funding_entry_to_update(entry, instrument_id))
.collect();
if let Some(limit) = limit
&& funding_rates.len() > limit
{
funding_rates.truncate(limit);
}
log::debug!(
"Fetched {} funding rates for {instrument_id}",
funding_rates.len(),
);
let response = DataResponse::FundingRates(FundingRatesResponse::new(
request_id,
client_id,
instrument_id,
funding_rates,
start_nanos,
end_nanos,
clock.get_time_ns(),
params,
));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send funding rates response: {e}");
}
Ok(())
});
Ok(())
}
fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
let instrument_id = request.instrument_id;
let instruments = self.instruments.load();
let instrument = instruments
.get(&instrument_id)
.ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
let raw_symbol = instrument.raw_symbol().to_string();
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let depth = request.depth.map(|d| d.get());
let http = self.http_client.clone();
let sender = self.data_sender.clone();
let client_id = request.client_id.unwrap_or(self.client_id);
let request_id = request.request_id;
let params = request.params;
let clock = self.clock;
self.spawn_task("request_book_snapshot", async move {
let l2_book = http
.info_l2_book(&raw_symbol)
.await
.with_context(|| format!("book snapshot request failed for {instrument_id}"))?;
let book = parse_l2_book_snapshot(
&l2_book,
instrument_id,
price_precision,
size_precision,
depth,
);
let response = DataResponse::Book(BookResponse::new(
request_id,
client_id,
instrument_id,
book,
None,
None,
clock.get_time_ns(),
params,
));
if let Err(e) = sender.send(DataEvent::Response(response)) {
log::error!("Failed to send book snapshot response: {e}");
}
Ok(())
});
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum MarketDataChannel {
Deltas,
Depth10,
Quote,
}
impl MarketDataChannel {
const fn as_str(self) -> &'static str {
match self {
Self::Deltas => "deltas",
Self::Depth10 => "depth10",
Self::Quote => "quote",
}
}
}
type MarketDataStreamKey = (MarketDataChannel, InstrumentId);
#[derive(Debug, Clone)]
struct MarketDataStreamHealth {
last_receive_at: Instant,
last_venue_ts_event: Option<UnixNanos>,
consecutive_stale_count: u32,
last_warning_at: Option<Instant>,
last_recovery_at: Option<Instant>,
resubscribe_attempts: u32,
}
impl MarketDataStreamHealth {
fn new(receive_at: Instant) -> Self {
Self {
last_receive_at: receive_at,
last_venue_ts_event: None,
consecutive_stale_count: 0,
last_warning_at: None,
last_recovery_at: None,
resubscribe_attempts: 0,
}
}
fn record_receive(&mut self, receive_at: Instant, venue_ts_event: UnixNanos) {
self.last_receive_at = receive_at;
self.last_venue_ts_event = Some(venue_ts_event);
self.consecutive_stale_count = 0;
self.last_warning_at = None;
self.last_recovery_at = None;
self.resubscribe_attempts = 0;
}
}
#[derive(Debug, Clone, Copy)]
struct StreamRecoveryConfig {
cooldown: Duration,
max_targeted_resubscribes: u32,
}
#[derive(Debug)]
struct MarketDataStreamHealthMonitor {
stale_receive_threshold: Duration,
warning_cooldown: Duration,
recovery: Option<StreamRecoveryConfig>,
streams: AHashMap<MarketDataStreamKey, MarketDataStreamHealth>,
}
impl MarketDataStreamHealthMonitor {
fn new(stale_receive_threshold: Duration, warning_cooldown: Duration) -> Self {
Self {
stale_receive_threshold,
warning_cooldown,
recovery: None,
streams: AHashMap::new(),
}
}
fn with_recovery(mut self, cooldown: Duration, max_targeted_resubscribes: u32) -> Self {
self.recovery = Some(StreamRecoveryConfig {
cooldown,
max_targeted_resubscribes,
});
self
}
fn subscribe(
&mut self,
channel: MarketDataChannel,
instrument_id: InstrumentId,
receive_at: Instant,
) {
self.streams.insert(
(channel, instrument_id),
MarketDataStreamHealth::new(receive_at),
);
}
fn unsubscribe(&mut self, channel: MarketDataChannel, instrument_id: InstrumentId) {
self.streams.remove(&(channel, instrument_id));
}
fn clear(&mut self) {
self.streams.clear();
}
fn record_receive(
&mut self,
channel: MarketDataChannel,
instrument_id: InstrumentId,
receive_at: Instant,
venue_ts_event: UnixNanos,
) {
if let Some(stream) = self.streams.get_mut(&(channel, instrument_id)) {
stream.record_receive(receive_at, venue_ts_event);
}
}
fn check_stale(
&mut self,
now: Instant,
wall_clock_now: UnixNanos,
) -> Vec<MarketDataStaleEvent> {
let fresh_quote_instruments: AHashSet<InstrumentId> = self
.streams
.iter()
.filter(|((channel, _), stream)| {
*channel == MarketDataChannel::Quote
&& now.saturating_duration_since(stream.last_receive_at)
< self.stale_receive_threshold
})
.map(|((_, instrument_id), _)| *instrument_id)
.collect();
let mut events = Vec::new();
for ((channel, instrument_id), stream) in &mut self.streams {
let receive_age = now.saturating_duration_since(stream.last_receive_at);
if receive_age < self.stale_receive_threshold {
stream.consecutive_stale_count = 0;
continue;
}
stream.consecutive_stale_count = stream.consecutive_stale_count.saturating_add(1);
let quote_is_fresh = matches!(
channel,
MarketDataChannel::Deltas | MarketDataChannel::Depth10
) && fresh_quote_instruments.contains(instrument_id);
let venue_age = stream.last_venue_ts_event.map(|ts_event| {
Duration::from_nanos(wall_clock_now.as_u64().saturating_sub(ts_event.as_u64()))
});
if let Some(recovery) = self.recovery {
let stale_since = stream.last_receive_at + self.stale_receive_threshold;
let anchor = stream.last_recovery_at.unwrap_or(stale_since);
if stream.last_warning_at.is_some()
&& now.saturating_duration_since(anchor) >= recovery.cooldown
{
let action = if stream.resubscribe_attempts < recovery.max_targeted_resubscribes
{
stream.resubscribe_attempts += 1;
StaleStreamAction::Resubscribe
} else {
stream.resubscribe_attempts = 0;
StaleStreamAction::Reconnect
};
stream.last_recovery_at = Some(now);
stream.last_warning_at = Some(now);
events.push(MarketDataStaleEvent {
channel: *channel,
instrument_id: *instrument_id,
receive_age,
venue_age,
stale_count: stream.consecutive_stale_count,
action,
cooldown: recovery.cooldown,
quote_is_fresh,
});
continue;
}
}
let should_warn = stream.last_warning_at.is_none_or(|last_warning_at| {
now.saturating_duration_since(last_warning_at) >= self.warning_cooldown
});
if !should_warn {
continue;
}
stream.last_warning_at = Some(now);
events.push(MarketDataStaleEvent {
channel: *channel,
instrument_id: *instrument_id,
receive_age,
venue_age,
stale_count: stream.consecutive_stale_count,
action: StaleStreamAction::Warn,
cooldown: self.warning_cooldown,
quote_is_fresh,
});
}
events
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StaleStreamAction {
Warn,
Resubscribe,
Reconnect,
}
impl StaleStreamAction {
const fn as_str(self) -> &'static str {
match self {
Self::Warn => "warn",
Self::Resubscribe => "resubscribe",
Self::Reconnect => "reconnect",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MarketDataStaleEvent {
channel: MarketDataChannel,
instrument_id: InstrumentId,
receive_age: Duration,
venue_age: Option<Duration>,
stale_count: u32,
action: StaleStreamAction,
cooldown: Duration,
quote_is_fresh: bool,
}
fn stream_health_update(
msg: &NautilusWsMessage,
) -> Option<(MarketDataChannel, InstrumentId, UnixNanos)> {
match msg {
NautilusWsMessage::Quote(quote) => Some((
MarketDataChannel::Quote,
quote.instrument_id,
quote.ts_event,
)),
NautilusWsMessage::Deltas(deltas) => Some((
MarketDataChannel::Deltas,
deltas.instrument_id,
deltas.ts_event,
)),
NautilusWsMessage::Depth10(depth) => Some((
MarketDataChannel::Depth10,
depth.instrument_id,
depth.ts_event,
)),
_ => None,
}
}
fn record_stream_receive(
stream_health: &Arc<Mutex<MarketDataStreamHealthMonitor>>,
channel: MarketDataChannel,
instrument_id: InstrumentId,
venue_ts_event: UnixNanos,
) {
stream_health.lock().expect(MUTEX_POISONED).record_receive(
channel,
instrument_id,
Instant::now(),
venue_ts_event,
);
}
fn log_stream_health_event(event: &MarketDataStaleEvent) {
let venue_age_ms = event
.venue_age
.map_or_else(|| "n/a".to_string(), |age| age.as_millis().to_string());
let prefix = if event.quote_is_fresh {
"Hyperliquid book stream stale while bbo advances"
} else {
"Hyperliquid market data stream stale"
};
log::warn!(
"{prefix}: channel={}, instrument_id={}, receive_age_ms={}, venue_age_ms={}, \
stale_count={}, action={}, cooldown_secs={}",
event.channel.as_str(),
event.instrument_id,
event.receive_age.as_millis(),
venue_age_ms,
event.stale_count,
event.action.as_str(),
event.cooldown.as_secs(),
);
}
async fn handle_stream_health_events(
ws_client: &HyperliquidWebSocketClient,
events: &[MarketDataStaleEvent],
) {
let mut resubscribed_books: AHashSet<InstrumentId> = AHashSet::new();
let mut reconnect_requested = false;
for event in events {
log_stream_health_event(event);
match event.action {
StaleStreamAction::Warn => {}
StaleStreamAction::Resubscribe => match event.channel {
MarketDataChannel::Deltas | MarketDataChannel::Depth10 => {
if resubscribed_books.insert(event.instrument_id)
&& let Err(e) = ws_client.resubscribe_book(event.instrument_id).await
{
log::warn!(
"Failed targeted l2Book resubscribe for {}: {e}",
event.instrument_id,
);
}
}
MarketDataChannel::Quote => {
if let Err(e) = ws_client.resubscribe_quotes(event.instrument_id).await {
log::warn!(
"Failed targeted bbo resubscribe for {}: {e}",
event.instrument_id,
);
}
}
},
StaleStreamAction::Reconnect => reconnect_requested = true,
}
}
if reconnect_requested {
if ws_client.request_reconnect() {
log::warn!("Requested full WebSocket reconnect after failed targeted stream recovery");
} else {
log::debug!("Skipping reconnect request: connection not active");
}
}
}
fn filter_recent_trades(
trades: Vec<TradeTick>,
start: Option<UnixNanos>,
end: Option<UnixNanos>,
limit: Option<usize>,
instrument_id: InstrumentId,
) -> Vec<TradeTick> {
let Some(floor) = trades.first().map(|trade| trade.ts_event) else {
return Vec::new();
};
if let Some(end) = end
&& end < floor
{
log::warn!(
"Recent trades for {instrument_id} are entirely older than the requested window; \
snapshot only covers back to {}",
unix_nanos_to_iso8601(floor),
);
return Vec::new();
}
if let Some(start) = start
&& start < floor
{
log::warn!(
"Recent trades for {instrument_id} only cover back to {}; \
the requested start is earlier and cannot be served",
unix_nanos_to_iso8601(floor),
);
}
let mut filtered: Vec<TradeTick> = trades
.into_iter()
.filter(|trade| start.is_none_or(|s| trade.ts_event >= s))
.filter(|trade| end.is_none_or(|e| trade.ts_event <= e))
.collect();
if let Some(limit) = limit
&& filtered.len() > limit
{
filtered.drain(0..filtered.len() - limit);
}
filtered
}
pub(crate) fn parse_l2_book_snapshot(
l2_book: &HyperliquidL2Book,
instrument_id: InstrumentId,
price_precision: u8,
size_precision: u8,
depth: Option<usize>,
) -> OrderBook {
let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
let ts_event = UnixNanos::from(l2_book.time * 1_000_000);
let all_bids = l2_book
.levels
.first()
.map_or([].as_slice(), |v| v.as_slice());
let all_asks = l2_book
.levels
.get(1)
.map_or([].as_slice(), |v| v.as_slice());
let bids = match depth {
Some(d) if d < all_bids.len() => &all_bids[..d],
_ => all_bids,
};
let asks = match depth {
Some(d) if d < all_asks.len() => &all_asks[..d],
_ => all_asks,
};
for (i, level) in bids.iter().enumerate() {
if level.sz <= Decimal::ZERO {
continue;
}
let Ok(price) = Price::from_decimal_dp(level.px, price_precision) else {
continue;
};
let Ok(size) = Quantity::from_decimal_dp(level.sz, size_precision) else {
continue;
};
let order = BookOrder::new(OrderSide::Buy, price, size, i as u64);
book.add(order, 0, i as u64, ts_event);
}
let bids_len = bids.len();
for (i, level) in asks.iter().enumerate() {
if level.sz <= Decimal::ZERO {
continue;
}
let Ok(price) = Price::from_decimal_dp(level.px, price_precision) else {
continue;
};
let Ok(size) = Quantity::from_decimal_dp(level.sz, size_precision) else {
continue;
};
let order = BookOrder::new(OrderSide::Sell, price, size, (bids_len + i) as u64);
book.add(order, 0, (bids_len + i) as u64, ts_event);
}
log::debug!(
"Built order book for {instrument_id} with {} bids and {} asks",
bids.len(),
asks.len(),
);
book
}
pub(crate) fn parse_book_precision_params(
params: Option<&Params>,
) -> anyhow::Result<(Option<u32>, Option<u32>)> {
let Some(params) = params else {
return Ok((None, None));
};
let read_u32 = |key: &str| -> anyhow::Result<Option<u32>> {
match params.get(key) {
None => Ok(None),
Some(v) => v
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| anyhow::anyhow!("`{key}` must be a positive u32"))
.map(Some),
}
};
Ok((read_u32("n_sig_figs")?, read_u32("mantissa")?))
}
pub(crate) fn funding_entry_to_update(
entry: &HyperliquidFundingHistoryEntry,
instrument_id: InstrumentId,
) -> FundingRateUpdate {
let rate = entry.funding_rate;
let ts = UnixNanos::from(entry.time * 1_000_000);
FundingRateUpdate::new(instrument_id, rate, Some(60), None, ts, ts)
}
pub(crate) fn candle_to_bar(
candle: &HyperliquidCandle,
bar_type: BarType,
price_precision: u8,
size_precision: u8,
) -> anyhow::Result<Bar> {
let ts_init = UnixNanos::from(candle.timestamp * 1_000_000);
let ts_event = ts_init;
let open = Price::from_decimal_dp(candle.open, price_precision)
.map_err(|e| anyhow::anyhow!("invalid open price: {e}"))?;
let high = Price::from_decimal_dp(candle.high, price_precision)
.map_err(|e| anyhow::anyhow!("invalid high price: {e}"))?;
let low = Price::from_decimal_dp(candle.low, price_precision)
.map_err(|e| anyhow::anyhow!("invalid low price: {e}"))?;
let close = Price::from_decimal_dp(candle.close, price_precision)
.map_err(|e| anyhow::anyhow!("invalid close price: {e}"))?;
let volume = Quantity::from_decimal_dp(candle.volume, size_precision)
.map_err(|e| anyhow::anyhow!("invalid volume: {e}"))?;
Ok(Bar::new(
bar_type, open, high, low, close, volume, ts_event, ts_init,
))
}
async fn request_bars_from_http(
http_client: HyperliquidHttpClient,
bar_type: BarType,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
limit: Option<u32>,
instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
) -> anyhow::Result<Vec<Bar>> {
let instrument_id = bar_type.instrument_id();
let instrument = instruments
.load()
.get(&instrument_id)
.cloned()
.context("instrument not found in cache")?;
let price_precision = instrument.price_precision();
let size_precision = instrument.size_precision();
let raw_symbol = instrument.raw_symbol();
let coin = raw_symbol.as_str();
let interval = bar_type_to_interval(&bar_type)?;
let now = Utc::now();
let end_time = end.unwrap_or(now).timestamp_millis() as u64;
let start_time = if let Some(start) = start {
start.timestamp_millis() as u64
} else {
let spec = bar_type.spec();
let step_ms = match spec.aggregation {
BarAggregation::Minute => spec.step.get() as u64 * 60_000,
BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
_ => 60_000,
};
end_time.saturating_sub(1000 * step_ms)
};
let candles = http_client
.info_candle_snapshot(coin, interval, start_time, end_time)
.await
.context("failed to fetch candle snapshot from Hyperliquid")?;
let mut bars: Vec<Bar> = candles
.iter()
.filter_map(|candle| {
candle_to_bar(candle, bar_type, price_precision, size_precision)
.map_err(|e| {
log::warn!("Failed to convert candle to bar: {e}");
e
})
.ok()
})
.collect();
if let Some(limit) = limit
&& bars.len() > limit as usize
{
bars = bars.into_iter().take(limit as usize).collect();
}
log::debug!("Fetched {} bars for {}", bars.len(), bar_type);
Ok(bars)
}
#[cfg(test)]
mod tests {
use nautilus_common::live::runner::set_data_event_sender;
use nautilus_model::{
data::{
QuoteTick,
stubs::{stub_deltas, stub_depth10},
},
enums::AggressorSide,
identifiers::TradeId,
};
use rstest::rstest;
use rust_decimal_macros::dec;
use ustr::Ustr;
use super::*;
use crate::common::testing::load_test_data;
fn btc_perp_id() -> InstrumentId {
InstrumentId::from("BTC-PERP.HYPERLIQUID")
}
#[rstest]
fn test_stream_health_monitor_fresh_stream_does_not_warn() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
let warnings = monitor.check_stale(
start + Duration::from_secs(4),
UnixNanos::from(4_000_000_000),
);
assert!(warnings.is_empty());
}
#[rstest]
fn test_stream_health_monitor_warns_once_after_threshold() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
monitor.record_receive(
MarketDataChannel::Quote,
instrument_id,
start + Duration::from_secs(1),
UnixNanos::from(1_000_000_000),
);
let warnings = monitor.check_stale(
start + Duration::from_secs(7),
UnixNanos::from(9_000_000_000),
);
assert_eq!(
warnings,
vec![MarketDataStaleEvent {
channel: MarketDataChannel::Quote,
instrument_id,
receive_age: Duration::from_secs(6),
venue_age: Some(Duration::from_secs(8)),
stale_count: 1,
action: StaleStreamAction::Warn,
cooldown: Duration::from_secs(30),
quote_is_fresh: false,
}]
);
}
#[rstest]
fn test_stream_health_monitor_warns_at_receive_threshold() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
let warnings = monitor.check_stale(
start + Duration::from_secs(5),
UnixNanos::from(5_000_000_000),
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].receive_age, Duration::from_secs(5));
assert_eq!(warnings[0].stale_count, 1);
}
#[rstest]
fn test_stream_health_monitor_new_update_resets_age_and_stale_count() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Depth10, instrument_id, start);
assert_eq!(
monitor
.check_stale(
start + Duration::from_secs(6),
UnixNanos::from(6_000_000_000),
)
.len(),
1,
);
monitor.record_receive(
MarketDataChannel::Depth10,
instrument_id,
start + Duration::from_secs(7),
UnixNanos::from(7_000_000_000),
);
assert!(
monitor
.check_stale(
start + Duration::from_secs(11),
UnixNanos::from(11_000_000_000),
)
.is_empty()
);
let warnings = monitor.check_stale(
start + Duration::from_secs(13),
UnixNanos::from(13_000_000_000),
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].stale_count, 1);
assert_eq!(warnings[0].receive_age, Duration::from_secs(6));
}
#[rstest]
fn test_stream_health_monitor_unsubscribe_removes_stream() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
monitor.unsubscribe(MarketDataChannel::Deltas, instrument_id);
let warnings = monitor.check_stale(
start + Duration::from_secs(6),
UnixNanos::from(6_000_000_000),
);
assert!(warnings.is_empty());
}
#[rstest]
#[case(0, 15)]
#[case(120, 0)]
fn test_data_client_stream_health_config_zero_disables_monitor(
#[case] stale_receive_timeout_secs: u64,
#[case] check_interval_secs: u64,
) {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
set_data_event_sender(tx);
let client = HyperliquidDataClient::new(
*crate::common::consts::HYPERLIQUID_CLIENT_ID,
HyperliquidDataClientConfig {
stale_stream_receive_timeout_secs: stale_receive_timeout_secs,
stream_health_check_interval_secs: check_interval_secs,
..HyperliquidDataClientConfig::default()
},
)
.unwrap();
let instrument_id = btc_perp_id();
let start = Instant::now();
assert!(!client.stream_health_monitor_enabled());
client.register_stream_health(MarketDataChannel::Deltas, instrument_id);
let warnings = client
.stream_health
.lock()
.expect(MUTEX_POISONED)
.check_stale(
start + Duration::from_secs(121),
UnixNanos::from(121_000_000_000),
);
assert!(warnings.is_empty());
}
#[rstest]
fn test_data_client_recovery_requires_positive_cooldown() {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
set_data_event_sender(tx);
let client = HyperliquidDataClient::new(
*crate::common::consts::HYPERLIQUID_CLIENT_ID,
HyperliquidDataClientConfig {
stale_stream_recovery_enabled: true,
stale_stream_recovery_cooldown_secs: 0,
..HyperliquidDataClientConfig::default()
},
)
.unwrap();
assert!(
client
.stream_health
.lock()
.expect(MUTEX_POISONED)
.recovery
.is_none(),
"a zero recovery cooldown must leave the monitor observability-only",
);
}
#[rstest]
fn test_stream_health_monitor_warning_cooldown_prevents_repeated_logs() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
let first = monitor.check_stale(
start + Duration::from_secs(6),
UnixNanos::from(6_000_000_000),
);
let inside_cooldown = monitor.check_stale(
start + Duration::from_secs(7),
UnixNanos::from(7_000_000_000),
);
let second = monitor.check_stale(
start + Duration::from_secs(16),
UnixNanos::from(16_000_000_000),
);
assert_eq!(first.len(), 1);
assert!(inside_cooldown.is_empty());
assert_eq!(second.len(), 1);
assert_eq!(second[0].stale_count, 3);
}
fn check_at(
monitor: &mut MarketDataStreamHealthMonitor,
start: Instant,
secs: u64,
) -> Vec<MarketDataStaleEvent> {
monitor.check_stale(
start + Duration::from_secs(secs),
UnixNanos::from(secs * 1_000_000_000),
)
}
#[rstest]
fn test_stream_health_recovery_ladder_escalates_and_resets() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10))
.with_recovery(Duration::from_secs(30), 2);
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
let events = check_at(&mut monitor, start, 5);
assert_eq!(events.len(), 1);
assert_eq!(events[0].action, StaleStreamAction::Warn);
let events = check_at(&mut monitor, start, 20);
assert_eq!(events[0].action, StaleStreamAction::Warn);
let events = check_at(&mut monitor, start, 35);
assert_eq!(
events,
vec![MarketDataStaleEvent {
channel: MarketDataChannel::Deltas,
instrument_id,
receive_age: Duration::from_secs(35),
venue_age: None,
stale_count: 3,
action: StaleStreamAction::Resubscribe,
cooldown: Duration::from_secs(30),
quote_is_fresh: false,
}],
);
let events = check_at(&mut monitor, start, 50);
assert_eq!(events[0].action, StaleStreamAction::Warn);
let events = check_at(&mut monitor, start, 65);
assert_eq!(events[0].action, StaleStreamAction::Resubscribe);
let events = check_at(&mut monitor, start, 95);
assert_eq!(events[0].action, StaleStreamAction::Reconnect);
let events = check_at(&mut monitor, start, 125);
assert_eq!(events[0].action, StaleStreamAction::Resubscribe);
}
#[rstest]
fn test_stream_health_recovery_first_breach_warns_even_past_cooldown() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10))
.with_recovery(Duration::from_secs(1), 1);
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
let events = check_at(&mut monitor, start, 40);
assert_eq!(events.len(), 1);
assert_eq!(events[0].action, StaleStreamAction::Warn);
let events = check_at(&mut monitor, start, 41);
assert_eq!(events.len(), 1);
assert_eq!(events[0].action, StaleStreamAction::Resubscribe);
}
#[rstest]
fn test_stream_health_receive_resets_recovery_state() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(10))
.with_recovery(Duration::from_secs(10), 1);
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
assert_eq!(
check_at(&mut monitor, start, 5)[0].action,
StaleStreamAction::Warn
);
assert_eq!(
check_at(&mut monitor, start, 15)[0].action,
StaleStreamAction::Resubscribe,
);
monitor.record_receive(
MarketDataChannel::Deltas,
instrument_id,
start + Duration::from_secs(16),
UnixNanos::from(16_000_000_000),
);
assert!(check_at(&mut monitor, start, 20).is_empty());
let events = check_at(&mut monitor, start, 21);
assert_eq!(events[0].action, StaleStreamAction::Warn);
assert_eq!(events[0].stale_count, 1);
assert_eq!(
check_at(&mut monitor, start, 31)[0].action,
StaleStreamAction::Resubscribe,
);
assert_eq!(
check_at(&mut monitor, start, 41)[0].action,
StaleStreamAction::Reconnect,
);
}
#[rstest]
fn test_check_stale_book_with_fresh_quote_flags_relative_staleness() {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
monitor.record_receive(
MarketDataChannel::Quote,
instrument_id,
start + Duration::from_secs(8),
UnixNanos::from(8_000_000_000),
);
let events = check_at(&mut monitor, start, 10);
assert_eq!(events.len(), 1, "fresh quote stream must not be reported");
assert_eq!(events[0].channel, MarketDataChannel::Deltas);
assert!(events[0].quote_is_fresh);
}
#[rstest]
#[case(true)]
#[case(false)]
fn test_check_stale_book_without_fresh_quote_is_not_flagged(#[case] quote_subscribed: bool) {
let mut monitor =
MarketDataStreamHealthMonitor::new(Duration::from_secs(5), Duration::from_secs(30));
let instrument_id = btc_perp_id();
let start = Instant::now();
monitor.subscribe(MarketDataChannel::Deltas, instrument_id, start);
if quote_subscribed {
monitor.subscribe(MarketDataChannel::Quote, instrument_id, start);
}
let events = check_at(&mut monitor, start, 10);
let deltas_event = events
.iter()
.find(|event| event.channel == MarketDataChannel::Deltas)
.expect("deltas event");
assert!(
!deltas_event.quote_is_fresh,
"a stale or absent quote stream must not flag relative staleness",
);
if quote_subscribed {
let quote_event = events
.iter()
.find(|event| event.channel == MarketDataChannel::Quote)
.expect("quote event");
assert!(!quote_event.quote_is_fresh);
}
}
#[rstest]
fn test_stream_health_update_extracts_tracked_market_data_messages() {
let quote = QuoteTick {
instrument_id: btc_perp_id(),
ts_event: UnixNanos::from(1),
..QuoteTick::default()
};
let deltas = stub_deltas();
let depth = stub_depth10();
assert_eq!(
stream_health_update(&NautilusWsMessage::Quote(quote)),
Some((
MarketDataChannel::Quote,
quote.instrument_id,
quote.ts_event
)),
);
assert_eq!(
stream_health_update(&NautilusWsMessage::Deltas(deltas.clone())),
Some((
MarketDataChannel::Deltas,
deltas.instrument_id,
deltas.ts_event
)),
);
assert_eq!(
stream_health_update(&NautilusWsMessage::Depth10(Box::new(depth))),
Some((
MarketDataChannel::Depth10,
depth.instrument_id,
depth.ts_event
)),
);
assert_eq!(stream_health_update(&NautilusWsMessage::Reconnected), None,);
}
#[rstest]
fn test_funding_entry_to_update_parses_positive_rate() {
let entry = HyperliquidFundingHistoryEntry {
coin: Ustr::from("BTC"),
funding_rate: dec!(0.0000125),
premium: Some(dec!(0.00029005)),
time: 1769908800000,
};
let instrument_id = btc_perp_id();
let update = funding_entry_to_update(&entry, instrument_id);
assert_eq!(update.instrument_id, instrument_id);
assert_eq!(update.rate, dec!(0.0000125));
assert_eq!(update.interval, Some(60));
assert!(update.next_funding_ns.is_none());
assert_eq!(update.ts_event, UnixNanos::from(1769908800000 * 1_000_000));
assert_eq!(update.ts_init, update.ts_event);
}
#[rstest]
fn test_funding_entry_to_update_handles_negative_rate() {
let entry = HyperliquidFundingHistoryEntry {
coin: Ustr::from("BTC"),
funding_rate: dec!(-0.0000081),
premium: None,
time: 1769912400000,
};
let update = funding_entry_to_update(&entry, btc_perp_id());
assert_eq!(update.rate, dec!(-0.0000081));
}
#[rstest]
fn test_funding_history_entry_rejects_invalid_rate() {
let json = r#"{"coin":"BTC","fundingRate":"not-a-number","time":1769912400000}"#;
assert!(serde_json::from_str::<HyperliquidFundingHistoryEntry>(json).is_err());
}
#[rstest]
fn test_parse_book_precision_params_none() {
let (n, m) = parse_book_precision_params(None).unwrap();
assert_eq!(n, None);
assert_eq!(m, None);
}
fn make_params(json: serde_json::Value) -> Params {
serde_json::from_value(json).expect("valid params payload")
}
#[rstest]
fn test_parse_book_precision_params_only_n_sig_figs() {
let params = make_params(serde_json::json!({"n_sig_figs": 4}));
let (n, m) = parse_book_precision_params(Some(¶ms)).unwrap();
assert_eq!(n, Some(4));
assert_eq!(m, None);
}
#[rstest]
fn test_parse_book_precision_params_both() {
let params = make_params(serde_json::json!({"n_sig_figs": 5, "mantissa": 2}));
let (n, m) = parse_book_precision_params(Some(¶ms)).unwrap();
assert_eq!(n, Some(5));
assert_eq!(m, Some(2));
}
#[rstest]
fn test_parse_book_precision_params_rejects_negative() {
let params = make_params(serde_json::json!({"n_sig_figs": -1}));
let err = parse_book_precision_params(Some(¶ms)).unwrap_err();
assert!(err.to_string().contains("n_sig_figs"));
}
#[rstest]
fn test_funding_history_fixture_parses() {
let entries: Vec<HyperliquidFundingHistoryEntry> =
load_test_data("http_funding_history.json");
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].coin.as_str(), "BTC");
assert_eq!(entries[0].funding_rate, dec!(0.0000125));
assert_eq!(entries[0].premium, Some(dec!(0.00029005)));
assert!(entries[2].premium.is_none());
let updates: Vec<FundingRateUpdate> = entries
.iter()
.map(|e| funding_entry_to_update(e, btc_perp_id()))
.collect();
assert_eq!(updates.len(), 3);
assert_eq!(updates[0].rate, dec!(0.0000125));
assert_eq!(updates[1].rate, dec!(-0.0000081));
assert_eq!(updates[2].rate, dec!(0.0000033));
}
fn level(px: &str, sz: &str) -> crate::http::models::HyperliquidLevel {
crate::http::models::HyperliquidLevel {
px: px.parse().unwrap(),
sz: sz.parse().unwrap(),
}
}
fn sample_l2_book() -> HyperliquidL2Book {
HyperliquidL2Book {
coin: Ustr::from("BTC"),
levels: vec![
vec![
level("98450.50", "2.5"),
level("98449.00", "1.2"),
level("98448.00", "0.8"),
],
vec![
level("98451.00", "1.5"),
level("98452.00", "2.0"),
level("98453.00", "0.5"),
],
],
time: 1769908800000,
}
}
#[rstest]
fn test_parse_l2_book_snapshot_populates_both_sides() {
let book_data = sample_l2_book();
let instrument_id = btc_perp_id();
let book = parse_l2_book_snapshot(&book_data, instrument_id, 2, 4, None);
assert_eq!(book.instrument_id, instrument_id);
assert_eq!(book.book_type, BookType::L2_MBP);
assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
assert_eq!(book.best_ask_price(), Some(Price::new(98451.00, 2)));
assert_eq!(book.best_bid_size(), Some(Quantity::new(2.5, 4)));
assert_eq!(book.best_ask_size(), Some(Quantity::new(1.5, 4)));
assert_eq!(book.update_count, 6);
}
#[rstest]
fn test_parse_l2_book_snapshot_truncates_to_depth() {
let book_data = sample_l2_book();
let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, Some(1));
assert_eq!(book.update_count, 2);
assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
assert_eq!(book.best_ask_price(), Some(Price::new(98451.00, 2)));
}
#[rstest]
fn test_parse_l2_book_snapshot_uses_venue_time_as_ts_event() {
let book_data = sample_l2_book();
let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
let expected_ts = UnixNanos::from(1769908800000_u64 * 1_000_000);
assert_eq!(book.ts_last, expected_ts);
}
#[rstest]
fn test_parse_l2_book_snapshot_skips_non_positive_size() {
let book_data = HyperliquidL2Book {
coin: Ustr::from("BTC"),
levels: vec![
vec![level("98450.50", "2.5"), level("98449.00", "0")],
vec![level("98451.00", "0"), level("98452.00", "1.5")],
],
time: 1769908800000,
};
let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
assert_eq!(book.update_count, 2, "zero-sized levels must be skipped");
assert_eq!(book.best_bid_price(), Some(Price::new(98450.50, 2)));
assert_eq!(book.best_ask_price(), Some(Price::new(98452.00, 2)));
}
#[rstest]
fn test_parse_l2_book_snapshot_skips_zero_size_levels() {
let book_data = HyperliquidL2Book {
coin: Ustr::from("BTC"),
levels: vec![
vec![level("98448.00", "0.0"), level("98449.00", "1.2")],
vec![level("98451.00", "0.0"), level("98452.00", "1.5")],
],
time: 1769908800000,
};
let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
assert_eq!(book.update_count, 2);
assert_eq!(book.best_bid_price(), Some(Price::new(98449.00, 2)));
assert_eq!(book.best_ask_price(), Some(Price::new(98452.00, 2)));
}
#[rstest]
fn test_parse_l2_book_snapshot_empty_levels_yields_empty_book() {
let book_data = HyperliquidL2Book {
coin: Ustr::from("BTC"),
levels: vec![],
time: 1769908800000,
};
let book = parse_l2_book_snapshot(&book_data, btc_perp_id(), 2, 4, None);
assert_eq!(book.update_count, 0);
assert!(book.best_bid_price().is_none());
assert!(book.best_ask_price().is_none());
}
fn trade_at(ts_ns: u64, tid: u64) -> TradeTick {
TradeTick::new(
btc_perp_id(),
Price::from("104300.0"),
Quantity::from("0.01000"),
AggressorSide::Buyer,
TradeId::new(tid.to_string()),
UnixNanos::from(ts_ns),
UnixNanos::from(ts_ns),
)
}
fn sample_trades() -> Vec<TradeTick> {
vec![trade_at(1000, 1), trade_at(2000, 2), trade_at(3000, 3)]
}
#[rstest]
fn test_recent_trades_fixture_parses_and_sorts() {
let raw: Vec<crate::http::models::HyperliquidRecentTrade> =
load_test_data("http_recent_trades_btc.json");
assert_eq!(raw.len(), 3);
assert_eq!(raw[0].tid, 300003);
let meta: crate::http::models::PerpMeta = load_test_data("http_meta_perp_sample.json");
let defs = crate::http::parse::parse_perp_instruments(&meta, 0).unwrap();
let instrument =
crate::http::parse::create_instrument_from_def(&defs[0], UnixNanos::default()).unwrap();
let mut trades: Vec<TradeTick> = raw
.iter()
.map(|t| parse_recent_trade(t, &instrument).unwrap())
.collect();
trades.sort_by_key(|trade| trade.ts_event);
assert_eq!(trades[0].trade_id.to_string(), "300001");
assert_eq!(trades[2].trade_id.to_string(), "300003");
assert!(trades[0].ts_event <= trades[2].ts_event);
assert_eq!(trades[0].ts_init, trades[0].ts_event);
}
#[rstest]
fn test_filter_recent_trades_full_window_returns_all() {
let filtered = filter_recent_trades(sample_trades(), None, None, None, btc_perp_id());
assert_eq!(filtered.len(), 3);
}
#[rstest]
fn test_filter_recent_trades_empty_snapshot_returns_empty() {
let filtered = filter_recent_trades(
Vec::new(),
Some(UnixNanos::from(500)),
Some(UnixNanos::from(2500)),
None,
btc_perp_id(),
);
assert!(filtered.is_empty());
}
#[rstest]
fn test_filter_recent_trades_entirely_older_returns_empty() {
let filtered = filter_recent_trades(
sample_trades(),
Some(UnixNanos::from(100)),
Some(UnixNanos::from(500)),
None,
btc_perp_id(),
);
assert!(filtered.is_empty());
}
#[rstest]
fn test_filter_recent_trades_partial_keeps_in_range_subset() {
let filtered = filter_recent_trades(
sample_trades(),
Some(UnixNanos::from(500)),
Some(UnixNanos::from(2500)),
None,
btc_perp_id(),
);
let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
assert_eq!(ts, vec![1000, 2000]);
}
#[rstest]
fn test_filter_recent_trades_within_window_filters_bounds() {
let filtered = filter_recent_trades(
sample_trades(),
Some(UnixNanos::from(1500)),
Some(UnixNanos::from(3000)),
None,
btc_perp_id(),
);
let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
assert_eq!(ts, vec![2000, 3000]);
}
#[rstest]
fn test_filter_recent_trades_limit_keeps_most_recent() {
let filtered = filter_recent_trades(sample_trades(), None, None, Some(2), btc_perp_id());
let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
assert_eq!(ts, vec![2000, 3000]);
}
#[rstest]
fn test_filter_recent_trades_end_equal_to_floor_keeps_floor_trade() {
let filtered = filter_recent_trades(
sample_trades(),
None,
Some(UnixNanos::from(1000)),
None,
btc_perp_id(),
);
let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
assert_eq!(ts, vec![1000]);
}
#[rstest]
fn test_filter_recent_trades_bounds_are_inclusive() {
let filtered = filter_recent_trades(
sample_trades(),
Some(UnixNanos::from(2000)),
Some(UnixNanos::from(3000)),
None,
btc_perp_id(),
);
let ts: Vec<u64> = filtered.iter().map(|t| t.ts_event.as_u64()).collect();
assert_eq!(ts, vec![2000, 3000]);
}
}