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