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    future::Future,
21    sync::{
22        Arc,
23        atomic::{AtomicBool, Ordering},
24    },
25};
26
27use ahash::{AHashMap, AHashSet};
28use nautilus_common::cache::fifo::FifoCache;
29use nautilus_core::{AtomicTime, Params, nanos::UnixNanos, time::get_atomic_clock_realtime};
30use nautilus_live::book::snapshot::SnapshotGate;
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    error::SendError,
40    retry::{RetryError, RetryManager, create_websocket_retry_manager},
41    websocket::{SubscriptionState, WebSocketClient},
42};
43use rust_decimal::Decimal;
44use tokio_tungstenite::tungstenite::Message;
45use tokio_util::sync::CancellationToken;
46use ustr::Ustr;
47
48use super::{
49    client::{AssetContextDataType, CloidCache},
50    enums::HyperliquidWsChannel,
51    error::HyperliquidWsError,
52    messages::{
53        CandleData, ExecutionReport, HyperliquidWsMessage, HyperliquidWsRequest, NautilusWsMessage,
54        PostRequest, SubscriptionRequest, WsActiveAssetCtxData, WsAllDexsAssetCtxsData,
55        WsUserEventData,
56    },
57    parse::{
58        parse_ws_asset_context, parse_ws_candle, parse_ws_fill_report, parse_ws_open_interest,
59        parse_ws_order_book_deltas, parse_ws_order_book_depth, parse_ws_order_status_report,
60        parse_ws_public_trade, parse_ws_quote_tick, parse_ws_trade_tick, parse_ws_twap_history_row,
61        parse_ws_twap_slice_fill,
62    },
63    post::PostRouter,
64    rate_limits::WebSocketRateLimits,
65    trades::TradeStreamUses,
66};
67use crate::{
68    common::consts::HEARTBEAT_INTERVAL,
69    data_types::{
70        HyperliquidAllDexsAssetCtxs, HyperliquidAllMids, HyperliquidDexAssetCtx,
71        HyperliquidImpactPrices,
72    },
73};
74
75const HEARTBEAT_MESSAGE: &str = r#"{"method":"ping"}"#;
76
77/// Commands sent from the outer client to the inner message handler.
78#[derive(Debug)]
79#[expect(
80    clippy::large_enum_variant,
81    reason = "Commands are ephemeral and immediately consumed"
82)]
83#[allow(private_interfaces)]
84pub enum HandlerCommand {
85    /// Set the WebSocketClient for the handler to use.
86    SetClient(WebSocketClient),
87    /// Disconnect the WebSocket connection.
88    Disconnect,
89    /// Subscribe to the given subscriptions.
90    Subscribe {
91        subscriptions: Vec<SubscriptionRequest>,
92    },
93    /// Unsubscribe from the given subscriptions.
94    Unsubscribe {
95        subscriptions: Vec<SubscriptionRequest>,
96    },
97    /// Resubscribes without interleaving handler input between the two sends.
98    Resubscribe { subscription: SubscriptionRequest },
99    /// Writes an `l2Book` subscription on the current connection, opening `gate` once written.
100    ///
101    /// With `replace`, sends the unsubscribe first so the venue restarts the stream with a
102    /// snapshot. The write always runs, since the stream registry already counts it; `cancel`
103    /// only keeps the gate closed.
104    WriteBook {
105        subscription: SubscriptionRequest,
106        replace: bool,
107        cancel: CancellationToken,
108        gate: SnapshotGate,
109        completion: tokio::sync::oneshot::Sender<Result<(), HyperliquidWsError>>,
110    },
111    /// Send a WebSocket post request.
112    Post {
113        id: u64,
114        request: PostRequest,
115        deadline: tokio::time::Instant,
116        cancellation_token: CancellationToken,
117    },
118    /// Initialize the instruments cache with the given instruments.
119    InitializeInstruments(Vec<InstrumentAny>),
120    /// Update a single instrument in the cache.
121    UpdateInstrument(InstrumentAny),
122    /// Add a bar type mapping for candle parsing.
123    AddBarType { key: String, bar_type: BarType },
124    /// Remove a bar type mapping.
125    RemoveBarType { key: String },
126    /// Update asset context subscriptions for a coin.
127    UpdateAssetContextSubs {
128        coin: Ustr,
129        data_types: AHashSet<AssetContextDataType>,
130    },
131    /// Update the logical consumers of a `trades` stream for a coin.
132    UpdateTradeSubs { coin: Ustr, uses: TradeStreamUses },
133    /// Cache the ordered instrument IDs needed to normalize `allDexsAssetCtxs`.
134    CacheAllDexAssetCtxsInstrumentIds(AHashMap<Ustr, Vec<Option<InstrumentId>>>),
135    /// Cache spot fill coin mappings for instrument lookup.
136    CacheSpotFillCoins(AHashMap<Ustr, Ustr>),
137    /// Flag whether the `l2Book` stream for `coin` should also be emitted
138    /// as [`NautilusWsMessage::Depth`] snapshots.
139    SetDepthSub { coin: Ustr, subscribed: bool },
140}
141
142#[derive(Default)]
143struct AssetContextCaches {
144    mark_price: AHashMap<Ustr, Decimal>,
145    index_price: AHashMap<Ustr, Decimal>,
146    funding_rate: AHashMap<Ustr, Decimal>,
147    open_interest: AHashMap<Ustr, Decimal>,
148}
149
150impl AssetContextCaches {
151    fn clear(&mut self, coin: Ustr, data_type: AssetContextDataType) {
152        match data_type {
153            AssetContextDataType::MarkPrice => {
154                self.mark_price.remove(&coin);
155            }
156            AssetContextDataType::IndexPrice => {
157                self.index_price.remove(&coin);
158            }
159            AssetContextDataType::FundingRate => {
160                self.funding_rate.remove(&coin);
161            }
162            AssetContextDataType::OpenInterest => {
163                self.open_interest.remove(&coin);
164            }
165        }
166    }
167
168    fn clear_removed(
169        &mut self,
170        coin: Ustr,
171        previous_data_types: Option<&AHashSet<AssetContextDataType>>,
172        next_data_types: &AHashSet<AssetContextDataType>,
173    ) {
174        let Some(previous_data_types) = previous_data_types else {
175            return;
176        };
177
178        for data_type in previous_data_types {
179            if !next_data_types.contains(data_type) {
180                self.clear(coin, *data_type);
181            }
182        }
183    }
184}
185
186#[derive(Debug)]
187struct AllMidsDataTypeCache {
188    dexes: BTreeSet<Option<String>>,
189    projected: Vec<DataType>,
190}
191
192impl Default for AllMidsDataTypeCache {
193    fn default() -> Self {
194        let mut cache = Self {
195            dexes: BTreeSet::new(),
196            projected: Vec::new(),
197        };
198        cache.rebuild();
199        cache
200    }
201}
202
203impl AllMidsDataTypeCache {
204    fn apply(&mut self, subscription: &SubscriptionRequest, subscribed: bool) {
205        let SubscriptionRequest::AllMids { dex } = subscription else {
206            return;
207        };
208        let changed = if subscribed {
209            self.dexes.insert(dex.clone())
210        } else {
211            self.dexes.remove(dex)
212        };
213
214        if changed {
215            self.rebuild();
216        }
217    }
218
219    fn as_slice(&self) -> &[DataType] {
220        &self.projected
221    }
222
223    fn rebuild(&mut self) {
224        self.projected.clear();
225        if self.dexes.is_empty() {
226            self.projected
227                .push(DataType::new("HyperliquidAllMids", None, None));
228            return;
229        }
230
231        self.projected.extend(self.dexes.iter().map(|dex| {
232            let metadata = dex.as_ref().map(|dex| {
233                let mut metadata = Params::new();
234                metadata.insert("dex".to_owned(), serde_json::Value::String(dex.clone()));
235                metadata
236            });
237            DataType::new("HyperliquidAllMids", metadata, None)
238        }));
239    }
240}
241
242pub(super) struct FeedHandler {
243    clock: &'static AtomicTime,
244    signal: Arc<AtomicBool>,
245    client: Option<WebSocketClient>,
246    cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
247    raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
248    cmd_closed: bool,
249    raw_closed: bool,
250    out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
251    account_id: Option<AccountId>,
252    subscriptions: SubscriptionState,
253    all_mids_data_types: AllMidsDataTypeCache,
254    post_router: Arc<PostRouter>,
255    rate_limits: Arc<WebSocketRateLimits>,
256    client_id: u64,
257    heartbeat: tokio::time::Interval,
258    retry_manager: RetryManager<HyperliquidWsError>,
259    retry_manager_post: RetryManager<PostSendError>,
260    message_buffer: VecDeque<NautilusWsMessage>,
261    instruments: AHashMap<Ustr, InstrumentAny>,
262    cloid_cache: CloidCache,
263    bar_types_cache: AHashMap<String, BarType>,
264    bar_cache: AHashMap<String, CandleData>,
265    asset_context_subs: AHashMap<Ustr, AHashSet<AssetContextDataType>>,
266    trade_subs: AHashMap<Ustr, TradeStreamUses>,
267    all_dex_asset_ctxs_instrument_ids: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
268    depth_subs: AHashSet<Ustr>,
269    processed_trade_ids: FifoCache<u64, 10_000>,
270    processed_public_trade_ids: FifoCache<(Ustr, u64), 10_000>,
271    asset_context_caches: AssetContextCaches,
272}
273
274impl FeedHandler {
275    /// Creates a new [`FeedHandler`] instance.
276    #[allow(
277        clippy::too_many_arguments,
278        reason = "constructs the handler from independent runtime channels and caches"
279    )]
280    pub(super) fn new(
281        signal: Arc<AtomicBool>,
282        cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
283        raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
284        out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
285        account_id: Option<AccountId>,
286        subscriptions: SubscriptionState,
287        cloid_cache: CloidCache,
288        post_router: Arc<PostRouter>,
289        rate_limits: Arc<WebSocketRateLimits>,
290        client_id: u64,
291    ) -> Self {
292        let heartbeat = tokio::time::interval_at(
293            tokio::time::Instant::now() + HEARTBEAT_INTERVAL,
294            HEARTBEAT_INTERVAL,
295        );
296        Self {
297            clock: get_atomic_clock_realtime(),
298            signal,
299            client: None,
300            cmd_rx,
301            raw_rx,
302            cmd_closed: false,
303            raw_closed: false,
304            out_tx,
305            account_id,
306            subscriptions,
307            all_mids_data_types: AllMidsDataTypeCache::default(),
308            post_router,
309            rate_limits,
310            client_id,
311            heartbeat,
312            retry_manager: create_websocket_retry_manager(),
313            retry_manager_post: create_websocket_retry_manager(),
314            message_buffer: VecDeque::new(),
315            instruments: AHashMap::new(),
316            cloid_cache,
317            bar_types_cache: AHashMap::new(),
318            bar_cache: AHashMap::new(),
319            asset_context_subs: AHashMap::new(),
320            trade_subs: AHashMap::new(),
321            all_dex_asset_ctxs_instrument_ids: AHashMap::new(),
322            depth_subs: AHashSet::new(),
323            processed_trade_ids: FifoCache::new(),
324            processed_public_trade_ids: FifoCache::new(),
325            asset_context_caches: AssetContextCaches::default(),
326        }
327    }
328
329    /// Send a message to the output channel.
330    pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
331        self.out_tx
332            .send(msg)
333            .map_err(|e| format!("Failed to send message: {e}"))
334    }
335
336    /// Check if the handler has received a stop signal.
337    pub(super) fn is_stopped(&self) -> bool {
338        self.signal.load(Ordering::Relaxed)
339    }
340
341    async fn send_with_retry(&self, payload: String) -> anyhow::Result<()> {
342        if let Some(client) = &self.client {
343            let rate_key = self.rate_limits.message_key();
344            self.retry_manager
345                .invocation(
346                    "websocket_send",
347                    || {
348                        let payload = payload.clone();
349                        async move {
350                            client
351                                .send_text(payload, Some(std::slice::from_ref(&rate_key)))
352                                .await
353                                .map_err(|e| {
354                                    HyperliquidWsError::ClientError(format!("Send failed: {e}"))
355                                })
356                        }
357                    },
358                    should_retry_hyperliquid_error,
359                    |e| create_hyperliquid_timeout_error(e.to_string()),
360                )
361                .execute()
362                .await
363                .map_err(|e| anyhow::anyhow!("{e}"))
364        } else {
365            Err(anyhow::anyhow!("No WebSocket client available"))
366        }
367    }
368
369    pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
370        if let Some(msg) = self.message_buffer.pop_front() {
371            return Some(msg);
372        }
373
374        loop {
375            if self.raw_closed && self.cmd_rx.is_empty() {
376                log::debug!("Handler shutting down: input stream closed");
377                return None;
378            }
379
380            tokio::select! {
381                cmd = self.cmd_rx.recv(), if !self.cmd_closed => {
382                    let Some(cmd) = cmd else {
383                        self.cmd_closed = true;
384                        continue;
385                    };
386
387                    match cmd {
388                        HandlerCommand::SetClient(client) => {
389                            log::debug!("Setting WebSocket client in handler");
390                            self.client = Some(client);
391                        }
392                        HandlerCommand::Disconnect => {
393                            log::debug!("Handler received disconnect command");
394
395                            if let Some(ref client) = self.client {
396                                self.rate_limits.acquire_message().await;
397                                client.disconnect().await;
398                            }
399                            self.signal.store(true, Ordering::SeqCst);
400                            return None;
401                        }
402                        HandlerCommand::Subscribe { subscriptions } => {
403                            self.subscribe(subscriptions).await;
404                        }
405                        HandlerCommand::Unsubscribe { subscriptions } => {
406                            self.unsubscribe(subscriptions).await;
407                        }
408                        HandlerCommand::Resubscribe { subscription } => {
409                            self.unsubscribe(vec![subscription.clone()]).await;
410                            self.subscribe(vec![subscription]).await;
411                        }
412                        HandlerCommand::WriteBook {
413                            subscription,
414                            replace,
415                            cancel,
416                            gate,
417                            completion,
418                        } => {
419                            // The registry already counts the stream, so skipping or interrupting
420                            // the write could leave a stream that depth shares unsubscribed; a
421                            // later queued unsubscribe settles the final state instead.
422                            let result = self.write_book(subscription, replace).await;
423
424                            if result.is_ok() && !cancel.is_cancelled() {
425                                gate.open();
426                            }
427                            let _ = completion.send(result);
428                        }
429                        HandlerCommand::Post {
430                            id,
431                            request,
432                            deadline,
433                            cancellation_token,
434                        } => {
435                            if cancellation_token.is_cancelled()
436                                || tokio::time::Instant::now() >= deadline
437                            {
438                                self.post_router
439                                    .cancel_registration(id, &cancellation_token)
440                                    .await;
441                                continue;
442                            }
443
444                            let request = HyperliquidWsRequest::Post { id, request };
445                            match serde_json::to_string(&request) {
446                                Ok(payload) => {
447                                    log::debug!("Sending post payload: id={id}");
448                                    let result = if let Some(client) = &self.client {
449                                        let rate_key = self.rate_limits.message_key();
450                                        send_post_with_retry(
451                                            &self.retry_manager_post,
452                                            deadline,
453                                            &cancellation_token,
454                                            || {
455                                                let payload = payload.clone();
456                                                async move {
457                                                    let connection_epoch =
458                                                        client.connection_epoch();
459                                                    client
460                                                        .send_text_on_connection(
461                                                            payload,
462                                                            Some(std::slice::from_ref(&rate_key)),
463                                                            connection_epoch,
464                                                        )
465                                                        .await
466                                                        .map_err(PostSendError::Transport)
467                                                }
468                                            },
469                                        )
470                                        .await
471                                        .map_err(|e| anyhow::anyhow!("{e}"))
472                                    } else {
473                                        Err(anyhow::anyhow!("No WebSocket client available"))
474                                    };
475
476                                    if let Err(e) = result {
477                                        log::warn!("Error sending post request id={id}: {e}");
478                                        self.post_router
479                                            .cancel_registration(id, &cancellation_token)
480                                            .await;
481                                    }
482                                }
483                                Err(e) => {
484                                    log::error!("Error serializing post request id={id}: {e}");
485                                    self.post_router
486                                        .cancel_registration(id, &cancellation_token)
487                                        .await;
488                                }
489                            }
490                        }
491                        HandlerCommand::InitializeInstruments(instruments) => {
492                            for inst in instruments {
493                                let coin = inst.raw_symbol().inner();
494                                self.instruments.insert(coin, inst);
495                            }
496                        }
497                        HandlerCommand::UpdateInstrument(inst) => {
498                            let coin = inst.raw_symbol().inner();
499                            self.instruments.insert(coin, inst);
500                        }
501                        HandlerCommand::AddBarType { key, bar_type } => {
502                            self.bar_types_cache.insert(key, bar_type);
503                        }
504                        HandlerCommand::RemoveBarType { key } => {
505                            self.bar_types_cache.remove(&key);
506                            self.bar_cache.remove(&key);
507                        }
508                        HandlerCommand::UpdateAssetContextSubs { coin, data_types } => {
509                            let previous_data_types = self.asset_context_subs.get(&coin).cloned();
510                            self.asset_context_caches.clear_removed(
511                                coin,
512                                previous_data_types.as_ref(),
513                                &data_types,
514                            );
515
516                            if data_types.is_empty() {
517                                self.asset_context_subs.remove(&coin);
518                            } else {
519                                self.asset_context_subs.insert(coin, data_types);
520                            }
521                        }
522                        HandlerCommand::UpdateTradeSubs { coin, uses } => {
523                            if uses.is_empty() {
524                                self.trade_subs.remove(&coin);
525                            } else {
526                                self.trade_subs.insert(coin, uses);
527                            }
528                        }
529                        HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mappings) => {
530                            // merge by dex to match the client cache, so a partial
531                            // mapping keeps the entries of dexes it did not cover
532                            self.all_dex_asset_ctxs_instrument_ids.extend(mappings);
533                        }
534                        HandlerCommand::CacheSpotFillCoins(_) => {
535                            // No longer needed - raw_symbol now contains the proper format
536                        }
537                        HandlerCommand::SetDepthSub { coin, subscribed } => {
538                            if subscribed {
539                                self.depth_subs.insert(coin);
540                            } else {
541                                self.depth_subs.remove(&coin);
542                            }
543                        }
544                    }
545                }
546
547                raw_msg = self.raw_rx.recv(), if !self.raw_closed => {
548                    let Some(raw_msg) = raw_msg else {
549                        self.raw_closed = true;
550                        continue;
551                    };
552
553                    match raw_msg {
554                        Message::Text(text) => {
555                            if text == RECONNECTED {
556                                log::info!("Received RECONNECTED sentinel");
557                                return Some(NautilusWsMessage::Reconnected);
558                            }
559
560                            match super::messages::decode_ws_message(&text) {
561                                Ok(msg) => {
562                                    if let HyperliquidWsMessage::Post { data } = msg {
563                                        self.post_router.complete(data).await;
564                                        continue;
565                                    }
566
567                                    if let HyperliquidWsMessage::SubscriptionResponse { data } = &msg {
568                                        let key = subscription_to_key(&data.subscription);
569                                        match data.method.as_str() {
570                                            "subscribe" => self.subscriptions.confirm_subscribe(&key),
571                                            "unsubscribe" => {
572                                                let was_pending = self
573                                                    .subscriptions
574                                                    .pending_unsubscribe_topics()
575                                                    .iter()
576                                                    .any(|topic| topic == &key);
577                                                self.subscriptions.confirm_unsubscribe(&key);
578
579                                                if was_pending {
580                                                    self.rate_limits.release_subscription(
581                                                        self.client_id,
582                                                        &key,
583                                                    );
584                                                }
585                                            }
586                                            method => {
587                                                log::warn!(
588                                                    "Unknown subscription response method: {method}"
589                                                );
590                                            }
591                                        }
592                                        continue;
593                                    }
594
595                                    let ts_init = self.clock.get_time_ns();
596
597                                    let nautilus_msgs = Self::parse_to_nautilus_messages(
598                                        msg,
599                                        &self.instruments,
600                                        &self.cloid_cache,
601                                        &self.bar_types_cache,
602                                        self.account_id,
603                                        ts_init,
604                                        &self.asset_context_subs,
605                                        &self.trade_subs,
606                                        &self.depth_subs,
607                                        &mut self.processed_trade_ids,
608                                        &mut self.processed_public_trade_ids,
609                                        &mut self.asset_context_caches,
610                                        &mut self.bar_cache,
611                                        &self.all_dex_asset_ctxs_instrument_ids,
612                                        self.all_mids_data_types.as_slice(),
613                                    );
614
615                                    if !nautilus_msgs.is_empty() {
616                                        let mut iter = nautilus_msgs.into_iter();
617                                        let first = iter.next().unwrap();
618                                        self.message_buffer.extend(iter);
619                                        return Some(first);
620                                    }
621                                }
622                                Err(e) => {
623                                    log::error!("Error parsing WebSocket message: {e}, text: {text}");
624                                }
625                            }
626                        }
627                        Message::Ping(data) => {
628                            if let Some(ref client) = self.client {
629                                self.rate_limits.acquire_message().await;
630
631                                if let Err(e) = client.send_pong(data.to_vec()).await {
632                                    log::error!("Error sending pong: {e}");
633                                }
634                            }
635                        }
636                        Message::Close(_) => {
637                            log::debug!("Received WebSocket close frame");
638                            return None;
639                        }
640                        _ => {}
641                    }
642                }
643
644                _ = self.heartbeat.tick() => {
645                    if self.client.as_ref().is_some_and(WebSocketClient::is_active)
646                        && let Err(e) = self.send_with_retry(
647                            HEARTBEAT_MESSAGE.to_string(),
648                        ).await
649                    {
650                        log::error!("Error sending WebSocket heartbeat: {e}");
651                    }
652                }
653
654            }
655        }
656    }
657
658    async fn subscribe(&mut self, subscriptions: Vec<SubscriptionRequest>) {
659        for subscription in subscriptions {
660            let key = subscription_to_key(&subscription);
661            self.subscriptions.mark_subscribe(&key);
662
663            if let Err(e) = self
664                .rate_limits
665                .reserve_subscription(self.client_id, &subscription)
666            {
667                log::error!("Cannot subscribe to {key}: {e}");
668                self.subscriptions.mark_unsubscribe(&key);
669                self.subscriptions.confirm_unsubscribe(&key);
670                continue;
671            }
672
673            self.all_mids_data_types.apply(&subscription, true);
674            let request = HyperliquidWsRequest::Subscribe { subscription };
675            match serde_json::to_string(&request) {
676                Ok(payload) => {
677                    log::debug!("Sending subscribe payload ({} bytes)", payload.len());
678                    if let Err(e) = self.send_with_retry(payload).await {
679                        log::error!("Error subscribing to {key}: {e}");
680                        self.subscriptions.mark_failure(&key);
681                    }
682                }
683                Err(e) => {
684                    log::error!("Error serializing subscription for {key}: {e}");
685                    self.subscriptions.mark_failure(&key);
686                }
687            }
688        }
689    }
690
691    async fn write_book(
692        &self,
693        subscription: SubscriptionRequest,
694        replace: bool,
695    ) -> Result<(), HyperliquidWsError> {
696        let client = self.client.as_ref().ok_or_else(|| {
697            HyperliquidWsError::ClientError("No WebSocket client available".to_string())
698        })?;
699
700        let key = subscription_to_key(&subscription);
701        let rate_key = self.rate_limits.message_key();
702        let rate_keys = Some(std::slice::from_ref(&rate_key));
703        let epoch = client.connection_epoch();
704
705        if replace {
706            // Keeps the stream desired for reconnect replay; its unsubscribe ack is then stale
707            self.subscriptions.mark_failure(&key);
708
709            let request = HyperliquidWsRequest::Unsubscribe {
710                subscription: subscription.clone(),
711            };
712
713            client
714                .send_text_on_connection(serialize_request(&request)?, rate_keys, epoch)
715                .await?;
716        } else {
717            self.subscriptions.mark_subscribe(&key);
718
719            if let Err(e) = self
720                .rate_limits
721                .reserve_subscription(self.client_id, &subscription)
722            {
723                self.subscriptions.mark_unsubscribe(&key);
724                self.subscriptions.confirm_unsubscribe(&key);
725                return Err(HyperliquidWsError::ClientError(e));
726            }
727        }
728
729        let request = HyperliquidWsRequest::Subscribe { subscription };
730
731        if let Err(e) = client
732            .send_text_on_connection(serialize_request(&request)?, rate_keys, epoch)
733            .await
734        {
735            self.subscriptions.mark_failure(&key);
736            return Err(e.into());
737        }
738
739        Ok(())
740    }
741
742    async fn unsubscribe(&mut self, subscriptions: Vec<SubscriptionRequest>) {
743        for subscription in subscriptions {
744            let key = subscription_to_key(&subscription);
745            self.subscriptions.mark_unsubscribe(&key);
746            self.all_mids_data_types.apply(&subscription, false);
747
748            let request = HyperliquidWsRequest::Unsubscribe { subscription };
749            match serde_json::to_string(&request) {
750                Ok(payload) => {
751                    log::debug!("Sending unsubscribe payload ({} bytes)", payload.len());
752                    if let Err(e) = self.send_with_retry(payload).await {
753                        log::error!("Error unsubscribing from {key}: {e}");
754                    }
755                }
756                Err(e) => {
757                    log::error!("Error serializing unsubscription for {key}: {e}");
758                }
759            }
760        }
761    }
762
763    #[expect(clippy::too_many_arguments)]
764    fn parse_to_nautilus_messages(
765        msg: HyperliquidWsMessage,
766        instruments: &AHashMap<Ustr, InstrumentAny>,
767        cloid_cache: &CloidCache,
768        bar_types: &AHashMap<String, BarType>,
769        account_id: Option<AccountId>,
770        ts_init: UnixNanos,
771        asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
772        trade_subs: &AHashMap<Ustr, TradeStreamUses>,
773        depth_subs: &AHashSet<Ustr>,
774        processed_trade_ids: &mut FifoCache<u64, 10_000>,
775        processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
776        asset_context_caches: &mut AssetContextCaches,
777        bar_cache: &mut AHashMap<String, CandleData>,
778        all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
779        all_mids_data_types: &[DataType],
780    ) -> Vec<NautilusWsMessage> {
781        let mut result = Vec::new();
782
783        match msg {
784            HyperliquidWsMessage::OrderUpdates { data } => {
785                if let Some(account_id) = account_id
786                    && let Some(msg) = Self::handle_order_updates(
787                        &data,
788                        instruments,
789                        cloid_cache,
790                        account_id,
791                        ts_init,
792                    )
793                {
794                    result.push(msg);
795                }
796            }
797            HyperliquidWsMessage::UserEvents { data } | HyperliquidWsMessage::User { data } => {
798                // Process fills from userEvents channel (userFills channel is redundant)
799                match data {
800                    WsUserEventData::Fills { fills } => {
801                        log::debug!("Received {} fill(s) from userEvents channel", fills.len());
802                        for fill in &fills {
803                            log::debug!(
804                                "Fill: oid={}, coin={}, side={:?}, sz={}, px={}",
805                                fill.oid,
806                                fill.coin,
807                                fill.side,
808                                fill.sz,
809                                fill.px
810                            );
811                        }
812
813                        if let Some(account_id) = account_id {
814                            log::debug!("Processing fills with account_id={account_id}");
815
816                            if let Some(msg) = Self::handle_user_fills(
817                                &fills,
818                                instruments,
819                                cloid_cache,
820                                account_id,
821                                ts_init,
822                                processed_trade_ids,
823                            ) {
824                                log::debug!("Successfully created fill message");
825                                result.push(msg);
826                            } else {
827                                log::debug!("handle_user_fills returned None (no new fills)");
828                            }
829                        } else {
830                            log::warn!("Cannot process fills: account_id is None");
831                        }
832                    }
833                    WsUserEventData::Liquidation { liquidation } => {
834                        log::warn!(
835                            "Liquidation event: lid={}, liquidator={}, liquidated_user={}, ntl_pos={}, account_value={}",
836                            liquidation.lid,
837                            liquidation.liquidator,
838                            liquidation.liquidated_user,
839                            liquidation.liquidated_ntl_pos,
840                            liquidation.liquidated_account_value,
841                        );
842                    }
843                    _ => {
844                        log::debug!("Received non-fill user event: {data:?}");
845                    }
846                }
847            }
848            HyperliquidWsMessage::UserFills { data } => {
849                // UserFills channel is redundant with userEvents, but handle it for
850                // backwards compatibility if explicitly subscribed
851                if let Some(account_id) = account_id
852                    && let Some(msg) = Self::handle_user_fills(
853                        &data.fills,
854                        instruments,
855                        cloid_cache,
856                        account_id,
857                        ts_init,
858                        processed_trade_ids,
859                    )
860                {
861                    result.push(msg);
862                }
863            }
864            HyperliquidWsMessage::Trades { data } => {
865                result.extend(Self::handle_trades(
866                    &data,
867                    instruments,
868                    trade_subs,
869                    processed_public_trade_ids,
870                    ts_init,
871                ));
872            }
873            HyperliquidWsMessage::AllMids { data } => {
874                let mut mids = std::collections::HashMap::with_capacity(
875                    data.mids.len().min(instruments.len()),
876                );
877
878                for (coin, mid_str) in &data.mids {
879                    if let Some(instrument) = instruments.get(coin) {
880                        match mid_str.parse::<Price>() {
881                            Ok(price) => {
882                                mids.insert(instrument.id(), price);
883                            }
884                            Err(e) => {
885                                log::warn!("Failed to parse mid price for {coin}: {e}");
886                            }
887                        }
888                    } else {
889                        log::debug!("No instrument found for coin: {coin}");
890                    }
891                }
892
893                if !mids.is_empty() {
894                    // Take instead of clone on the last subscriber
895                    let last_idx = all_mids_data_types.len().saturating_sub(1);
896                    for (i, data_type) in all_mids_data_types.iter().enumerate() {
897                        let mids_for_this = if i == last_idx {
898                            std::mem::take(&mut mids)
899                        } else {
900                            mids.clone()
901                        };
902                        let all_mids = HyperliquidAllMids::new(mids_for_this, ts_init, ts_init);
903                        result.push(NautilusWsMessage::CustomData(Data::Custom(
904                            CustomData::new(Arc::new(all_mids), data_type.clone()),
905                        )));
906                    }
907                }
908            }
909            HyperliquidWsMessage::AllDexsAssetCtxs { data } => {
910                if let Some(msg) = Self::handle_all_dexs_asset_ctxs(
911                    data,
912                    all_dex_asset_ctxs_instrument_ids,
913                    ts_init,
914                ) {
915                    result.push(msg);
916                }
917            }
918            HyperliquidWsMessage::Bbo { data } => {
919                if let Some(msg) = Self::handle_bbo(&data, instruments, ts_init) {
920                    result.push(msg);
921                }
922            }
923            HyperliquidWsMessage::L2Book { data } => {
924                result.extend(Self::handle_l2_book(
925                    &data,
926                    instruments,
927                    depth_subs,
928                    ts_init,
929                ));
930            }
931            HyperliquidWsMessage::Candle { data } => {
932                if let Some(msg) =
933                    Self::handle_candle(&data, instruments, bar_types, bar_cache, ts_init)
934                {
935                    result.push(msg);
936                }
937            }
938            HyperliquidWsMessage::ActiveAssetCtx { data }
939            | HyperliquidWsMessage::ActiveSpotAssetCtx { data } => {
940                result.extend(Self::handle_asset_context(
941                    &data,
942                    instruments,
943                    asset_context_subs,
944                    asset_context_caches,
945                    ts_init,
946                ));
947            }
948            HyperliquidWsMessage::UserTwapHistory { data } => {
949                result.extend(Self::handle_user_twap_history(&data, instruments, ts_init));
950            }
951            HyperliquidWsMessage::UserTwapSliceFills { data } => {
952                result.extend(Self::handle_user_twap_slice_fills(
953                    &data,
954                    instruments,
955                    ts_init,
956                ));
957            }
958            HyperliquidWsMessage::Error { data } => {
959                log::warn!("Received error from Hyperliquid WebSocket: {data}");
960            }
961            // Ignore other message types (subscription confirmations, etc)
962            _ => {}
963        }
964
965        result
966    }
967
968    fn handle_order_updates(
969        data: &[super::messages::WsOrderData],
970        instruments: &AHashMap<Ustr, InstrumentAny>,
971        cloid_cache: &CloidCache,
972        account_id: AccountId,
973        ts_init: UnixNanos,
974    ) -> Option<NautilusWsMessage> {
975        let mut exec_reports = Vec::new();
976
977        for order_update in data {
978            let instrument = instruments.get(&order_update.order.coin);
979
980            if let Some(instrument) = instrument {
981                match parse_ws_order_status_report(order_update, instrument, account_id, ts_init) {
982                    Ok(mut report) => {
983                        // Resolve cloid to real client_order_id if cached
984                        if let Some(cloid) = &order_update.order.cloid {
985                            let cloid_ustr = Ustr::from(cloid.as_str());
986                            let resolved = cloid_cache.lock().get(&cloid_ustr).copied();
987
988                            if let Some(real_client_order_id) = resolved {
989                                log::debug!("Resolved cloid {cloid} -> {real_client_order_id}");
990                                report.client_order_id = Some(real_client_order_id);
991                            }
992                        }
993                        exec_reports.push(ExecutionReport::Order(report));
994                    }
995                    Err(e) => {
996                        log::error!("Error parsing order update: {e}");
997                    }
998                }
999            } else {
1000                log::debug!("No instrument found for coin: {}", order_update.order.coin);
1001            }
1002        }
1003
1004        if exec_reports.is_empty() {
1005            None
1006        } else {
1007            Some(NautilusWsMessage::ExecutionReports(exec_reports))
1008        }
1009    }
1010
1011    fn handle_user_fills(
1012        fills: &[super::messages::WsFillData],
1013        instruments: &AHashMap<Ustr, InstrumentAny>,
1014        cloid_cache: &CloidCache,
1015        account_id: AccountId,
1016        ts_init: UnixNanos,
1017        processed_trade_ids: &mut FifoCache<u64, 10_000>,
1018    ) -> Option<NautilusWsMessage> {
1019        let mut exec_reports = Vec::new();
1020
1021        for fill in fills {
1022            if processed_trade_ids.contains(&fill.tid) {
1023                log::debug!("Skipping duplicate fill: tid={}", fill.tid);
1024                continue;
1025            }
1026
1027            let instrument = instruments.get(&fill.coin);
1028
1029            if let Some(instrument) = instrument {
1030                log::debug!("Found instrument for fill coin={}", fill.coin);
1031                match parse_ws_fill_report(fill, instrument, account_id, ts_init) {
1032                    Ok(mut report) => {
1033                        // Mark processed only after successful parse
1034                        processed_trade_ids.add(fill.tid);
1035
1036                        if let Some(cloid) = &fill.cloid {
1037                            let cloid_ustr = Ustr::from(cloid.as_str());
1038                            let resolved = cloid_cache.lock().get(&cloid_ustr).copied();
1039
1040                            if let Some(real_client_order_id) = resolved {
1041                                log::debug!(
1042                                    "Resolved fill cloid {cloid} -> {real_client_order_id}"
1043                                );
1044                                report.client_order_id = Some(real_client_order_id);
1045                            }
1046                        }
1047                        log::debug!(
1048                            "Parsed fill report: venue_order_id={:?}, trade_id={:?}",
1049                            report.venue_order_id,
1050                            report.trade_id
1051                        );
1052                        exec_reports.push(ExecutionReport::Fill(report));
1053                    }
1054                    Err(e) => {
1055                        log::error!("Error parsing fill: {e}");
1056                    }
1057                }
1058            } else {
1059                // Not marked as processed so fill is retried if instrument loads later
1060                log::warn!("No instrument found for fill coin={}", fill.coin);
1061            }
1062        }
1063
1064        if exec_reports.is_empty() {
1065            None
1066        } else {
1067            Some(NautilusWsMessage::ExecutionReports(exec_reports))
1068        }
1069    }
1070
1071    fn handle_trades(
1072        data: &[super::messages::WsTradeData],
1073        instruments: &AHashMap<Ustr, InstrumentAny>,
1074        trade_subs: &AHashMap<Ustr, TradeStreamUses>,
1075        processed_public_trade_ids: &mut FifoCache<(Ustr, u64), 10_000>,
1076        ts_init: UnixNanos,
1077    ) -> Vec<NautilusWsMessage> {
1078        let mut trade_ticks = Vec::new();
1079        let mut public_trades = Vec::new();
1080
1081        for trade in data {
1082            if let Some(instrument) = instruments.get(&trade.coin) {
1083                let uses = trade_subs.get(&trade.coin).copied().unwrap_or_default();
1084
1085                if uses.ticks {
1086                    match parse_ws_trade_tick(trade, instrument, ts_init) {
1087                        Ok(tick) => trade_ticks.push(tick),
1088                        Err(e) => {
1089                            log::error!("Error parsing trade tick: {e}");
1090                        }
1091                    }
1092                }
1093
1094                if uses.public_trades {
1095                    let trade_key = (trade.coin, trade.tid);
1096                    if processed_public_trade_ids.contains(&trade_key) {
1097                        log::debug!(
1098                            "Skipping replayed public trade: coin={}, tid={}",
1099                            trade.coin,
1100                            trade.tid
1101                        );
1102                        continue;
1103                    }
1104
1105                    match parse_ws_public_trade(trade, instrument, ts_init) {
1106                        Ok(trade) => {
1107                            processed_public_trade_ids.add(trade_key);
1108                            public_trades.push(trade);
1109                        }
1110                        Err(e) => {
1111                            log::error!("Error parsing public trade: {e}");
1112                        }
1113                    }
1114                }
1115            } else {
1116                log::debug!("No instrument found for coin: {}", trade.coin);
1117            }
1118        }
1119
1120        let mut result = Vec::with_capacity(1 + public_trades.len());
1121        if !trade_ticks.is_empty() {
1122            result.push(NautilusWsMessage::Trades(trade_ticks));
1123        }
1124        result.extend(public_trades.into_iter().map(|trade| {
1125            let instrument_id = trade.instrument_id;
1126            NautilusWsMessage::CustomData(Data::Custom(CustomData::new(
1127                Arc::new(trade),
1128                Self::public_trade_data_type(instrument_id),
1129            )))
1130        }));
1131        result
1132    }
1133
1134    fn handle_bbo(
1135        data: &super::messages::WsBboData,
1136        instruments: &AHashMap<Ustr, InstrumentAny>,
1137        ts_init: UnixNanos,
1138    ) -> Option<NautilusWsMessage> {
1139        if let Some(instrument) = instruments.get(&data.coin) {
1140            match parse_ws_quote_tick(data, instrument, ts_init) {
1141                Ok(quote_tick) => Some(NautilusWsMessage::Quote(quote_tick)),
1142                Err(e) => {
1143                    log::error!("Error parsing quote tick: {e}");
1144                    None
1145                }
1146            }
1147        } else {
1148            log::debug!("No instrument found for coin: {}", data.coin);
1149            None
1150        }
1151    }
1152
1153    fn handle_l2_book(
1154        data: &super::messages::WsBookData,
1155        instruments: &AHashMap<Ustr, InstrumentAny>,
1156        depth_subs: &AHashSet<Ustr>,
1157        ts_init: UnixNanos,
1158    ) -> Vec<NautilusWsMessage> {
1159        let mut out = Vec::new();
1160
1161        let Some(instrument) = instruments.get(&data.coin) else {
1162            log::debug!("No instrument found for coin: {}", data.coin);
1163            return out;
1164        };
1165
1166        match parse_ws_order_book_deltas(data, instrument, ts_init) {
1167            Ok(deltas) => out.push(NautilusWsMessage::Deltas(deltas)),
1168            Err(e) => {
1169                log::error!("Error parsing order book deltas: {e}");
1170                out.push(NautilusWsMessage::BookInvalid(instrument.id()));
1171            }
1172        }
1173
1174        if depth_subs.contains(&data.coin) {
1175            match parse_ws_order_book_depth(data, instrument, ts_init) {
1176                Ok(depth) => out.push(NautilusWsMessage::Depth(Box::new(depth))),
1177                Err(e) => log::error!("Error parsing order book depth: {e}"),
1178            }
1179        }
1180
1181        out
1182    }
1183
1184    fn handle_candle(
1185        data: &CandleData,
1186        instruments: &AHashMap<Ustr, InstrumentAny>,
1187        bar_types: &AHashMap<String, BarType>,
1188        bar_cache: &mut AHashMap<String, CandleData>,
1189        ts_init: UnixNanos,
1190    ) -> Option<NautilusWsMessage> {
1191        let key = format!("candle:{}:{}", data.s, data.i);
1192
1193        let mut closed_bar = None;
1194
1195        if let Some(cached) = bar_cache.get(&key) {
1196            // Emit cached bar when close_time changes, indicating the previous period closed
1197            if cached.close_time != data.close_time {
1198                log::debug!(
1199                    "Bar period changed for {}: prev_close_time={}, new_close_time={}",
1200                    data.s,
1201                    cached.close_time,
1202                    data.close_time
1203                );
1204                closed_bar = Some(cached.clone());
1205            }
1206        }
1207
1208        bar_cache.insert(key.clone(), data.clone());
1209
1210        if let Some(closed_data) = closed_bar {
1211            if let Some(bar_type) = bar_types.get(&key) {
1212                if let Some(instrument) = instruments.get(&data.s) {
1213                    match parse_ws_candle(&closed_data, instrument, bar_type, ts_init) {
1214                        Ok(bar) => return Some(NautilusWsMessage::Candle(bar)),
1215                        Err(e) => {
1216                            log::error!("Error parsing closed candle: {e}");
1217                        }
1218                    }
1219                } else {
1220                    log::debug!("No instrument found for coin: {}", data.s);
1221                }
1222            } else {
1223                log::debug!("No bar type found for key: {key}");
1224            }
1225        }
1226
1227        None
1228    }
1229
1230    fn handle_asset_context(
1231        data: &WsActiveAssetCtxData,
1232        instruments: &AHashMap<Ustr, InstrumentAny>,
1233        asset_context_subs: &AHashMap<Ustr, AHashSet<AssetContextDataType>>,
1234        asset_context_caches: &mut AssetContextCaches,
1235        ts_init: UnixNanos,
1236    ) -> Vec<NautilusWsMessage> {
1237        let mut result = Vec::new();
1238
1239        let coin = match data {
1240            WsActiveAssetCtxData::Perp { coin, .. } => coin,
1241            WsActiveAssetCtxData::Spot { coin, .. } => coin,
1242        };
1243
1244        if let Some(instrument) = instruments.get(coin) {
1245            let (mark_px, oracle_px, funding, open_interest) = match data {
1246                WsActiveAssetCtxData::Perp { ctx, .. } => (
1247                    &ctx.shared.mark_px,
1248                    Some(&ctx.oracle_px),
1249                    Some(&ctx.funding),
1250                    Some(&ctx.open_interest),
1251                ),
1252                WsActiveAssetCtxData::Spot { ctx, .. } => (&ctx.shared.mark_px, None, None, None),
1253            };
1254
1255            let mark_changed = asset_context_caches.mark_price.get(coin) != Some(mark_px);
1256            let index_changed =
1257                oracle_px.is_some_and(|px| asset_context_caches.index_price.get(coin) != Some(px));
1258            let funding_changed = funding
1259                .is_some_and(|rate| asset_context_caches.funding_rate.get(coin) != Some(rate));
1260            let open_interest_changed = open_interest
1261                .is_some_and(|value| asset_context_caches.open_interest.get(coin) != Some(value));
1262
1263            let subscribed_types = asset_context_subs.get(coin);
1264
1265            if mark_changed || index_changed || funding_changed {
1266                match parse_ws_asset_context(data, instrument, ts_init) {
1267                    Ok((mark_price, index_price, funding_rate)) => {
1268                        if mark_changed
1269                            && subscribed_types
1270                                .is_some_and(|s| s.contains(&AssetContextDataType::MarkPrice))
1271                        {
1272                            asset_context_caches.mark_price.insert(*coin, *mark_px);
1273                            result.push(NautilusWsMessage::MarkPrice(mark_price));
1274                        }
1275
1276                        if index_changed
1277                            && subscribed_types
1278                                .is_some_and(|s| s.contains(&AssetContextDataType::IndexPrice))
1279                        {
1280                            if let Some(px) = oracle_px {
1281                                asset_context_caches.index_price.insert(*coin, *px);
1282                            }
1283
1284                            if let Some(index) = index_price {
1285                                result.push(NautilusWsMessage::IndexPrice(index));
1286                            }
1287                        }
1288
1289                        if funding_changed
1290                            && subscribed_types
1291                                .is_some_and(|s| s.contains(&AssetContextDataType::FundingRate))
1292                        {
1293                            if let Some(rate) = funding {
1294                                asset_context_caches.funding_rate.insert(*coin, *rate);
1295                            }
1296
1297                            if let Some(funding) = funding_rate {
1298                                result.push(NautilusWsMessage::FundingRate(funding));
1299                            }
1300                        }
1301                    }
1302                    Err(e) => {
1303                        log::error!("Error parsing asset context: {e}");
1304                    }
1305                }
1306            }
1307
1308            if let Some(value) = open_interest
1309                && open_interest_changed
1310                && subscribed_types.is_some_and(|s| s.contains(&AssetContextDataType::OpenInterest))
1311            {
1312                match parse_ws_open_interest(*value, instrument, ts_init) {
1313                    Ok(open_interest_data) => {
1314                        asset_context_caches.open_interest.insert(*coin, *value);
1315
1316                        let data_type =
1317                            Self::open_interest_data_type(open_interest_data.instrument_id);
1318                        result.push(NautilusWsMessage::CustomData(Data::Custom(
1319                            CustomData::new(Arc::new(open_interest_data), data_type),
1320                        )));
1321                    }
1322                    Err(e) => {
1323                        log::error!("Error parsing open interest: {e}");
1324                    }
1325                }
1326            }
1327        } else {
1328            log::debug!("No instrument found for coin: {coin}");
1329        }
1330
1331        result
1332    }
1333
1334    fn handle_all_dexs_asset_ctxs(
1335        data: WsAllDexsAssetCtxsData,
1336        all_dex_asset_ctxs_instrument_ids: &AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1337        ts_init: UnixNanos,
1338    ) -> Option<NautilusWsMessage> {
1339        let mut entries = Vec::new();
1340
1341        for (dex, ctxs) in data.ctxs {
1342            let dex_key = Ustr::from(dex.as_str());
1343            let Some(instrument_ids) = all_dex_asset_ctxs_instrument_ids.get(&dex_key) else {
1344                log::warn!("Missing Hyperliquid allDexsAssetCtxs mapping for dex='{dex}'");
1345                continue;
1346            };
1347
1348            if ctxs.len() != instrument_ids.len() {
1349                // Mapping is rebuilt on each instrument refresh, instrument request, and data
1350                // client connect, so a count change means the universe drifted since the last
1351                // build and positional alignment can no longer be trusted until the next one.
1352                log::warn!(
1353                    "Hyperliquid allDexsAssetCtxs count mismatch for dex='{dex}': received {} contexts but cached {} instrument IDs (the next instrument refresh, instrument request, or data client connect rebuilds the mapping)",
1354                    ctxs.len(),
1355                    instrument_ids.len()
1356                );
1357            }
1358
1359            for (index, ctx) in ctxs.into_iter().enumerate() {
1360                let Some(Some(instrument_id)) = instrument_ids.get(index).copied() else {
1361                    log::warn!(
1362                        "Missing Hyperliquid allDexsAssetCtxs instrument mapping for dex='{dex}' index={index}"
1363                    );
1364                    continue;
1365                };
1366
1367                match Self::normalize_all_dex_asset_ctx_entry(&dex, instrument_id, ctx) {
1368                    Ok(entry) => entries.push(entry),
1369                    Err(e) => {
1370                        log::warn!(
1371                            "Failed to normalize Hyperliquid allDexsAssetCtxs entry dex='{dex}' index={index}: {e}"
1372                        );
1373                    }
1374                }
1375            }
1376        }
1377
1378        if entries.is_empty() {
1379            return None;
1380        }
1381
1382        let payload = HyperliquidAllDexsAssetCtxs::new(entries, ts_init, ts_init);
1383        let data_type = DataType::new("HyperliquidAllDexsAssetCtxs", None, None);
1384        Some(NautilusWsMessage::CustomData(Data::Custom(
1385            CustomData::new(Arc::new(payload), data_type),
1386        )))
1387    }
1388
1389    fn normalize_all_dex_asset_ctx_entry(
1390        dex: &str,
1391        instrument_id: InstrumentId,
1392        ctx: super::messages::PerpsAssetCtx,
1393    ) -> anyhow::Result<HyperliquidDexAssetCtx> {
1394        let mark_price = Price::from_decimal(ctx.shared.mark_px).map_err(anyhow::Error::msg)?;
1395        let oracle_price = Price::from_decimal(ctx.oracle_px).map_err(anyhow::Error::msg)?;
1396        let prev_day_price =
1397            Price::from_decimal(ctx.shared.prev_day_px).map_err(anyhow::Error::msg)?;
1398        let mid_price = ctx
1399            .shared
1400            .mid_px
1401            .map(|value| Price::from_decimal(value).map_err(anyhow::Error::msg))
1402            .transpose()?;
1403        let funding_rate = ctx.funding;
1404        let open_interest = ctx.open_interest;
1405        let premium = ctx.premium;
1406        let day_ntl_volume = ctx.shared.day_ntl_vlm;
1407        let day_base_volume = ctx
1408            .shared
1409            .day_base_vlm
1410            .ok_or_else(|| anyhow::anyhow!("missing dayBaseVlm"))?;
1411        let impact_prices = match ctx.shared.impact_pxs {
1412            Some(values) => match values.as_slice() {
1413                [bid, ask] => Some(HyperliquidImpactPrices {
1414                    bid: Price::from_decimal(*bid).map_err(anyhow::Error::msg)?,
1415                    ask: Price::from_decimal(*ask).map_err(anyhow::Error::msg)?,
1416                }),
1417                other => {
1418                    anyhow::bail!("expected 2 impact prices, received {}", other.len());
1419                }
1420            },
1421            None => None,
1422        };
1423
1424        Ok(HyperliquidDexAssetCtx {
1425            dex: dex.to_string(),
1426            instrument_id,
1427            mark_price,
1428            oracle_price,
1429            prev_day_price,
1430            mid_price,
1431            impact_prices,
1432            funding_rate,
1433            open_interest,
1434            premium,
1435            day_ntl_volume,
1436            day_base_volume,
1437        })
1438    }
1439
1440    fn open_interest_data_type(instrument_id: InstrumentId) -> DataType {
1441        let mut metadata = Params::new();
1442        metadata.insert(
1443            "instrument_id".to_string(),
1444            serde_json::Value::String(instrument_id.to_string()),
1445        );
1446        DataType::new(
1447            "HyperliquidOpenInterest",
1448            Some(metadata),
1449            Some(instrument_id.to_string()),
1450        )
1451    }
1452
1453    fn public_trade_data_type(instrument_id: InstrumentId) -> DataType {
1454        let mut metadata = Params::new();
1455        metadata.insert(
1456            "instrument_id".to_string(),
1457            serde_json::Value::String(instrument_id.to_string()),
1458        );
1459        DataType::new(
1460            "HyperliquidPublicTrade",
1461            Some(metadata),
1462            Some(instrument_id.to_string()),
1463        )
1464    }
1465
1466    fn handle_user_twap_history(
1467        data: &super::messages::WsUserTwapHistoryData,
1468        instruments: &AHashMap<Ustr, InstrumentAny>,
1469        ts_init: UnixNanos,
1470    ) -> Vec<NautilusWsMessage> {
1471        let is_snapshot = data.is_snapshot.unwrap_or(false);
1472        let mut result = Vec::with_capacity(data.history.len());
1473
1474        for row in &data.history {
1475            let instrument = instruments.get(&row.state.coin);
1476            match parse_ws_twap_history_row(row, &data.user, is_snapshot, instrument, ts_init) {
1477                Ok(payload) => {
1478                    let user = payload.user.clone();
1479                    result.push(NautilusWsMessage::CustomData(Data::Custom(
1480                        CustomData::new(Arc::new(payload), Self::twap_history_data_type(&user)),
1481                    )));
1482                }
1483                Err(e) => {
1484                    log::error!("Error parsing TWAP history row: {e}");
1485                }
1486            }
1487        }
1488
1489        result
1490    }
1491
1492    fn handle_user_twap_slice_fills(
1493        data: &super::messages::WsUserTwapSliceFillsData,
1494        instruments: &AHashMap<Ustr, InstrumentAny>,
1495        ts_init: UnixNanos,
1496    ) -> Vec<NautilusWsMessage> {
1497        let is_snapshot = data.is_snapshot.unwrap_or(false);
1498        let mut result = Vec::with_capacity(data.twap_slice_fills.len());
1499
1500        for item in &data.twap_slice_fills {
1501            let instrument = instruments.get(&item.fill.coin);
1502            match parse_ws_twap_slice_fill(item, &data.user, is_snapshot, instrument, ts_init) {
1503                Ok(payload) => {
1504                    let user = payload.user.clone();
1505                    result.push(NautilusWsMessage::CustomData(Data::Custom(
1506                        CustomData::new(Arc::new(payload), Self::twap_slice_fill_data_type(&user)),
1507                    )));
1508                }
1509                Err(e) => {
1510                    log::error!("Error parsing TWAP slice fill: {e}");
1511                }
1512            }
1513        }
1514
1515        result
1516    }
1517
1518    fn twap_history_data_type(user: &str) -> DataType {
1519        let mut metadata = Params::new();
1520        metadata.insert(
1521            "user".to_string(),
1522            serde_json::Value::String(user.to_string()),
1523        );
1524        DataType::new(
1525            "HyperliquidTwapHistory",
1526            Some(metadata),
1527            Some(user.to_string()),
1528        )
1529    }
1530
1531    fn twap_slice_fill_data_type(user: &str) -> DataType {
1532        let mut metadata = Params::new();
1533        metadata.insert(
1534            "user".to_string(),
1535            serde_json::Value::String(user.to_string()),
1536        );
1537        DataType::new(
1538            "HyperliquidTwapSliceFill",
1539            Some(metadata),
1540            Some(user.to_string()),
1541        )
1542    }
1543}
1544
1545#[derive(Debug, thiserror::Error)]
1546enum PostSendError {
1547    #[error(transparent)]
1548    Transport(SendError),
1549    #[error(transparent)]
1550    Retry(RetryError),
1551    #[error("Post deadline expired")]
1552    Deadline,
1553}
1554
1555async fn send_post_with_retry<F, Fut>(
1556    retry_manager: &RetryManager<PostSendError>,
1557    deadline: tokio::time::Instant,
1558    cancellation_token: &CancellationToken,
1559    send: F,
1560) -> Result<(), PostSendError>
1561where
1562    F: Fn() -> Fut + Clone,
1563    Fut: Future<Output = Result<(), PostSendError>>,
1564{
1565    let invocation = retry_manager
1566        .invocation(
1567            "websocket_post_send",
1568            || {
1569                let send = send.clone();
1570                async move { send_post_before_deadline(deadline, cancellation_token, send).await }
1571            },
1572            should_retry_post_send,
1573            PostSendError::Retry,
1574        )
1575        .cancellation_token(cancellation_token)
1576        .execute();
1577    tokio::pin!(invocation);
1578
1579    tokio::select! {
1580        biased;
1581        () = tokio::time::sleep_until(deadline) => {
1582            Err(PostSendError::Deadline)
1583        }
1584        result = &mut invocation => result,
1585    }
1586}
1587
1588async fn send_post_before_deadline<F, Fut>(
1589    deadline: tokio::time::Instant,
1590    cancellation_token: &CancellationToken,
1591    send: F,
1592) -> Result<(), PostSendError>
1593where
1594    F: FnOnce() -> Fut,
1595    Fut: Future<Output = Result<(), PostSendError>>,
1596{
1597    if cancellation_token.is_cancelled() {
1598        return Err(PostSendError::Retry(RetryError::Canceled));
1599    }
1600
1601    if tokio::time::Instant::now() >= deadline {
1602        return Err(PostSendError::Deadline);
1603    }
1604
1605    send().await
1606}
1607
1608fn should_retry_post_send(error: &PostSendError) -> bool {
1609    matches!(
1610        error,
1611        PostSendError::Transport(SendError::Timeout | SendError::ConnectionChanged)
1612    )
1613}
1614
1615pub(super) fn subscription_to_key(sub: &SubscriptionRequest) -> String {
1616    match sub {
1617        SubscriptionRequest::AllMids { dex } => {
1618            if let Some(dex_name) = dex {
1619                format!("{}:{dex_name}", HyperliquidWsChannel::AllMids.as_str())
1620            } else {
1621                HyperliquidWsChannel::AllMids.as_str().to_string()
1622            }
1623        }
1624        SubscriptionRequest::AllDexsAssetCtxs => {
1625            HyperliquidWsChannel::AllDexsAssetCtxs.as_str().to_string()
1626        }
1627        SubscriptionRequest::Notification { user } => {
1628            format!("{}:{user}", HyperliquidWsChannel::Notification.as_str())
1629        }
1630        SubscriptionRequest::WebData2 { user } => {
1631            format!("{}:{user}", HyperliquidWsChannel::WebData2.as_str())
1632        }
1633        SubscriptionRequest::Candle { coin, interval } => {
1634            format!(
1635                "{}:{coin}:{}",
1636                HyperliquidWsChannel::Candle.as_str(),
1637                interval.as_str()
1638            )
1639        }
1640        SubscriptionRequest::L2Book { coin, .. } => {
1641            format!("{}:{coin}", HyperliquidWsChannel::L2Book.as_str())
1642        }
1643        SubscriptionRequest::Trades { coin } => {
1644            format!("{}:{coin}", HyperliquidWsChannel::Trades.as_str())
1645        }
1646        SubscriptionRequest::OrderUpdates { user } => {
1647            format!("{}:{user}", HyperliquidWsChannel::OrderUpdates.as_str())
1648        }
1649        SubscriptionRequest::UserEvents { user } => {
1650            format!("{}:{user}", HyperliquidWsChannel::UserEvents.as_str())
1651        }
1652        SubscriptionRequest::UserFills { user, .. } => {
1653            format!("{}:{user}", HyperliquidWsChannel::UserFills.as_str())
1654        }
1655        SubscriptionRequest::UserFundings { user } => {
1656            format!("{}:{user}", HyperliquidWsChannel::UserFundings.as_str())
1657        }
1658        SubscriptionRequest::UserNonFundingLedgerUpdates { user } => {
1659            format!(
1660                "{}:{user}",
1661                HyperliquidWsChannel::UserNonFundingLedgerUpdates.as_str()
1662            )
1663        }
1664        SubscriptionRequest::ActiveAssetCtx { coin } => {
1665            format!("{}:{coin}", HyperliquidWsChannel::ActiveAssetCtx.as_str())
1666        }
1667        SubscriptionRequest::ActiveSpotAssetCtx { coin } => {
1668            format!(
1669                "{}:{coin}",
1670                HyperliquidWsChannel::ActiveSpotAssetCtx.as_str()
1671            )
1672        }
1673        SubscriptionRequest::ActiveAssetData { user, coin } => {
1674            format!(
1675                "{}:{user}:{coin}",
1676                HyperliquidWsChannel::ActiveAssetData.as_str()
1677            )
1678        }
1679        SubscriptionRequest::UserTwapSliceFills { user } => {
1680            format!(
1681                "{}:{user}",
1682                HyperliquidWsChannel::UserTwapSliceFills.as_str()
1683            )
1684        }
1685        SubscriptionRequest::UserTwapHistory { user } => {
1686            format!("{}:{user}", HyperliquidWsChannel::UserTwapHistory.as_str())
1687        }
1688        SubscriptionRequest::Bbo { coin } => {
1689            format!("{}:{coin}", HyperliquidWsChannel::Bbo.as_str())
1690        }
1691    }
1692}
1693
1694/// Determines whether a Hyperliquid WebSocket error should trigger a retry.
1695pub(crate) fn should_retry_hyperliquid_error(error: &HyperliquidWsError) -> bool {
1696    match error {
1697        HyperliquidWsError::TungsteniteError(_) => true,
1698        HyperliquidWsError::ClientError(msg) => {
1699            let msg_lower = msg.to_lowercase();
1700            msg_lower.contains("timeout")
1701                || msg_lower.contains("timed out")
1702                || msg_lower.contains("connection")
1703                || msg_lower.contains("network")
1704        }
1705        _ => false,
1706    }
1707}
1708
1709fn serialize_request(request: &HyperliquidWsRequest) -> Result<String, HyperliquidWsError> {
1710    serde_json::to_string(request)
1711        .map_err(|e| HyperliquidWsError::MessageSerialization(e.to_string()))
1712}
1713
1714/// Creates a timeout error for Hyperliquid retry logic.
1715pub(crate) fn create_hyperliquid_timeout_error(msg: String) -> HyperliquidWsError {
1716    HyperliquidWsError::ClientError(msg)
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721    use std::{
1722        sync::{
1723            Arc,
1724            atomic::{AtomicBool, AtomicUsize, Ordering},
1725        },
1726        time::Duration,
1727    };
1728
1729    use ahash::{AHashMap, AHashSet};
1730    use log::{Level, LevelFilter, Log, Metadata, Record};
1731    use nautilus_common::cache::fifo::FifoCacheMap;
1732    use nautilus_core::nanos::UnixNanos;
1733    use nautilus_live::book::snapshot::SnapshotGate;
1734    use nautilus_model::{
1735        data::Data,
1736        identifiers::{ClientOrderId, InstrumentId, Symbol},
1737        instruments::{CryptoPerpetual, Instrument, InstrumentAny},
1738        types::{Currency, Price, Quantity},
1739    };
1740    use nautilus_network::{
1741        error::SendError,
1742        retry::{RetryConfig, RetryError, RetryManager},
1743        websocket::SubscriptionState,
1744    };
1745    use parking_lot::Mutex;
1746    use rstest::rstest;
1747    use rust_decimal::Decimal;
1748    use rust_decimal_macros::dec;
1749    use serde_json::json;
1750    use tokio_tungstenite::tungstenite::Message;
1751    use tokio_util::sync::CancellationToken;
1752    use ustr::Ustr;
1753
1754    use super::{
1755        super::{
1756            client::{AssetContextDataType, CLOID_CACHE_CAPACITY, CloidCache},
1757            error::HyperliquidWsError,
1758            messages::{
1759                HyperliquidWsRequest, NautilusWsMessage, PerpsAssetCtx, PostRequest,
1760                SharedAssetCtx, SpotAssetCtx, SubscriptionRequest, WsActiveAssetCtxData,
1761                WsAllDexsAssetCtxsData, WsBookData, WsLevelData,
1762            },
1763            post::PostRouter,
1764            rate_limits::WebSocketRateLimits,
1765        },
1766        AllMidsDataTypeCache, AssetContextCaches, FeedHandler, HandlerCommand, PostSendError,
1767        send_post_before_deadline, send_post_with_retry, should_retry_post_send,
1768    };
1769    use crate::{
1770        common::consts::{
1771            HYPERLIQUID_VENUE, HYPERLIQUID_WS_MESSAGES_PER_MINUTE, HYPERLIQUID_WS_SUBSCRIPTIONS_MAX,
1772        },
1773        data_types::{HyperliquidAllDexsAssetCtxs, HyperliquidOpenInterest},
1774    };
1775
1776    const SECRET_MARKER: &str = "OUTBOUND_SECRET_MARKER";
1777
1778    struct OutboundLogCapture {
1779        messages: Mutex<Vec<String>>,
1780    }
1781
1782    static OUTBOUND_LOG_CAPTURE: OutboundLogCapture = OutboundLogCapture {
1783        messages: Mutex::new(Vec::new()),
1784    };
1785
1786    impl OutboundLogCapture {
1787        fn clear(&self) {
1788            self.messages.lock().clear();
1789        }
1790
1791        fn messages(&self) -> Vec<String> {
1792            self.messages.lock().clone()
1793        }
1794    }
1795
1796    impl Log for OutboundLogCapture {
1797        fn enabled(&self, metadata: &Metadata<'_>) -> bool {
1798            metadata.level() == Level::Debug
1799                && metadata.target() == "nautilus_hyperliquid::websocket::handler"
1800        }
1801
1802        fn log(&self, record: &Record<'_>) {
1803            if self.enabled(record.metadata()) {
1804                let message = record.args().to_string();
1805                if message.starts_with("Sending ") {
1806                    self.messages.lock().push(message);
1807                }
1808            }
1809        }
1810
1811        fn flush(&self) {}
1812    }
1813
1814    #[rstest]
1815    fn all_mids_cache_projects_subscriptions_without_scanning_every_websocket_message() {
1816        let mut cache = AllMidsDataTypeCache::default();
1817
1818        assert_eq!(cache.as_slice().len(), 1);
1819        assert!(cache.as_slice()[0].metadata().is_none());
1820
1821        cache.apply(
1822            &SubscriptionRequest::AllMids {
1823                dex: Some("xyz".to_owned()),
1824            },
1825            true,
1826        );
1827        assert_eq!(cache.as_slice().len(), 1);
1828        assert_eq!(
1829            cache.as_slice()[0]
1830                .metadata()
1831                .and_then(|metadata| metadata.get_str("dex")),
1832            Some("xyz"),
1833        );
1834
1835        cache.apply(&SubscriptionRequest::AllMids { dex: None }, true);
1836        assert_eq!(cache.as_slice().len(), 2);
1837
1838        cache.apply(
1839            &SubscriptionRequest::AllMids {
1840                dex: Some("xyz".to_owned()),
1841            },
1842            false,
1843        );
1844        assert_eq!(cache.as_slice().len(), 1);
1845        assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1846        assert!(cache.as_slice()[0].metadata().is_none());
1847
1848        cache.apply(&SubscriptionRequest::AllMids { dex: None }, false);
1849        assert_eq!(cache.as_slice().len(), 1);
1850        assert_eq!(cache.as_slice()[0].type_name(), "HyperliquidAllMids");
1851        assert!(cache.as_slice()[0].metadata().is_none());
1852    }
1853
1854    fn btc_perp() -> InstrumentAny {
1855        InstrumentAny::CryptoPerpetual(
1856            CryptoPerpetual::builder()
1857                .instrument_id(InstrumentId::new(
1858                    Symbol::new("BTC-PERP"),
1859                    *HYPERLIQUID_VENUE,
1860                ))
1861                .raw_symbol(Symbol::new("BTC-PERP"))
1862                .base_currency(Currency::from("BTC"))
1863                .quote_currency(Currency::from("USDC"))
1864                .settlement_currency(Currency::from("USDC"))
1865                .is_inverse(false)
1866                .price_precision(2)
1867                .size_precision(3)
1868                .price_increment(Price::from("0.01"))
1869                .size_increment(Quantity::from("0.001"))
1870                .ts_event(UnixNanos::default())
1871                .ts_init(UnixNanos::default())
1872                .build()
1873                .unwrap(),
1874        )
1875    }
1876
1877    fn one_level_book() -> WsBookData {
1878        WsBookData {
1879            coin: Ustr::from("BTC"),
1880            levels: [
1881                vec![WsLevelData {
1882                    px: dec!(100.00),
1883                    sz: dec!(1.0),
1884                    n: 1,
1885                }],
1886                vec![WsLevelData {
1887                    px: dec!(100.01),
1888                    sz: dec!(1.0),
1889                    n: 1,
1890                }],
1891            ],
1892            time: 1_700_000_000_000,
1893        }
1894    }
1895
1896    fn btc_active_spot_asset_ctx() -> WsActiveAssetCtxData {
1897        WsActiveAssetCtxData::Spot {
1898            coin: Ustr::from("BTC"),
1899            ctx: SpotAssetCtx {
1900                shared: SharedAssetCtx {
1901                    day_ntl_vlm: dec!(1000000.0),
1902                    prev_day_px: dec!(49000.0),
1903                    mark_px: dec!(50000.0),
1904                    mid_px: Some(dec!(50001.0)),
1905                    impact_pxs: None,
1906                    day_base_vlm: Some(dec!(100.0)),
1907                },
1908                circulating_supply: dec!(19000000.0),
1909            },
1910        }
1911    }
1912
1913    fn btc_active_asset_ctx(open_interest: Decimal) -> WsActiveAssetCtxData {
1914        WsActiveAssetCtxData::Perp {
1915            coin: Ustr::from("BTC"),
1916            ctx: PerpsAssetCtx {
1917                shared: SharedAssetCtx {
1918                    day_ntl_vlm: dec!(1000000.0),
1919                    prev_day_px: dec!(49000.0),
1920                    mark_px: dec!(50000.0),
1921                    mid_px: Some(dec!(50001.0)),
1922                    impact_pxs: Some(vec![dec!(50000.0), dec!(50002.0)]),
1923                    day_base_vlm: Some(dec!(100.0)),
1924                },
1925                funding: dec!(0.0001),
1926                open_interest,
1927                oracle_px: dec!(50005.0),
1928                premium: Some(dec!(-0.0001)),
1929            },
1930        }
1931    }
1932
1933    fn sample_all_dexs_asset_ctxs() -> WsAllDexsAssetCtxsData {
1934        let raw = include_str!("../../test_data/ws_all_dexs_asset_ctxs.json");
1935        let msg: super::super::messages::HyperliquidWsMessage =
1936            serde_json::from_str(raw).expect("expected valid allDexsAssetCtxs fixture");
1937
1938        let super::super::messages::HyperliquidWsMessage::AllDexsAssetCtxs { data } = msg else {
1939            panic!("expected allDexsAssetCtxs fixture message");
1940        };
1941
1942        let default_entry = data
1943            .ctxs
1944            .iter()
1945            .find(|(dex, _)| dex.is_empty())
1946            .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1947            .expect("expected default dex sample");
1948        let xyz_entry = data
1949            .ctxs
1950            .iter()
1951            .find(|(dex, _)| dex == "xyz")
1952            .and_then(|(dex, ctxs)| ctxs.first().cloned().map(|ctx| (dex.clone(), vec![ctx])))
1953            .expect("expected xyz dex sample");
1954
1955        WsAllDexsAssetCtxsData {
1956            ctxs: vec![default_entry, xyz_entry],
1957        }
1958    }
1959
1960    #[tokio::test]
1961    async fn post_send_failure_cancels_router_waiter() {
1962        let signal = Arc::new(AtomicBool::new(false));
1963        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
1964        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
1965        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
1966        let post_router = PostRouter::new();
1967        let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
1968            Ustr,
1969            ClientOrderId,
1970            CLOID_CACHE_CAPACITY,
1971        >::new()));
1972        let mut handler = FeedHandler::new(
1973            signal,
1974            cmd_rx,
1975            raw_rx,
1976            out_tx,
1977            None,
1978            SubscriptionState::new(':'),
1979            cloid_cache,
1980            Arc::clone(&post_router),
1981            Arc::new(WebSocketRateLimits::new()),
1982            1,
1983        );
1984
1985        let id = 99;
1986        let cancellation_token = CancellationToken::new();
1987        let rx = post_router
1988            .register_with_cancellation(id, &cancellation_token)
1989            .await
1990            .unwrap();
1991
1992        let task = tokio::spawn(async move { handler.next().await });
1993
1994        cmd_tx
1995            .send(HandlerCommand::Post {
1996                id,
1997                request: PostRequest::Info {
1998                    payload: json!({"type": "userRateLimit", "user": "0x123"}),
1999                },
2000                deadline: tokio::time::Instant::now() + Duration::from_secs(1),
2001                cancellation_token,
2002            })
2003            .unwrap();
2004        drop(cmd_tx);
2005        drop(raw_tx);
2006
2007        let closed = tokio::time::timeout(Duration::from_millis(100), rx)
2008            .await
2009            .expect("post waiter should close without waiting for post timeout");
2010        assert!(closed.is_err(), "post router cancel must close the waiter");
2011        let _rx = post_router
2012            .register(id)
2013            .await
2014            .expect("post id should be reusable after cancellation");
2015        assert!(task.await.unwrap().is_none());
2016    }
2017
2018    #[rstest]
2019    #[tokio::test]
2020    async fn write_book_opens_gate_only_after_completed_write() {
2021        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2022        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
2023        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
2024
2025        let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
2026            Ustr,
2027            ClientOrderId,
2028            CLOID_CACHE_CAPACITY,
2029        >::new()));
2030
2031        let mut handler = FeedHandler::new(
2032            Arc::new(AtomicBool::new(false)),
2033            cmd_rx,
2034            raw_rx,
2035            out_tx,
2036            None,
2037            SubscriptionState::new(':'),
2038            cloid_cache,
2039            PostRouter::new(),
2040            Arc::new(WebSocketRateLimits::new()),
2041            1,
2042        );
2043
2044        let subscription = || SubscriptionRequest::L2Book {
2045            coin: Ustr::from("BTC"),
2046            n_sig_figs: None,
2047            mantissa: None,
2048        };
2049
2050        let failed_gate = SnapshotGate::default();
2051        failed_gate.lock().close();
2052        let (failed_tx, failed_rx) = tokio::sync::oneshot::channel();
2053        let cancelled_gate = SnapshotGate::default();
2054        cancelled_gate.lock().close();
2055        let cancelled = CancellationToken::new();
2056        cancelled.cancel();
2057        let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
2058
2059        // No client is set, so the write fails before any send
2060        cmd_tx
2061            .send(HandlerCommand::WriteBook {
2062                subscription: subscription(),
2063                replace: false,
2064                cancel: CancellationToken::new(),
2065                gate: failed_gate.clone(),
2066                completion: failed_tx,
2067            })
2068            .unwrap();
2069
2070        cmd_tx
2071            .send(HandlerCommand::WriteBook {
2072                subscription: subscription(),
2073                replace: true,
2074                cancel: cancelled,
2075                gate: cancelled_gate.clone(),
2076                completion: cancelled_tx,
2077            })
2078            .unwrap();
2079
2080        drop(cmd_tx);
2081        drop(raw_tx);
2082
2083        let next = handler.next().await;
2084        let failed = failed_rx.await.unwrap();
2085        let attempted = cancelled_rx.await.unwrap();
2086
2087        assert!(next.is_none());
2088        assert!(matches!(
2089            failed,
2090            Err(HyperliquidWsError::ClientError(message))
2091                if message == "No WebSocket client available"
2092        ));
2093        assert!(failed_gate.lock().is_closed());
2094        assert!(
2095            matches!(
2096                attempted,
2097                Err(HyperliquidWsError::ClientError(message))
2098                    if message == "No WebSocket client available"
2099            ),
2100            "a cancelled write still runs",
2101        );
2102        assert!(cancelled_gate.lock().is_closed());
2103    }
2104
2105    fn retry_manager_with_backoff() -> RetryManager<PostSendError> {
2106        RetryManager::new(RetryConfig {
2107            max_retries: 1,
2108            initial_delay_ms: 1_000,
2109            max_delay_ms: 1_000,
2110            backoff_factor: 1.0,
2111            jitter_ms: 0,
2112            operation_timeout_ms: None,
2113            immediate_first: false,
2114            max_elapsed_ms: None,
2115        })
2116    }
2117
2118    #[rstest]
2119    #[tokio::test(start_paused = true)]
2120    async fn expired_post_deadline_prevents_first_send() {
2121        let cancellation_token = CancellationToken::new();
2122        let sends = Arc::new(AtomicUsize::new(0));
2123        let send_count = Arc::clone(&sends);
2124
2125        let error = send_post_before_deadline(
2126            tokio::time::Instant::now(),
2127            &cancellation_token,
2128            move || {
2129                let send_count = Arc::clone(&send_count);
2130                async move {
2131                    send_count.fetch_add(1, Ordering::SeqCst);
2132                    Ok(())
2133                }
2134            },
2135        )
2136        .await
2137        .unwrap_err();
2138
2139        assert!(matches!(error, PostSendError::Deadline));
2140        assert_eq!(sends.load(Ordering::SeqCst), 0);
2141    }
2142
2143    #[rstest]
2144    #[tokio::test(start_paused = true)]
2145    async fn post_deadline_while_waiting_for_message_quota_prevents_send() {
2146        let manager = retry_manager_with_backoff();
2147        let cancellation_token = CancellationToken::new();
2148        let limits = Arc::new(WebSocketRateLimits::new());
2149        let rate_key = limits.message_key();
2150        for _ in 0..HYPERLIQUID_WS_MESSAGES_PER_MINUTE {
2151            assert!(limits.messages.check_key(&rate_key).is_ok());
2152        }
2153        let sends = Arc::new(AtomicUsize::new(0));
2154        let send_count = Arc::clone(&sends);
2155        let send_limits = Arc::clone(&limits);
2156        let deadline = tokio::time::Instant::now() + Duration::from_millis(10);
2157
2158        let error = send_post_with_retry(&manager, deadline, &cancellation_token, move || {
2159            let send_count = Arc::clone(&send_count);
2160            let send_limits = Arc::clone(&send_limits);
2161            async move {
2162                send_limits.acquire_message().await;
2163                send_count.fetch_add(1, Ordering::SeqCst);
2164                Ok(())
2165            }
2166        })
2167        .await
2168        .unwrap_err();
2169
2170        assert!(matches!(error, PostSendError::Deadline));
2171        assert_eq!(sends.load(Ordering::SeqCst), 0);
2172    }
2173
2174    #[rstest]
2175    #[tokio::test(start_paused = true)]
2176    async fn post_deadline_during_backoff_prevents_retry() {
2177        let manager = retry_manager_with_backoff();
2178        let cancellation_token = CancellationToken::new();
2179        let sends = Arc::new(AtomicUsize::new(0));
2180        let send_count = Arc::clone(&sends);
2181        let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
2182
2183        let error = send_post_with_retry(&manager, deadline, &cancellation_token, move || {
2184            let send_count = Arc::clone(&send_count);
2185            async move {
2186                send_count.fetch_add(1, Ordering::SeqCst);
2187                Err(PostSendError::Transport(SendError::Timeout))
2188            }
2189        })
2190        .await
2191        .unwrap_err();
2192
2193        assert!(matches!(error, PostSendError::Deadline));
2194        assert_eq!(sends.load(Ordering::SeqCst), 1);
2195    }
2196
2197    #[rstest]
2198    #[tokio::test(start_paused = true)]
2199    async fn post_deadline_after_send_starts_preserves_unknown_outcome() {
2200        let manager = retry_manager_with_backoff();
2201        let cancellation_token = CancellationToken::new();
2202        let sends = Arc::new(AtomicUsize::new(0));
2203        let send_count = Arc::clone(&sends);
2204        let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
2205
2206        let error = send_post_with_retry(&manager, deadline, &cancellation_token, move || {
2207            let send_count = Arc::clone(&send_count);
2208            async move {
2209                send_count.fetch_add(1, Ordering::SeqCst);
2210                std::future::pending::<Result<(), PostSendError>>().await
2211            }
2212        })
2213        .await
2214        .unwrap_err();
2215
2216        assert!(matches!(error, PostSendError::Deadline));
2217        assert_eq!(sends.load(Ordering::SeqCst), 1);
2218    }
2219
2220    #[rstest]
2221    #[tokio::test(start_paused = true)]
2222    async fn post_cancellation_stops_started_send() {
2223        let manager = retry_manager_with_backoff();
2224        let cancellation_token = CancellationToken::new();
2225        let task_cancellation_token = cancellation_token.clone();
2226        let started = Arc::new(tokio::sync::Notify::new());
2227        let task_started = Arc::clone(&started);
2228        let sends = Arc::new(AtomicUsize::new(0));
2229        let task_sends = Arc::clone(&sends);
2230        let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
2231        let task = tokio::spawn(async move {
2232            send_post_with_retry(&manager, deadline, &task_cancellation_token, move || {
2233                let task_started = Arc::clone(&task_started);
2234                let task_sends = Arc::clone(&task_sends);
2235                async move {
2236                    task_sends.fetch_add(1, Ordering::SeqCst);
2237                    task_started.notify_one();
2238                    std::future::pending::<Result<(), PostSendError>>().await
2239                }
2240            })
2241            .await
2242        });
2243
2244        started.notified().await;
2245        cancellation_token.cancel();
2246        let error = task.await.unwrap().unwrap_err();
2247
2248        assert!(matches!(error, PostSendError::Retry(RetryError::Canceled)));
2249        assert_eq!(sends.load(Ordering::SeqCst), 1);
2250    }
2251
2252    #[rstest]
2253    #[case(SendError::Timeout, true)]
2254    #[case(SendError::ConnectionChanged, true)]
2255    #[case(SendError::WriteTimeout, false)]
2256    #[case(SendError::BrokenPipe("transport failed".to_string()), false)]
2257    #[case(SendError::Closed, false)]
2258    #[case(SendError::InvalidInput("invalid payload".to_string()), false)]
2259    fn post_send_retries_only_before_writing(#[case] error: SendError, #[case] expected: bool) {
2260        assert_eq!(
2261            should_retry_post_send(&PostSendError::Transport(error)),
2262            expected
2263        );
2264    }
2265
2266    #[rstest]
2267    #[tokio::test]
2268    async fn outbound_subscription_logs_omit_payload_bodies() {
2269        log::set_logger(&OUTBOUND_LOG_CAPTURE).expect("test logger already installed");
2270        log::set_max_level(LevelFilter::Debug);
2271
2272        let signal = Arc::new(AtomicBool::new(false));
2273        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2274        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
2275        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
2276        let post_router = PostRouter::new();
2277        let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
2278            Ustr,
2279            ClientOrderId,
2280            CLOID_CACHE_CAPACITY,
2281        >::new()));
2282        let mut handler = FeedHandler::new(
2283            signal,
2284            cmd_rx,
2285            raw_rx,
2286            out_tx,
2287            None,
2288            SubscriptionState::new(':'),
2289            cloid_cache,
2290            post_router,
2291            Arc::new(WebSocketRateLimits::new()),
2292            1,
2293        );
2294        let subscription = SubscriptionRequest::Notification {
2295            user: SECRET_MARKER.to_string(),
2296        };
2297        let subscribe_len = serde_json::to_string(&HyperliquidWsRequest::Subscribe {
2298            subscription: subscription.clone(),
2299        })
2300        .unwrap()
2301        .len();
2302        let unsubscribe_len = serde_json::to_string(&HyperliquidWsRequest::Unsubscribe {
2303            subscription: subscription.clone(),
2304        })
2305        .unwrap()
2306        .len();
2307        OUTBOUND_LOG_CAPTURE.clear();
2308
2309        cmd_tx
2310            .send(HandlerCommand::Subscribe {
2311                subscriptions: vec![subscription.clone()],
2312            })
2313            .unwrap();
2314        cmd_tx
2315            .send(HandlerCommand::Unsubscribe {
2316                subscriptions: vec![subscription],
2317            })
2318            .unwrap();
2319        drop(cmd_tx);
2320        drop(raw_tx);
2321
2322        assert!(handler.next().await.is_none());
2323
2324        let messages = OUTBOUND_LOG_CAPTURE.messages();
2325
2326        assert!(
2327            messages
2328                .iter()
2329                .all(|message| !message.contains(SECRET_MARKER)),
2330            "outbound logs exposed the secret marker: {messages:?}"
2331        );
2332        assert!(
2333            messages
2334                .iter()
2335                .any(|message| message
2336                    == &format!("Sending subscribe payload ({subscribe_len} bytes)")),
2337            "subscribe metadata missing or inaccurate: {messages:?}"
2338        );
2339        assert!(
2340            messages.iter().any(|message| {
2341                message == &format!("Sending unsubscribe payload ({unsubscribe_len} bytes)")
2342            }),
2343            "unsubscribe metadata missing or inaccurate: {messages:?}"
2344        );
2345    }
2346
2347    #[tokio::test]
2348    async fn unsubscribe_confirmation_releases_shared_reservation() {
2349        let signal = Arc::new(AtomicBool::new(false));
2350        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2351        let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel();
2352        let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel();
2353        let subscriptions = SubscriptionState::new(':');
2354        let limits = Arc::new(WebSocketRateLimits::new());
2355        let released = SubscriptionRequest::Trades {
2356            coin: Ustr::from("RELEASED"),
2357        };
2358        let released_key = super::subscription_to_key(&released);
2359        subscriptions.mark_subscribe(&released_key);
2360        subscriptions.confirm_subscribe(&released_key);
2361        subscriptions.mark_unsubscribe(&released_key);
2362        assert!(limits.reserve_subscription(1, &released).unwrap());
2363
2364        for index in 1..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
2365            assert!(
2366                limits
2367                    .reserve_subscription(
2368                        1,
2369                        &SubscriptionRequest::Trades {
2370                            coin: Ustr::from(&format!("COIN-{index}")),
2371                        },
2372                    )
2373                    .unwrap()
2374            );
2375        }
2376        assert!(
2377            limits
2378                .reserve_subscription(
2379                    2,
2380                    &SubscriptionRequest::Trades {
2381                        coin: Ustr::from("BEFORE-ACK"),
2382                    },
2383                )
2384                .is_err()
2385        );
2386
2387        let cloid_cache: CloidCache = Arc::new(Mutex::new(FifoCacheMap::<
2388            Ustr,
2389            ClientOrderId,
2390            CLOID_CACHE_CAPACITY,
2391        >::new()));
2392        let mut handler = FeedHandler::new(
2393            signal,
2394            cmd_rx,
2395            raw_rx,
2396            out_tx,
2397            None,
2398            subscriptions,
2399            cloid_cache,
2400            PostRouter::new(),
2401            Arc::clone(&limits),
2402            1,
2403        );
2404        raw_tx
2405            .send(Message::Text(
2406                json!({
2407                    "channel": "subscriptionResponse",
2408                    "data": {
2409                        "method": "unsubscribe",
2410                        "subscription": {
2411                            "type": "trades",
2412                            "coin": "RELEASED",
2413                        },
2414                    },
2415                })
2416                .to_string()
2417                .into(),
2418            ))
2419            .unwrap();
2420        drop(raw_tx);
2421        drop(cmd_tx);
2422
2423        assert!(handler.next().await.is_none());
2424        assert!(
2425            limits
2426                .reserve_subscription(
2427                    2,
2428                    &SubscriptionRequest::Trades {
2429                        coin: Ustr::from("AFTER-ACK"),
2430                    },
2431                )
2432                .unwrap()
2433        );
2434    }
2435
2436    #[rstest]
2437    fn handle_l2_book_emits_deltas_only_when_not_in_depth_subs() {
2438        let mut instruments = AHashMap::new();
2439        instruments.insert(Ustr::from("BTC"), btc_perp());
2440        let depth_subs = AHashSet::<Ustr>::new();
2441
2442        let msgs = FeedHandler::handle_l2_book(
2443            &one_level_book(),
2444            &instruments,
2445            &depth_subs,
2446            UnixNanos::default(),
2447        );
2448
2449        assert_eq!(msgs.len(), 1);
2450        assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
2451    }
2452
2453    #[rstest]
2454    fn handle_l2_book_emits_deltas_and_depth_when_coin_in_subs() {
2455        let mut instruments = AHashMap::new();
2456        instruments.insert(Ustr::from("BTC"), btc_perp());
2457        let mut depth_subs = AHashSet::<Ustr>::new();
2458        depth_subs.insert(Ustr::from("BTC"));
2459
2460        let msgs = FeedHandler::handle_l2_book(
2461            &one_level_book(),
2462            &instruments,
2463            &depth_subs,
2464            UnixNanos::default(),
2465        );
2466
2467        assert_eq!(msgs.len(), 2);
2468        assert!(matches!(msgs[0], NautilusWsMessage::Deltas(_)));
2469        assert!(matches!(msgs[1], NautilusWsMessage::Depth(_)));
2470    }
2471
2472    #[rstest]
2473    fn handle_l2_book_reports_unparsable_frame_as_invalid_book() {
2474        let instrument = btc_perp();
2475        let instrument_id = instrument.id();
2476        let mut instruments = AHashMap::new();
2477        instruments.insert(Ustr::from("BTC"), instrument);
2478        let mut book = one_level_book();
2479        book.time = u64::MAX;
2480
2481        let msgs = FeedHandler::handle_l2_book(
2482            &book,
2483            &instruments,
2484            &AHashSet::<Ustr>::new(),
2485            UnixNanos::default(),
2486        );
2487
2488        assert_eq!(msgs.len(), 1);
2489        assert!(matches!(
2490            msgs[0],
2491            NautilusWsMessage::BookInvalid(id) if id == instrument_id
2492        ));
2493    }
2494
2495    #[rstest]
2496    fn handle_l2_book_returns_empty_when_instrument_unknown() {
2497        let instruments = AHashMap::<Ustr, InstrumentAny>::new();
2498        let depth_subs = AHashSet::<Ustr>::new();
2499
2500        let msgs = FeedHandler::handle_l2_book(
2501            &one_level_book(),
2502            &instruments,
2503            &depth_subs,
2504            UnixNanos::default(),
2505        );
2506
2507        assert!(msgs.is_empty());
2508    }
2509
2510    #[rstest]
2511    fn handle_asset_context_emits_open_interest_custom_data_when_subscribed() {
2512        let instrument = btc_perp();
2513        let instrument_id = instrument.id();
2514        let mut instruments = AHashMap::new();
2515        instruments.insert(Ustr::from("BTC"), instrument);
2516
2517        let mut asset_context_subs = AHashMap::new();
2518        asset_context_subs.insert(
2519            Ustr::from("BTC"),
2520            AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2521        );
2522
2523        let mut asset_context_caches = AssetContextCaches::default();
2524
2525        let msgs = FeedHandler::handle_asset_context(
2526            &btc_active_asset_ctx(dec!(100000.0)),
2527            &instruments,
2528            &asset_context_subs,
2529            &mut asset_context_caches,
2530            UnixNanos::default(),
2531        );
2532
2533        assert_eq!(msgs.len(), 1);
2534
2535        match &msgs[0] {
2536            NautilusWsMessage::CustomData(Data::Custom(custom)) => {
2537                let open_interest = custom
2538                    .data
2539                    .as_any()
2540                    .downcast_ref::<HyperliquidOpenInterest>()
2541                    .expect("expected HyperliquidOpenInterest");
2542                assert_eq!(open_interest.instrument_id, instrument_id);
2543                assert_eq!(open_interest.open_interest.to_string(), "100000.0");
2544                assert_eq!(
2545                    custom
2546                        .data_type
2547                        .metadata()
2548                        .and_then(|metadata| metadata.get_str("instrument_id"))
2549                        .map(ToString::to_string),
2550                    Some(instrument_id.to_string()),
2551                );
2552            }
2553            other => panic!("unexpected message type: {other:?}"),
2554        }
2555    }
2556
2557    #[rstest]
2558    fn handle_all_dexs_asset_ctxs_emits_normalized_custom_data() {
2559        let mapping = AHashMap::from_iter([
2560            (
2561                Ustr::from(""),
2562                vec![Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"))],
2563            ),
2564            (
2565                Ustr::from("xyz"),
2566                vec![Some(InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID"))],
2567            ),
2568        ]);
2569
2570        let msg = FeedHandler::handle_all_dexs_asset_ctxs(
2571            sample_all_dexs_asset_ctxs(),
2572            &mapping,
2573            UnixNanos::default(),
2574        )
2575        .expect("expected custom data");
2576
2577        match msg {
2578            NautilusWsMessage::CustomData(Data::Custom(custom)) => {
2579                let payload = custom
2580                    .data
2581                    .as_any()
2582                    .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
2583                    .expect("expected HyperliquidAllDexsAssetCtxs");
2584                let btc_impact_prices = payload.entries[0].impact_prices.as_ref().unwrap();
2585                let xyz_impact_prices = payload.entries[1].impact_prices.as_ref().unwrap();
2586
2587                assert_eq!(payload.entries.len(), 2);
2588                assert_eq!(
2589                    payload.entries[0].instrument_id,
2590                    InstrumentId::from("BTC-USD-PERP.HYPERLIQUID")
2591                );
2592                assert_eq!(payload.entries[1].dex, "xyz");
2593                assert_eq!(
2594                    payload.entries[1].instrument_id,
2595                    InstrumentId::from("xyz:XYZ100-USD-PERP.HYPERLIQUID")
2596                );
2597                assert_eq!(payload.entries[0].mark_price.to_string(), "77562.0");
2598                assert_eq!(payload.entries[1].day_base_volume.to_string(), "5135.2458");
2599                assert_eq!(btc_impact_prices.bid.as_decimal(), dec!(77558.0));
2600                assert_eq!(btc_impact_prices.ask.as_decimal(), dec!(77559.0));
2601                assert_eq!(xyz_impact_prices.bid.as_decimal(), dec!(29854.0));
2602                assert_eq!(xyz_impact_prices.ask.as_decimal(), dec!(29855.0));
2603            }
2604            other => panic!("expected custom data, found {other:?}"),
2605        }
2606    }
2607
2608    #[rstest]
2609    fn handle_all_dexs_asset_ctxs_preserves_index_alignment_when_mappings_are_missing() {
2610        let data = WsAllDexsAssetCtxsData {
2611            ctxs: vec![(
2612                String::new(),
2613                vec![
2614                    PerpsAssetCtx {
2615                        shared: SharedAssetCtx {
2616                            day_ntl_vlm: dec!(1516669192.1953897476),
2617                            prev_day_px: dec!(76317.0),
2618                            mark_px: dec!(77562.0),
2619                            mid_px: Some(dec!(77558.5)),
2620                            impact_pxs: Some(vec![dec!(77558.0), dec!(77559.0)]),
2621                            day_base_vlm: Some(dec!(19707.77457)),
2622                        },
2623                        funding: dec!(-0.0000015186),
2624                        open_interest: dec!(27353.17682),
2625                        oracle_px: dec!(77605.0),
2626                        premium: Some(dec!(-0.0005927453)),
2627                    },
2628                    PerpsAssetCtx {
2629                        shared: SharedAssetCtx {
2630                            day_ntl_vlm: dec!(591989409.9392402172),
2631                            prev_day_px: dec!(2094.6),
2632                            mark_px: dec!(2123.7),
2633                            mid_px: Some(dec!(2123.95)),
2634                            impact_pxs: Some(vec![dec!(2123.65), dec!(2124.0)]),
2635                            day_base_vlm: Some(dec!(281686.8234999999)),
2636                        },
2637                        funding: dec!(0.0000125),
2638                        open_interest: dec!(605822.2557999999),
2639                        oracle_px: dec!(2124.6),
2640                        premium: Some(dec!(-0.0002824061)),
2641                    },
2642                ],
2643            )],
2644        };
2645
2646        let mapping = AHashMap::from_iter([(
2647            Ustr::from(""),
2648            vec![None, Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"))],
2649        )]);
2650
2651        let msg = FeedHandler::handle_all_dexs_asset_ctxs(data, &mapping, UnixNanos::default())
2652            .expect("expected custom data");
2653
2654        match msg {
2655            NautilusWsMessage::CustomData(Data::Custom(custom)) => {
2656                let payload = custom
2657                    .data
2658                    .as_any()
2659                    .downcast_ref::<HyperliquidAllDexsAssetCtxs>()
2660                    .expect("expected HyperliquidAllDexsAssetCtxs");
2661                assert_eq!(payload.entries.len(), 1);
2662                assert_eq!(
2663                    payload.entries[0].instrument_id,
2664                    InstrumentId::from("ETH-USD-PERP.HYPERLIQUID")
2665                );
2666                assert_eq!(payload.entries[0].mark_price.to_string(), "2123.7");
2667            }
2668            other => panic!("expected custom data, found {other:?}"),
2669        }
2670    }
2671
2672    #[rstest]
2673    fn handle_asset_context_skips_open_interest_for_spot_payload() {
2674        let instrument = btc_perp();
2675        let mut instruments = AHashMap::new();
2676        instruments.insert(Ustr::from("BTC"), instrument);
2677
2678        let mut asset_context_subs = AHashMap::new();
2679        asset_context_subs.insert(
2680            Ustr::from("BTC"),
2681            AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2682        );
2683
2684        let mut asset_context_caches = AssetContextCaches::default();
2685
2686        let msgs = FeedHandler::handle_asset_context(
2687            &btc_active_spot_asset_ctx(),
2688            &instruments,
2689            &asset_context_subs,
2690            &mut asset_context_caches,
2691            UnixNanos::default(),
2692        );
2693
2694        assert!(msgs.is_empty());
2695        assert!(asset_context_caches.open_interest.is_empty());
2696    }
2697
2698    #[rstest]
2699    fn handle_asset_context_suppresses_unchanged_open_interest() {
2700        let instrument = btc_perp();
2701        let mut instruments = AHashMap::new();
2702        instruments.insert(Ustr::from("BTC"), instrument);
2703
2704        let mut asset_context_subs = AHashMap::new();
2705        asset_context_subs.insert(
2706            Ustr::from("BTC"),
2707            AHashSet::from_iter([AssetContextDataType::OpenInterest]),
2708        );
2709
2710        let mut asset_context_caches = AssetContextCaches::default();
2711
2712        let first = FeedHandler::handle_asset_context(
2713            &btc_active_asset_ctx(dec!(100000.0)),
2714            &instruments,
2715            &asset_context_subs,
2716            &mut asset_context_caches,
2717            UnixNanos::default(),
2718        );
2719        let second = FeedHandler::handle_asset_context(
2720            &btc_active_asset_ctx(dec!(100000.0)),
2721            &instruments,
2722            &asset_context_subs,
2723            &mut asset_context_caches,
2724            UnixNanos::default(),
2725        );
2726
2727        assert_eq!(first.len(), 1);
2728        assert!(second.is_empty());
2729    }
2730
2731    #[rstest]
2732    fn asset_context_caches_clear_removed_data_types() {
2733        let coin = Ustr::from("BTC");
2734        let mut caches = AssetContextCaches::default();
2735        caches.mark_price.insert(coin, dec!(98455.5));
2736        caches.index_price.insert(coin, dec!(98460.0));
2737        caches.funding_rate.insert(coin, dec!(0.0001));
2738        caches.open_interest.insert(coin, dec!(1500.0));
2739
2740        let previous_data_types = AHashSet::from_iter([
2741            AssetContextDataType::MarkPrice,
2742            AssetContextDataType::IndexPrice,
2743            AssetContextDataType::FundingRate,
2744            AssetContextDataType::OpenInterest,
2745        ]);
2746        let next_data_types = AHashSet::from_iter([
2747            AssetContextDataType::MarkPrice,
2748            AssetContextDataType::FundingRate,
2749        ]);
2750
2751        caches.clear_removed(coin, Some(&previous_data_types), &next_data_types);
2752
2753        assert_eq!(caches.mark_price.get(&coin).copied(), Some(dec!(98455.5)));
2754        assert!(caches.index_price.get(&coin).is_none());
2755        assert_eq!(caches.funding_rate.get(&coin).copied(), Some(dec!(0.0001)));
2756        assert!(caches.open_interest.get(&coin).is_none());
2757    }
2758}