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