Skip to main content

nautilus_hyperliquid/websocket/
client.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
16use std::{
17    fmt::Debug,
18    str::FromStr,
19    sync::{
20        Arc,
21        atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
22    },
23    time::Duration,
24};
25
26use ahash::{AHashMap, AHashSet};
27use anyhow::Context;
28use arc_swap::ArcSwap;
29use dashmap::DashMap;
30use nautilus_common::cache::{InstrumentLookupError, fifo::FifoCacheMap};
31#[cfg(test)]
32use nautilus_common::live::get_runtime;
33#[cfg(test)]
34use nautilus_core::string::secret::REDACTED;
35use nautilus_core::{AtomicMap, string::secret::SecretString};
36use nautilus_live::{
37    SocketControl,
38    task::{SharedTaskSlot, TaskJoinOutcome},
39};
40use nautilus_model::{
41    data::BarType,
42    enums::{OrderSide, OrderType, TimeInForce},
43    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
44    instruments::{Instrument, InstrumentAny},
45    orders::{Order, OrderAny},
46    reports::OrderStatusReport,
47    types::{Price, Quantity},
48};
49use nautilus_network::{
50    SocketStateSink,
51    mode::ConnectionMode,
52    websocket::{
53        AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
54        channel_message_handler,
55    },
56};
57use parking_lot::Mutex;
58use rust_decimal::Decimal;
59use tokio_util::sync::CancellationToken;
60use ustr::Ustr;
61
62use crate::{
63    common::{
64        consts::{HEARTBEAT_INTERVAL, HTTP_TIMEOUT, ws_url},
65        enums::{HyperliquidBarInterval, HyperliquidEnvironment},
66        parse::{
67            bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
68            determine_order_list_grouping, extract_error_message, extract_inner_error,
69            extract_inner_errors, normalize_or_validate_wire_price,
70            order_to_hyperliquid_request_with_optional_decimals, round_to_sig_figs,
71            time_in_force_to_hyperliquid_tif,
72        },
73    },
74    http::{
75        client::HyperliquidHttpClient,
76        error::{Error as HyperliquidError, Result as HyperliquidResult},
77        models::{
78            HyperliquidExchangeAction, HyperliquidExchangeCancelByCloidRequest,
79            HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
80            HyperliquidExchangeLimitParams, HyperliquidExchangeModifyOrderRequest,
81            HyperliquidExchangeModifyTarget, HyperliquidExchangeOrderKind,
82            HyperliquidExchangePlaceOrderRequest, HyperliquidExchangeResponse,
83            HyperliquidExchangeTif, HyperliquidExchangeTpSl, HyperliquidExchangeTriggerParams,
84            RESPONSE_STATUS_OK,
85        },
86        rate_limits::exec_action_weight,
87    },
88    websocket::{
89        book::{BookStreamOptions, BookStreamRegistry, BookStreamRelease, BookStreamUse},
90        enums::HyperliquidWsChannel,
91        handler::{FeedHandler, HandlerCommand},
92        messages::{
93            NautilusWsMessage, PostRequest, PostResponse, PostResponsePayload, SubscriptionRequest,
94        },
95        post::{PostIds, PostRouter},
96        rate_limits::{WebSocketRateLimits, shared_websocket_limits},
97        trades::{TradeStreamRegistry, TradeStreamUse},
98    },
99};
100
101static NEXT_WEBSOCKET_CLIENT_ID: AtomicU64 = AtomicU64::new(1);
102
103/// FIFO bound on the cloid -> `ClientOrderId` resolution cache so missed
104/// evictions self-recover (see GH-3972 cancel-replace drain path).
105pub(super) const CLOID_CACHE_CAPACITY: usize = 10_000;
106
107/// Shared cloid -> `ClientOrderId` cache used by the WS handler.
108pub(super) type CloidCache = Arc<Mutex<FifoCacheMap<Ustr, ClientOrderId, CLOID_CACHE_CAPACITY>>>;
109
110/// Represents the different data types available from asset context subscriptions.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub(super) enum AssetContextDataType {
113    MarkPrice,
114    IndexPrice,
115    FundingRate,
116    OpenInterest,
117}
118
119/// Hyperliquid WebSocket client following the BitMEX pattern.
120///
121/// Orchestrates WebSocket connection and subscriptions using a command-based architecture,
122/// where the inner FeedHandler owns the WebSocketClient and handles all I/O.
123#[derive(Debug)]
124pub struct HyperliquidWebSocketClient {
125    url: String,
126    connection_mode: Arc<ArcSwap<AtomicU8>>,
127    signal: Arc<AtomicBool>,
128    cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
129    out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
130    auth_tracker: AuthTracker,
131    subscriptions: SubscriptionState,
132    book_streams: BookStreamRegistry,
133    trade_streams: TradeStreamRegistry,
134    trade_stream_lock: Arc<Mutex<()>>,
135    quote_streams: Arc<DashMap<Ustr, ()>>,
136    instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
137    bar_types: Arc<AtomicMap<String, BarType>>,
138    asset_context_subs: Arc<DashMap<Ustr, AHashSet<AssetContextDataType>>>,
139    all_dex_asset_ctxs_instrument_ids: Arc<AtomicMap<Ustr, Vec<Option<InstrumentId>>>>,
140    cloid_cache: CloidCache,
141    post_router: Arc<PostRouter>,
142    post_ids: Arc<PostIds>,
143    rate_limits: Arc<WebSocketRateLimits>,
144    client_id: u64,
145    connection_permit: Arc<Mutex<Option<tokio::sync::OwnedSemaphorePermit>>>,
146    post_timeout: Duration,
147    task_handle: Arc<SharedTaskSlot<()>>,
148    connect_lock: Arc<tokio::sync::Mutex<()>>,
149    account_id: Option<AccountId>,
150    transport_backend: TransportBackend,
151    proxy_url: Option<SecretString>,
152    socket_sink: Option<SocketStateSink>,
153    socket_control: Option<SocketControl>,
154}
155
156impl Clone for HyperliquidWebSocketClient {
157    fn clone(&self) -> Self {
158        Self {
159            url: self.url.clone(),
160            connection_mode: Arc::clone(&self.connection_mode),
161            signal: Arc::clone(&self.signal),
162            cmd_tx: Arc::clone(&self.cmd_tx),
163            out_rx: None,
164            auth_tracker: self.auth_tracker.clone(),
165            subscriptions: self.subscriptions.clone(),
166            book_streams: self.book_streams.clone(),
167            trade_streams: self.trade_streams.clone(),
168            trade_stream_lock: Arc::clone(&self.trade_stream_lock),
169            quote_streams: Arc::clone(&self.quote_streams),
170            instruments: Arc::clone(&self.instruments),
171            bar_types: Arc::clone(&self.bar_types),
172            asset_context_subs: Arc::clone(&self.asset_context_subs),
173            all_dex_asset_ctxs_instrument_ids: Arc::clone(&self.all_dex_asset_ctxs_instrument_ids),
174            cloid_cache: Arc::clone(&self.cloid_cache),
175            post_router: Arc::clone(&self.post_router),
176            post_ids: Arc::clone(&self.post_ids),
177            rate_limits: Arc::clone(&self.rate_limits),
178            client_id: self.client_id,
179            connection_permit: Arc::clone(&self.connection_permit),
180            post_timeout: self.post_timeout,
181            task_handle: Arc::clone(&self.task_handle),
182            connect_lock: Arc::clone(&self.connect_lock),
183            account_id: self.account_id,
184            transport_backend: self.transport_backend,
185            proxy_url: self.proxy_url.clone(),
186            socket_sink: self.socket_sink.clone(),
187            socket_control: self.socket_control.clone(),
188        }
189    }
190}
191
192impl HyperliquidWebSocketClient {
193    /// Creates a new Hyperliquid WebSocket client without connecting.
194    ///
195    /// If `url` is `None`, the appropriate URL will be determined from the `environment`:
196    /// - `Mainnet`: `wss://api.hyperliquid.xyz/ws`
197    /// - `Testnet`: `wss://api.hyperliquid-testnet.xyz/ws`
198    ///
199    /// The connection will be established when `connect()` is called.
200    pub fn new(
201        url: Option<String>,
202        environment: HyperliquidEnvironment,
203        account_id: Option<AccountId>,
204        transport_backend: TransportBackend,
205        proxy_url: Option<String>,
206    ) -> Self {
207        let url = url.unwrap_or_else(|| ws_url(environment).to_string());
208        let rate_limits = shared_websocket_limits(environment, &url, proxy_url.as_deref());
209        let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
210            ConnectionMode::Closed as u8,
211        ))));
212        Self {
213            url,
214            connection_mode,
215            signal: Arc::new(AtomicBool::new(false)),
216            auth_tracker: AuthTracker::new(),
217            subscriptions: SubscriptionState::new(':'),
218            book_streams: BookStreamRegistry::default(),
219            trade_streams: TradeStreamRegistry::default(),
220            trade_stream_lock: Arc::new(Mutex::new(())),
221            quote_streams: Arc::new(DashMap::new()),
222            instruments: Arc::new(AtomicMap::new()),
223            bar_types: Arc::new(AtomicMap::new()),
224            asset_context_subs: Arc::new(DashMap::new()),
225            all_dex_asset_ctxs_instrument_ids: Arc::new(AtomicMap::new()),
226            cloid_cache: Arc::new(Mutex::new(FifoCacheMap::new())),
227            post_router: PostRouter::with_inflight(Arc::clone(&rate_limits.post_slots)),
228            post_ids: Arc::new(PostIds::new(1)),
229            rate_limits,
230            client_id: NEXT_WEBSOCKET_CLIENT_ID.fetch_add(1, Ordering::Relaxed),
231            connection_permit: Arc::new(Mutex::new(None)),
232            post_timeout: HTTP_TIMEOUT,
233            cmd_tx: {
234                // Placeholder channel until connect() creates the real handler and replays queued instruments
235                let (tx, _) = tokio::sync::mpsc::unbounded_channel();
236                Arc::new(tokio::sync::RwLock::new(tx))
237            },
238            out_rx: None,
239            task_handle: Arc::new(SharedTaskSlot::new()),
240            connect_lock: Arc::new(tokio::sync::Mutex::new(())),
241            account_id,
242            transport_backend,
243            proxy_url: proxy_url.map(SecretString::from),
244            socket_sink: None,
245            socket_control: None,
246        }
247    }
248
249    /// Configures socket state reporting for the underlying transport.
250    #[must_use]
251    pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
252        self.socket_sink = Some(state_sink);
253        self
254    }
255
256    /// Configures state reporting and reconnect control for the underlying transport.
257    #[must_use]
258    pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
259        self.socket_control = Some(control);
260        self
261    }
262
263    /// Establishes WebSocket connection and spawns the message handler.
264    pub async fn connect(&mut self) -> anyhow::Result<()> {
265        let connect_lock = Arc::clone(&self.connect_lock);
266        let _guard = connect_lock.lock().await;
267        self.connect_locked().await
268    }
269
270    async fn connect_locked(&mut self) -> anyhow::Result<()> {
271        if self.is_active() {
272            log::warn!("WebSocket already connected");
273            return Ok(());
274        }
275
276        if !self.task_handle.is_empty() {
277            self.disconnect_locked().await?;
278        }
279
280        if self.connection_permit.lock().is_none() {
281            let permit = Arc::clone(&self.rate_limits.connection_slots)
282                .try_acquire_owned()
283                .map_err(|_| {
284                    anyhow::anyhow!(
285                        "Hyperliquid allows at most {} WebSocket connections per route",
286                        crate::common::consts::HYPERLIQUID_WS_CONNECTIONS_MAX,
287                    )
288                })?;
289            *self.connection_permit.lock() = Some(permit);
290        }
291
292        // A fresh socket has no venue-side subscriptions; stale book stream
293        // entries must not gate the venue subscribe for re-subscriptions
294        self.book_streams.clear();
295
296        let (message_handler, raw_rx) = channel_message_handler();
297        let cfg = WebSocketConfig {
298            url: self.url.clone(),
299            headers: vec![],
300            heartbeat_interval_secs: None,
301            heartbeat_payload: None,
302            connect_timeout_ms: Some(15_000),
303            reconnect_delay_initial_ms: Some(250),
304            reconnect_delay_max_ms: Some(5_000),
305            reconnect_backoff_factor: Some(2.0),
306            reconnect_jitter_ms: Some(200),
307            reconnect_max_attempts: None,
308            heartbeat_timeout_secs: Some(HEARTBEAT_INTERVAL.as_secs() * 3),
309            idle_timeout_ms: None,
310            backend: self.transport_backend,
311            proxy_url: self
312                .proxy_url
313                .as_ref()
314                .map(|value| value.expose_secret().to_owned()),
315        };
316        let connection_rate_keys: Arc<[Ustr]> = Arc::from([self.rate_limits.connection_key()]);
317        let client_result = WebSocketClient::builder()
318            .config(cfg)
319            .message_handler(message_handler)
320            .rate_limiter(Arc::clone(&self.rate_limits.messages))
321            .connection_rate_limiter(Arc::clone(&self.rate_limits.connections))
322            .connection_rate_keys(connection_rate_keys)
323            .maybe_state_sink(
324                self.socket_control
325                    .as_ref()
326                    .map(SocketControl::sink)
327                    .or_else(|| self.socket_sink.clone()),
328            )
329            .connect()
330            .await;
331        let client = match client_result {
332            Ok(client) => client,
333            Err(e) => {
334                self.connection_permit.lock().take();
335                return Err(e.into());
336            }
337        };
338
339        if let Some(control) = &self.socket_control {
340            let handle = client.reconnect_handle();
341            control.register(move || handle.request_reconnect());
342        }
343
344        // Create channels for handler communication
345        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
346        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
347
348        // Update cmd_tx before connection_mode to avoid race where is_active() returns
349        // true but subscriptions still go to the old placeholder channel
350        *self.cmd_tx.write().await = cmd_tx.clone();
351        self.out_rx = Some(out_rx);
352
353        self.connection_mode.store(client.connection_mode_atomic());
354        log::debug!("Hyperliquid WebSocket connected: {}", self.url);
355
356        // Send SetClient command immediately
357        if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
358            self.release_limit_reservations();
359            anyhow::bail!("Failed to send SetClient command: {e}");
360        }
361
362        // Initialize handler with existing instruments
363        let instruments_vec: Vec<InstrumentAny> =
364            self.instruments.load().values().cloned().collect();
365
366        if !instruments_vec.is_empty()
367            && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
368        {
369            log::error!("Failed to send InitializeInstruments: {e}");
370        }
371
372        for (coin, uses) in self.trade_streams.snapshot() {
373            if let Err(e) = cmd_tx.send(HandlerCommand::UpdateTradeSubs { coin, uses }) {
374                log::error!("Failed to send UpdateTradeSubs: {e}");
375            }
376        }
377
378        let all_dex_asset_ctxs_instrument_ids = self
379            .all_dex_asset_ctxs_instrument_ids
380            .load()
381            .iter()
382            .map(|(dex, instrument_ids)| (*dex, instrument_ids.clone()))
383            .collect();
384
385        if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(
386            all_dex_asset_ctxs_instrument_ids,
387        )) {
388            log::error!("Failed to send CacheAllDexAssetCtxsInstrumentIds: {e}");
389        }
390
391        // Spawn handler task
392        let signal = Arc::clone(&self.signal);
393        let account_id = self.account_id;
394        let subscriptions = self.subscriptions.clone();
395        let book_streams = self.book_streams.clone();
396        let cmd_tx_for_reconnect = cmd_tx.clone();
397        let cloid_cache = Arc::clone(&self.cloid_cache);
398        let post_router = Arc::clone(&self.post_router);
399        let rate_limits = Arc::clone(&self.rate_limits);
400        let client_id = self.client_id;
401        let connection_permit = Arc::clone(&self.connection_permit);
402
403        if let Err(e) = self.task_handle.spawn(async move {
404            let mut handler = FeedHandler::new(
405                signal,
406                cmd_rx,
407                raw_rx,
408                out_tx,
409                account_id,
410                subscriptions.clone(),
411                cloid_cache,
412                post_router,
413                Arc::clone(&rate_limits),
414                client_id,
415            );
416
417            let resubscribe_all = || {
418                let topics = subscriptions.all_topics();
419                if topics.is_empty() {
420                    log::debug!("No active subscriptions to restore after reconnection");
421                    return;
422                }
423
424                log::info!(
425                    "Resubscribing to {} active subscriptions after reconnection",
426                    topics.len()
427                );
428
429                for topic in topics {
430                    match subscription_from_topic(&topic) {
431                        Ok(mut subscription) => {
432                            // Topic text cannot carry l2Book precision options;
433                            // replay the shape the stream was opened with
434                            if let SubscriptionRequest::L2Book {
435                                coin,
436                                n_sig_figs,
437                                mantissa,
438                            } = &mut subscription
439                                && let Some(options) = book_streams.options(coin)
440                            {
441                                *n_sig_figs = options.n_sig_figs;
442                                *mantissa = options.mantissa;
443                            }
444
445                            if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
446                                subscriptions: vec![subscription],
447                            }) {
448                                log::error!("Failed to send resubscribe command: {e}");
449                            }
450                        }
451                        Err(e) => {
452                            log::error!(
453                                "Failed to reconstruct subscription from topic: topic={topic}, {e}"
454                            );
455                        }
456                    }
457                }
458            };
459
460            loop {
461                match handler.next().await {
462                    Some(NautilusWsMessage::Reconnected) => {
463                        log::info!("WebSocket reconnected");
464                        let pending_unsubscribe = subscriptions.pending_unsubscribe_topics();
465                        rate_limits.release_subscriptions(
466                            client_id,
467                            pending_unsubscribe.iter().map(String::as_str),
468                        );
469                        subscriptions.reset_after_reconnect();
470                        resubscribe_all();
471
472                        if handler.send(NautilusWsMessage::Reconnected).is_err() {
473                            if handler.is_stopped() {
474                                log::debug!("Failed to send reconnect event (receiver dropped)");
475                            } else {
476                                log::error!("Failed to send reconnect event (receiver dropped)");
477                            }
478                            break;
479                        }
480                    }
481                    Some(msg) => {
482                        if handler.send(msg).is_err() {
483                            if handler.is_stopped() {
484                                log::debug!("Failed to send message (receiver dropped)");
485                            } else {
486                                log::error!("Failed to send message (receiver dropped)");
487                            }
488                            break;
489                        }
490                    }
491                    None => {
492                        if handler.is_stopped() {
493                            log::debug!("Stop signal received, ending message processing");
494                            break;
495                        }
496                        log::warn!("WebSocket stream ended unexpectedly");
497                        break;
498                    }
499                }
500            }
501            rate_limits.release_client(client_id);
502            connection_permit.lock().take();
503            log::debug!("Handler task completed");
504        }) {
505            self.out_rx = None;
506            self.release_limit_reservations();
507            anyhow::bail!("Failed to start Hyperliquid WebSocket handler task: {e}");
508        }
509        Ok(())
510    }
511
512    pub fn set_post_timeout(&mut self, timeout: Duration) {
513        self.post_timeout = timeout;
514    }
515
516    pub(crate) fn begin_shutdown(&self) {
517        self.signal.store(true, Ordering::Relaxed);
518    }
519
520    /// Replaces state owned by a terminated WebSocket generation.
521    ///
522    /// This must run only after the handler task has stopped. Replacing the
523    /// shared containers, rather than clearing them, prevents old clones or
524    /// in-flight work from mutating a subsequent connection generation.
525    pub(crate) fn reset_runtime_state(&mut self) {
526        self.release_limit_reservations();
527        self.subscriptions = SubscriptionState::new(':');
528        self.book_streams = BookStreamRegistry::default();
529        self.trade_streams = TradeStreamRegistry::default();
530        self.trade_stream_lock = Arc::new(Mutex::new(()));
531        self.quote_streams = Arc::new(DashMap::new());
532        self.instruments = Arc::new(AtomicMap::new());
533        self.bar_types = Arc::new(AtomicMap::new());
534        self.asset_context_subs = Arc::new(DashMap::new());
535        self.all_dex_asset_ctxs_instrument_ids = Arc::new(AtomicMap::new());
536        self.cloid_cache = Arc::new(Mutex::new(FifoCacheMap::new()));
537        self.out_rx = None;
538
539        if let Some(control) = &self.socket_control {
540            control.deregister();
541        }
542        self.connection_mode
543            .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
544        self.signal.store(false, Ordering::Relaxed);
545    }
546
547    /// Disconnects the WebSocket connection.
548    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
549        let connect_lock = Arc::clone(&self.connect_lock);
550        let _guard = connect_lock.lock().await;
551        self.disconnect_locked().await
552    }
553
554    async fn disconnect_locked(&self) -> anyhow::Result<()> {
555        log::debug!("Disconnecting Hyperliquid WebSocket");
556
557        if let Some(control) = &self.socket_control {
558            control.deregister();
559        }
560        self.signal.store(true, Ordering::Relaxed);
561
562        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
563            log::debug!(
564                "Failed to send disconnect command (handler may already be shut down): {e}"
565            );
566        }
567
568        if self.task_handle.is_empty() {
569            log::debug!("No task handle to await");
570        } else {
571            log::debug!("Waiting for task handle to complete");
572
573            if let Some(outcome) = self
574                .task_handle
575                .finish(Duration::from_secs(2), Duration::from_secs(2))
576                .await
577            {
578                match outcome {
579                    TaskJoinOutcome::Completed(()) | TaskJoinOutcome::Aborted => {}
580                    TaskJoinOutcome::Failed(error) => {
581                        self.release_limit_reservations();
582                        anyhow::bail!("Hyperliquid WebSocket handler failed: {error}");
583                    }
584                    TaskJoinOutcome::Incomplete => {
585                        self.release_limit_reservations();
586                        anyhow::bail!("Hyperliquid WebSocket handler did not stop after abort");
587                    }
588                }
589            }
590        }
591        self.release_limit_reservations();
592        log::debug!("Disconnected");
593        Ok(())
594    }
595
596    /// Requests a full transport reconnect.
597    ///
598    /// Transitions the connection from `Active` to `Reconnect`; the network
599    /// layer re-establishes the socket with backoff and the handler replays all
600    /// active subscriptions once reconnected. Returns `false` when the
601    /// connection is not active (already reconnecting, disconnecting, or
602    /// closed), leaving any in-flight transition untouched.
603    pub fn request_reconnect(&self) -> bool {
604        ConnectionMode::request_reconnect(&self.connection_mode.load())
605    }
606
607    /// Send a typed exchange action through the Hyperliquid WebSocket post API.
608    ///
609    /// The supplied HTTP client is used only as the canonical signer for the
610    /// action envelope. The signed payload is sent over the active WebSocket
611    /// connection and the response is correlated by post id.
612    pub async fn post_action_exec(
613        &self,
614        signer: &HyperliquidHttpClient,
615        action: &HyperliquidExchangeAction,
616    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
617        self.post_action_exec_with_timeout(signer, action, self.post_timeout, None)
618            .await
619    }
620
621    /// Send a typed exchange action with a caller-specified timeout and optional expiry.
622    pub async fn post_action_exec_with_timeout(
623        &self,
624        signer: &HyperliquidHttpClient,
625        action: &HyperliquidExchangeAction,
626        timeout: Duration,
627        expires_after: Option<u64>,
628    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
629        self.post_action_result(signer, action, timeout, expires_after)
630            .await
631            .map_err(PostRequestError::into_error)
632    }
633
634    pub(crate) async fn post_action_command(
635        &self,
636        signer: &HyperliquidHttpClient,
637        action: &HyperliquidExchangeAction,
638    ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
639        self.post_action_result(signer, action, self.post_timeout, None)
640            .await
641    }
642
643    async fn post_action_result(
644        &self,
645        signer: &HyperliquidHttpClient,
646        action: &HyperliquidExchangeAction,
647        timeout: Duration,
648        expires_after: Option<u64>,
649    ) -> Result<HyperliquidExchangeResponse, PostRequestError> {
650        let weight = exec_action_weight(action);
651        let payload = signer
652            .sign_action_exec_request(action, expires_after)
653            .map_err(PostRequestError::BeforeDispatch)?;
654        let response = self
655            .send_post_request_result(PostRequest::Action { payload }, timeout)
656            .await?;
657
658        match response.response {
659            PostResponsePayload::Action { payload } => {
660                let parsed: HyperliquidExchangeResponse = serde_json::from_value(payload)
661                    .map_err(HyperliquidError::Serde)
662                    .map_err(PostRequestError::AfterDispatch)?;
663
664                match &parsed {
665                    HyperliquidExchangeResponse::Status { status, response }
666                        if status != RESPONSE_STATUS_OK =>
667                    {
668                        let reason = response
669                            .as_str()
670                            .map_or_else(|| response.to_string(), str::to_string);
671                        let error = HyperliquidError::bad_request(format!("API error: {reason}"));
672
673                        if status == "err" {
674                            Err(PostRequestError::Rejected {
675                                error,
676                                reason: extract_error_message(&parsed),
677                            })
678                        } else {
679                            Err(PostRequestError::AfterDispatch(error))
680                        }
681                    }
682                    HyperliquidExchangeResponse::Error { error } => {
683                        Err(PostRequestError::Rejected {
684                            error: HyperliquidError::bad_request(format!("API error: {error}")),
685                            reason: error.clone(),
686                        })
687                    }
688                    _ => Ok(parsed),
689                }
690            }
691            PostResponsePayload::Error { payload } => Err(PostRequestError::AfterDispatch(
692                map_post_payload_error(payload, weight),
693            )),
694            PostResponsePayload::Info { payload } => {
695                Err(PostRequestError::AfterDispatch(HyperliquidError::decode(
696                    format!("expected action post response, received info payload: {payload}"),
697                )))
698            }
699        }
700    }
701
702    /// Submit an order through the Hyperliquid WebSocket post API.
703    ///
704    /// The HTTP client supplies signing credentials, builder attribution, and
705    /// cached instrument metadata. The action itself is sent over WebSocket.
706    ///
707    /// Returns an [`OrderStatusReport`] describing the venue's immediate
708    /// response (`Filled` for an atomic IOC fill, `Accepted` for a resting
709    /// order), or `None` when the venue deferred the order without an oid (for
710    /// example a `waitingForFill` trigger child): the order stays `SUBMITTED`
711    /// until the user-events stream delivers the first `OrderAccepted`.
712    #[allow(
713        clippy::too_many_arguments,
714        reason = "matches the Python and HTTP order submit surface"
715    )]
716    pub async fn submit_order(
717        &self,
718        signer: &HyperliquidHttpClient,
719        instrument_id: InstrumentId,
720        client_order_id: ClientOrderId,
721        order_side: OrderSide,
722        order_type: OrderType,
723        quantity: Quantity,
724        time_in_force: TimeInForce,
725        price: Option<Price>,
726        trigger_price: Option<Price>,
727        post_only: bool,
728        reduce_only: bool,
729    ) -> HyperliquidResult<Option<OrderStatusReport>> {
730        let symbol = instrument_id.symbol.inner();
731        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
732            HyperliquidError::bad_request(format!(
733                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
734            ))
735        })?;
736        let is_buy = matches!(order_side, OrderSide::Buy);
737        let price_precision = signer.get_price_precision_for_symbol(symbol);
738
739        let price_decimal = match price {
740            Some(px) => normalize_or_validate_wire_price(
741                px.as_decimal(),
742                "Price",
743                price_precision,
744                signer.normalize_prices(),
745            )
746            .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?,
747            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
748            None if matches!(
749                order_type,
750                OrderType::StopMarket | OrderType::MarketIfTouched
751            ) =>
752            {
753                match trigger_price {
754                    Some(tp) => {
755                        let derived = derive_limit_from_trigger(
756                            tp.as_decimal().normalize(),
757                            is_buy,
758                            signer.market_order_slippage_bps(),
759                        );
760                        let sig_rounded = round_to_sig_figs(derived, 5);
761                        clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
762                            .normalize()
763                    }
764                    None => Decimal::ZERO,
765                }
766            }
767            None => {
768                return Err(HyperliquidError::bad_request(
769                    "Limit orders require a price",
770                ));
771            }
772        };
773
774        let size_decimal = quantity.as_decimal().normalize();
775        let kind = hyperliquid_order_kind(
776            order_type,
777            time_in_force,
778            post_only,
779            trigger_price,
780            signer.normalize_prices(),
781            price_precision,
782        )?;
783
784        let order = HyperliquidExchangePlaceOrderRequest {
785            asset,
786            is_buy,
787            price: price_decimal,
788            size: size_decimal,
789            reduce_only,
790            kind,
791            cloid: Some(signer.get_or_generate_client_order_id_cloid(client_order_id)),
792        };
793
794        if let Some(cloid) = order.cloid {
795            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
796        }
797        let action = HyperliquidExchangeAction::Order {
798            orders: vec![order],
799            grouping: HyperliquidExchangeGrouping::Na,
800            builder: signer.builder_attribution(),
801        };
802        let response = self.post_action_exec(signer, &action).await?;
803
804        // Verdict first: a real rejection must still error
805        ensure_ws_action_accepted(&response, "Order submission")?;
806
807        // Past the verdict, a build failure is local; defer to WS, never reject
808        match signer.build_submit_order_report(
809            instrument_id,
810            client_order_id,
811            order_side,
812            order_type,
813            quantity,
814            time_in_force,
815            price,
816            trigger_price,
817            response,
818        ) {
819            Ok(report) => Ok(report),
820            Err(e) => {
821                log::warn!(
822                    "Failed to build submit report for {client_order_id}: {e}; awaiting WS reconciliation"
823                );
824                Ok(None)
825            }
826        }
827    }
828
829    /// Submit multiple orders through the Hyperliquid WebSocket post API.
830    ///
831    /// Returns one [`OrderStatusReport`] per accepted order in submission
832    /// order. Deferred trigger children of a `normalTpsl` bracket are absent
833    /// from the result; they stay `SUBMITTED` until the user-events stream
834    /// delivers an `OrderAccepted` with the real oid.
835    ///
836    /// # Errors
837    ///
838    /// Returns an error for any quote-denominated quantity: this raw path has
839    /// no cached market data for a quote-to-base conversion, so such orders
840    /// must be submitted through the execution client instead.
841    pub async fn submit_orders(
842        &self,
843        signer: &HyperliquidHttpClient,
844        orders: &[&OrderAny],
845    ) -> HyperliquidResult<Vec<OrderStatusReport>> {
846        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
847        let mut client_order_ids = Vec::with_capacity(orders.len());
848
849        for order in orders {
850            if order.is_quote_quantity() {
851                return Err(HyperliquidError::bad_request(format!(
852                    "Quote-denominated quantity order {} must submit through the execution \
853                     client for quote-to-base conversion",
854                    order.client_order_id()
855                )));
856            }
857
858            let instrument_id = order.instrument_id();
859            let symbol = instrument_id.symbol.inner();
860            let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
861                HyperliquidError::bad_request(format!(
862                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
863                ))
864            })?;
865            let price_decimals = signer.get_price_precision_for_symbol(symbol);
866            let request = order_to_hyperliquid_request_with_optional_decimals(
867                order,
868                asset,
869                price_decimals,
870                signer.normalize_prices(),
871                signer.market_order_slippage_bps(),
872                None,
873            )
874            .map_err(|e| HyperliquidError::bad_request(format!("Failed to convert order: {e}")))?;
875            client_order_ids.push(order.client_order_id());
876            hyperliquid_orders.push(request);
877        }
878
879        for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
880            let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
881            request.cloid = Some(cloid);
882            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
883        }
884
885        let grouping =
886            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
887        let action = HyperliquidExchangeAction::Order {
888            orders: hyperliquid_orders,
889            grouping,
890            builder: signer.builder_attribution(),
891        };
892        let response = self.post_action_exec(signer, &action).await?;
893
894        ensure_ws_action_accepted(&response, "Order list submission")?;
895
896        // Past the verdict, a build failure is local; defer to WS, never reject
897        match signer.build_submit_orders_reports(orders, grouping, response) {
898            Ok(reports) => Ok(reports),
899            Err(e) => {
900                log::warn!(
901                    "Failed to build submit reports for order list: {e}; awaiting WS reconciliation"
902                );
903                Ok(Vec::new())
904            }
905        }
906    }
907
908    /// Cancel an order through the Hyperliquid WebSocket post API.
909    pub async fn cancel_order(
910        &self,
911        signer: &HyperliquidHttpClient,
912        instrument_id: InstrumentId,
913        client_order_id: Option<ClientOrderId>,
914        venue_order_id: Option<VenueOrderId>,
915    ) -> HyperliquidResult<()> {
916        let symbol = instrument_id.symbol.inner();
917        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
918            HyperliquidError::bad_request(format!(
919                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
920            ))
921        })?;
922        let action = if let Some(client_order_id) = client_order_id {
923            if let Some(cloid) = signer.cached_client_order_id_cloid(&client_order_id) {
924                HyperliquidExchangeAction::CancelByCloid {
925                    cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
926                    fast: None,
927                }
928            } else if let Some(oid) = venue_order_id {
929                let oid = oid
930                    .as_str()
931                    .parse::<u64>()
932                    .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
933                HyperliquidExchangeAction::Cancel {
934                    cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
935                    fast: None,
936                }
937            } else {
938                let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
939                HyperliquidExchangeAction::CancelByCloid {
940                    cancels: vec![HyperliquidExchangeCancelByCloidRequest { asset, cloid }],
941                    fast: None,
942                }
943            }
944        } else if let Some(oid) = venue_order_id {
945            let oid = oid
946                .as_str()
947                .parse::<u64>()
948                .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
949            HyperliquidExchangeAction::Cancel {
950                cancels: vec![HyperliquidExchangeCancelOrderRequest { asset, oid }],
951                fast: None,
952            }
953        } else {
954            return Err(HyperliquidError::bad_request(
955                "Either client_order_id or venue_order_id must be provided",
956            ));
957        };
958        let response = self.post_action_exec(signer, &action).await?;
959
960        ensure_ws_action_accepted(&response, "Cancel order")
961    }
962
963    /// Cancel multiple orders through one Hyperliquid WebSocket post action.
964    pub async fn cancel_orders(
965        &self,
966        signer: &HyperliquidHttpClient,
967        cancels: &[(InstrumentId, ClientOrderId, Option<VenueOrderId>)],
968    ) -> HyperliquidResult<Vec<Option<String>>> {
969        let mut cloid_requests = Vec::new();
970        let mut cloid_indices = Vec::new();
971        let mut oid_requests = Vec::new();
972        let mut oid_indices = Vec::new();
973        let mut results = vec![None; cancels.len()];
974
975        for (index, (instrument_id, client_order_id, venue_order_id)) in cancels.iter().enumerate()
976        {
977            let symbol = instrument_id.symbol.inner();
978            let Some(asset) = signer.get_asset_index_for_symbol(symbol) else {
979                results[index] = Some(format!(
980                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
981                ));
982                continue;
983            };
984
985            if let Some(cloid) = signer.cached_client_order_id_cloid(client_order_id) {
986                cloid_requests.push(HyperliquidExchangeCancelByCloidRequest { asset, cloid });
987                cloid_indices.push(index);
988            } else if let Some(venue_order_id) = venue_order_id {
989                match venue_order_id.as_str().parse::<u64>() {
990                    Ok(oid) => {
991                        oid_requests.push(HyperliquidExchangeCancelOrderRequest { asset, oid });
992                        oid_indices.push(index);
993                    }
994                    Err(_) => {
995                        results[index] = Some("Invalid venue order ID format".to_string());
996                    }
997                }
998            } else {
999                let cloid = signer.get_or_generate_client_order_id_cloid(*client_order_id);
1000                cloid_requests.push(HyperliquidExchangeCancelByCloidRequest { asset, cloid });
1001                cloid_indices.push(index);
1002            }
1003        }
1004
1005        if cloid_requests.is_empty() && oid_requests.is_empty() {
1006            return Ok(results);
1007        }
1008
1009        if !cloid_requests.is_empty() {
1010            let action = HyperliquidExchangeAction::CancelByCloid {
1011                cancels: cloid_requests,
1012                fast: None,
1013            };
1014            let errors = self
1015                .post_cancel_action_errors(signer, &action, cloid_indices.len())
1016                .await?;
1017
1018            for (index, error) in cloid_indices.into_iter().zip(errors) {
1019                results[index] = error;
1020            }
1021        }
1022
1023        if !oid_requests.is_empty() {
1024            let action = HyperliquidExchangeAction::Cancel {
1025                cancels: oid_requests,
1026                fast: None,
1027            };
1028            let errors = self
1029                .post_cancel_action_errors(signer, &action, oid_indices.len())
1030                .await?;
1031
1032            for (index, error) in oid_indices.into_iter().zip(errors) {
1033                results[index] = error;
1034            }
1035        }
1036
1037        Ok(results)
1038    }
1039
1040    async fn post_cancel_action_errors(
1041        &self,
1042        signer: &HyperliquidHttpClient,
1043        action: &HyperliquidExchangeAction,
1044        request_count: usize,
1045    ) -> HyperliquidResult<Vec<Option<String>>> {
1046        match self.post_cancel_action(signer, action).await {
1047            Ok(response) if response.is_ok() => {
1048                match cancel_errors_for_requests(extract_inner_errors(&response), request_count) {
1049                    Ok(errors) => Ok(errors),
1050                    Err(e) => Ok(vec![Some(e.to_string()); request_count]),
1051                }
1052            }
1053            Ok(response) => Ok(vec![
1054                Some(format!(
1055                    "Cancel orders failed: {}",
1056                    extract_error_message(&response)
1057                ));
1058                request_count
1059            ]),
1060            Err(e) => Err(e),
1061        }
1062    }
1063
1064    async fn post_cancel_action(
1065        &self,
1066        signer: &HyperliquidHttpClient,
1067        action: &HyperliquidExchangeAction,
1068    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
1069        let weight = exec_action_weight(action);
1070
1071        let payload = signer.sign_action_exec_request(action, None)?;
1072        let response = self
1073            .send_post_request(PostRequest::Action { payload }, self.post_timeout)
1074            .await?;
1075
1076        match response.response {
1077            PostResponsePayload::Action { payload } => {
1078                serde_json::from_value(payload).map_err(HyperliquidError::Serde)
1079            }
1080            PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
1081            PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
1082                "expected action post response, received info payload: {payload}"
1083            ))),
1084        }
1085    }
1086
1087    /// Modify an order through the Hyperliquid WebSocket post API.
1088    #[allow(
1089        clippy::too_many_arguments,
1090        reason = "matches the Python and HTTP order modify surface"
1091    )]
1092    pub async fn modify_order(
1093        &self,
1094        signer: &HyperliquidHttpClient,
1095        instrument_id: InstrumentId,
1096        venue_order_id: Option<VenueOrderId>,
1097        order_side: OrderSide,
1098        order_type: OrderType,
1099        price: Price,
1100        quantity: Quantity,
1101        trigger_price: Option<Price>,
1102        reduce_only: bool,
1103        post_only: bool,
1104        time_in_force: TimeInForce,
1105        client_order_id: Option<ClientOrderId>,
1106    ) -> HyperliquidResult<()> {
1107        let symbol = instrument_id.symbol.inner();
1108        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1109            HyperliquidError::bad_request(format!(
1110                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1111            ))
1112        })?;
1113        let oid = match client_order_id
1114            .as_ref()
1115            .and_then(|id| signer.unique_cached_client_order_id_cloid(id))
1116        {
1117            Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
1118            None => {
1119                let Some(venue_order_id) = venue_order_id.as_ref() else {
1120                    return Err(HyperliquidError::bad_request(
1121                        "venue_order_id or unique cached CLOID is required for modify",
1122                    ));
1123                };
1124                HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
1125                    .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?
1126            }
1127        };
1128        let is_buy = matches!(order_side, OrderSide::Buy);
1129        let price_decimals = signer.get_price_precision_for_symbol(symbol);
1130        let price = normalize_or_validate_wire_price(
1131            price.as_decimal(),
1132            "Price",
1133            price_decimals,
1134            signer.normalize_prices(),
1135        )
1136        .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
1137        let kind = hyperliquid_order_kind(
1138            order_type,
1139            time_in_force,
1140            post_only,
1141            trigger_price,
1142            signer.normalize_prices(),
1143            price_decimals,
1144        )?;
1145        let cloid =
1146            client_order_id.map(|id| (id, signer.get_or_generate_client_order_id_cloid(id)));
1147        let order = HyperliquidExchangePlaceOrderRequest {
1148            asset,
1149            is_buy,
1150            price,
1151            size: quantity.as_decimal().normalize(),
1152            reduce_only,
1153            kind,
1154            cloid: cloid.map(|(_, cloid)| cloid),
1155        };
1156
1157        if let Some((client_order_id, cloid)) = cloid {
1158            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
1159        }
1160        let action = HyperliquidExchangeAction::Modify {
1161            modify: HyperliquidExchangeModifyOrderRequest { oid, order },
1162        };
1163        let response = self.post_action_exec(signer, &action).await?;
1164
1165        ensure_ws_action_accepted(&response, "Modify order")
1166    }
1167
1168    async fn send_post_request(
1169        &self,
1170        request: PostRequest,
1171        timeout: Duration,
1172    ) -> HyperliquidResult<PostResponse> {
1173        self.send_post_request_result(request, timeout)
1174            .await
1175            .map_err(PostRequestError::into_error)
1176    }
1177
1178    async fn send_post_request_result(
1179        &self,
1180        request: PostRequest,
1181        timeout: Duration,
1182    ) -> Result<PostResponse, PostRequestError> {
1183        let id = self.post_ids.next();
1184        let Some(deadline) = tokio::time::Instant::now().checked_add(timeout) else {
1185            return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1186        };
1187
1188        let cancellation_token = CancellationToken::new();
1189        let rx = tokio::select! {
1190            biased;
1191            () = tokio::time::sleep_until(deadline) => return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout)),
1192            result = self.post_router.register_with_cancellation(id, &cancellation_token) => result.map_err(PostRequestError::BeforeDispatch)?,
1193        };
1194
1195        let _cancellation_guard = cancellation_token.drop_guard_ref();
1196
1197        let send_result = {
1198            let cmd_tx = tokio::select! {
1199                biased;
1200                () = tokio::time::sleep_until(deadline) => {
1201                    self.cancel_post_registration(id, &cancellation_token).await;
1202                    return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1203                }
1204                cmd_tx = self.cmd_tx.read() => cmd_tx,
1205            };
1206
1207            if cancellation_token.is_cancelled() || tokio::time::Instant::now() >= deadline {
1208                self.cancel_post_registration(id, &cancellation_token).await;
1209                return Err(PostRequestError::BeforeDispatch(HyperliquidError::Timeout));
1210            }
1211
1212            cmd_tx.send(HandlerCommand::Post {
1213                id,
1214                request,
1215                deadline,
1216                cancellation_token: cancellation_token.clone(),
1217            })
1218        };
1219
1220        if let Err(e) = send_result {
1221            self.cancel_post_registration(id, &cancellation_token).await;
1222            return Err(PostRequestError::BeforeDispatch(
1223                HyperliquidError::transport(format!("post command channel closed: {e}")),
1224            ));
1225        }
1226
1227        self.await_post_response(id, rx, deadline, &cancellation_token)
1228            .await
1229            .map_err(PostRequestError::AfterDispatch)
1230    }
1231
1232    async fn await_post_response(
1233        &self,
1234        id: u64,
1235        rx: tokio::sync::oneshot::Receiver<PostResponse>,
1236        deadline: tokio::time::Instant,
1237        cancellation_token: &CancellationToken,
1238    ) -> HyperliquidResult<PostResponse> {
1239        tokio::select! {
1240            biased;
1241            result = rx => match result {
1242                Ok(response) => Ok(response),
1243                Err(_closed) => {
1244                    self.cancel_post_registration(id, cancellation_token).await;
1245                    Err(HyperliquidError::transport("post response channel closed"))
1246                },
1247            },
1248            () = tokio::time::sleep_until(deadline) => {
1249                self.cancel_post_registration(id, cancellation_token).await;
1250                Err(HyperliquidError::Timeout)
1251            },
1252        }
1253    }
1254
1255    async fn cancel_post_registration(&self, id: u64, cancellation_token: &CancellationToken) {
1256        cancellation_token.cancel();
1257        self.post_router
1258            .cancel_registration(id, cancellation_token)
1259            .await;
1260    }
1261
1262    /// Returns true if the WebSocket is actively connected.
1263    pub fn is_active(&self) -> bool {
1264        let mode = self.connection_mode.load();
1265        mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8
1266    }
1267
1268    /// Returns the URL of this WebSocket client.
1269    pub fn url(&self) -> &str {
1270        &self.url
1271    }
1272
1273    /// Caches multiple instruments.
1274    ///
1275    /// Clears the existing cache first, then adds all provided instruments.
1276    /// Instruments are keyed by their raw_symbol which is unique per instrument:
1277    /// - Perps use base currency (e.g., "BTC")
1278    /// - Spot uses @{pair_index} format (e.g., "@107") or slash format for PURR
1279    pub fn cache_instruments(&mut self, instruments: Vec<InstrumentAny>) {
1280        let mut map = AHashMap::new();
1281
1282        for inst in instruments {
1283            let coin = inst.raw_symbol().inner();
1284            map.insert(coin, inst);
1285        }
1286        let count = map.len();
1287        self.instruments.store(map);
1288        log::debug!("Hyperliquid instrument cache initialized with {count} instruments");
1289    }
1290
1291    /// Caches a single instrument.
1292    ///
1293    /// Any existing instrument with the same raw_symbol will be replaced.
1294    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1295        let coin = instrument.raw_symbol().inner();
1296        self.instruments.insert(coin, instrument.clone());
1297
1298        // Before connect() the handler isn't running; this send will fail and that's expected
1299        // because connect() replays the instruments via InitializeInstruments
1300        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1301            let _ = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument));
1302        }
1303    }
1304
1305    /// Returns a shared reference to the instrument cache.
1306    #[must_use]
1307    pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
1308        self.instruments.clone()
1309    }
1310
1311    /// Caches spot fill coin mappings for instrument lookup.
1312    ///
1313    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1314    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1315    /// This mapping allows the handler to look up instruments from spot fills.
1316    pub fn cache_spot_fill_coins(&self, mapping: AHashMap<Ustr, Ustr>) {
1317        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1318            let _ = cmd_tx.send(HandlerCommand::CacheSpotFillCoins(mapping));
1319        }
1320    }
1321
1322    /// Caches a venue CLOID to client_order_id mapping for order/fill resolution.
1323    ///
1324    /// This mapping allows WebSocket order status and fill reports to be resolved back to
1325    /// the original client_order_id.
1326    ///
1327    /// This writes directly to a shared cache that the handler reads from, avoiding any
1328    /// race conditions between caching and WebSocket message processing.
1329    pub fn cache_cloid_mapping(&self, cloid: Ustr, client_order_id: ClientOrderId) {
1330        log::debug!("Caching cloid mapping: {cloid} -> {client_order_id}");
1331        self.cloid_cache.lock().insert(cloid, client_order_id);
1332    }
1333
1334    /// Removes a cloid mapping from the cache.
1335    ///
1336    /// Called on terminal order state. The cache is FIFO-bounded so missed
1337    /// removals self-evict (see GH-3972 cancel-replace drain).
1338    pub fn remove_cloid_mapping(&self, cloid: &Ustr) {
1339        if self.cloid_cache.lock().remove(cloid).is_some() {
1340            log::debug!("Removed cloid mapping: {cloid}");
1341        }
1342    }
1343
1344    /// Clears all cloid mappings from the cache.
1345    ///
1346    /// Useful for cleanup during reconnection or shutdown.
1347    pub fn clear_cloid_cache(&self) {
1348        let mut cache = self.cloid_cache.lock();
1349        let count = cache.len();
1350        cache.clear();
1351
1352        if count > 0 {
1353            log::debug!("Cleared {count} cloid mappings from cache");
1354        }
1355    }
1356
1357    /// Returns the number of cloid mappings in the cache.
1358    #[must_use]
1359    pub fn cloid_cache_len(&self) -> usize {
1360        self.cloid_cache.lock().len()
1361    }
1362
1363    /// Looks up a client_order_id by its venue CLOID.
1364    ///
1365    /// Returns `Some(ClientOrderId)` if the mapping exists, `None` otherwise.
1366    #[must_use]
1367    pub fn get_cloid_mapping(&self, cloid: &Ustr) -> Option<ClientOrderId> {
1368        self.cloid_cache.lock().get(cloid).copied()
1369    }
1370
1371    /// Gets an instrument from the cache by ID.
1372    ///
1373    /// Searches the cache for a matching instrument ID.
1374    pub fn get_instrument(&self, id: &InstrumentId) -> Option<InstrumentAny> {
1375        self.instruments
1376            .load()
1377            .values()
1378            .find(|inst| inst.id() == *id)
1379            .cloned()
1380    }
1381
1382    /// Gets an instrument from the cache by raw_symbol (coin).
1383    pub fn get_instrument_by_symbol(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1384        self.instruments.get_cloned(symbol)
1385    }
1386
1387    /// Returns the count of confirmed subscriptions.
1388    pub fn subscription_count(&self) -> usize {
1389        self.subscriptions.len()
1390    }
1391
1392    /// Gets a bar type from the cache by coin and interval.
1393    ///
1394    /// This looks up the subscription key created when subscribing to bars.
1395    pub fn get_bar_type(&self, coin: &str, interval: &str) -> Option<BarType> {
1396        // Use canonical key format matching subscribe_bars
1397        let key = format!("candle:{coin}:{interval}");
1398        self.bar_types.load().get(&key).copied()
1399    }
1400
1401    /// Subscribe to L2 order book for an instrument.
1402    pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1403        self.subscribe_book_with_options(instrument_id, None, None)
1404            .await
1405    }
1406
1407    /// Subscribe to L2 order book with optional `nSigFigs` / `mantissa`
1408    /// precision controls passed through to the venue's `l2Book` stream.
1409    ///
1410    /// One venue `l2Book` stream per coin is shared with depth10 snapshots;
1411    /// the first logical use opens the stream and its options win. Requesting
1412    /// different options while the stream is active logs a warning.
1413    pub async fn subscribe_book_with_options(
1414        &self,
1415        instrument_id: InstrumentId,
1416        n_sig_figs: Option<u32>,
1417        mantissa: Option<u32>,
1418    ) -> anyhow::Result<()> {
1419        let instrument = self
1420            .get_instrument(&instrument_id)
1421            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1422        let coin = instrument.raw_symbol().inner();
1423
1424        let cmd_tx = self.cmd_tx.read().await;
1425
1426        // Update the handler's coin→instrument mapping for this subscription
1427        cmd_tx
1428            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1429            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1430
1431        self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Deltas, n_sig_figs, mantissa)
1432    }
1433
1434    /// Subscribe to order book depth-10 snapshots.
1435    ///
1436    /// Reuses the same `l2Book` WebSocket subscription as
1437    /// [`Self::subscribe_book`] and flags the handler to additionally emit
1438    /// `NautilusWsMessage::Depth10` for this coin.
1439    pub async fn subscribe_book_depth10(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1440        self.subscribe_book_depth10_with_options(instrument_id, None, None)
1441            .await
1442    }
1443
1444    /// Subscribe to depth-10 snapshots with optional `nSigFigs` /
1445    /// `mantissa` precision controls.
1446    ///
1447    /// Shares the coin's `l2Book` stream with deltas subscribers; the first
1448    /// logical use opens the stream and its options win. Requesting different
1449    /// options while the stream is active logs a warning.
1450    pub async fn subscribe_book_depth10_with_options(
1451        &self,
1452        instrument_id: InstrumentId,
1453        n_sig_figs: Option<u32>,
1454        mantissa: Option<u32>,
1455    ) -> anyhow::Result<()> {
1456        let instrument = self
1457            .get_instrument(&instrument_id)
1458            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1459        let coin = instrument.raw_symbol().inner();
1460
1461        let cmd_tx = self.cmd_tx.read().await;
1462
1463        cmd_tx
1464            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1465            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1466
1467        cmd_tx
1468            .send(HandlerCommand::SetDepth10Sub {
1469                coin,
1470                subscribed: true,
1471            })
1472            .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1473
1474        self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Depth10, n_sig_figs, mantissa)
1475    }
1476
1477    /// Unsubscribe from order book depth-10 snapshots.
1478    ///
1479    /// Clears the depth10 emission flag and tears down the underlying
1480    /// `l2Book` stream unless active deltas subscribers still need it.
1481    pub async fn unsubscribe_book_depth10(
1482        &self,
1483        instrument_id: InstrumentId,
1484    ) -> anyhow::Result<()> {
1485        let instrument = self
1486            .get_instrument(&instrument_id)
1487            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1488        let coin = instrument.raw_symbol().inner();
1489
1490        let cmd_tx = self.cmd_tx.read().await;
1491
1492        cmd_tx
1493            .send(HandlerCommand::SetDepth10Sub {
1494                coin,
1495                subscribed: false,
1496            })
1497            .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1498
1499        self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Depth10)
1500    }
1501
1502    /// Subscribe to best bid/offer (BBO) quotes for an instrument.
1503    pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1504        let instrument = self
1505            .get_instrument(&instrument_id)
1506            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1507        let coin = instrument.raw_symbol().inner();
1508
1509        let cmd_tx = self.cmd_tx.read().await;
1510        self.quote_streams.insert(coin, ());
1511
1512        // Update the handler's coin→instrument mapping for this subscription
1513        if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument.clone())) {
1514            self.quote_streams.remove(&coin);
1515            anyhow::bail!("Failed to send UpdateInstrument command: {e}");
1516        }
1517
1518        let subscription = SubscriptionRequest::Bbo { coin };
1519
1520        if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
1521            self.quote_streams.remove(&coin);
1522            return Err(e);
1523        }
1524        Ok(())
1525    }
1526
1527    /// Subscribe to all mid prices across markets.
1528    pub async fn subscribe_all_mids(&self) -> anyhow::Result<()> {
1529        self.subscribe_all_mids_with_dex(None).await
1530    }
1531
1532    /// Subscribe to aggregate asset contexts across all perp dexes.
1533    pub async fn subscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1534        self.send_subscription(
1535            &*self.cmd_tx.read().await,
1536            SubscriptionRequest::AllDexsAssetCtxs,
1537        )?;
1538        Ok(())
1539    }
1540
1541    /// Subscribe to all mid prices across markets, optionally scoped to a specific dex.
1542    pub async fn subscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1543        let cmd_tx = self.cmd_tx.read().await;
1544
1545        let subscription = SubscriptionRequest::AllMids {
1546            dex: dex.map(ToString::to_string),
1547        };
1548
1549        self.send_subscription(&cmd_tx, subscription)?;
1550        Ok(())
1551    }
1552
1553    /// Unsubscribe from all mid prices across markets.
1554    pub async fn unsubscribe_all_mids(&self) -> anyhow::Result<()> {
1555        self.unsubscribe_all_mids_with_dex(None).await
1556    }
1557
1558    /// Unsubscribe from aggregate asset contexts across all perp dexes.
1559    pub async fn unsubscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1560        self.send_unsubscription(
1561            &*self.cmd_tx.read().await,
1562            SubscriptionRequest::AllDexsAssetCtxs,
1563        )?;
1564        Ok(())
1565    }
1566
1567    /// Unsubscribe from all mid prices across markets, optionally scoped to a specific dex.
1568    pub async fn unsubscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1569        let cmd_tx = self.cmd_tx.read().await;
1570
1571        let subscription = SubscriptionRequest::AllMids {
1572            dex: dex.map(ToString::to_string),
1573        };
1574
1575        self.send_unsubscription(&cmd_tx, subscription)?;
1576        Ok(())
1577    }
1578
1579    /// Subscribe to trades for an instrument.
1580    pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1581        self.subscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1582            .await
1583    }
1584
1585    /// Subscribe to complete public trades for an instrument.
1586    pub async fn subscribe_public_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1587        self.subscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1588            .await
1589    }
1590
1591    async fn subscribe_trade_stream(
1592        &self,
1593        instrument_id: InstrumentId,
1594        stream_use: TradeStreamUse,
1595    ) -> anyhow::Result<()> {
1596        let instrument = self
1597            .get_instrument(&instrument_id)
1598            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1599        let coin = instrument.raw_symbol().inner();
1600
1601        let cmd_tx = self.cmd_tx.read().await;
1602
1603        // Update the handler's coin→instrument mapping for this subscription
1604        cmd_tx
1605            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1606            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1607
1608        // Keep registry mutations and their handler commands ordered across
1609        // concurrent generic/custom subscriptions for the same coin.
1610        let _trade_stream_guard = self.trade_stream_lock.lock();
1611        let registration = self.trade_streams.register(coin, stream_use);
1612        cmd_tx
1613            .send(HandlerCommand::UpdateTradeSubs {
1614                coin,
1615                uses: registration.uses,
1616            })
1617            .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1618
1619        if registration.subscribe
1620            && let Err(e) = self.send_subscription(&cmd_tx, SubscriptionRequest::Trades { coin })
1621        {
1622            let rollback = self.trade_streams.release(&coin, stream_use);
1623            let _ = cmd_tx.send(HandlerCommand::UpdateTradeSubs {
1624                coin,
1625                uses: rollback.uses,
1626            });
1627            return Err(e);
1628        }
1629        Ok(())
1630    }
1631
1632    /// Subscribe to mark price updates for an instrument.
1633    pub async fn subscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1634        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1635            .await
1636    }
1637
1638    /// Subscribe to index/oracle price updates for an instrument.
1639    pub async fn subscribe_index_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1640        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1641            .await
1642    }
1643
1644    /// Subscribe to candle/bar data for a specific coin and interval.
1645    pub async fn subscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1646        let instrument_id = bar_type.instrument_id();
1647        let instrument = self
1648            .get_instrument(&instrument_id)
1649            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1650        let coin = instrument.raw_symbol().inner();
1651        let interval = bar_type_to_interval(&bar_type)?;
1652        let subscription = SubscriptionRequest::Candle { coin, interval };
1653
1654        // Cache the bar type for parsing using canonical key
1655        let key = format!("candle:{coin}:{interval}");
1656        self.bar_types.insert(key.clone(), bar_type);
1657
1658        let cmd_tx = self.cmd_tx.read().await;
1659
1660        cmd_tx
1661            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1662            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1663
1664        cmd_tx
1665            .send(HandlerCommand::AddBarType {
1666                key: key.clone(),
1667                bar_type,
1668            })
1669            .map_err(|e| anyhow::anyhow!("Failed to send AddBarType command: {e}"))?;
1670
1671        if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
1672            self.bar_types.remove(&key);
1673            let _ = cmd_tx.send(HandlerCommand::RemoveBarType { key });
1674            return Err(e);
1675        }
1676        Ok(())
1677    }
1678
1679    /// Subscribe to funding rate updates for an instrument.
1680    pub async fn subscribe_funding_rates(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1681        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1682            .await
1683    }
1684
1685    /// Subscribe to open interest updates for an instrument.
1686    pub async fn subscribe_open_interest(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1687        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1688            .await
1689    }
1690
1691    /// Subscribe to order updates for a specific user address.
1692    pub async fn subscribe_order_updates(&self, user: &str) -> anyhow::Result<()> {
1693        let subscription = SubscriptionRequest::OrderUpdates {
1694            user: user.to_string(),
1695        };
1696        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1697        Ok(())
1698    }
1699
1700    /// Subscribe to user events (fills, funding, liquidations) for a specific user address.
1701    pub async fn subscribe_user_events(&self, user: &str) -> anyhow::Result<()> {
1702        let subscription = SubscriptionRequest::UserEvents {
1703            user: user.to_string(),
1704        };
1705        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1706        Ok(())
1707    }
1708
1709    /// Subscribe to user fills for a specific user address.
1710    ///
1711    /// Note: This channel is redundant with `userEvents` which already includes fills.
1712    /// Prefer using `subscribe_user_events` or `subscribe_all_user_channels` instead.
1713    pub async fn subscribe_user_fills(&self, user: &str) -> anyhow::Result<()> {
1714        let subscription = SubscriptionRequest::UserFills {
1715            user: user.to_string(),
1716            aggregate_by_time: None,
1717        };
1718        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1719        Ok(())
1720    }
1721
1722    /// Subscribe to all user channels (order updates + user events) for convenience.
1723    ///
1724    /// Note: `userEvents` already includes fills, so we don't subscribe to `userFills`
1725    /// separately to avoid duplicate fill messages.
1726    ///
1727    /// This does **not** include opt-in TWAP custom-data channels
1728    /// (`userTwapHistory` / `userTwapSliceFills`).
1729    pub async fn subscribe_all_user_channels(&self, user: &str) -> anyhow::Result<()> {
1730        self.subscribe_order_updates(user).await?;
1731        self.subscribe_user_events(user).await?;
1732        Ok(())
1733    }
1734
1735    /// Subscribe to TWAP history for a user address (`userTwapHistory`).
1736    ///
1737    /// Opt-in custom data. The address need not be the adapter trading account.
1738    pub async fn subscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1739        let subscription = SubscriptionRequest::UserTwapHistory {
1740            user: user.to_string(),
1741        };
1742        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1743        Ok(())
1744    }
1745
1746    /// Unsubscribe from TWAP history for a user address.
1747    pub async fn unsubscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1748        let subscription = SubscriptionRequest::UserTwapHistory {
1749            user: user.to_string(),
1750        };
1751        self.send_unsubscription(&*self.cmd_tx.read().await, subscription)?;
1752        Ok(())
1753    }
1754
1755    /// Subscribe to TWAP slice fills for a user address (`userTwapSliceFills`).
1756    ///
1757    /// Opt-in custom data. The address need not be the adapter trading account.
1758    pub async fn subscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1759        let subscription = SubscriptionRequest::UserTwapSliceFills {
1760            user: user.to_string(),
1761        };
1762        self.send_subscription(&*self.cmd_tx.read().await, subscription)?;
1763        Ok(())
1764    }
1765
1766    /// Unsubscribe from TWAP slice fills for a user address.
1767    pub async fn unsubscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1768        let subscription = SubscriptionRequest::UserTwapSliceFills {
1769            user: user.to_string(),
1770        };
1771        self.send_unsubscription(&*self.cmd_tx.read().await, subscription)?;
1772        Ok(())
1773    }
1774
1775    /// Unsubscribe from L2 order book for an instrument.
1776    ///
1777    /// Tears down the venue `l2Book` stream unless active depth10 subscribers
1778    /// still need it.
1779    pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1780        let instrument = self
1781            .get_instrument(&instrument_id)
1782            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1783        let coin = instrument.raw_symbol().inner();
1784
1785        let cmd_tx = self.cmd_tx.read().await;
1786
1787        self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Deltas)
1788    }
1789
1790    /// Resubscribes the venue `l2Book` stream for an instrument in place.
1791    ///
1792    /// Sends an unsubscribe immediately followed by a subscribe, both echoing
1793    /// the stream's original precision options (the venue matches unsubscribes
1794    /// by full payload). Registry state is left untouched so the logical
1795    /// deltas/depth10 uses and first-wins options survive the cycle. Used by
1796    /// stale-stream recovery, where a plain subscribe would be gated off by
1797    /// the existing registry entry.
1798    pub async fn resubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1799        let instrument = self
1800            .get_instrument(&instrument_id)
1801            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1802        let coin = instrument.raw_symbol().inner();
1803
1804        // Serialize the registry check with read-locked subscribe/unsubscribe senders
1805        let cmd_tx = self.cmd_tx.write().await;
1806
1807        let Some(options) = self.book_streams.options(&coin) else {
1808            log::debug!("Skipping l2Book resubscribe for {coin}: stream no longer registered");
1809            return Ok(());
1810        };
1811
1812        let subscription = SubscriptionRequest::L2Book {
1813            coin,
1814            mantissa: options.mantissa,
1815            n_sig_figs: options.n_sig_figs,
1816        };
1817
1818        self.send_stream_resubscribe(&cmd_tx, subscription)
1819    }
1820
1821    fn send_book_stream_subscribe(
1822        &self,
1823        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1824        coin: Ustr,
1825        stream_use: BookStreamUse,
1826        n_sig_figs: Option<u32>,
1827        mantissa: Option<u32>,
1828    ) -> anyhow::Result<()> {
1829        let registration = self.book_streams.register(
1830            coin,
1831            stream_use,
1832            BookStreamOptions {
1833                n_sig_figs,
1834                mantissa,
1835            },
1836        );
1837
1838        if registration.options_mismatch {
1839            log::warn!(
1840                "Requested l2Book options for {coin} (n_sig_figs={n_sig_figs:?}, mantissa={mantissa:?}) \
1841                differ from the active stream ({:?}), keeping active options",
1842                registration.options,
1843            );
1844        }
1845
1846        if registration.subscribe {
1847            let subscription = SubscriptionRequest::L2Book {
1848                coin,
1849                mantissa: registration.options.mantissa,
1850                n_sig_figs: registration.options.n_sig_figs,
1851            };
1852
1853            if let Err(e) = self.send_subscription(cmd_tx, subscription) {
1854                self.book_streams.release(&coin, stream_use);
1855                return Err(e);
1856            }
1857        }
1858        Ok(())
1859    }
1860
1861    fn send_book_stream_unsubscribe(
1862        &self,
1863        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1864        coin: Ustr,
1865        stream_use: BookStreamUse,
1866    ) -> anyhow::Result<()> {
1867        match self.book_streams.release(&coin, stream_use) {
1868            BookStreamRelease::Unsubscribe(options) => {
1869                let subscription = SubscriptionRequest::L2Book {
1870                    coin,
1871                    mantissa: options.mantissa,
1872                    n_sig_figs: options.n_sig_figs,
1873                };
1874
1875                self.send_unsubscription(cmd_tx, subscription)?;
1876            }
1877            BookStreamRelease::Retained => {
1878                let remaining_use = match stream_use {
1879                    BookStreamUse::Deltas => "depth10",
1880                    BookStreamUse::Depth10 => "deltas",
1881                };
1882                log::debug!("Keeping shared l2Book stream for {coin}: {remaining_use} use remains");
1883            }
1884        }
1885        Ok(())
1886    }
1887
1888    /// Unsubscribe from quote ticks for an instrument.
1889    pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1890        let instrument = self
1891            .get_instrument(&instrument_id)
1892            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1893        let coin = instrument.raw_symbol().inner();
1894
1895        let subscription = SubscriptionRequest::Bbo { coin };
1896        let cmd_tx = self.cmd_tx.read().await;
1897
1898        self.quote_streams.remove(&coin);
1899
1900        self.send_unsubscription(&cmd_tx, subscription)?;
1901        Ok(())
1902    }
1903
1904    /// Resubscribes the venue `bbo` stream for an instrument in place
1905    /// (unsubscribe immediately followed by subscribe). Used by stale-stream
1906    /// recovery.
1907    pub async fn resubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1908        let instrument = self
1909            .get_instrument(&instrument_id)
1910            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1911        let coin = instrument.raw_symbol().inner();
1912
1913        // Keep the registration check atomic with the resubscribe pair
1914        let cmd_tx = self.cmd_tx.write().await;
1915
1916        if !self.quote_streams.contains_key(&coin) {
1917            log::debug!("Skipping bbo resubscribe for {coin}: stream no longer registered");
1918            return Ok(());
1919        }
1920
1921        self.send_stream_resubscribe(&cmd_tx, SubscriptionRequest::Bbo { coin })
1922    }
1923
1924    fn send_stream_resubscribe(
1925        &self,
1926        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1927        subscription: SubscriptionRequest,
1928    ) -> anyhow::Result<()> {
1929        cmd_tx
1930            .send(HandlerCommand::Resubscribe { subscription })
1931            .map_err(|e| anyhow::anyhow!("Failed to send resubscribe command: {e}"))
1932    }
1933
1934    /// Unsubscribe from trades for an instrument.
1935    pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1936        self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1937            .await
1938    }
1939
1940    /// Unsubscribe from complete public trades for an instrument.
1941    pub async fn unsubscribe_public_trades(
1942        &self,
1943        instrument_id: InstrumentId,
1944    ) -> anyhow::Result<()> {
1945        self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1946            .await
1947    }
1948
1949    async fn unsubscribe_trade_stream(
1950        &self,
1951        instrument_id: InstrumentId,
1952        stream_use: TradeStreamUse,
1953    ) -> anyhow::Result<()> {
1954        let instrument = self
1955            .get_instrument(&instrument_id)
1956            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1957        let coin = instrument.raw_symbol().inner();
1958
1959        let cmd_tx = self.cmd_tx.read().await;
1960        // Keep registry mutations and their handler commands ordered across
1961        // concurrent generic/custom unsubscriptions for the same coin.
1962        let _trade_stream_guard = self.trade_stream_lock.lock();
1963        let release = self.trade_streams.release(&coin, stream_use);
1964        cmd_tx
1965            .send(HandlerCommand::UpdateTradeSubs {
1966                coin,
1967                uses: release.uses,
1968            })
1969            .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1970
1971        if release.unsubscribe {
1972            self.send_unsubscription(&cmd_tx, SubscriptionRequest::Trades { coin })?;
1973        }
1974        Ok(())
1975    }
1976
1977    /// Unsubscribe from mark price updates for an instrument.
1978    pub async fn unsubscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1979        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1980            .await
1981    }
1982
1983    /// Unsubscribe from index/oracle price updates for an instrument.
1984    pub async fn unsubscribe_index_prices(
1985        &self,
1986        instrument_id: InstrumentId,
1987    ) -> anyhow::Result<()> {
1988        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1989            .await
1990    }
1991
1992    /// Unsubscribe from candle/bar data.
1993    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1994        let instrument_id = bar_type.instrument_id();
1995        let instrument = self
1996            .get_instrument(&instrument_id)
1997            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1998        let coin = instrument.raw_symbol().inner();
1999        let interval = bar_type_to_interval(&bar_type)?;
2000        let subscription = SubscriptionRequest::Candle { coin, interval };
2001
2002        let key = format!("candle:{coin}:{interval}");
2003        self.bar_types.remove(&key);
2004
2005        let cmd_tx = self.cmd_tx.read().await;
2006
2007        cmd_tx
2008            .send(HandlerCommand::RemoveBarType { key })
2009            .map_err(|e| anyhow::anyhow!("Failed to send RemoveBarType command: {e}"))?;
2010
2011        self.send_unsubscription(&cmd_tx, subscription)?;
2012        Ok(())
2013    }
2014
2015    /// Unsubscribe from funding rate updates for an instrument.
2016    pub async fn unsubscribe_funding_rates(
2017        &self,
2018        instrument_id: InstrumentId,
2019    ) -> anyhow::Result<()> {
2020        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
2021            .await
2022    }
2023
2024    /// Unsubscribe from open interest updates for an instrument.
2025    pub async fn unsubscribe_open_interest(
2026        &self,
2027        instrument_id: InstrumentId,
2028    ) -> anyhow::Result<()> {
2029        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
2030            .await
2031    }
2032
2033    /// Cache the ordered instrument IDs required to normalize `allDexsAssetCtxs`.
2034    ///
2035    /// Entries merge by dex: each dex in `mapping` replaces its cached entry and
2036    /// dexes absent from `mapping` keep theirs. A mapping built from the
2037    /// standard `meta` fallback covers only the standard dex, and replacing the
2038    /// whole cache with it would drop every HIP-3 context until a later complete
2039    /// build. A stale entry for a dex the venue no longer lists is harmless
2040    /// because no contexts arrive for it.
2041    pub fn cache_all_dex_asset_ctxs_instrument_ids(
2042        &self,
2043        mapping: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
2044    ) {
2045        self.all_dex_asset_ctxs_instrument_ids.rcu(|cached| {
2046            cached.extend(mapping.iter().map(|(dex, ids)| (*dex, ids.clone())));
2047        });
2048
2049        if let Ok(cmd_tx) = self.cmd_tx.try_read()
2050            && let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mapping))
2051        {
2052            log::debug!(
2053                "Failed to send CacheAllDexAssetCtxsInstrumentIds command (handler may not be connected yet): {e}"
2054            );
2055        }
2056    }
2057
2058    async fn subscribe_asset_context_data(
2059        &self,
2060        instrument_id: InstrumentId,
2061        data_type: AssetContextDataType,
2062    ) -> anyhow::Result<()> {
2063        let instrument = self
2064            .get_instrument(&instrument_id)
2065            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2066        let coin = instrument.raw_symbol().inner();
2067
2068        let mut entry = self.asset_context_subs.entry(coin).or_default();
2069        let is_first_subscription = entry.is_empty();
2070        entry.insert(data_type);
2071        let data_types = entry.clone();
2072        drop(entry);
2073
2074        let cmd_tx = self.cmd_tx.read().await;
2075
2076        cmd_tx
2077            .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2078            .map_err(|e| anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}"))?;
2079
2080        if is_first_subscription {
2081            log::debug!(
2082                "First asset context subscription for coin '{coin}', subscribing to ActiveAssetCtx"
2083            );
2084            let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2085
2086            cmd_tx
2087                .send(HandlerCommand::UpdateInstrument(instrument.clone()))
2088                .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
2089
2090            if let Err(e) = self.send_subscription(&cmd_tx, subscription) {
2091                if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2092                    entry.remove(&data_type);
2093                    let rollback = entry.clone();
2094                    let remove_entry = entry.is_empty();
2095                    drop(entry);
2096
2097                    if remove_entry {
2098                        self.asset_context_subs.remove(&coin);
2099                    }
2100                    let _ = cmd_tx.send(HandlerCommand::UpdateAssetContextSubs {
2101                        coin,
2102                        data_types: rollback,
2103                    });
2104                }
2105                return Err(e);
2106            }
2107        } else {
2108            log::debug!(
2109                "Already subscribed to ActiveAssetCtx for coin '{coin}', adding {data_type:?} to tracked types"
2110            );
2111        }
2112
2113        Ok(())
2114    }
2115
2116    async fn unsubscribe_asset_context_data(
2117        &self,
2118        instrument_id: InstrumentId,
2119        data_type: AssetContextDataType,
2120    ) -> anyhow::Result<()> {
2121        let instrument = self
2122            .get_instrument(&instrument_id)
2123            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2124        let coin = instrument.raw_symbol().inner();
2125
2126        if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2127            entry.remove(&data_type);
2128            let should_unsubscribe = entry.is_empty();
2129            let data_types = entry.clone();
2130            drop(entry);
2131
2132            let cmd_tx = self.cmd_tx.read().await;
2133
2134            if should_unsubscribe {
2135                self.asset_context_subs.remove(&coin);
2136
2137                log::debug!(
2138                    "Last asset context subscription removed for coin '{coin}', unsubscribing from ActiveAssetCtx"
2139                );
2140                let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2141
2142                cmd_tx
2143                    .send(HandlerCommand::UpdateAssetContextSubs {
2144                        coin,
2145                        data_types: AHashSet::new(),
2146                    })
2147                    .map_err(|e| {
2148                        anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2149                    })?;
2150
2151                self.send_unsubscription(&cmd_tx, subscription)?;
2152            } else {
2153                log::debug!(
2154                    "Removed {data_type:?} from tracked types for coin '{coin}', but keeping ActiveAssetCtx subscription"
2155                );
2156
2157                cmd_tx
2158                    .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2159                    .map_err(|e| {
2160                        anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2161                    })?;
2162            }
2163        }
2164
2165        Ok(())
2166    }
2167
2168    fn send_subscription(
2169        &self,
2170        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2171        subscription: SubscriptionRequest,
2172    ) -> anyhow::Result<()> {
2173        let key = crate::websocket::handler::subscription_to_key(&subscription);
2174        let reserved = self
2175            .rate_limits
2176            .reserve_subscription(self.client_id, &subscription)
2177            .map_err(anyhow::Error::msg)?;
2178
2179        if let Err(e) = cmd_tx.send(HandlerCommand::Subscribe {
2180            subscriptions: vec![subscription],
2181        }) {
2182            if reserved {
2183                self.rate_limits.release_subscription(self.client_id, &key);
2184            }
2185            anyhow::bail!("Failed to send subscribe command: {e}");
2186        }
2187        Ok(())
2188    }
2189
2190    fn send_unsubscription(
2191        &self,
2192        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
2193        subscription: SubscriptionRequest,
2194    ) -> anyhow::Result<()> {
2195        cmd_tx
2196            .send(HandlerCommand::Unsubscribe {
2197                subscriptions: vec![subscription],
2198            })
2199            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))
2200    }
2201
2202    /// Receives the next message from the WebSocket handler.
2203    ///
2204    /// Returns `None` if the handler has disconnected or the receiver was already taken.
2205    pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
2206        if let Some(ref mut rx) = self.out_rx {
2207            rx.recv().await
2208        } else {
2209            None
2210        }
2211    }
2212
2213    fn release_limit_reservations(&self) {
2214        self.rate_limits.release_client(self.client_id);
2215        self.connection_permit.lock().take();
2216    }
2217}
2218
2219impl Drop for HyperliquidWebSocketClient {
2220    fn drop(&mut self) {
2221        if Arc::strong_count(&self.task_handle) == 1 {
2222            self.release_limit_reservations();
2223
2224            if self.task_handle.is_empty() {
2225                return;
2226            }
2227
2228            self.signal.store(true, Ordering::Relaxed);
2229            self.task_handle.abort();
2230
2231            if let Some(control) = &self.socket_control {
2232                control.deregister();
2233            }
2234        }
2235    }
2236}
2237
2238#[derive(Debug)]
2239pub(crate) enum PostRequestError {
2240    BeforeDispatch(HyperliquidError),
2241    AfterDispatch(HyperliquidError),
2242    Rejected {
2243        error: HyperliquidError,
2244        reason: String,
2245    },
2246}
2247
2248impl PostRequestError {
2249    fn into_error(self) -> HyperliquidError {
2250        match self {
2251            Self::BeforeDispatch(error)
2252            | Self::AfterDispatch(error)
2253            | Self::Rejected { error, .. } => error,
2254        }
2255    }
2256}
2257
2258fn cancel_errors_for_requests(
2259    errors: Vec<Option<String>>,
2260    request_count: usize,
2261) -> HyperliquidResult<Vec<Option<String>>> {
2262    if errors.is_empty() {
2263        return Ok(vec![None; request_count]);
2264    }
2265
2266    if errors.len() != request_count {
2267        return Err(HyperliquidError::exchange(format!(
2268            "Cancel orders returned {} statuses for {request_count} cancels",
2269            errors.len()
2270        )));
2271    }
2272
2273    Ok(errors)
2274}
2275
2276fn map_post_payload_error(payload: String, weight: u32) -> HyperliquidError {
2277    let lower = payload.to_ascii_lowercase();
2278    let message = format!("WebSocket post error: {payload}");
2279
2280    if starts_with_status(&lower, &["429"])
2281        || lower.contains("too many requests")
2282        || lower.contains("rate limit")
2283    {
2284        HyperliquidError::rate_limit("exchange", weight, None)
2285    } else if starts_with_status(&lower, &["401", "403"])
2286        || lower.contains("unauthorized")
2287        || lower.contains("forbidden")
2288        || lower.contains("authentication")
2289        || lower.contains("authorization")
2290        || lower.contains("invalid signature")
2291        || contains_word(&lower, "auth")
2292    {
2293        HyperliquidError::auth(message)
2294    } else if starts_with_status(&lower, &["400"]) || lower.contains("bad request") {
2295        HyperliquidError::bad_request(message)
2296    } else if starts_with_status(&lower, &["500", "502", "503", "504"]) {
2297        HyperliquidError::exchange(message)
2298    } else {
2299        HyperliquidError::exchange(payload)
2300    }
2301}
2302
2303fn hyperliquid_order_kind(
2304    order_type: OrderType,
2305    time_in_force: TimeInForce,
2306    post_only: bool,
2307    trigger_price: Option<Price>,
2308    normalize_prices_enabled: bool,
2309    price_precision: Option<u8>,
2310) -> HyperliquidResult<HyperliquidExchangeOrderKind> {
2311    match order_type {
2312        OrderType::Market => Ok(HyperliquidExchangeOrderKind::Limit {
2313            limit: HyperliquidExchangeLimitParams {
2314                tif: HyperliquidExchangeTif::Ioc,
2315            },
2316        }),
2317        OrderType::Limit => {
2318            let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2319                .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2320            Ok(HyperliquidExchangeOrderKind::Limit {
2321                limit: HyperliquidExchangeLimitParams { tif },
2322            })
2323        }
2324        OrderType::StopMarket
2325        | OrderType::StopLimit
2326        | OrderType::MarketIfTouched
2327        | OrderType::LimitIfTouched => {
2328            let trigger_price = trigger_price.ok_or_else(|| {
2329                HyperliquidError::bad_request("Trigger orders require a trigger price")
2330            })?;
2331            let trigger_px = normalize_or_validate_wire_price(
2332                trigger_price.as_decimal(),
2333                "Trigger price",
2334                price_precision,
2335                normalize_prices_enabled,
2336            )
2337            .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2338            let tpsl = match order_type {
2339                OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2340                OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2341                    HyperliquidExchangeTpSl::Tp
2342                }
2343                _ => unreachable!(),
2344            };
2345            let is_market = matches!(
2346                order_type,
2347                OrderType::StopMarket | OrderType::MarketIfTouched
2348            );
2349
2350            Ok(HyperliquidExchangeOrderKind::Trigger {
2351                trigger: HyperliquidExchangeTriggerParams {
2352                    is_market,
2353                    trigger_px,
2354                    tpsl,
2355                },
2356            })
2357        }
2358        _ => Err(HyperliquidError::bad_request(format!(
2359            "Order type {order_type:?} not supported"
2360        ))),
2361    }
2362}
2363
2364fn ensure_ws_action_accepted(
2365    response: &HyperliquidExchangeResponse,
2366    action_name: &str,
2367) -> HyperliquidResult<()> {
2368    if response.is_ok() {
2369        if let Some(error_msg) = extract_inner_errors(response).into_iter().flatten().next() {
2370            return Err(HyperliquidError::bad_request(format!(
2371                "{action_name} rejected: {error_msg}"
2372            )));
2373        }
2374
2375        if let Some(error_msg) = extract_inner_error(response) {
2376            return Err(HyperliquidError::bad_request(format!(
2377                "{action_name} rejected: {error_msg}"
2378            )));
2379        }
2380
2381        return Ok(());
2382    }
2383
2384    Err(HyperliquidError::bad_request(format!(
2385        "{action_name} failed: {}",
2386        extract_error_message(response)
2387    )))
2388}
2389
2390fn starts_with_status(payload: &str, statuses: &[&str]) -> bool {
2391    let trimmed = payload.trim_start();
2392    statuses
2393        .iter()
2394        .any(|status| starts_with_status_token(trimmed, status))
2395        || trimmed.strip_prefix("http").is_some_and(|rest| {
2396            let rest = rest
2397                .trim_start_matches(|c: char| c.is_ascii_whitespace() || matches!(c, ':' | '/'));
2398            statuses
2399                .iter()
2400                .any(|status| starts_with_status_token(rest, status))
2401        })
2402}
2403
2404fn starts_with_status_token(payload: &str, status: &str) -> bool {
2405    payload.strip_prefix(status).is_some_and(|rest| {
2406        rest.chars()
2407            .next()
2408            .is_none_or(|c| !c.is_ascii_alphanumeric())
2409    })
2410}
2411
2412fn contains_word(payload: &str, word: &str) -> bool {
2413    payload
2414        .split(|c: char| !c.is_ascii_alphanumeric())
2415        .any(|part| part == word)
2416}
2417
2418// Uses split_once/rsplit_once because coin names can contain colons
2419// (e.g., vault tokens `vntls:vCURSOR`)
2420fn subscription_from_topic(topic: &str) -> anyhow::Result<SubscriptionRequest> {
2421    let (kind, rest) = topic
2422        .split_once(':')
2423        .map_or((topic, None), |(k, r)| (k, Some(r)));
2424
2425    let channel = HyperliquidWsChannel::from_wire_str(kind)
2426        .ok_or_else(|| anyhow::anyhow!("Unknown subscription channel: {kind}"))?;
2427
2428    match channel {
2429        HyperliquidWsChannel::AllMids => Ok(SubscriptionRequest::AllMids {
2430            dex: rest.map(|s| s.to_string()),
2431        }),
2432        HyperliquidWsChannel::AllDexsAssetCtxs => Ok(SubscriptionRequest::AllDexsAssetCtxs),
2433        HyperliquidWsChannel::Notification => Ok(SubscriptionRequest::Notification {
2434            user: rest.context("Missing user")?.to_string(),
2435        }),
2436        HyperliquidWsChannel::WebData2 => Ok(SubscriptionRequest::WebData2 {
2437            user: rest.context("Missing user")?.to_string(),
2438        }),
2439        HyperliquidWsChannel::Candle => {
2440            // Format: candle:{coin}:{interval} - interval is last segment
2441            let rest = rest.context("Missing candle params")?;
2442            let (coin, interval_str) = rest.rsplit_once(':').context("Missing interval")?;
2443            let interval = HyperliquidBarInterval::from_str(interval_str)?;
2444            Ok(SubscriptionRequest::Candle {
2445                coin: Ustr::from(coin),
2446                interval,
2447            })
2448        }
2449        HyperliquidWsChannel::L2Book => Ok(SubscriptionRequest::L2Book {
2450            coin: Ustr::from(rest.context("Missing coin")?),
2451            mantissa: None,
2452            n_sig_figs: None,
2453        }),
2454        HyperliquidWsChannel::Trades => Ok(SubscriptionRequest::Trades {
2455            coin: Ustr::from(rest.context("Missing coin")?),
2456        }),
2457        HyperliquidWsChannel::OrderUpdates => Ok(SubscriptionRequest::OrderUpdates {
2458            user: rest.context("Missing user")?.to_string(),
2459        }),
2460        HyperliquidWsChannel::UserEvents => Ok(SubscriptionRequest::UserEvents {
2461            user: rest.context("Missing user")?.to_string(),
2462        }),
2463        HyperliquidWsChannel::UserFills => Ok(SubscriptionRequest::UserFills {
2464            user: rest.context("Missing user")?.to_string(),
2465            aggregate_by_time: None,
2466        }),
2467        HyperliquidWsChannel::UserFundings => Ok(SubscriptionRequest::UserFundings {
2468            user: rest.context("Missing user")?.to_string(),
2469        }),
2470        HyperliquidWsChannel::UserNonFundingLedgerUpdates => {
2471            Ok(SubscriptionRequest::UserNonFundingLedgerUpdates {
2472                user: rest.context("Missing user")?.to_string(),
2473            })
2474        }
2475        HyperliquidWsChannel::ActiveAssetCtx => Ok(SubscriptionRequest::ActiveAssetCtx {
2476            coin: Ustr::from(rest.context("Missing coin")?),
2477        }),
2478        HyperliquidWsChannel::ActiveSpotAssetCtx => Ok(SubscriptionRequest::ActiveSpotAssetCtx {
2479            coin: Ustr::from(rest.context("Missing coin")?),
2480        }),
2481        HyperliquidWsChannel::ActiveAssetData => {
2482            // Format: activeAssetData:{user}:{coin} - user is eth addr (no colons)
2483            let rest = rest.context("Missing params")?;
2484            let (user, coin) = rest.split_once(':').context("Missing coin")?;
2485            Ok(SubscriptionRequest::ActiveAssetData {
2486                user: user.to_string(),
2487                coin: coin.to_string(),
2488            })
2489        }
2490        HyperliquidWsChannel::UserTwapSliceFills => Ok(SubscriptionRequest::UserTwapSliceFills {
2491            user: rest.context("Missing user")?.to_string(),
2492        }),
2493        HyperliquidWsChannel::UserTwapHistory => Ok(SubscriptionRequest::UserTwapHistory {
2494            user: rest.context("Missing user")?.to_string(),
2495        }),
2496        HyperliquidWsChannel::Bbo => Ok(SubscriptionRequest::Bbo {
2497            coin: Ustr::from(rest.context("Missing coin")?),
2498        }),
2499
2500        // Response-only channels are not valid subscription topics
2501        HyperliquidWsChannel::SubscriptionResponse
2502        | HyperliquidWsChannel::User
2503        | HyperliquidWsChannel::Post
2504        | HyperliquidWsChannel::Pong
2505        | HyperliquidWsChannel::Error => {
2506            anyhow::bail!("Not a subscription channel: {kind}")
2507        }
2508    }
2509}
2510
2511#[cfg(test)]
2512mod tests {
2513    use nautilus_live::{SocketReconnectRegistry, SocketReconnectRequestOutcome};
2514    use nautilus_model::identifiers::ClientId;
2515    use nautilus_network::mode::ReconnectRequestOutcome;
2516    use rstest::rstest;
2517    use ustr::Ustr;
2518
2519    use super::*;
2520    use crate::{
2521        common::{
2522            consts::{HYPERLIQUID_WS_POST_INFLIGHT_MAX, HYPERLIQUID_WS_SUBSCRIPTIONS_MAX},
2523            enums::HyperliquidBarInterval,
2524        },
2525        websocket::handler::subscription_to_key,
2526    };
2527
2528    #[rstest]
2529    fn test_debug_redacts_proxy_url() {
2530        let proxy_url = "http://user:password@proxy.example:8080";
2531        let client = HyperliquidWebSocketClient::new(
2532            Some("wss://test".to_string()),
2533            HyperliquidEnvironment::Testnet,
2534            None,
2535            TransportBackend::default(),
2536            Some(proxy_url.to_string()),
2537        );
2538
2539        let debug = format!("{client:?}");
2540
2541        assert!(debug.contains(REDACTED));
2542        assert!(!debug.contains(proxy_url));
2543    }
2544
2545    #[rstest]
2546    fn test_cache_all_dex_asset_ctxs_instrument_ids_keeps_dexes_absent_from_update() {
2547        let client = HyperliquidWebSocketClient::new(
2548            Some("wss://test".to_string()),
2549            HyperliquidEnvironment::Testnet,
2550            None,
2551            TransportBackend::default(),
2552            None,
2553        );
2554        let btc = Some(InstrumentId::from("BTC-USD-PERP.HYPERLIQUID"));
2555        let eth = Some(InstrumentId::from("ETH-USD-PERP.HYPERLIQUID"));
2556        let tsla = Some(InstrumentId::from("xyz:TSLA-USD-PERP.HYPERLIQUID"));
2557
2558        client.cache_all_dex_asset_ctxs_instrument_ids(AHashMap::from_iter([
2559            (Ustr::from(""), vec![btc]),
2560            (Ustr::from("xyz"), vec![tsla]),
2561        ]));
2562        // a `meta` fallback build covers only the default dex
2563        client.cache_all_dex_asset_ctxs_instrument_ids(AHashMap::from_iter([(
2564            Ustr::from(""),
2565            vec![btc, eth],
2566        )]));
2567
2568        let cached = client.all_dex_asset_ctxs_instrument_ids.load();
2569        assert_eq!(cached.len(), 2);
2570        assert_eq!(cached.get(&Ustr::from("")), Some(&vec![btc, eth]));
2571        assert_eq!(cached.get(&Ustr::from("xyz")), Some(&vec![tsla]));
2572    }
2573
2574    #[tokio::test]
2575    async fn test_drop_clone_does_not_stop_handler() {
2576        let client = HyperliquidWebSocketClient::new(
2577            Some("wss://test".to_string()),
2578            HyperliquidEnvironment::Testnet,
2579            None,
2580            TransportBackend::default(),
2581            None,
2582        );
2583        client
2584            .task_handle
2585            .insert(get_runtime().spawn(std::future::pending()));
2586        let clone = client.clone();
2587
2588        drop(clone);
2589
2590        assert!(!client.signal.load(Ordering::Acquire));
2591        assert!(!client.task_handle.is_empty());
2592    }
2593
2594    /// Generates a unique topic key for a subscription request.
2595    fn subscription_topic(sub: &SubscriptionRequest) -> String {
2596        subscription_to_key(sub)
2597    }
2598
2599    #[rstest]
2600    #[case(SubscriptionRequest::Trades { coin: "BTC".into() }, "trades:BTC")]
2601    #[case(SubscriptionRequest::Bbo { coin: "BTC".into() }, "bbo:BTC")]
2602    #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() }, "orderUpdates:0x123")]
2603    #[case(SubscriptionRequest::UserEvents { user: "0xabc".to_string() }, "userEvents:0xabc")]
2604    fn test_subscription_topic_generation(
2605        #[case] subscription: SubscriptionRequest,
2606        #[case] expected_topic: &str,
2607    ) {
2608        assert_eq!(subscription_topic(&subscription), expected_topic);
2609    }
2610
2611    #[rstest]
2612    fn test_subscription_topics_unique() {
2613        let sub1 = SubscriptionRequest::Trades { coin: "BTC".into() };
2614        let sub2 = SubscriptionRequest::Bbo { coin: "BTC".into() };
2615
2616        let topic1 = subscription_topic(&sub1);
2617        let topic2 = subscription_topic(&sub2);
2618
2619        assert_ne!(topic1, topic2);
2620    }
2621
2622    #[rstest]
2623    #[case(SubscriptionRequest::Trades { coin: "BTC".into() })]
2624    #[case(SubscriptionRequest::Bbo { coin: "ETH".into() })]
2625    #[case(SubscriptionRequest::Candle { coin: "SOL".into(), interval: HyperliquidBarInterval::OneHour })]
2626    #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() })]
2627    #[case(SubscriptionRequest::Trades { coin: "vntls:vCURSOR".into() })]
2628    #[case(SubscriptionRequest::L2Book { coin: "vntls:vCURSOR".into(), mantissa: None, n_sig_figs: None })]
2629    #[case(SubscriptionRequest::Candle { coin: "vntls:vCURSOR".into(), interval: HyperliquidBarInterval::OneHour })]
2630    fn test_subscription_reconstruction(#[case] subscription: SubscriptionRequest) {
2631        let topic = subscription_topic(&subscription);
2632        let reconstructed = subscription_from_topic(&topic).expect("Failed to reconstruct");
2633        assert_eq!(subscription_topic(&reconstructed), topic);
2634    }
2635
2636    #[rstest]
2637    fn test_subscription_topic_candle() {
2638        let sub = SubscriptionRequest::Candle {
2639            coin: "BTC".into(),
2640            interval: HyperliquidBarInterval::OneHour,
2641        };
2642
2643        let topic = subscription_topic(&sub);
2644        assert_eq!(topic, "candle:BTC:1h");
2645    }
2646
2647    #[rstest]
2648    fn with_state_sink_survives_clone() {
2649        let client = HyperliquidWebSocketClient::new(
2650            None,
2651            HyperliquidEnvironment::Testnet,
2652            None,
2653            TransportBackend::default(),
2654            None,
2655        )
2656        .with_state_sink(SocketStateSink::new(|_| {}));
2657
2658        let cloned = client.clone();
2659        assert!(client.socket_sink.is_some());
2660        assert!(cloned.socket_sink.is_some());
2661    }
2662
2663    #[rstest]
2664    fn clone_can_own_socket_registration() {
2665        let registry = SocketReconnectRegistry::default();
2666        let endpoint = Ustr::from("hyperliquid-data-streams");
2667        let client = HyperliquidWebSocketClient::new(
2668            None,
2669            HyperliquidEnvironment::Testnet,
2670            None,
2671            TransportBackend::default(),
2672            None,
2673        )
2674        .with_socket_control(SocketControl::with_registry(
2675            ClientId::from("HYPERLIQUID"),
2676            None,
2677            endpoint,
2678            &registry,
2679        ));
2680        let cloned = client.clone();
2681        let _sink = cloned.socket_control.as_ref().unwrap().sink();
2682        cloned
2683            .socket_control
2684            .as_ref()
2685            .unwrap()
2686            .register(|| ReconnectRequestOutcome::Accepted);
2687
2688        assert!(cloned.socket_control.is_some());
2689        assert!(cloned.socket_sink.is_none());
2690        assert!(
2691            registry
2692                .handle(ClientId::from("HYPERLIQUID"), endpoint)
2693                .is_some()
2694        );
2695
2696        client.socket_control.as_ref().unwrap().deregister();
2697        assert!(
2698            registry
2699                .handle(ClientId::from("HYPERLIQUID"), endpoint)
2700                .is_some()
2701        );
2702
2703        let handle = registry
2704            .handle(ClientId::from("HYPERLIQUID"), endpoint)
2705            .unwrap();
2706        assert_eq!(
2707            handle.request_reconnect(),
2708            SocketReconnectRequestOutcome::Accepted
2709        );
2710        drop(cloned);
2711        assert!(
2712            registry
2713                .handle(ClientId::from("HYPERLIQUID"), endpoint)
2714                .is_none()
2715        );
2716    }
2717
2718    #[rstest]
2719    fn set_post_timeout_updates_client_and_clone() {
2720        let mut client = HyperliquidWebSocketClient::new(
2721            None,
2722            HyperliquidEnvironment::Testnet,
2723            None,
2724            TransportBackend::default(),
2725            None,
2726        );
2727        let timeout = std::time::Duration::from_secs(7);
2728
2729        client.set_post_timeout(timeout);
2730
2731        assert_eq!(client.post_timeout, timeout);
2732        assert_eq!(client.clone().post_timeout, timeout);
2733    }
2734
2735    #[rstest]
2736    #[tokio::test]
2737    async fn post_action_command_without_credentials_is_not_sent() {
2738        let client = HyperliquidWebSocketClient::new(
2739            None,
2740            HyperliquidEnvironment::Testnet,
2741            None,
2742            TransportBackend::default(),
2743            None,
2744        );
2745        let signer = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 10, None).unwrap();
2746        let action = HyperliquidExchangeAction::Cancel {
2747            cancels: Vec::new(),
2748            fast: None,
2749        };
2750        let result = client.post_action_command(&signer, &action).await;
2751        let PostRequestError::BeforeDispatch(error) = result.unwrap_err() else {
2752            panic!("Expected failure before dispatch");
2753        };
2754        assert_eq!(
2755            error.to_string(),
2756            "auth error: credentials required for exchange operations"
2757        );
2758    }
2759
2760    #[tokio::test]
2761    async fn failed_connect_releases_connection_slot() {
2762        let mut client = HyperliquidWebSocketClient::new(
2763            Some("invalid-websocket-url".to_string()),
2764            HyperliquidEnvironment::Testnet,
2765            None,
2766            TransportBackend::default(),
2767            None,
2768        );
2769        let available_before = client.rate_limits.connection_slots.available_permits();
2770
2771        client
2772            .connect()
2773            .await
2774            .expect_err("invalid URL should fail the connection attempt");
2775
2776        assert!(client.connection_permit.lock().is_none());
2777        assert_eq!(
2778            client.rate_limits.connection_slots.available_permits(),
2779            available_before
2780        );
2781    }
2782
2783    #[rstest]
2784    #[case::before_dispatch(false)]
2785    #[case::after_dispatch(true)]
2786    #[tokio::test(start_paused = true)]
2787    async fn post_request_timeout_preserves_dispatch_evidence(#[case] dispatched: bool) {
2788        let client = HyperliquidWebSocketClient::new(
2789            None,
2790            HyperliquidEnvironment::Testnet,
2791            None,
2792            TransportBackend::default(),
2793            None,
2794        );
2795        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2796        *client.cmd_tx.write().await = tx;
2797        let timeout = if dispatched {
2798            Duration::from_millis(100)
2799        } else {
2800            Duration::ZERO
2801        };
2802        let started = tokio::time::Instant::now();
2803        let failure = client
2804            .send_post_request_result(
2805                PostRequest::Info {
2806                    payload: serde_json::json!({"type": "meta"}),
2807                },
2808                timeout,
2809            )
2810            .await
2811            .unwrap_err();
2812
2813        assert_eq!(tokio::time::Instant::now() - started, timeout);
2814
2815        if dispatched {
2816            assert!(matches!(
2817                failure,
2818                PostRequestError::AfterDispatch(HyperliquidError::Timeout)
2819            ));
2820            let HandlerCommand::Post {
2821                id,
2822                request,
2823                deadline,
2824                cancellation_token,
2825            } = rx.try_recv().unwrap()
2826            else {
2827                panic!("Expected post command");
2828            };
2829            assert_eq!(id, 1);
2830            assert_eq!(
2831                serde_json::to_value(request).unwrap(),
2832                serde_json::json!({"type": "info", "payload": {"type": "meta"}})
2833            );
2834            assert_eq!(deadline, started + timeout);
2835            assert!(cancellation_token.is_cancelled());
2836        } else {
2837            assert!(matches!(
2838                failure,
2839                PostRequestError::BeforeDispatch(HyperliquidError::Timeout)
2840            ));
2841        }
2842        assert!(matches!(
2843            rx.try_recv(),
2844            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2845        ));
2846    }
2847
2848    #[rstest]
2849    #[tokio::test(flavor = "multi_thread")]
2850    async fn send_post_request_times_out_while_waiting_for_inflight_slot() {
2851        let client = HyperliquidWebSocketClient::new(
2852            None,
2853            HyperliquidEnvironment::Testnet,
2854            None,
2855            TransportBackend::default(),
2856            None,
2857        );
2858        let mut receivers = Vec::with_capacity(HYPERLIQUID_WS_POST_INFLIGHT_MAX);
2859        for offset in 0..HYPERLIQUID_WS_POST_INFLIGHT_MAX {
2860            receivers.push(
2861                client
2862                    .post_router
2863                    .register(10_000 + offset as u64)
2864                    .await
2865                    .unwrap(),
2866            );
2867        }
2868
2869        let err = client
2870            .send_post_request(
2871                PostRequest::Info {
2872                    payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
2873                },
2874                std::time::Duration::from_millis(25),
2875            )
2876            .await
2877            .expect_err("request should timeout before acquiring an inflight slot");
2878
2879        assert!(matches!(err, HyperliquidError::Timeout));
2880        assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
2881    }
2882
2883    #[rstest]
2884    #[tokio::test(flavor = "multi_thread")]
2885    async fn send_post_request_shares_inflight_limit_across_clients() {
2886        let url = Some("wss://shared-post-limit.test/ws".to_string());
2887        let first = HyperliquidWebSocketClient::new(
2888            url.clone(),
2889            HyperliquidEnvironment::Testnet,
2890            None,
2891            TransportBackend::default(),
2892            None,
2893        );
2894        let second = HyperliquidWebSocketClient::new(
2895            url,
2896            HyperliquidEnvironment::Testnet,
2897            None,
2898            TransportBackend::default(),
2899            None,
2900        );
2901        let mut receivers = Vec::with_capacity(HYPERLIQUID_WS_POST_INFLIGHT_MAX);
2902
2903        for offset in 0..HYPERLIQUID_WS_POST_INFLIGHT_MAX / 2 {
2904            receivers.push(first.post_router.register(offset as u64).await.unwrap());
2905            receivers.push(
2906                second
2907                    .post_router
2908                    .register(10_000 + offset as u64)
2909                    .await
2910                    .unwrap(),
2911            );
2912        }
2913
2914        let error = second
2915            .send_post_request(
2916                PostRequest::Info {
2917                    payload: serde_json::json!({"type": "meta"}),
2918                },
2919                Duration::from_millis(25),
2920            )
2921            .await
2922            .expect_err("shared in-flight limit should block the next post");
2923
2924        assert!(matches!(error, HyperliquidError::Timeout));
2925        assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
2926    }
2927
2928    #[rstest]
2929    fn subscription_limit_is_rejected_before_queueing_across_clients() {
2930        let url = Some("wss://shared-subscription-limit.test/ws".to_string());
2931        let first = HyperliquidWebSocketClient::new(
2932            url.clone(),
2933            HyperliquidEnvironment::Testnet,
2934            None,
2935            TransportBackend::default(),
2936            None,
2937        );
2938        let second = HyperliquidWebSocketClient::new(
2939            url,
2940            HyperliquidEnvironment::Testnet,
2941            None,
2942            TransportBackend::default(),
2943            None,
2944        );
2945        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2946
2947        for index in 0..HYPERLIQUID_WS_SUBSCRIPTIONS_MAX {
2948            let subscription = SubscriptionRequest::Trades {
2949                coin: Ustr::from(&format!("COIN-{index}")),
2950            };
2951            let client = if index % 2 == 0 { &first } else { &second };
2952            client.send_subscription(&cmd_tx, subscription).unwrap();
2953        }
2954
2955        let error = first
2956            .send_subscription(
2957                &cmd_tx,
2958                SubscriptionRequest::Trades {
2959                    coin: Ustr::from("OVER-LIMIT"),
2960                },
2961            )
2962            .expect_err("shared subscription limit should reject the next subscription");
2963
2964        assert!(error.to_string().contains("at most 1000"));
2965        assert_eq!(cmd_rx.len(), HYPERLIQUID_WS_SUBSCRIPTIONS_MAX);
2966    }
2967
2968    #[rstest]
2969    fn stream_resubscribe_queues_one_atomic_command() {
2970        let client = HyperliquidWebSocketClient::new(
2971            Some("wss://atomic-resubscribe.test/ws".to_string()),
2972            HyperliquidEnvironment::Testnet,
2973            None,
2974            TransportBackend::default(),
2975            None,
2976        );
2977        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
2978        let subscription = SubscriptionRequest::Bbo {
2979            coin: Ustr::from("BTC"),
2980        };
2981
2982        client
2983            .send_stream_resubscribe(&cmd_tx, subscription)
2984            .unwrap();
2985
2986        assert!(matches!(
2987            cmd_rx.try_recv(),
2988            Ok(HandlerCommand::Resubscribe { subscription })
2989                if subscription_to_key(&subscription) == "bbo:BTC"
2990        ));
2991        assert!(matches!(
2992            cmd_rx.try_recv(),
2993            Err(tokio::sync::mpsc::error::TryRecvError::Empty)
2994        ));
2995    }
2996
2997    #[rstest]
2998    #[tokio::test(start_paused = true)]
2999    async fn send_post_request_uses_deadline_while_waiting_for_command_channel() {
3000        let client = HyperliquidWebSocketClient::new(
3001            None,
3002            HyperliquidEnvironment::Testnet,
3003            None,
3004            TransportBackend::default(),
3005            None,
3006        );
3007        let _cmd_tx = client.cmd_tx.write().await;
3008        let timeout = Duration::from_millis(100);
3009        let started = tokio::time::Instant::now();
3010
3011        let error = client
3012            .send_post_request(
3013                PostRequest::Info {
3014                    payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
3015                },
3016                timeout,
3017            )
3018            .await
3019            .unwrap_err();
3020
3021        assert!(matches!(error, HyperliquidError::Timeout));
3022        assert_eq!(tokio::time::Instant::now() - started, timeout);
3023        client
3024            .post_router
3025            .register(1)
3026            .await
3027            .expect("post ID should be reusable when timeout returns");
3028    }
3029
3030    #[rstest]
3031    #[tokio::test(start_paused = true)]
3032    async fn post_response_ready_before_deadline_wins_deadline_race() {
3033        let client = HyperliquidWebSocketClient::new(
3034            None,
3035            HyperliquidEnvironment::Testnet,
3036            None,
3037            TransportBackend::default(),
3038            None,
3039        );
3040        let id = 1;
3041        let cancellation_token = CancellationToken::new();
3042        let rx = client
3043            .post_router
3044            .register_with_cancellation(id, &cancellation_token)
3045            .await
3046            .unwrap();
3047        let payload = serde_json::json!({"type": "meta", "data": {"universe": []}});
3048        client
3049            .post_router
3050            .complete(PostResponse {
3051                id,
3052                response: PostResponsePayload::Info {
3053                    payload: payload.clone(),
3054                },
3055            })
3056            .await;
3057
3058        let response = client
3059            .await_post_response(id, rx, tokio::time::Instant::now(), &cancellation_token)
3060            .await
3061            .expect("ready response should win");
3062
3063        assert_eq!(response.id, id);
3064        let PostResponsePayload::Info {
3065            payload: response_payload,
3066        } = response.response
3067        else {
3068            panic!("expected info response");
3069        };
3070        assert_eq!(response_payload, payload);
3071    }
3072
3073    #[rstest]
3074    #[tokio::test]
3075    async fn dropping_post_request_cancels_work_and_releases_registration() {
3076        let client = HyperliquidWebSocketClient::new(
3077            None,
3078            HyperliquidEnvironment::Testnet,
3079            None,
3080            TransportBackend::default(),
3081            None,
3082        );
3083        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3084        *client.cmd_tx.write().await = cmd_tx;
3085        let request_client = client.clone();
3086        let task = tokio::spawn(async move {
3087            request_client
3088                .send_post_request(
3089                    PostRequest::Info {
3090                        payload: serde_json::json!({"type": "userRateLimit", "user": "0x123"}),
3091                    },
3092                    Duration::from_secs(60),
3093                )
3094                .await
3095        });
3096        let command = cmd_rx.recv().await.expect("post command should be queued");
3097        let HandlerCommand::Post {
3098            id,
3099            cancellation_token,
3100            ..
3101        } = command
3102        else {
3103            panic!("expected post command");
3104        };
3105
3106        task.abort();
3107        assert!(task.await.unwrap_err().is_cancelled());
3108        assert!(cancellation_token.is_cancelled());
3109
3110        let reused = tokio::time::timeout(Duration::from_secs(1), async {
3111            loop {
3112                match client.post_router.register(id).await {
3113                    Ok(rx) => break Ok(rx),
3114                    Err(e) if e.to_string().contains("already registered") => {
3115                        tokio::task::yield_now().await;
3116                    }
3117                    Err(e) => break Err(e),
3118                }
3119            }
3120        })
3121        .await
3122        .expect("post ID should be reusable after cancellation")
3123        .expect("post ID reuse should succeed");
3124        client
3125            .post_router
3126            .cancel_registration(id, &cancellation_token)
3127            .await;
3128        client
3129            .post_router
3130            .register(id)
3131            .await
3132            .expect_err("stale cancellation must not remove the reused registration");
3133
3134        let mut receivers = vec![reused];
3135        tokio::time::timeout(Duration::from_secs(1), async {
3136            for offset in 1..HYPERLIQUID_WS_POST_INFLIGHT_MAX {
3137                receivers.push(
3138                    client
3139                        .post_router
3140                        .register(10_000 + offset as u64)
3141                        .await
3142                        .unwrap(),
3143                );
3144            }
3145        })
3146        .await
3147        .expect("cancellation should release the inflight permit");
3148
3149        assert_eq!(receivers.len(), HYPERLIQUID_WS_POST_INFLIGHT_MAX);
3150    }
3151
3152    #[rstest]
3153    #[tokio::test(start_paused = true)]
3154    async fn post_timeout_after_queueing_preserves_unknown_outcome_and_releases_registration() {
3155        let client = HyperliquidWebSocketClient::new(
3156            None,
3157            HyperliquidEnvironment::Testnet,
3158            None,
3159            TransportBackend::default(),
3160            None,
3161        );
3162        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel();
3163        *client.cmd_tx.write().await = cmd_tx;
3164        let request_client = client.clone();
3165        let task = tokio::spawn(async move {
3166            request_client
3167                .send_post_request(
3168                    PostRequest::Info {
3169                        payload: serde_json::json!({"type": "userRateLimit", "user": "0x123"}),
3170                    },
3171                    Duration::from_millis(100),
3172                )
3173                .await
3174        });
3175        let command = cmd_rx.recv().await.expect("post command should be queued");
3176        let HandlerCommand::Post {
3177            id,
3178            cancellation_token,
3179            ..
3180        } = command
3181        else {
3182            panic!("expected post command");
3183        };
3184
3185        let error = task.await.unwrap().unwrap_err();
3186
3187        assert!(matches!(&error, HyperliquidError::Timeout));
3188        assert!(error.is_transport_error());
3189        assert!(cancellation_token.is_cancelled());
3190        client
3191            .post_router
3192            .register(id)
3193            .await
3194            .expect("post ID should be reusable when timeout returns");
3195    }
3196
3197    #[rstest]
3198    fn cancel_errors_for_requests_accepts_empty_as_success() {
3199        let errors = cancel_errors_for_requests(Vec::new(), 2).unwrap();
3200
3201        assert_eq!(errors, vec![None, None]);
3202    }
3203
3204    #[rstest]
3205    fn cancel_errors_for_requests_rejects_status_count_mismatch() {
3206        let err = cancel_errors_for_requests(vec![None], 2).expect_err("mismatch should fail");
3207
3208        assert!(
3209            err.to_string()
3210                .contains("returned 1 statuses for 2 cancels")
3211        );
3212    }
3213
3214    #[rstest]
3215    fn test_post_payload_error_maps_rate_limit() {
3216        let err = map_post_payload_error("429 Too Many Requests".to_string(), 3);
3217
3218        assert!(matches!(
3219            err,
3220            HyperliquidError::RateLimit {
3221                scope: "exchange",
3222                weight: 3,
3223                retry_after_ms: None,
3224            }
3225        ));
3226    }
3227
3228    #[rstest]
3229    #[case("401 Unauthorized")]
3230    #[case("HTTP 403: forbidden")]
3231    #[case("invalid signature")]
3232    #[case("authentication failed")]
3233    fn test_post_payload_error_maps_auth(#[case] payload: &str) {
3234        let err = map_post_payload_error(payload.to_string(), 1);
3235
3236        assert!(matches!(err, HyperliquidError::Auth(_)));
3237    }
3238
3239    #[rstest]
3240    #[case("400 Bad Request")]
3241    #[case("HTTP 400: malformed payload")]
3242    #[case("bad request: missing action")]
3243    fn test_post_payload_error_maps_bad_request(#[case] payload: &str) {
3244        let err = map_post_payload_error(payload.to_string(), 1);
3245
3246        assert!(matches!(err, HyperliquidError::BadRequest(_)));
3247    }
3248
3249    #[rstest]
3250    #[case("500 Internal Server Error")]
3251    #[case("HTTP 503: service unavailable")]
3252    fn test_post_payload_error_maps_exchange_status(#[case] payload: &str) {
3253        let err = map_post_payload_error(payload.to_string(), 1);
3254
3255        assert!(matches!(err, HyperliquidError::Exchange(_)));
3256    }
3257
3258    #[rstest]
3259    #[case("order 429001 rejected")]
3260    #[case("asset 5001 is not tradable")]
3261    #[case("authoritative nonce window exceeded")]
3262    fn test_post_payload_error_does_not_match_embedded_codes_or_words(#[case] payload: &str) {
3263        let err = map_post_payload_error(payload.to_string(), 1);
3264
3265        assert!(matches!(err, HyperliquidError::Exchange(_)));
3266    }
3267}