Skip to main content

nautilus_hyperliquid/websocket/
handler.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
16//! WebSocket message handler for Hyperliquid.
17
18use std::{
19    collections::{BTreeSet, VecDeque},
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24};
25
26use ahash::{AHashMap, AHashSet};
27use nautilus_common::cache::fifo::FifoCache;
28use nautilus_core::{
29    AtomicTime, MUTEX_POISONED, Params, nanos::UnixNanos, time::get_atomic_clock_realtime,
30};
31use nautilus_model::{
32    data::{BarType, CustomData, Data, DataType},
33    identifiers::{AccountId, InstrumentId},
34    instruments::{Instrument, InstrumentAny},
35    types::Price,
36};
37use nautilus_network::{
38    RECONNECTED,
39    retry::{RetryManager, create_websocket_retry_manager},
40    websocket::{SubscriptionState, WebSocketClient},
41};
42use rust_decimal::Decimal;
43use tokio_tungstenite::tungstenite::Message;
44use ustr::Ustr;
45
46use super::{
47    client::{AssetContextDataType, CloidCache},
48    enums::HyperliquidWsChannel,
49    error::HyperliquidWsError,
50    messages::{
51        CandleData, ExecutionReport, HyperliquidWsMessage, HyperliquidWsRequest, NautilusWsMessage,
52        PostRequest, SubscriptionRequest, WsActiveAssetCtxData, WsAllDexsAssetCtxsData,
53        WsUserEventData,
54    },
55    parse::{
56        parse_ws_asset_context, parse_ws_candle, parse_ws_fill_report, parse_ws_open_interest,
57        parse_ws_order_book_deltas, parse_ws_order_book_depth10, parse_ws_order_status_report,
58        parse_ws_public_trade, parse_ws_quote_tick, parse_ws_trade_tick, parse_ws_twap_history_row,
59        parse_ws_twap_slice_fill,
60    },
61    post::PostRouter,
62    trades::TradeStreamUses,
63};
64use crate::data_types::{
65    HyperliquidAllDexsAssetCtxs, HyperliquidAllMids, HyperliquidDexAssetCtx,
66    HyperliquidImpactPrices,
67};
68
69/// Commands sent from the outer client to the inner message handler.
70#[derive(Debug)]
71#[expect(
72    clippy::large_enum_variant,
73    reason = "Commands are ephemeral and immediately consumed"
74)]
75#[allow(private_interfaces)]
76pub enum HandlerCommand {
77    /// Set the WebSocketClient for the handler to use.
78    SetClient(WebSocketClient),
79    /// Disconnect the WebSocket connection.
80    Disconnect,
81    /// Subscribe to the given subscriptions.
82    Subscribe {
83        subscriptions: Vec<SubscriptionRequest>,
84    },
85    /// Unsubscribe from the given subscriptions.
86    Unsubscribe {
87        subscriptions: Vec<SubscriptionRequest>,
88    },
89    /// Send a WebSocket post request.
90    Post { id: u64, request: PostRequest },
91    /// Initialize the instruments cache with the given instruments.
92    InitializeInstruments(Vec<InstrumentAny>),
93    /// Update a single instrument in the cache.
94    UpdateInstrument(InstrumentAny),
95    /// Add a bar type mapping for candle parsing.
96    AddBarType { key: String, bar_type: BarType },
97    /// Remove a bar type mapping.
98    RemoveBarType { key: String },
99    /// Update asset context subscriptions for a coin.
100    UpdateAssetContextSubs {
101        coin: Ustr,
102        data_types: AHashSet<AssetContextDataType>,
103    },
104    /// Update the logical consumers of a `trades` stream for a coin.
105    UpdateTradeSubs { coin: Ustr, uses: TradeStreamUses },
106    /// Cache the ordered instrument IDs needed to normalize `allDexsAssetCtxs`.
107    CacheAllDexAssetCtxsInstrumentIds(AHashMap<Ustr, Vec<Option<InstrumentId>>>),
108    /// Cache spot fill coin mappings for instrument lookup.
109    CacheSpotFillCoins(AHashMap<Ustr, Ustr>),
110    /// Flag whether the `l2Book` stream for `coin` should also be emitted
111    /// as [`NautilusWsMessage::Depth10`] snapshots.
112    SetDepth10Sub { coin: Ustr, subscribed: bool },
113}
114
115#[derive(Default)]
116struct AssetContextCaches {
117    mark_price: AHashMap<Ustr, Decimal>,
118    index_price: AHashMap<Ustr, Decimal>,
119    funding_rate: AHashMap<Ustr, Decimal>,
120    open_interest: AHashMap<Ustr, Decimal>,
121}
122
123impl AssetContextCaches {
124    fn clear(&mut self, coin: Ustr, data_type: AssetContextDataType) {
125        match data_type {
126            AssetContextDataType::MarkPrice => {
127                self.mark_price.remove(&coin);
128            }
129            AssetContextDataType::IndexPrice => {
130                self.index_price.remove(&coin);
131            }
132            AssetContextDataType::FundingRate => {
133                self.funding_rate.remove(&coin);
134            }
135            AssetContextDataType::OpenInterest => {
136                self.open_interest.remove(&coin);
137            }
138        }
139    }
140
141    fn clear_removed(
142        &mut self,
143        coin: Ustr,
144        previous_data_types: Option<&AHashSet<AssetContextDataType>>,
145        next_data_types: &AHashSet<AssetContextDataType>,
146    ) {
147        let Some(previous_data_types) = previous_data_types else {
148            return;
149        };
150
151        for data_type in previous_data_types {
152            if !next_data_types.contains(data_type) {
153                self.clear(coin, *data_type);
154            }
155        }
156    }
157}
158
159#[derive(Debug)]
160struct AllMidsDataTypeCache {
161    dexes: BTreeSet<Option<String>>,
162    projected: Vec<DataType>,
163}
164
165impl Default for AllMidsDataTypeCache {
166    fn default() -> Self {
167        let mut cache = Self {
168            dexes: BTreeSet::new(),
169            projected: Vec::new(),
170        };
171        cache.rebuild();
172        cache
173    }
174}
175
176impl AllMidsDataTypeCache {
177    fn apply(&mut self, subscription: &SubscriptionRequest, subscribed: bool) {
178        let SubscriptionRequest::AllMids { dex } = subscription else {
179            return;
180        };
181        let changed = if subscribed {
182            self.dexes.insert(dex.clone())
183        } else {
184            self.dexes.remove(dex)
185        };
186
187        if changed {
188            self.rebuild();
189        }
190    }
191
192    fn as_slice(&self) -> &[DataType] {
193        &self.projected
194    }
195
196    fn rebuild(&mut self) {
197        self.projected.clear();
198        if self.dexes.is_empty() {
199            self.projected
200                .push(DataType::new("HyperliquidAllMids", None, None));
201            return;
202        }
203
204        self.projected.extend(self.dexes.iter().map(|dex| {
205            let metadata = dex.as_ref().map(|dex| {
206                let mut metadata = Params::new();
207                metadata.insert("dex".to_owned(), serde_json::Value::String(dex.clone()));
208                metadata
209            });
210            DataType::new("HyperliquidAllMids", metadata, None)
211        }));
212    }
213}
214
215pub(super) struct FeedHandler {
216    clock: &'static AtomicTime,
217    signal: Arc<AtomicBool>,
218    client: Option<WebSocketClient>,
219    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
220    raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
221    out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
222    account_id: Option<AccountId>,
223    subscriptions: SubscriptionState,
224    all_mids_data_types: AllMidsDataTypeCache,
225    post_router: Arc<PostRouter>,
226    retry_manager: RetryManager<HyperliquidWsError>,
227    message_buffer: VecDeque<NautilusWsMessage>,
228    instruments: AHashMap<Ustr, InstrumentAny>,
229    cloid_cache: CloidCache,
230    bar_types_cache: AHashMap<String, BarType>,
231    bar_cache: AHashMap<String, CandleData>,
232    asset_context_subs: AHashMap<Ustr, AHashSet<AssetContextDataType>>,
233    trade_subs: AHashMap<Ustr, TradeStreamUses>,
234    all_dex_asset_ctxs_instrument_ids: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
235    depth10_subs: AHashSet<Ustr>,
236    processed_trade_ids: FifoCache<u64, 10_000>,
237    processed_public_trade_ids: FifoCache<(Ustr, u64), 10_000>,
238    asset_context_caches: AssetContextCaches,
239}
240
241impl FeedHandler {
242    /// Creates a new [`FeedHandler`] instance.
243    #[allow(
244        clippy::too_many_arguments,
245        reason = "constructs the handler from independent runtime channels and caches"
246    )]
247    pub(super) fn new(
248        signal: Arc<AtomicBool>,
249        cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
250        raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
251        out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
252        account_id: Option<AccountId>,
253        subscriptions: SubscriptionState,
254        cloid_cache: CloidCache,
255        post_router: Arc<PostRouter>,
256    ) -> Self {
257        Self {
258            clock: get_atomic_clock_realtime(),
259            signal,
260            client: None,
261            cmd_rx,
262            raw_rx,
263            out_tx,
264            account_id,
265            subscriptions,
266            all_mids_data_types: AllMidsDataTypeCache::default(),
267            post_router,
268            retry_manager: create_websocket_retry_manager(),
269            message_buffer: VecDeque::new(),
270            instruments: AHashMap::new(),
271            cloid_cache,
272            bar_types_cache: AHashMap::new(),
273            bar_cache: AHashMap::new(),
274            asset_context_subs: AHashMap::new(),
275            trade_subs: AHashMap::new(),
276            all_dex_asset_ctxs_instrument_ids: AHashMap::new(),
277            depth10_subs: AHashSet::new(),
278            processed_trade_ids: FifoCache::new(),
279            processed_public_trade_ids: FifoCache::new(),
280            asset_context_caches: AssetContextCaches::default(),
281        }
282    }
283
284    /// Send a message to the output channel.
285    pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
286        self.out_tx
287            .send(msg)
288            .map_err(|e| format!("Failed to send message: {e}"))
289    }
290
291    /// Check if the handler has received a stop signal.
292    pub(super) fn is_stopped(&self) -> bool {
293        self.signal.load(Ordering::Relaxed)
294    }
295
296    async fn send_with_retry(&self, payload: String) -> anyhow::Result<()> {
297        if let Some(client) = &self.client {
298            self.retry_manager
299                .execute_with_retry(
300                    "websocket_send",
301                    || {
302                        let payload = payload.clone();
303                        async move {
304                            client.send_text(payload, None).await.map_err(|e| {
305                                HyperliquidWsError::ClientError(format!("Send failed: {e}"))
306                            })
307                        }
308                    },
309                    should_retry_hyperliquid_error,
310                    |e| create_hyperliquid_timeout_error(e.to_string()),
311                )
312                .await
313                .map_err(|e| anyhow::anyhow!("{e}"))
314        } else {
315            Err(anyhow::anyhow!("No WebSocket client available"))
316        }
317    }
318
319    pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
320        if let Some(msg) = self.message_buffer.pop_front() {
321            return Some(msg);
322        }
323
324        loop {
325            tokio::select! {
326                Some(cmd) = self.cmd_rx.recv() => {
327                    match cmd {
328                        HandlerCommand::SetClient(client) => {
329                            log::debug!("Setting WebSocket client in handler");
330                            self.client = Some(client);
331                        }
332                        HandlerCommand::Disconnect => {
333                            log::debug!("Handler received disconnect command");
334
335                            if let Some(ref client) = self.client {
336                                client.disconnect().await;
337                            }
338                            self.signal.store(true, Ordering::SeqCst);
339                            return None;
340                        }
341                        HandlerCommand::Subscribe { subscriptions } => {
342                            for subscription in subscriptions {
343                                let key = subscription_to_key(&subscription);
344                                self.subscriptions.mark_subscribe(&key);
345                                self.all_mids_data_types.apply(&subscription, true);
346
347                                let request = HyperliquidWsRequest::Subscribe { subscription };
348                                match serde_json::to_string(&request) {
349                                    Ok(payload) => {
350                                        log::debug!("Sending subscribe payload ({} bytes)", payload.len());
351                                        if let Err(e) = self.send_with_retry(payload).await {
352                                            log::error!("Error subscribing to {key}: {e}");
353                                            self.subscriptions.mark_failure(&key);
354                                        }
355                                    }
356                                    Err(e) => {
357                                        log::error!("Error serializing subscription for {key}: {e}");
358                                        self.subscriptions.mark_failure(&key);
359                                    }
360                                }
361                            }
362                        }
363                        HandlerCommand::Unsubscribe { subscriptions } => {
364                            for subscription in subscriptions {
365                                let key = subscription_to_key(&subscription);
366                                self.subscriptions.mark_unsubscribe(&key);
367                                self.all_mids_data_types.apply(&subscription, false);
368
369                                let request = HyperliquidWsRequest::Unsubscribe { subscription };
370                                match serde_json::to_string(&request) {
371                                    Ok(payload) => {
372                                        log::debug!("Sending unsubscribe payload ({} bytes)", payload.len());
373                                        if let Err(e) = self.send_with_retry(payload).await {
374                                            log::error!("Error unsubscribing from {key}: {e}");
375                                        }
376                                    }
377                                    Err(e) => {
378                                        log::error!("Error serializing unsubscription for {key}: {e}");
379                                    }
380                                }
381                            }
382                        }
383                        HandlerCommand::Post { id, request } => {
384                            let request = HyperliquidWsRequest::Post { id, request };
385                            match serde_json::to_string(&request) {
386                                Ok(payload) => {
387                                    log::debug!("Sending post payload: id={id}");
388                                    if let Err(e) = self.send_with_retry(payload).await {
389                                        log::error!("Error sending post request id={id}: {e}");
390                                        self.post_router.cancel(id).await;
391                                    }
392                                }
393                                Err(e) => {
394                                    log::error!("Error serializing post request id={id}: {e}");
395                                    self.post_router.cancel(id).await;
396                                }
397                            }
398                        }
399                        HandlerCommand::InitializeInstruments(instruments) => {
400                            for inst in instruments {
401                                let coin = inst.raw_symbol().inner();
402                                self.instruments.insert(coin, inst);
403                            }
404                        }
405                        HandlerCommand::UpdateInstrument(inst) => {
406                            let coin = inst.raw_symbol().inner();
407                            self.instruments.insert(coin, inst);
408                        }
409                        HandlerCommand::AddBarType { key, bar_type } => {
410                            self.bar_types_cache.insert(key, bar_type);
411                        }
412                        HandlerCommand::RemoveBarType { key } => {
413                            self.bar_types_cache.remove(&key);
414                            self.bar_cache.remove(&key);
415                        }
416                        HandlerCommand::UpdateAssetContextSubs { coin, data_types } => {
417                            let previous_data_types = self.asset_context_subs.get(&coin).cloned();
418                            self.asset_context_caches.clear_removed(
419                                coin,
420                                previous_data_types.as_ref(),
421                                &data_types,
422                            );
423
424                            if data_types.is_empty() {
425                                self.asset_context_subs.remove(&coin);
426                            } else {
427                                self.asset_context_subs.insert(coin, data_types);
428                            }
429                        }
430                        HandlerCommand::UpdateTradeSubs { coin, uses } => {
431                            if uses.is_empty() {
432                                self.trade_subs.remove(&coin);
433                            } else {
434                                self.trade_subs.insert(coin, uses);
435                            }
436                        }
437                        HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mappings) => {
438                            self.all_dex_asset_ctxs_instrument_ids = mappings;
439                        }
440                        HandlerCommand::CacheSpotFillCoins(_) => {
441                            // No longer needed - raw_symbol now contains the proper format
442                        }
443                        HandlerCommand::SetDepth10Sub { coin, subscribed } => {
444                            if subscribed {
445                                self.depth10_subs.insert(coin);
446                            } else {
447                                self.depth10_subs.remove(&coin);
448                            }
449                        }
450                    }
451                }
452
453                Some(raw_msg) = self.raw_rx.recv() => {
454                    match raw_msg {
455                        Message::Text(text) => {
456                            if text == RECONNECTED {
457                                log::info!("Received RECONNECTED sentinel");
458                                return Some(NautilusWsMessage::Reconnected);
459                            }
460
461                            match serde_json::from_str::<HyperliquidWsMessage>(&text) {
462                                Ok(msg) => {
463                                    if let HyperliquidWsMessage::Post { data } = msg {
464                                        self.post_router.complete(data).await;
465                                        continue;
466                                    }
467
468                                    let ts_init = self.clock.get_time_ns();
469
470                                    let nautilus_msgs = Self::parse_to_nautilus_messages(
471                                        msg,
472                                        &self.instruments,
473                                        &self.cloid_cache,
474                                        &self.bar_types_cache,
475                                        self.account_id,
476                                        ts_init,
477                                        &self.asset_context_subs,
478                                        &self.trade_subs,
479                                        &self.depth10_subs,
480                                        &mut self.processed_trade_ids,
481                                        &mut self.processed_public_trade_ids,
482                                        &mut self.asset_context_caches,
483                                        &mut self.bar_cache,
484                                        &self.all_dex_asset_ctxs_instrument_ids,
485                                        self.all_mids_data_types.as_slice(),
486                                    );
487
488                                    if !nautilus_msgs.is_empty() {
489                                        let mut iter = nautilus_msgs.into_iter();
490                                        let first = iter.next().unwrap();
491                                        self.message_buffer.extend(iter);
492                                        return Some(first);
493                                    }
494                                }
495                                Err(e) => {
496                                    log::error!("Error parsing WebSocket message: {e}, text: {text}");
497                                }
498                            }
499                        }
500                        Message::Ping(data) => {
501                            if let Some(ref client) = self.client
502                                && let Err(e) = client.send_pong(data.to_vec()).await {
503                                log::error!("Error sending pong: {e}");
504                            }
505                        }
506                        Message::Close(_) => {
507                            log::debug!("Received WebSocket close frame");
508                            return None;
509                        }
510                        _ => {}
511                    }
512                }
513
514                else => {
515                    log::debug!("Handler shutting down: stream ended or command channel closed");
516                    return None;
517                }
518            }
519        }
520    }
521
522    #[expect(clippy::too_many_arguments)]
523    fn parse_to_nautilus_messages(
524        msg: HyperliquidWsMessage,
525        instruments: &AHashMap<Ustr, InstrumentAny>,
526        cloid_cache: &CloidCache,
527        bar_types: &AHashMap<String, BarType>,
528        account_id: Option<AccountId>,
529        ts_init: UnixNanos,
530        asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
531        trade_subs: &AHashMap<Ustr, TradeStreamUses>,
532        depth10_subs: &AHashSet<Ustr>,
533        processed_trade_ids: &mut FifoCache<u64, 10_000>,
534        processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
535        asset_context_caches: &mut AssetContextCaches,
536        bar_cache: &mut AHashMap<String, CandleData>,
537        all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
538        all_mids_data_types: &[DataType],
539    ) -> Vec<NautilusWsMessage> {
540        let mut result = Vec::new();
541
542        match msg {
543            HyperliquidWsMessage::OrderUpdates { data } => {
544                if let Some(account_id) = account_id
545                    && let Some(msg) = Self::handle_order_updates(
546                        &data,
547                        instruments,
548                        cloid_cache,
549                        account_id,
550                        ts_init,
551                    )
552                {
553                    result.push(msg);
554                }
555            }
556            HyperliquidWsMessage::UserEvents { data } | HyperliquidWsMessage::User { data } => {
557                // Process fills from userEvents channel (userFills channel is redundant)
558                match data {
559                    WsUserEventData::Fills { fills } => {
560                        log::debug!("Received {} fill(s) from userEvents channel", fills.len());
561                        for fill in &fills {
562                            log::debug!(
563                                "Fill: oid={}, coin={}, side={:?}, sz={}, px={}",
564                                fill.oid,
565                                fill.coin,
566                                fill.side,
567                                fill.sz,
568                                fill.px
569                            );
570                        }
571
572                        if let Some(account_id) = account_id {
573                            log::debug!("Processing fills with account_id={account_id}");
574
575                            if let Some(msg) = Self::handle_user_fills(
576                                &fills,
577                                instruments,
578                                cloid_cache,
579                                account_id,
580                                ts_init,
581                                processed_trade_ids,
582                            ) {
583                                log::debug!("Successfully created fill message");
584                                result.push(msg);
585                            } else {
586                                log::debug!("handle_user_fills returned None (no new fills)");
587                            }
588                        } else {
589                            log::warn!("Cannot process fills: account_id is None");
590                        }
591                    }
592                    WsUserEventData::Liquidation { liquidation } => {
593                        log::warn!(
594                            "Liquidation event: lid={}, liquidator={}, liquidated_user={}, ntl_pos={}, account_value={}",
595                            liquidation.lid,
596                            liquidation.liquidator,
597                            liquidation.liquidated_user,
598                            liquidation.liquidated_ntl_pos,
599                            liquidation.liquidated_account_value,
600                        );
601                    }
602                    _ => {
603                        log::debug!("Received non-fill user event: {data:?}");
604                    }
605                }
606            }
607            HyperliquidWsMessage::UserFills { data } => {
608                // UserFills channel is redundant with userEvents, but handle it for
609                // backwards compatibility if explicitly subscribed
610                if let Some(account_id) = account_id
611                    && let Some(msg) = Self::handle_user_fills(
612                        &data.fills,
613                        instruments,
614                        cloid_cache,
615                        account_id,
616                        ts_init,
617                        processed_trade_ids,
618                    )
619                {
620                    result.push(msg);
621                }
622            }
623            HyperliquidWsMessage::Trades { data } => {
624                result.extend(Self::handle_trades(
625                    &data,
626                    instruments,
627                    trade_subs,
628                    processed_public_trade_ids,
629                    ts_init,
630                ));
631            }
632            HyperliquidWsMessage::AllMids { data } => {
633                let mut mids = std::collections::HashMap::with_capacity(
634                    data.mids.len().min(instruments.len()),
635                );
636
637                for (coin, mid_str) in &data.mids {
638                    if let Some(instrument) = instruments.get(coin) {
639                        match mid_str.parse::<Price>() {
640                            Ok(price) => {
641                                mids.insert(instrument.id(), price);
642                            }
643                            Err(e) => {
644                                log::warn!("Failed to parse mid price for {coin}: {e}");
645                            }
646                        }
647                    } else {
648                        log::debug!("No instrument found for coin: {coin}");
649                    }
650                }
651
652                if !mids.is_empty() {
653                    // Take instead of clone on the last subscriber
654                    let last_idx = all_mids_data_types.len().saturating_sub(1);
655                    for (i, data_type) in all_mids_data_types.iter().enumerate() {
656                        let mids_for_this = if i == last_idx {
657                            std::mem::take(&mut mids)
658                        } else {
659                            mids.clone()
660                        };
661                        let all_mids = HyperliquidAllMids::new(mids_for_this, ts_init, ts_init);
662                        result.push(NautilusWsMessage::CustomData(Data::Custom(
663                            CustomData::new(Arc::new(all_mids), data_type.clone()),
664                        )));
665                    }
666                }
667            }
668            HyperliquidWsMessage::AllDexsAssetCtxs { data } => {
669                if let Some(msg) = Self::handle_all_dexs_asset_ctxs(
670                    data,
671                    all_dex_asset_ctxs_instrument_ids,
672                    ts_init,
673                ) {
674                    result.push(msg);
675                }
676            }
677            HyperliquidWsMessage::Bbo { data } => {
678                if let Some(msg) = Self::handle_bbo(&data, instruments, ts_init) {
679                    result.push(msg);
680                }
681            }
682            HyperliquidWsMessage::L2Book { data } => {
683                result.extend(Self::handle_l2_book(
684                    &data,
685                    instruments,
686                    depth10_subs,
687                    ts_init,
688                ));
689            }
690            HyperliquidWsMessage::Candle { data } => {
691                if let Some(msg) =
692                    Self::handle_candle(&data, instruments, bar_types, bar_cache, ts_init)
693                {
694                    result.push(msg);
695                }
696            }
697            HyperliquidWsMessage::ActiveAssetCtx { data }
698            | HyperliquidWsMessage::ActiveSpotAssetCtx { data } => {
699                result.extend(Self::handle_asset_context(
700                    &data,
701                    instruments,
702                    asset_context_subs,
703                    asset_context_caches,
704                    ts_init,
705                ));
706            }
707            HyperliquidWsMessage::UserTwapHistory { data } => {
708                result.extend(Self::handle_user_twap_history(&data, instruments, ts_init));
709            }
710            HyperliquidWsMessage::UserTwapSliceFills { data } => {
711                result.extend(Self::handle_user_twap_slice_fills(
712                    &data,
713                    instruments,
714                    ts_init,
715                ));
716            }
717            HyperliquidWsMessage::Error { data } => {
718                log::warn!("Received error from Hyperliquid WebSocket: {data}");
719            }
720            // Ignore other message types (subscription confirmations, etc)
721            _ => {}
722        }
723
724        result
725    }
726
727    fn handle_order_updates(
728        data: &[super::messages::WsOrderData],
729        instruments: &AHashMap<Ustr, InstrumentAny>,
730        cloid_cache: &CloidCache,
731        account_id: AccountId,
732        ts_init: UnixNanos,
733    ) -> Option<NautilusWsMessage> {
734        let mut exec_reports = Vec::new();
735
736        for order_update in data {
737            let instrument = instruments.get(&order_update.order.coin);
738
739            if let Some(instrument) = instrument {
740                match parse_ws_order_status_report(order_update, instrument, account_id, ts_init) {
741                    Ok(mut report) => {
742                        // Resolve cloid to real client_order_id if cached
743                        if let Some(cloid) = &order_update.order.cloid {
744                            let cloid_ustr = Ustr::from(cloid.as_str());
745                            let resolved = cloid_cache
746                                .lock()
747                                .expect(MUTEX_POISONED)
748                                .get(&cloid_ustr)
749                                .copied();
750
751                            if let Some(real_client_order_id) = resolved {
752                                log::debug!("Resolved cloid {cloid} -> {real_client_order_id}");
753                                report.client_order_id = Some(real_client_order_id);
754                            }
755                        }
756                        exec_reports.push(ExecutionReport::Order(report));
757                    }
758                    Err(e) => {
759                        log::error!("Error parsing order update: {e}");
760                    }
761                }
762            } else {
763                log::debug!("No instrument found for coin: {}", order_update.order.coin);
764            }
765        }
766
767        if exec_reports.is_empty() {
768            None
769        } else {
770            Some(NautilusWsMessage::ExecutionReports(exec_reports))
771        }
772    }
773
774    fn handle_user_fills(
775        fills: &[super::messages::WsFillData],
776        instruments: &AHashMap<Ustr, InstrumentAny>,
777        cloid_cache: &CloidCache,
778        account_id: AccountId,
779        ts_init: UnixNanos,
780        processed_trade_ids: &mut FifoCache<u64, 10_000>,
781    ) -> Option<NautilusWsMessage> {
782        let mut exec_reports = Vec::new();
783
784        for fill in fills {
785            if processed_trade_ids.contains(&fill.tid) {
786                log::debug!("Skipping duplicate fill: tid={}", fill.tid);
787                continue;
788            }
789
790            let instrument = instruments.get(&fill.coin);
791
792            if let Some(instrument) = instrument {
793                log::debug!("Found instrument for fill coin={}", fill.coin);
794                match parse_ws_fill_report(fill, instrument, account_id, ts_init) {
795                    Ok(mut report) => {
796                        // Mark processed only after successful parse
797                        processed_trade_ids.add(fill.tid);
798
799                        if let Some(cloid) = &fill.cloid {
800                            let cloid_ustr = Ustr::from(cloid.as_str());
801                            let resolved = cloid_cache
802                                .lock()
803                                .expect(MUTEX_POISONED)
804                                .get(&cloid_ustr)
805                                .copied();
806
807                            if let Some(real_client_order_id) = resolved {
808                                log::debug!(
809                                    "Resolved fill cloid {cloid} -> {real_client_order_id}"
810                                );
811                                report.client_order_id = Some(real_client_order_id);
812                            }
813                        }
814                        log::debug!(
815                            "Parsed fill report: venue_order_id={:?}, trade_id={:?}",
816                            report.venue_order_id,
817                            report.trade_id
818                        );
819                        exec_reports.push(ExecutionReport::Fill(report));
820                    }
821                    Err(e) => {
822                        log::error!("Error parsing fill: {e}");
823                    }
824                }
825            } else {
826                // Not marked as processed so fill is retried if instrument loads later
827                log::warn!("No instrument found for fill coin={}", fill.coin);
828            }
829        }
830
831        if exec_reports.is_empty() {
832            None
833        } else {
834            Some(NautilusWsMessage::ExecutionReports(exec_reports))
835        }
836    }
837
838    fn handle_trades(
839        data: &[super::messages::WsTradeData],
840        instruments: &AHashMap<Ustr, InstrumentAny>,
841        trade_subs: &AHashMap<Ustr, TradeStreamUses>,
842        processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
843        ts_init: UnixNanos,
844    ) -> Vec<NautilusWsMessage> {
845        let mut trade_ticks = Vec::new();
846        let mut public_trades = Vec::new();
847
848        for trade in data {
849            if let Some(instrument) = instruments.get(&trade.coin) {
850                let uses = trade_subs.get(&trade.coin).copied().unwrap_or_default();
851
852                if uses.ticks {
853                    match parse_ws_trade_tick(trade, instrument, ts_init) {
854                        Ok(tick) => trade_ticks.push(tick),
855                        Err(e) => {
856                            log::error!("Error parsing trade tick: {e}");
857                        }
858                    }
859                }
860
861                if uses.public_trades {
862                    let trade_key = (trade.coin, trade.tid);
863                    if processed_public_trade_ids.contains(&trade_key) {
864                        log::debug!(
865                            "Skipping replayed public trade: coin={}, tid={}",
866                            trade.coin,
867                            trade.tid
868                        );
869                        continue;
870                    }
871
872                    match parse_ws_public_trade(trade, instrument, ts_init) {
873                        Ok(trade) => {
874                            processed_public_trade_ids.add(trade_key);
875                            public_trades.push(trade);
876                        }
877                        Err(e) => {
878                            log::error!("Error parsing public trade: {e}");
879                        }
880                    }
881                }
882            } else {
883                log::debug!("No instrument found for coin: {}", trade.coin);
884            }
885        }
886
887        let mut result = Vec::with_capacity(1 + public_trades.len());
888        if !trade_ticks.is_empty() {
889            result.push(NautilusWsMessage::Trades(trade_ticks));
890        }
891        result.extend(public_trades.into_iter().map(|trade| {
892            let instrument_id = trade.instrument_id;
893            NautilusWsMessage::CustomData(Data::Custom(CustomData::new(
894                Arc::new(trade),
895                Self::public_trade_data_type(instrument_id),
896            )))
897        }));
898        result
899    }
900
901    fn handle_bbo(
902        data: &super::messages::WsBboData,
903        instruments: &AHashMap<Ustr, InstrumentAny>,
904        ts_init: UnixNanos,
905    ) -> Option<NautilusWsMessage> {
906        if let Some(instrument) = instruments.get(&data.coin) {
907            match parse_ws_quote_tick(data, instrument, ts_init) {
908                Ok(quote_tick) => Some(NautilusWsMessage::Quote(quote_tick)),
909                Err(e) => {
910                    log::error!("Error parsing quote tick: {e}");
911                    None
912                }
913            }
914        } else {
915            log::debug!("No instrument found for coin: {}", data.coin);
916            None
917        }
918    }
919
920    fn handle_l2_book(
921        data: &super::messages::WsBookData,
922        instruments: &AHashMap<Ustr, InstrumentAny>,
923        depth10_subs: &AHashSet<Ustr>,
924        ts_init: UnixNanos,
925    ) -> Vec<NautilusWsMessage> {
926        let mut out = Vec::new();
927
928        let Some(instrument) = instruments.get(&data.coin) else {
929            log::debug!("No instrument found for coin: {}", data.coin);
930            return out;
931        };
932
933        match parse_ws_order_book_deltas(data, instrument, ts_init) {
934            Ok(deltas) => out.push(NautilusWsMessage::Deltas(deltas)),
935            Err(e) => log::error!("Error parsing order book deltas: {e}"),
936        }
937
938        if depth10_subs.contains(&data.coin) {
939            match parse_ws_order_book_depth10(data, instrument, ts_init) {
940                Ok(depth) => out.push(NautilusWsMessage::Depth10(Box::new(depth))),
941                Err(e) => log::error!("Error parsing order book depth10: {e}"),
942            }
943        }
944
945        out
946    }
947
948    fn handle_candle(
949        data: &CandleData,
950        instruments: &AHashMap<Ustr, InstrumentAny>,
951        bar_types: &AHashMap<String, BarType>,
952        bar_cache: &mut AHashMap<String, CandleData>,
953        ts_init: UnixNanos,
954    ) -> Option<NautilusWsMessage> {
955        let key = format!("candle:{}:{}", data.s, data.i);
956
957        let mut closed_bar = None;
958
959        if let Some(cached) = bar_cache.get(&key) {
960            // Emit cached bar when close_time changes, indicating the previous period closed
961            if cached.close_time != data.close_time {
962                log::debug!(
963                    "Bar period changed for {}: prev_close_time={}, new_close_time={}",
964                    data.s,
965                    cached.close_time,
966                    data.close_time
967                );
968                closed_bar = Some(cached.clone());
969            }
970        }
971
972        bar_cache.insert(key.clone(), data.clone());
973
974        if let Some(closed_data) = closed_bar {
975            if let Some(bar_type) = bar_types.get(&key) {
976                if let Some(instrument) = instruments.get(&data.s) {
977                    match parse_ws_candle(&closed_data, instrument, bar_type, ts_init) {
978                        Ok(bar) => return Some(NautilusWsMessage::Candle(bar)),
979                        Err(e) => {
980                            log::error!("Error parsing closed candle: {e}");
981                        }
982                    }
983                } else {
984                    log::debug!("No instrument found for coin: {}", data.s);
985                }
986            } else {
987                log::debug!("No bar type found for key: {key}");
988            }
989        }
990
991        None
992    }
993
994    fn handle_asset_context(
995        data: &WsActiveAssetCtxData,
996        instruments: &AHashMap<Ustr, InstrumentAny>,
997        asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
998        asset_context_caches: &mut AssetContextCaches,
999        ts_init: UnixNanos,
1000    ) -> Vec<NautilusWsMessage> {
1001        let mut result = Vec::new();
1002
1003        let coin = match data {
1004            WsActiveAssetCtxData::Perp { coin, .. } => coin,
1005            WsActiveAssetCtxData::Spot { coin, .. } => coin,
1006        };
1007
1008        if let Some(instrument) = instruments.get(coin) {
1009            let (mark_px, oracle_px, funding, open_interest) = match data {
1010                WsActiveAssetCtxData::Perp { ctx, .. } => (
1011                    &ctx.shared.mark_px,
1012                    Some(&ctx.oracle_px),
1013                    Some(&ctx.funding),
1014                    Some(&ctx.open_interest),
1015                ),
1016                WsActiveAssetCtxData::Spot { ctx, .. } => (&ctx.shared.mark_px, None, None, None),
1017            };
1018
1019            let mark_changed = asset_context_caches.mark_price.get(coin) != Some(mark_px);
1020            let index_changed =
1021                oracle_px.is_some_and(|px| asset_context_caches.index_price.get(coin) != Some(px));
1022            let funding_changed = funding
1023                .is_some_and(|rate| asset_context_caches.funding_rate.get(coin) != Some(rate));
1024            let open_interest_changed = open_interest
1025                .is_some_and(|value| asset_context_caches.open_interest.get(coin) != Some(value));
1026
1027            let subscribed_types = asset_context_subs.get(coin);
1028
1029            if mark_changed || index_changed || funding_changed {
1030                match parse_ws_asset_context(data, instrument, ts_init) {
1031                    Ok((mark_price, index_price, funding_rate)) => {
1032                        if mark_changed
1033                            && subscribed_types
1034                                .is_some_and(|s| s.contains(&AssetContextDataType::MarkPrice))
1035                        {
1036                            asset_context_caches.mark_price.insert(*coin, *mark_px);
1037                            result.push(NautilusWsMessage::MarkPrice(mark_price));
1038                        }
1039
1040                        if index_changed
1041                            && subscribed_types
1042                                .is_some_and(|s| s.contains(&AssetContextDataType::IndexPrice))
1043                        {
1044                            if let Some(px) = oracle_px {
1045                                asset_context_caches.index_price.insert(*coin, *px);
1046                            }
1047
1048                            if let Some(index) = index_price {
1049                                result.push(NautilusWsMessage::IndexPrice(index));
1050                            }
1051                        }
1052
1053                        if funding_changed
1054                            && subscribed_types
1055                                .is_some_and(|s| s.contains(&AssetContextDataType::FundingRate))
1056                        {
1057                            if let Some(rate) = funding {
1058                                asset_context_caches.funding_rate.insert(*coin, *rate);
1059                            }
1060
1061                            if let Some(funding) = funding_rate {
1062                                result.push(NautilusWsMessage::FundingRate(funding));
1063                            }
1064                        }
1065                    }
1066                    Err(e) => {
1067                        log::error!("Error parsing asset context: {e}");
1068                    }
1069                }
1070            }
1071
1072            if let Some(value) = open_interest
1073                && open_interest_changed
1074                && subscribed_types.is_some_and(|s| s.contains(&AssetContextDataType::OpenInterest))
1075            {
1076                match parse_ws_open_interest(*value, instrument, ts_init) {
1077                    Ok(open_interest_data) => {
1078                        asset_context_caches.open_interest.insert(*coin, *value);
1079
1080                        let data_type =
1081                            Self::open_interest_data_type(open_interest_data.instrument_id);
1082                        result.push(NautilusWsMessage::CustomData(Data::Custom(
1083                            CustomData::new(Arc::new(open_interest_data), data_type),
1084                        )));
1085                    }
1086                    Err(e) => {
1087                        log::error!("Error parsing open interest: {e}");
1088                    }
1089                }
1090            }
1091        } else {
1092            log::debug!("No instrument found for coin: {coin}");
1093        }
1094
1095        result
1096    }
1097
1098    fn handle_all_dexs_asset_ctxs(
1099        data: WsAllDexsAssetCtxsData,
1100        all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1101        ts_init: UnixNanos,
1102    ) -> Option<NautilusWsMessage> {
1103        let mut entries = Vec::new();
1104
1105        for (dex, ctxs) in data.ctxs {
1106            let dex_key = Ustr::from(dex.as_str());
1107            let Some(instrument_ids) = all_dex_asset_ctxs_instrument_ids.get(&dex_key) else {
1108                log::warn!("Missing Hyperliquid allDexsAssetCtxs mapping for dex='{dex}'");
1109                continue;
1110            };
1111
1112            if ctxs.len() != instrument_ids.len() {
1113                // Mapping is built once at bootstrap, so a count change means the universe
1114                // drifted and positional alignment can no longer be trusted.
1115                log::warn!(
1116                    "Hyperliquid allDexsAssetCtxs count mismatch for dex='{dex}': received {} contexts but cached {} instrument IDs (reconnect to refresh)",
1117                    ctxs.len(),
1118                    instrument_ids.len()
1119                );
1120            }
1121
1122            for (index, ctx) in ctxs.into_iter().enumerate() {
1123                let Some(Some(instrument_id)) = instrument_ids.get(index).copied() else {
1124                    log::warn!(
1125                        "Missing Hyperliquid allDexsAssetCtxs instrument mapping for dex='{dex}' index={index}"
1126                    );
1127                    continue;
1128                };
1129
1130                match Self::normalize_all_dex_asset_ctx_entry(&dex, instrument_id, ctx) {
1131                    Ok(entry) => entries.push(entry),
1132                    Err(e) => {
1133                        log::warn!(
1134                            "Failed to normalize Hyperliquid allDexsAssetCtxs entry dex='{dex}' index={index}: {e}"
1135                        );
1136                    }
1137                }
1138            }
1139        }
1140
1141        if entries.is_empty() {
1142            return None;
1143        }
1144
1145        let payload = HyperliquidAllDexsAssetCtxs::new(entries, ts_init, ts_init);
1146        let data_type = DataType::new("HyperliquidAllDexsAssetCtxs", None, None);
1147        Some(NautilusWsMessage::CustomData(Data::Custom(
1148            CustomData::new(Arc::new(payload), data_type),
1149        )))
1150    }
1151
1152    fn normalize_all_dex_asset_ctx_entry(
1153        dex: &str,
1154        instrument_id: InstrumentId,
1155        ctx: super::messages::PerpsAssetCtx,
1156    ) -> anyhow::Result<HyperliquidDexAssetCtx> {
1157        let mark_price = Price::from_decimal(ctx.shared.mark_px).map_err(anyhow::Error::msg)?;
1158        let oracle_price = Price::from_decimal(ctx.oracle_px).map_err(anyhow::Error::msg)?;
1159        let prev_day_price =
1160            Price::from_decimal(ctx.shared.prev_day_px).map_err(anyhow::Error::msg)?;
1161        let mid_price = ctx
1162            .shared
1163            .mid_px
1164            .map(|value| Price::from_decimal(value).map_err(anyhow::Error::msg))
1165            .transpose()?;
1166        let funding_rate = ctx.funding;
1167        let open_interest = ctx.open_interest;
1168        let premium = ctx.premium;
1169        let day_ntl_volume = ctx.shared.day_ntl_vlm;
1170        let day_base_volume = ctx
1171            .shared
1172            .day_base_vlm
1173            .ok_or_else(|| anyhow::anyhow!("missing dayBaseVlm"))?;
1174        let impact_prices = match ctx.shared.impact_pxs {
1175            Some(values) => match values.as_slice() {
1176                [bid, ask] => Some(HyperliquidImpactPrices {
1177                    bid: bid.parse::<Price>().map_err(anyhow::Error::msg)?,
1178                    ask: ask.parse::<Price>().map_err(anyhow::Error::msg)?,
1179                }),
1180                other => {
1181                    anyhow::bail!("expected 2 impact prices, received {}", other.len());
1182                }
1183            },
1184            None => None,
1185        };
1186
1187        Ok(HyperliquidDexAssetCtx {
1188            dex: dex.to_string(),
1189            instrument_id,
1190            mark_price,
1191            oracle_price,
1192            prev_day_price,
1193            mid_price,
1194            impact_prices,
1195            funding_rate,
1196            open_interest,
1197            premium,
1198            day_ntl_volume,
1199            day_base_volume,
1200        })
1201    }
1202
1203    fn open_interest_data_type(instrument_id: InstrumentId) -> DataType {
1204        let mut metadata = Params::new();
1205        metadata.insert(
1206            "instrument_id".to_string(),
1207            serde_json::Value::String(instrument_id.to_string()),
1208        );
1209        DataType::new(
1210            "HyperliquidOpenInterest",
1211            Some(metadata),
1212            Some(instrument_id.to_string()),
1213        )
1214    }
1215
1216    fn public_trade_data_type(instrument_id: InstrumentId) -> DataType {
1217        let mut metadata = Params::new();
1218        metadata.insert(
1219            "instrument_id".to_string(),
1220            serde_json::Value::String(instrument_id.to_string()),
1221        );
1222        DataType::new(
1223            "HyperliquidPublicTrade",
1224            Some(metadata),
1225            Some(instrument_id.to_string()),
1226        )
1227    }
1228
1229    fn handle_user_twap_history(
1230        data: &super::messages::WsUserTwapHistoryData,
1231        instruments: &AHashMap<Ustr, InstrumentAny>,
1232        ts_init: UnixNanos,
1233    ) -> Vec<NautilusWsMessage> {
1234        let is_snapshot = data.is_snapshot.unwrap_or(false);
1235        let mut result = Vec::with_capacity(data.history.len());
1236
1237        for row in &data.history {
1238            let instrument = instruments.get(&row.state.coin);
1239            match parse_ws_twap_history_row(row, &data.user, is_snapshot, instrument, ts_init) {
1240                Ok(payload) => {
1241                    let user = payload.user.clone();
1242                    result.push(NautilusWsMessage::CustomData(Data::Custom(
1243                        CustomData::new(Arc::new(payload), Self::twap_history_data_type(&user)),
1244                    )));
1245                }
1246                Err(e) => {
1247                    log::error!("Error parsing TWAP history row: {e}");
1248                }
1249            }
1250        }
1251
1252        result
1253    }
1254
1255    fn handle_user_twap_slice_fills(
1256        data: &super::messages::WsUserTwapSliceFillsData,
1257        instruments: &AHashMap<Ustr, InstrumentAny>,
1258        ts_init: UnixNanos,
1259    ) -> Vec<NautilusWsMessage> {
1260        let is_snapshot = data.is_snapshot.unwrap_or(false);
1261        let mut result = Vec::with_capacity(data.twap_slice_fills.len());
1262
1263        for item in &data.twap_slice_fills {
1264            let instrument = instruments.get(&item.fill.coin);
1265            match parse_ws_twap_slice_fill(item, &data.user, is_snapshot, instrument, ts_init) {
1266                Ok(payload) => {
1267                    let user = payload.user.clone();
1268                    result.push(NautilusWsMessage::CustomData(Data::Custom(
1269                        CustomData::new(Arc::new(payload), Self::twap_slice_fill_data_type(&user)),
1270                    )));
1271                }
1272                Err(e) => {
1273                    log::error!("Error parsing TWAP slice fill: {e}");
1274                }
1275            }
1276        }
1277
1278        result
1279    }
1280
1281    fn twap_history_data_type(user: &str) -> DataType {
1282        let mut metadata = Params::new();
1283        metadata.insert(
1284            "user".to_string(),
1285            serde_json::Value::String(user.to_string()),
1286        );
1287        DataType::new(
1288            "HyperliquidTwapHistory",
1289            Some(metadata),
1290            Some(user.to_string()),
1291        )
1292    }
1293
1294    fn twap_slice_fill_data_type(user: &str) -> DataType {
1295        let mut metadata = Params::new();
1296        metadata.insert(
1297            "user".to_string(),
1298            serde_json::Value::String(user.to_string()),
1299        );
1300        DataType::new(
1301            "HyperliquidTwapSliceFill",
1302            Some(metadata),
1303            Some(user.to_string()),
1304        )
1305    }
1306}
1307
1308pub(crate) fn subscription_to_key(sub: &SubscriptionRequest) -> String {
1309    match sub {
1310        SubscriptionRequest::AllMids { dex } => {
1311            if let Some(dex_name) = dex {
1312                format!("{}:{dex_name}", HyperliquidWsChannel::AllMids.as_str())
1313            } else {
1314                HyperliquidWsChannel::AllMids.as_str().to_string()
1315            }
1316        }
1317        SubscriptionRequest::AllDexsAssetCtxs => {
1318            HyperliquidWsChannel::AllDexsAssetCtxs.as_str().to_string()
1319        }
1320        SubscriptionRequest::Notification { user } => {
1321            format!("{}:{user}", HyperliquidWsChannel::Notification.as_str())
1322        }
1323        SubscriptionRequest::WebData2 { user } => {
1324            format!("{}:{user}", HyperliquidWsChannel::WebData2.as_str())
1325        }
1326        SubscriptionRequest::Candle { coin, interval } => {
1327            format!(
1328                "{}:{coin}:{}",
1329                HyperliquidWsChannel::Candle.as_str(),
1330                interval.as_str()
1331            )
1332        }
1333        SubscriptionRequest::L2Book { coin, .. } => {
1334            format!("{}:{coin}", HyperliquidWsChannel::L2Book.as_str())
1335        }
1336        SubscriptionRequest::Trades { coin } => {
1337            format!("{}:{coin}", HyperliquidWsChannel::Trades.as_str())
1338        }
1339        SubscriptionRequest::OrderUpdates { user } => {
1340            format!("{}:{user}", HyperliquidWsChannel::OrderUpdates.as_str())
1341        }
1342        SubscriptionRequest::UserEvents { user } => {
1343            format!("{}:{user}", HyperliquidWsChannel::UserEvents.as_str())
1344        }
1345        SubscriptionRequest::UserFills { user, .. } => {
1346            format!("{}:{user}", HyperliquidWsChannel::UserFills.as_str())
1347        }
1348        SubscriptionRequest::UserFundings { user } => {
1349            format!("{}:{user}", HyperliquidWsChannel::UserFundings.as_str())
1350        }
1351        SubscriptionRequest::UserNonFundingLedgerUpdates { user } => {
1352            format!(
1353                "{}:{user}",
1354                HyperliquidWsChannel::UserNonFundingLedgerUpdates.as_str()
1355            )
1356        }
1357        SubscriptionRequest::ActiveAssetCtx { coin } => {
1358            format!("{}:{coin}", HyperliquidWsChannel::ActiveAssetCtx.as_str())
1359        }
1360        SubscriptionRequest::ActiveSpotAssetCtx { coin } => {
1361            format!(
1362                "{}:{coin}",
1363                HyperliquidWsChannel::ActiveSpotAssetCtx.as_str()
1364            )
1365        }
1366        SubscriptionRequest::ActiveAssetData { user, coin } => {
1367            format!(
1368                "{}:{user}:{coin}",
1369                HyperliquidWsChannel::ActiveAssetData.as_str()
1370            )
1371        }
1372        SubscriptionRequest::UserTwapSliceFills { user } => {
1373            format!(
1374                "{}:{user}",
1375                HyperliquidWsChannel::UserTwapSliceFills.as_str()
1376            )
1377        }
1378        SubscriptionRequest::UserTwapHistory { user } => {
1379            format!("{}:{user}", HyperliquidWsChannel::UserTwapHistory.as_str())
1380        }
1381        SubscriptionRequest::Bbo { coin } => {
1382            format!("{}:{coin}", HyperliquidWsChannel::Bbo.as_str())
1383        }
1384    }
1385}
1386
1387/// Determines whether a Hyperliquid WebSocket error should trigger a retry.
1388pub(crate) fn should_retry_hyperliquid_error(error: &HyperliquidWsError) -> bool {
1389    match error {
1390        HyperliquidWsError::TungsteniteError(_) => true,
1391        HyperliquidWsError::ClientError(msg) => {
1392            let msg_lower = msg.to_lowercase();
1393            msg_lower.contains("timeout")
1394                || msg_lower.contains("timed out")
1395                || msg_lower.contains("connection")
1396                || msg_lower.contains("network")
1397        }
1398        _ => false,
1399    }
1400}
1401
1402/// Creates a timeout error for Hyperliquid retry logic.
1403pub(crate) fn create_hyperliquid_timeout_error(msg: String) -> HyperliquidWsError {
1404    HyperliquidWsError::ClientError(msg)
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use std::{
1410        sync::{Arc, Mutex, atomic::AtomicBool},
1411        time::Duration,
1412    };
1413
1414    use ahash::{AHashMap, AHashSet};
1415    use log::{Level, LevelFilter, Log, Metadata, Record};
1416    use nautilus_common::cache::fifo::FifoCacheMap;
1417    use nautilus_core::nanos::UnixNanos;
1418    use nautilus_model::{
1419        data::Data,
1420        identifiers::{ClientOrderId, InstrumentId, Symbol},
1421        instruments::{CryptoPerpetual, Instrument, InstrumentAny},
1422        types::{Currency, Price, Quantity},
1423    };
1424    use nautilus_network::websocket::SubscriptionState;
1425    use rstest::rstest;
1426    use rust_decimal::Decimal;
1427    use rust_decimal_macros::dec;
1428    use serde_json::json;
1429    use ustr::Ustr;
1430
1431    use super::{
1432        super::{
1433            client::{AssetContextDataType, CLOID_CACHE_CAPACITY, CloidCache},
1434            messages::{
1435                HyperliquidWsRequest, NautilusWsMessage, PerpsAssetCtx, PostRequest,
1436                SharedAssetCtx, SpotAssetCtx, SubscriptionRequest, WsActiveAssetCtxData,
1437                WsAllDexsAssetCtxsData, WsBookData, WsLevelData,
1438            },
1439            post::PostRouter,
1440        },
1441        AllMidsDataTypeCache, AssetContextCaches, FeedHandler, HandlerCommand,
1442    };
1443    use crate::{
1444        common::consts::HYPERLIQUID_VENUE,
1445        data_types::{HyperliquidAllDexsAssetCtxs, HyperliquidOpenInterest},
1446    };
1447
1448    const SECRET_MARKER: &str = "OUTBOUND_SECRET_MARKER";
1449
1450    struct OutboundLogCapture {
1451        messages: Mutex<Vec<String>>,
1452    }
1453
1454    static OUTBOUND_LOG_CAPTURE: OutboundLogCapture = OutboundLogCapture {
1455        messages: Mutex::new(Vec::new()),
1456    };
1457
1458    impl OutboundLogCapture {
1459        fn clear(&self) {
1460            self.messages.lock().unwrap().clear();
1461        }
1462
1463        fn messages(&self) -> Vec<String> {
1464            self.messages.lock().unwrap().clone()
1465        }
1466    }
1467
1468    impl Log for OutboundLogCapture {
1469        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
1470            metadata.level() == Level::Debug
1471                && metadata.target() == "nautilus_hyperliquid::websocket::handler"
1472        }
1473
1474        fn log(&self, record: &Record<'_>) {
1475            if self.enabled(record.metadata()) {
1476                let message = record.args().to_string();
1477                if message.starts_with("Sending ") {
1478                    self.messages.lock().unwrap().push(message);
1479                }
1480            }
1481        }
1482
1483        fn flush(&self) {}
1484    }
1485
1486    #[rstest]
1487    fn all_mids_cache_projects_subscriptions_without_scanning_every_websocket_message() {
1488        let mut cache = AllMidsDataTypeCache::default();
1489
1490        assert_eq!(cache.as_slice().len(), 1);
1491        assert!(cache.as_slice()[0].metadata().is_none());
1492
1493        cache.apply(
1494            &SubscriptionRequest::AllMids {
1495                dex: Some("xyz".to_owned()),
1496            },
1497            true,
1498        );
1499        assert_eq!(cache.as_slice().len(), 1);
1500        assert_eq!(
1501            cache.as_slice()[0]
1502                .metadata()
1503                .and_then(|metadata| metadata.get_str("dex")),
1504            Some("xyz"),
1505        );
1506
1507        cache.apply(&SubscriptionRequest::AllMids { dex: None }, true);
1508        assert_eq!(cache.as_slice().len(), 2);
1509
1510        cache.apply(
1511            &SubscriptionRequest::AllMids {
1512                dex: Some("xyz".to_owned()),
1513            },
1514            false,
1515        );
1516        assert_eq!(cache.as_slice().len(), 1);
1517        assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1518        assert!(cache.as_slice()[0].metadata().is_none());
1519
1520        cache.apply(&SubscriptionRequest::AllMids { dex: None }, false);
1521        assert_eq!(cache.as_slice().len(), 1);
1522        assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1523        assert!(cache.as_slice()[0].metadata().is_none());
1524    }
1525
1526    fn btc_perp() -> InstrumentAny {
1527        InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1528            InstrumentId::new(Symbol::new("BTC-PERP"), *HYPERLIQUID_VENUE),
1529            Symbol::new("BTC-PERP"),
1530            Currency::from("BTC"),
1531            Currency::from("USDC"),
1532            Currency::from("USDC"),
1533            false,
1534            2,
1535            3,
1536            Price::from("0.01"),
1537            Quantity::from("0.001"),
1538            None,
1539            None,
1540            None,
1541            None,
1542            None,
1543            None,
1544            None,
1545            None,
1546            None,
1547            None,
1548            None,
1549            None,
1550            None,
1551            None,
1552            UnixNanos::default(),
1553            UnixNanos::default(),
1554        ))
1555    }
1556
1557    fn one_level_book() -> WsBookData {
1558        WsBookData {
1559            coin: Ustr::from("BTC"),
1560            levels: [
1561                vec![WsLevelData {
1562                    px: dec!(100.00),
1563                    sz: dec!(1.0),
1564                    n: 1,
1565                }],
1566                vec![WsLevelData {
1567                    px: dec!(100.01),
1568                    sz: dec!(1.0),
1569                    n: 1,
1570                }],
1571            ],
1572            time: 1_700_000_000_000,
1573        }
1574    }
1575
1576    fn btc_active_spot_asset_ctx() -> WsActiveAssetCtxData {
1577        WsActiveAssetCtxData::Spot {
1578            coin: Ustr::from("BTC"),
1579            ctx: SpotAssetCtx {
1580                shared: SharedAssetCtx {
1581                    day_ntl_vlm: dec!(1000000.0),
1582                    prev_day_px: dec!(49000.0),
1583                    mark_px: dec!(50000.0),
1584                    mid_px: Some(dec!(50001.0)),
1585                    impact_pxs: None,
1586                    day_base_vlm: Some(dec!(100.0)),
1587                },
1588                circulating_supply: dec!(19000000.0),
1589            },
1590        }
1591    }
1592
1593    fn btc_active_asset_ctx(open_interest: Decimal) -> WsActiveAssetCtxData {
1594        WsActiveAssetCtxData::Perp {
1595            coin: Ustr::from("BTC"),
1596            ctx: PerpsAssetCtx {
1597                shared: SharedAssetCtx {
1598                    day_ntl_vlm: dec!(1000000.0),
1599                    prev_day_px: dec!(49000.0),
1600                    mark_px: dec!(50000.0),
1601                    mid_px: Some(dec!(50001.0)),
1602                    impact_pxs: Some(vec!["50000.0".to_string(), "50002.0".to_string()]),
1603                    day_base_vlm: Some(dec!(100.0)),
1604                },
1605                funding: dec!(0.0001),
1606                open_interest,
1607                oracle_px: dec!(50005.0),
1608                premium: Some(dec!(-0.0001)),
1609            },
1610        }
1611    }
1612
1613    fn sample_all_dexs_asset_ctxs() -> WsAllDexsAssetCtxsData {
1614        let raw = include_str!("../../test_data/ws_all_dexs_asset_ctxs.json");
1615        let msg: super::super::messages::HyperliquidWsMessage =
1616            serde_json::from_str(raw).expect("expected valid allDexsAssetCtxs fixture");
1617
1618        let super::super::messages::HyperliquidWsMessage::AllDexsAssetCtxs { data } = msg else {
1619            panic!("expected allDexsAssetCtxs fixture message");
1620        };
1621
1622        let default_entry = data
1623            .ctxs
1624            .iter()
1625            .find(|(dex, _)| dex.is_empty())
1626            .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1627            .expect("expected default dex sample");
1628        let xyz_entry = data
1629            .ctxs
1630            .iter()
1631            .find(|(dex, _)| dex == "xyz")
1632            .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1633            .expect("expected xyz dex sample");
1634
1635        WsAllDexsAssetCtxsData {
1636            ctxs: vec![default_entry, xyz_entry],
1637        }
1638    }
1639
1640    #[tokio::test]
1641    async fn post_send_failure_cancels_router_waiter() {
1642        let signal = Arc::new(AtomicBool::new(false));
1643        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1644        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1645        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1646        let post_router = PostRouter::new();
1647        let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
1648            Ustr,
1649            ClientOrderId,
1650            CLOID_CACHE_CAPACITY,
1651        >::new()));
1652        let mut handler = FeedHandler::new(
1653            signal,
1654            cmd_rx,
1655            raw_rx,
1656            out_tx,
1657            None,
1658            SubscriptionState::new(':'),
1659            cloid_cache,
1660            Arc::clone(&post_router),
1661        );
1662
1663        let id = 99;
1664        let rx = post_router.register(id).await.unwrap();
1665
1666        let task = tokio::spawn(async move { handler.next().await });
1667
1668        cmd_tx
1669            .send(HandlerCommand::Post {
1670                id,
1671                request: PostRequest::Info {
1672                    payload: json!({"type": "userRateLimit", "user": "0x123"}),
1673                },
1674            })
1675            .unwrap();
1676        drop(cmd_tx);
1677        drop(raw_tx);
1678
1679        let closed = tokio::time::timeout(Duration::from_millis(100), rx)
1680            .await
1681            .expect("post waiter should close without waiting for post timeout");
1682        assert!(closed.is_err(), "post router cancel must close the waiter");
1683        let _rx = post_router
1684            .register(id)
1685            .await
1686            .expect("post id should be reusable after cancellation");
1687        assert!(task.await.unwrap().is_none());
1688    }
1689
1690    #[rstest]
1691    #[tokio::test]
1692    async fn outbound_subscription_logs_omit_payload_bodies() {
1693        log::set_logger(&OUTBOUND_LOG_CAPTURE).expect("test logger already installed");
1694        log::set_max_level(LevelFilter::Debug);
1695
1696        let signal = Arc::new(AtomicBool::new(false));
1697        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1698        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1699        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1700        let post_router = PostRouter::new();
1701        let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
1702            Ustr,
1703            ClientOrderId,
1704            CLOID_CACHE_CAPACITY,
1705        >::new()));
1706        let mut handler = FeedHandler::new(
1707            signal,
1708            cmd_rx,
1709            raw_rx,
1710            out_tx,
1711            None,
1712            SubscriptionState::new(':'),
1713            cloid_cache,
1714            post_router,
1715        );
1716        let subscription = SubscriptionRequest::Notification {
1717            user: SECRET_MARKER.to_string(),
1718        };
1719        let subscribe_len = serde_json::to_string(&HyperliquidWsRequest::Subscribe {
1720            subscription: subscription.clone(),
1721        })
1722        .unwrap()
1723        .len();
1724        let unsubscribe_len = serde_json::to_string(&HyperliquidWsRequest::Unsubscribe {
1725            subscription: subscription.clone(),
1726        })
1727        .unwrap()
1728        .len();
1729        OUTBOUND_LOG_CAPTURE.clear();
1730
1731        cmd_tx
1732            .send(HandlerCommand::Subscribe {
1733                subscriptions: vec![subscription.clone()],
1734            })
1735            .unwrap();
1736        cmd_tx
1737            .send(HandlerCommand::Unsubscribe {
1738                subscriptions: vec![subscription],
1739            })
1740            .unwrap();
1741        drop(cmd_tx);
1742        drop(raw_tx);
1743
1744        assert!(handler.next().await.is_none());
1745
1746        let messages = OUTBOUND_LOG_CAPTURE.messages();
1747
1748        assert!(
1749            messages
1750                .iter()
1751                .all(|message| !message.contains(SECRET_MARKER)),
1752            "outbound logs exposed the secret marker: {messages:?}"
1753        );
1754        assert!(
1755            messages
1756                .iter()
1757                .any(|message| message
1758                    == &format!("Sending subscribe payload ({subscribe_len} bytes)")),
1759            "subscribe metadata missing or inaccurate: {messages:?}"
1760        );
1761        assert!(
1762            messages.iter().any(|message| {
1763                message == &format!("Sending unsubscribe payload ({unsubscribe_len} bytes)")
1764            }),
1765            "unsubscribe metadata missing or inaccurate: {messages:?}"
1766        );
1767    }
1768
1769    #[rstest]
1770    fn handle_l2_book_emits_deltas_only_when_not_in_depth10_subs() {
1771        let mut instruments = AHashMap::new();
1772        instruments.insert(Ustr::from("BTC"), btc_perp());
1773        let depth10_subs = AHashSet::<Ustr>::new();
1774
1775        let msgs = FeedHandler::handle_l2_book(
1776            &one_level_book(),
1777            &instruments,
1778            &depth10_subs,
1779            UnixNanos::default(),
1780        );
1781
1782        assert_eq!(msgs.len(), 1);
1783        assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
1784    }
1785
1786    #[rstest]
1787    fn handle_l2_book_emits_deltas_and_depth10_when_coin_in_subs() {
1788        let mut instruments = AHashMap::new();
1789        instruments.insert(Ustr::from("BTC"), btc_perp());
1790        let mut depth10_subs = AHashSet::<Ustr>::new();
1791        depth10_subs.insert(Ustr::from("BTC"));
1792
1793        let msgs = FeedHandler::handle_l2_book(
1794            &one_level_book(),
1795            &instruments,
1796            &depth10_subs,
1797            UnixNanos::default(),
1798        );
1799
1800        assert_eq!(msgs.len(), 2);
1801        assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
1802        assert!(matches!(msgs[1], NautilusWsMessage::Depth10(_)));
1803    }
1804
1805    #[rstest]
1806    fn handle_l2_book_returns_empty_when_instrument_unknown() {
1807        let instruments = AHashMap::<Ustr, InstrumentAny>::new();
1808        let depth10_subs = AHashSet::<Ustr>::new();
1809
1810        let msgs = FeedHandler::handle_l2_book(
1811            &one_level_book(),
1812            &instruments,
1813            &depth10_subs,
1814            UnixNanos::default(),
1815        );
1816
1817        assert!(msgs.is_empty());
1818    }
1819
1820    #[rstest]
1821    fn handle_asset_context_emits_open_interest_custom_data_when_subscribed() {
1822        let instrument = btc_perp();
1823        let instrument_id = instrument.id();
1824        let mut instruments = AHashMap::new();
1825        instruments.insert(Ustr::from("BTC"), instrument);
1826
1827        let mut asset_context_subs = AHashMap::new();
1828        asset_context_subs.insert(
1829            Ustr::from("BTC"),
1830            AHashSet::from_iter([AssetContextDataType::OpenInterest]),
1831        );
1832
1833        let mut asset_context_caches = AssetContextCaches::default();
1834
1835        let msgs = FeedHandler::handle_asset_context(
1836            &btc_active_asset_ctx(dec!(100000.0)),
1837            &instruments,
1838            &asset_context_subs,
1839            &mut asset_context_caches,
1840            UnixNanos::default(),
1841        );
1842
1843        assert_eq!(msgs.len(), 1);
1844
1845        match &msgs[0] {
1846            NautilusWsMessage::CustomData(Data::Custom(custom)) => {
1847                let open_interest = custom
1848                    .data
1849                    .as_any()
1850                    .downcast_ref::<HyperliquidOpenInterest>()
1851                    .expect("expected HyperliquidOpenInterest");
1852                assert_eq!(open_interest.instrument_id, instrument_id);
1853                assert_eq!(open_interest.open_interest.to_string(), "100000.0");
1854                assert_eq!(
1855                    custom
1856                        .data_type
1857                        .metadata()
1858                        .and_then(|metadata| metadata.get_str("instrument_id"))
1859                        .map(ToString::to_string),
1860                    Some(instrument_id.to_string()),
1861                );
1862            }
1863            other => panic!("unexpected message type: {other:?}"),
1864        }
1865    }
1866
1867    #[rstest]
1868    fn handle_all_dexs_asset_ctxs_emits_normalized_custom_data() {
1869        let mapping = AHashMap::from_iter([
1870            (
1871                Ustr::from(""),
1872                vec![Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"))],
1873            ),
1874            (
1875                Ustr::from("xyz"),
1876                vec![Some(InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID"))],
1877            ),
1878        ]);
1879
1880        let msg = FeedHandler::handle_all_dexs_asset_ctxs(
1881            sample_all_dexs_asset_ctxs(),
1882            &mapping,
1883            UnixNanos::default(),
1884        )
1885        .expect("expected custom data");
1886
1887        match msg {
1888            NautilusWsMessage::CustomData(Data::Custom(custom)) => {
1889                let payload = custom
1890                    .data
1891                    .as_any()
1892                    .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
1893                    .expect("expected HyperliquidAllDexsAssetCtxs");
1894                assert_eq!(payload.entries.len(), 2);
1895                assert_eq!(
1896                    payload.entries[0].instrument_id,
1897                    InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")
1898                );
1899                assert_eq!(payload.entries[1].dex, "xyz");
1900                assert_eq!(
1901                    payload.entries[1].instrument_id,
1902                    InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID")
1903                );
1904                assert_eq!(payload.entries[0].mark_price.to_string(), "77562.0");
1905                assert_eq!(payload.entries[1].day_base_volume.to_string(), "5135.2458");
1906            }
1907            other => panic!("expected custom data, found {other:?}"),
1908        }
1909    }
1910
1911    #[rstest]
1912    fn handle_all_dexs_asset_ctxs_preserves_index_alignment_when_mappings_are_missing() {
1913        let data = WsAllDexsAssetCtxsData {
1914            ctxs: vec![(
1915                String::new(),
1916                vec![
1917                    PerpsAssetCtx {
1918                        shared: SharedAssetCtx {
1919                            day_ntl_vlm: dec!(1516669192.1953897476),
1920                            prev_day_px: dec!(76317.0),
1921                            mark_px: dec!(77562.0),
1922                            mid_px: Some(dec!(77558.5)),
1923                            impact_pxs: Some(vec!["77558.0".to_string(), "77559.0".to_string()]),
1924                            day_base_vlm: Some(dec!(19707.77457)),
1925                        },
1926                        funding: dec!(-0.0000015186),
1927                        open_interest: dec!(27353.17682),
1928                        oracle_px: dec!(77605.0),
1929                        premium: Some(dec!(-0.0005927453)),
1930                    },
1931                    PerpsAssetCtx {
1932                        shared: SharedAssetCtx {
1933                            day_ntl_vlm: dec!(591989409.9392402172),
1934                            prev_day_px: dec!(2094.6),
1935                            mark_px: dec!(2123.7),
1936                            mid_px: Some(dec!(2123.95)),
1937                            impact_pxs: Some(vec!["2123.65".to_string(), "2124.0".to_string()]),
1938                            day_base_vlm: Some(dec!(281686.8234999999)),
1939                        },
1940                        funding: dec!(0.0000125),
1941                        open_interest: dec!(605822.2557999999),
1942                        oracle_px: dec!(2124.6),
1943                        premium: Some(dec!(-0.0002824061)),
1944                    },
1945                ],
1946            )],
1947        };
1948
1949        let mapping = AHashMap::from_iter([(
1950            Ustr::from(""),
1951            vec![None, Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"))],
1952        )]);
1953
1954        let msg = FeedHandler::handle_all_dexs_asset_ctxs(data, &mapping, UnixNanos::default())
1955            .expect("expected custom data");
1956
1957        match msg {
1958            NautilusWsMessage::CustomData(Data::Custom(custom)) => {
1959                let payload = custom
1960                    .data
1961                    .as_any()
1962                    .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
1963                    .expect("expected HyperliquidAllDexsAssetCtxs");
1964                assert_eq!(payload.entries.len(), 1);
1965                assert_eq!(
1966                    payload.entries[0].instrument_id,
1967                    InstrumentId::from("ETH-USD-PERP.HYPERLIQUID")
1968                );
1969                assert_eq!(payload.entries[0].mark_price.to_string(), "2123.7");
1970            }
1971            other => panic!("expected custom data, found {other:?}"),
1972        }
1973    }
1974
1975    #[rstest]
1976    fn handle_asset_context_skips_open_interest_for_spot_payload() {
1977        let instrument = btc_perp();
1978        let mut instruments = AHashMap::new();
1979        instruments.insert(Ustr::from("BTC"), instrument);
1980
1981        let mut asset_context_subs = AHashMap::new();
1982        asset_context_subs.insert(
1983            Ustr::from("BTC"),
1984            AHashSet::from_iter([AssetContextDataType::OpenInterest]),
1985        );
1986
1987        let mut asset_context_caches = AssetContextCaches::default();
1988
1989        let msgs = FeedHandler::handle_asset_context(
1990            &btc_active_spot_asset_ctx(),
1991            &instruments,
1992            &asset_context_subs,
1993            &mut asset_context_caches,
1994            UnixNanos::default(),
1995        );
1996
1997        assert!(msgs.is_empty());
1998        assert!(asset_context_caches.open_interest.is_empty());
1999    }
2000
2001    #[rstest]
2002    fn handle_asset_context_suppresses_unchanged_open_interest() {
2003        let instrument = btc_perp();
2004        let mut instruments = AHashMap::new();
2005        instruments.insert(Ustr::from("BTC"), instrument);
2006
2007        let mut asset_context_subs = AHashMap::new();
2008        asset_context_subs.insert(
2009            Ustr::from("BTC"),
2010            AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2011        );
2012
2013        let mut asset_context_caches = AssetContextCaches::default();
2014
2015        let first = FeedHandler::handle_asset_context(
2016            &btc_active_asset_ctx(dec!(100000.0)),
2017            &instruments,
2018            &asset_context_subs,
2019            &mut asset_context_caches,
2020            UnixNanos::default(),
2021        );
2022        let second = FeedHandler::handle_asset_context(
2023            &btc_active_asset_ctx(dec!(100000.0)),
2024            &instruments,
2025            &asset_context_subs,
2026            &mut asset_context_caches,
2027            UnixNanos::default(),
2028        );
2029
2030        assert_eq!(first.len(), 1);
2031        assert!(second.is_empty());
2032    }
2033
2034    #[rstest]
2035    fn asset_context_caches_clear_removed_data_types() {
2036        let coin = Ustr::from("BTC");
2037        let mut caches = AssetContextCaches::default();
2038        caches.mark_price.insert(coin, dec!(98455.5));
2039        caches.index_price.insert(coin, dec!(98460.0));
2040        caches.funding_rate.insert(coin, dec!(0.0001));
2041        caches.open_interest.insert(coin, dec!(1500.0));
2042
2043        let previous_data_types = AHashSet::from_iter([
2044            AssetContextDataType::MarkPrice,
2045            AssetContextDataType::IndexPrice,
2046            AssetContextDataType::FundingRate,
2047            AssetContextDataType::OpenInterest,
2048        ]);
2049        let next_data_types = AHashSet::from_iter([
2050            AssetContextDataType::MarkPrice,
2051            AssetContextDataType::FundingRate,
2052        ]);
2053
2054        caches.clear_removed(coin, Some(&previous_data_types), &next_data_types);
2055
2056        assert_eq!(caches.mark_price.get(&coin).copied(), Some(dec!(98455.5)));
2057        assert!(caches.index_price.get(&coin).is_none());
2058        assert_eq!(caches.funding_rate.get(&coin).copied(), Some(dec!(0.0001)));
2059        assert!(caches.open_interest.get(&coin).is_none());
2060    }
2061}