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