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    str::FromStr,
18    sync::{
19        Arc, Mutex,
20        atomic::{AtomicBool, AtomicU8, Ordering},
21    },
22    time::Duration,
23};
24
25use ahash::{AHashMap, AHashSet};
26use anyhow::Context;
27use arc_swap::ArcSwap;
28use dashmap::DashMap;
29use nautilus_common::{
30    cache::{InstrumentLookupError, fifo::FifoCacheMap},
31    clients::SocketReconnectRegistration,
32    live::get_runtime,
33};
34use nautilus_core::{AtomicMap, MUTEX_POISONED};
35use nautilus_model::{
36    data::BarType,
37    enums::{OrderSide, OrderType, TimeInForce},
38    identifiers::{AccountId, ClientOrderId, InstrumentId, VenueOrderId},
39    instruments::{Instrument, InstrumentAny},
40    orders::{Order, OrderAny},
41    reports::OrderStatusReport,
42    types::{Price, Quantity},
43};
44use nautilus_network::{
45    SocketStateSink,
46    mode::ConnectionMode,
47    websocket::{
48        AuthTracker, SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
49        channel_message_handler,
50    },
51};
52use rust_decimal::Decimal;
53use ustr::Ustr;
54
55use crate::{
56    common::{
57        consts::{HTTP_TIMEOUT, ws_url},
58        enums::{HyperliquidBarInterval, HyperliquidEnvironment},
59        parse::{
60            bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
61            determine_order_list_grouping, extract_error_message, extract_inner_error,
62            extract_inner_errors, normalize_price,
63            order_to_hyperliquid_request_with_asset_and_cloid, round_to_sig_figs,
64            time_in_force_to_hyperliquid_tif,
65        },
66        socket::SocketControl,
67    },
68    http::{
69        client::HyperliquidHttpClient,
70        error::{Error as HyperliquidError, Result as HyperliquidResult},
71        models::{
72            HyperliquidExchangeResponse, HyperliquidExecAction,
73            HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
74            HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecModifyOrderRequest,
75            HyperliquidExecModifyTarget, HyperliquidExecOrderKind,
76            HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
77            HyperliquidExecTriggerParams, RESPONSE_STATUS_OK,
78        },
79        rate_limits::{WeightedLimiter, exec_action_weight},
80    },
81    websocket::{
82        book::{BookStreamOptions, BookStreamRegistry, BookStreamRelease, BookStreamUse},
83        enums::HyperliquidWsChannel,
84        handler::{FeedHandler, HandlerCommand},
85        messages::{
86            NautilusWsMessage, PostRequest, PostResponse, PostResponsePayload, SubscriptionRequest,
87        },
88        post::{PostIds, PostRouter},
89        trades::{TradeStreamRegistry, TradeStreamUse},
90    },
91};
92
93const HYPERLIQUID_HEARTBEAT_MSG: &str = r#"{"method":"ping"}"#;
94
95/// FIFO bound on the cloid -> `ClientOrderId` resolution cache so missed
96/// evictions self-recover (see GH-3972 cancel-replace drain path).
97pub(super) const CLOID_CACHE_CAPACITY: usize = 10_000;
98
99/// Shared cloid -> `ClientOrderId` cache used by the WS handler.
100pub(super) type CloidCache = Arc<Mutex<FifoCacheMap<Ustr, ClientOrderId, CLOID_CACHE_CAPACITY>>>;
101
102/// Represents the different data types available from asset context subscriptions.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub(super) enum AssetContextDataType {
105    MarkPrice,
106    IndexPrice,
107    FundingRate,
108    OpenInterest,
109}
110
111/// Hyperliquid WebSocket client following the BitMEX pattern.
112///
113/// Orchestrates WebSocket connection and subscriptions using a command-based architecture,
114/// where the inner FeedHandler owns the WebSocketClient and handles all I/O.
115#[derive(Debug)]
116#[cfg_attr(
117    feature = "python",
118    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
119)]
120#[cfg_attr(
121    feature = "python",
122    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
123)]
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    post_limiter: Arc<WeightedLimiter>,
144    post_timeout: Duration,
145    task_handle: Option<tokio::task::JoinHandle<()>>,
146    account_id: Option<AccountId>,
147    transport_backend: TransportBackend,
148    proxy_url: Option<String>,
149    socket_sink: Option<SocketStateSink>,
150    socket_control: Option<SocketControl>,
151    socket_registration: Option<SocketReconnectRegistration>,
152}
153
154impl Clone for HyperliquidWebSocketClient {
155    fn clone(&self) -> Self {
156        Self {
157            url: self.url.clone(),
158            connection_mode: Arc::clone(&self.connection_mode),
159            signal: Arc::clone(&self.signal),
160            cmd_tx: Arc::clone(&self.cmd_tx),
161            out_rx: None,
162            auth_tracker: self.auth_tracker.clone(),
163            subscriptions: self.subscriptions.clone(),
164            book_streams: self.book_streams.clone(),
165            trade_streams: self.trade_streams.clone(),
166            trade_stream_lock: Arc::clone(&self.trade_stream_lock),
167            quote_streams: Arc::clone(&self.quote_streams),
168            instruments: Arc::clone(&self.instruments),
169            bar_types: Arc::clone(&self.bar_types),
170            asset_context_subs: Arc::clone(&self.asset_context_subs),
171            all_dex_asset_ctxs_instrument_ids: Arc::clone(&self.all_dex_asset_ctxs_instrument_ids),
172            cloid_cache: Arc::clone(&self.cloid_cache),
173            post_router: Arc::clone(&self.post_router),
174            post_ids: Arc::clone(&self.post_ids),
175            post_limiter: Arc::clone(&self.post_limiter),
176            post_timeout: self.post_timeout,
177            task_handle: None,
178            account_id: self.account_id,
179            transport_backend: self.transport_backend,
180            proxy_url: self.proxy_url.clone(),
181            socket_sink: self.socket_sink.clone(),
182            socket_control: self.socket_control.clone(),
183            socket_registration: None,
184        }
185    }
186}
187
188impl HyperliquidWebSocketClient {
189    /// Creates a new Hyperliquid WebSocket client without connecting.
190    ///
191    /// If `url` is `None`, the appropriate URL will be determined from the `environment`:
192    /// - `Mainnet`: `wss://api.hyperliquid.xyz/ws`
193    /// - `Testnet`: `wss://api.hyperliquid-testnet.xyz/ws`
194    ///
195    /// The connection will be established when `connect()` is called.
196    pub fn new(
197        url: Option<String>,
198        environment: HyperliquidEnvironment,
199        account_id: Option<AccountId>,
200        transport_backend: TransportBackend,
201        proxy_url: Option<String>,
202    ) -> Self {
203        let url = url.unwrap_or_else(|| ws_url(environment).to_string());
204        let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
205            ConnectionMode::Closed as u8,
206        ))));
207        Self {
208            url,
209            connection_mode,
210            signal: Arc::new(AtomicBool::new(false)),
211            auth_tracker: AuthTracker::new(),
212            subscriptions: SubscriptionState::new(':'),
213            book_streams: BookStreamRegistry::default(),
214            trade_streams: TradeStreamRegistry::default(),
215            trade_stream_lock: Arc::new(Mutex::new(())),
216            quote_streams: Arc::new(DashMap::new()),
217            instruments: Arc::new(AtomicMap::new()),
218            bar_types: Arc::new(AtomicMap::new()),
219            asset_context_subs: Arc::new(DashMap::new()),
220            all_dex_asset_ctxs_instrument_ids: Arc::new(AtomicMap::new()),
221            cloid_cache: Arc::new(Mutex::new(FifoCacheMap::new())),
222            post_router: PostRouter::new(),
223            post_ids: Arc::new(PostIds::new(1)),
224            post_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
225            post_timeout: HTTP_TIMEOUT,
226            cmd_tx: {
227                // Placeholder channel until connect() creates the real handler and replays queued instruments
228                let (tx, _) = tokio::sync::mpsc::unbounded_channel();
229                Arc::new(tokio::sync::RwLock::new(tx))
230            },
231            out_rx: None,
232            task_handle: None,
233            account_id,
234            transport_backend,
235            proxy_url,
236            socket_sink: None,
237            socket_control: None,
238            socket_registration: None,
239        }
240    }
241
242    /// Configures socket state reporting for the underlying transport.
243    #[must_use]
244    pub fn with_state_sink(mut self, state_sink: SocketStateSink) -> Self {
245        self.socket_sink = Some(state_sink);
246        self
247    }
248
249    /// Configures state reporting and reconnect control for the underlying transport.
250    #[must_use]
251    pub(crate) fn with_socket_control(mut self, control: SocketControl) -> Self {
252        self.socket_sink = Some(control.sink());
253        self.socket_control = Some(control);
254        self
255    }
256
257    /// Establishes WebSocket connection and spawns the message handler.
258    pub async fn connect(&mut self) -> anyhow::Result<()> {
259        if self.is_active() {
260            log::warn!("WebSocket already connected");
261            return Ok(());
262        }
263
264        // A fresh socket has no venue-side subscriptions; stale book stream
265        // entries must not gate the venue subscribe for re-subscriptions
266        self.book_streams.clear();
267
268        let (message_handler, raw_rx) = channel_message_handler();
269        let cfg = WebSocketConfig {
270            url: self.url.clone(),
271            headers: vec![],
272            heartbeat_interval_secs: Some(30),
273            heartbeat_payload: Some(HYPERLIQUID_HEARTBEAT_MSG.to_string()),
274            connect_timeout_ms: Some(15_000),
275            reconnect_delay_initial_ms: Some(250),
276            reconnect_delay_max_ms: Some(5_000),
277            reconnect_backoff_factor: Some(2.0),
278            reconnect_jitter_ms: Some(200),
279            reconnect_max_attempts: None,
280            heartbeat_timeout_secs: None,
281            idle_timeout_ms: None,
282            backend: self.transport_backend,
283            proxy_url: self.proxy_url.clone(),
284        };
285        let client = WebSocketClient::connect_with_state_sink(
286            cfg,
287            Some(message_handler),
288            None,
289            vec![],
290            None,
291            self.socket_sink.clone(),
292        )
293        .await?;
294        self.socket_registration = self
295            .socket_control
296            .as_ref()
297            .map(|control| control.register(client.reconnect_handle()));
298
299        // Create channels for handler communication
300        let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
301        let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
302
303        // Update cmd_tx before connection_mode to avoid race where is_active() returns
304        // true but subscriptions still go to the old placeholder channel
305        *self.cmd_tx.write().await = cmd_tx.clone();
306        self.out_rx = Some(out_rx);
307
308        self.connection_mode.store(client.connection_mode_atomic());
309        log::debug!("Hyperliquid WebSocket connected: {}", self.url);
310
311        // Send SetClient command immediately
312        if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
313            anyhow::bail!("Failed to send SetClient command: {e}");
314        }
315
316        // Initialize handler with existing instruments
317        let instruments_vec: Vec<InstrumentAny> =
318            self.instruments.load().values().cloned().collect();
319
320        if !instruments_vec.is_empty()
321            && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments_vec))
322        {
323            log::error!("Failed to send InitializeInstruments: {e}");
324        }
325
326        for (coin, uses) in self.trade_streams.snapshot() {
327            if let Err(e) = cmd_tx.send(HandlerCommand::UpdateTradeSubs { coin, uses }) {
328                log::error!("Failed to send UpdateTradeSubs: {e}");
329            }
330        }
331
332        let all_dex_asset_ctxs_instrument_ids = self
333            .all_dex_asset_ctxs_instrument_ids
334            .load()
335            .iter()
336            .map(|(dex, instrument_ids)| (*dex, instrument_ids.clone()))
337            .collect();
338
339        if let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(
340            all_dex_asset_ctxs_instrument_ids,
341        )) {
342            log::error!("Failed to send CacheAllDexAssetCtxsInstrumentIds: {e}");
343        }
344
345        // Spawn handler task
346        let signal = Arc::clone(&self.signal);
347        let account_id = self.account_id;
348        let subscriptions = self.subscriptions.clone();
349        let book_streams = self.book_streams.clone();
350        let cmd_tx_for_reconnect = cmd_tx.clone();
351        let cloid_cache = Arc::clone(&self.cloid_cache);
352        let post_router = Arc::clone(&self.post_router);
353
354        let stream_handle = get_runtime().spawn(async move {
355            let mut handler = FeedHandler::new(
356                signal,
357                cmd_rx,
358                raw_rx,
359                out_tx,
360                account_id,
361                subscriptions.clone(),
362                cloid_cache,
363                post_router,
364            );
365
366            let resubscribe_all = || {
367                let topics = subscriptions.all_topics();
368                if topics.is_empty() {
369                    log::debug!("No active subscriptions to restore after reconnection");
370                    return;
371                }
372
373                log::info!(
374                    "Resubscribing to {} active subscriptions after reconnection",
375                    topics.len()
376                );
377
378                for topic in topics {
379                    match subscription_from_topic(&topic) {
380                        Ok(mut subscription) => {
381                            // Topic text cannot carry l2Book precision options;
382                            // replay the shape the stream was opened with
383                            if let SubscriptionRequest::L2Book {
384                                coin,
385                                n_sig_figs,
386                                mantissa,
387                            } = &mut subscription
388                                && let Some(options) = book_streams.options(coin)
389                            {
390                                *n_sig_figs = options.n_sig_figs;
391                                *mantissa = options.mantissa;
392                            }
393
394                            if let Err(e) = cmd_tx_for_reconnect.send(HandlerCommand::Subscribe {
395                                subscriptions: vec![subscription],
396                            }) {
397                                log::error!("Failed to send resubscribe command: {e}");
398                            }
399                        }
400                        Err(e) => {
401                            log::error!(
402                                "Failed to reconstruct subscription from topic: topic={topic}, {e}"
403                            );
404                        }
405                    }
406                }
407            };
408
409            loop {
410                match handler.next().await {
411                    Some(NautilusWsMessage::Reconnected) => {
412                        log::info!("WebSocket reconnected");
413                        resubscribe_all();
414
415                        if handler.send(NautilusWsMessage::Reconnected).is_err() {
416                            if handler.is_stopped() {
417                                log::debug!("Failed to send reconnect event (receiver dropped)");
418                            } else {
419                                log::error!("Failed to send reconnect event (receiver dropped)");
420                            }
421                            break;
422                        }
423                    }
424                    Some(msg) => {
425                        if handler.send(msg).is_err() {
426                            if handler.is_stopped() {
427                                log::debug!("Failed to send message (receiver dropped)");
428                            } else {
429                                log::error!("Failed to send message (receiver dropped)");
430                            }
431                            break;
432                        }
433                    }
434                    None => {
435                        if handler.is_stopped() {
436                            log::debug!("Stop signal received, ending message processing");
437                            break;
438                        }
439                        log::warn!("WebSocket stream ended unexpectedly");
440                        break;
441                    }
442                }
443            }
444            log::debug!("Handler task completed");
445        });
446        self.task_handle = Some(stream_handle);
447        Ok(())
448    }
449
450    /// Takes the handler task handle from this client so that another
451    /// instance (e.g., the non-clone original) can await it on disconnect.
452    pub fn take_task_handle(&mut self) -> Option<tokio::task::JoinHandle<()>> {
453        self.task_handle.take()
454    }
455
456    pub fn set_task_handle(&mut self, handle: tokio::task::JoinHandle<()>) {
457        self.task_handle = Some(handle);
458    }
459
460    pub fn set_post_timeout(&mut self, timeout: Duration) {
461        self.post_timeout = timeout;
462    }
463
464    /// Force-close fallback for the sync `stop()` path.
465    /// Prefer `disconnect()` for graceful shutdown.
466    pub(crate) fn abort(&mut self) {
467        self.signal.store(true, Ordering::Relaxed);
468        self.connection_mode
469            .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
470        self.socket_registration = None;
471
472        if let Some(handle) = self.task_handle.take() {
473            handle.abort();
474        }
475    }
476
477    /// Replaces state owned by a terminated WebSocket generation.
478    ///
479    /// This must run only after the handler task has stopped. Replacing the
480    /// shared containers, rather than clearing them, prevents old clones or
481    /// in-flight work from mutating a subsequent connection generation.
482    pub(crate) fn reset_runtime_state(&mut self) {
483        self.subscriptions = SubscriptionState::new(':');
484        self.book_streams = BookStreamRegistry::default();
485        self.trade_streams = TradeStreamRegistry::default();
486        self.trade_stream_lock = Arc::new(Mutex::new(()));
487        self.quote_streams = Arc::new(DashMap::new());
488        self.instruments = Arc::new(AtomicMap::new());
489        self.bar_types = Arc::new(AtomicMap::new());
490        self.asset_context_subs = Arc::new(DashMap::new());
491        self.all_dex_asset_ctxs_instrument_ids = Arc::new(AtomicMap::new());
492        self.cloid_cache = Arc::new(Mutex::new(FifoCacheMap::new()));
493        self.out_rx = None;
494        self.socket_registration = None;
495        self.connection_mode
496            .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
497        self.signal.store(false, Ordering::Relaxed);
498    }
499
500    /// Disconnects the WebSocket connection.
501    pub async fn disconnect(&mut self) -> anyhow::Result<()> {
502        log::debug!("Disconnecting Hyperliquid WebSocket");
503        self.socket_registration = None;
504        self.signal.store(true, Ordering::Relaxed);
505
506        if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
507            log::debug!(
508                "Failed to send disconnect command (handler may already be shut down): {e}"
509            );
510        }
511
512        if let Some(handle) = self.task_handle.take() {
513            log::debug!("Waiting for task handle to complete");
514            let abort_handle = handle.abort_handle();
515            tokio::select! {
516                result = handle => {
517                    match result {
518                        Ok(()) => log::debug!("Task handle completed successfully"),
519                        Err(e) if e.is_cancelled() => {
520                            log::debug!("Task was cancelled");
521                        }
522                        Err(e) => log::error!("Task handle encountered an error: {e:?}"),
523                    }
524                }
525                () = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {
526                    log::warn!("Timeout waiting for task handle, aborting task");
527                    abort_handle.abort();
528                }
529            }
530        } else {
531            log::debug!("No task handle to await");
532        }
533        log::debug!("Disconnected");
534        Ok(())
535    }
536
537    /// Requests a full transport reconnect.
538    ///
539    /// Transitions the connection from `Active` to `Reconnect`; the network
540    /// layer re-establishes the socket with backoff and the handler replays all
541    /// active subscriptions once reconnected. Returns `false` when the
542    /// connection is not active (already reconnecting, disconnecting, or
543    /// closed), leaving any in-flight transition untouched.
544    pub fn request_reconnect(&self) -> bool {
545        ConnectionMode::request_reconnect(&self.connection_mode.load())
546    }
547
548    /// Send a typed exchange action through the Hyperliquid WebSocket post API.
549    ///
550    /// The supplied HTTP client is used only as the canonical signer for the
551    /// action envelope. The signed payload is sent over the active WebSocket
552    /// connection and the response is correlated by post id.
553    pub async fn post_action_exec(
554        &self,
555        signer: &HyperliquidHttpClient,
556        action: &HyperliquidExecAction,
557    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
558        self.post_action_exec_with_timeout(signer, action, self.post_timeout, None)
559            .await
560    }
561
562    /// Send a typed exchange action with a caller-specified timeout and optional expiry.
563    pub async fn post_action_exec_with_timeout(
564        &self,
565        signer: &HyperliquidHttpClient,
566        action: &HyperliquidExecAction,
567        timeout: Duration,
568        expires_after: Option<u64>,
569    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
570        let weight = exec_action_weight(action);
571        self.post_limiter.acquire(weight).await;
572
573        let payload = signer.sign_action_exec_request(action, expires_after)?;
574        let response = self
575            .send_post_request(PostRequest::Action { payload }, timeout)
576            .await?;
577
578        match response.response {
579            PostResponsePayload::Action { payload } => {
580                let parsed: HyperliquidExchangeResponse =
581                    serde_json::from_value(payload).map_err(HyperliquidError::Serde)?;
582
583                match &parsed {
584                    HyperliquidExchangeResponse::Status {
585                        status,
586                        response: response_data,
587                    } if status != RESPONSE_STATUS_OK => {
588                        let error_msg = response_data
589                            .as_str()
590                            .map_or_else(|| response_data.to_string(), |s| s.to_string());
591                        Err(HyperliquidError::bad_request(format!(
592                            "API error: {error_msg}"
593                        )))
594                    }
595                    HyperliquidExchangeResponse::Error { error } => {
596                        Err(HyperliquidError::bad_request(format!("API error: {error}")))
597                    }
598                    _ => Ok(parsed),
599                }
600            }
601            PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
602            PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
603                "expected action post response, received info payload: {payload}"
604            ))),
605        }
606    }
607
608    /// Submit an order through the Hyperliquid WebSocket post API.
609    ///
610    /// The HTTP client supplies signing credentials, builder attribution, and
611    /// cached instrument metadata. The action itself is sent over WebSocket.
612    ///
613    /// Returns an [`OrderStatusReport`] describing the venue's immediate
614    /// response (`Filled` for an atomic IOC fill, `Accepted` for a resting
615    /// order), or `None` when the venue deferred the order without an oid (for
616    /// example a `waitingForFill` trigger child): the order stays `SUBMITTED`
617    /// until the user-events stream delivers the first `OrderAccepted`.
618    #[allow(
619        clippy::too_many_arguments,
620        reason = "matches the Python and HTTP order submit surface"
621    )]
622    pub async fn submit_order(
623        &self,
624        signer: &HyperliquidHttpClient,
625        instrument_id: InstrumentId,
626        client_order_id: ClientOrderId,
627        order_side: OrderSide,
628        order_type: OrderType,
629        quantity: Quantity,
630        time_in_force: TimeInForce,
631        price: Option<Price>,
632        trigger_price: Option<Price>,
633        post_only: bool,
634        reduce_only: bool,
635    ) -> HyperliquidResult<Option<OrderStatusReport>> {
636        let symbol = instrument_id.symbol.inner();
637        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
638            HyperliquidError::bad_request(format!(
639                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
640            ))
641        })?;
642        let is_buy = matches!(order_side, OrderSide::Buy);
643        let price_precision = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
644
645        let price_decimal = match price {
646            Some(px) if signer.normalize_prices() => {
647                normalize_price(px.as_decimal(), price_precision).normalize()
648            }
649            Some(px) => px.as_decimal().normalize(),
650            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
651            None if matches!(
652                order_type,
653                OrderType::StopMarket | OrderType::MarketIfTouched
654            ) =>
655            {
656                match trigger_price {
657                    Some(tp) => {
658                        let derived = derive_limit_from_trigger(
659                            tp.as_decimal().normalize(),
660                            is_buy,
661                            signer.market_order_slippage_bps(),
662                        );
663                        let sig_rounded = round_to_sig_figs(derived, 5);
664                        clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
665                    }
666                    None => Decimal::ZERO,
667                }
668            }
669            None => {
670                return Err(HyperliquidError::bad_request(
671                    "Limit orders require a price",
672                ));
673            }
674        };
675
676        let size_decimal = quantity.as_decimal().normalize();
677        let kind = hyperliquid_order_kind(
678            order_type,
679            time_in_force,
680            post_only,
681            trigger_price,
682            signer.normalize_prices(),
683            price_precision,
684        )?;
685
686        let order = HyperliquidExecPlaceOrderRequest {
687            asset,
688            is_buy,
689            price: price_decimal,
690            size: size_decimal,
691            reduce_only,
692            kind,
693            cloid: Some(signer.get_or_generate_client_order_id_cloid(client_order_id)),
694        };
695
696        if let Some(cloid) = order.cloid {
697            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
698        }
699        let action = HyperliquidExecAction::Order {
700            orders: vec![order],
701            grouping: HyperliquidExecGrouping::Na,
702            builder: signer.builder_attribution(),
703        };
704        let response = self.post_action_exec(signer, &action).await?;
705
706        // Verdict first: a real rejection must still error
707        ensure_ws_action_accepted(&response, "Order submission")?;
708
709        // Past the verdict, a build failure is local; defer to WS, never reject
710        match signer.build_submit_order_report(
711            instrument_id,
712            client_order_id,
713            order_side,
714            order_type,
715            quantity,
716            time_in_force,
717            price,
718            trigger_price,
719            response,
720        ) {
721            Ok(report) => Ok(report),
722            Err(e) => {
723                log::warn!(
724                    "Failed to build submit report for {client_order_id}: {e}; awaiting WS reconciliation"
725                );
726                Ok(None)
727            }
728        }
729    }
730
731    /// Submit multiple orders through the Hyperliquid WebSocket post API.
732    ///
733    /// Returns one [`OrderStatusReport`] per accepted order in submission
734    /// order. Deferred trigger children of a `normalTpsl` bracket are absent
735    /// from the result; they stay `SUBMITTED` until the user-events stream
736    /// delivers an `OrderAccepted` with the real oid.
737    pub async fn submit_orders(
738        &self,
739        signer: &HyperliquidHttpClient,
740        orders: &[&OrderAny],
741    ) -> HyperliquidResult<Vec<OrderStatusReport>> {
742        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
743        let mut client_order_ids = Vec::with_capacity(orders.len());
744
745        for order in orders {
746            let instrument_id = order.instrument_id();
747            let symbol = instrument_id.symbol.inner();
748            let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
749                HyperliquidError::bad_request(format!(
750                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
751                ))
752            })?;
753            let price_decimals = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
754            let request = order_to_hyperliquid_request_with_asset_and_cloid(
755                order,
756                asset,
757                price_decimals,
758                signer.normalize_prices(),
759                signer.market_order_slippage_bps(),
760                None,
761            )
762            .map_err(|e| HyperliquidError::bad_request(format!("Failed to convert order: {e}")))?;
763            client_order_ids.push(order.client_order_id());
764            hyperliquid_orders.push(request);
765        }
766
767        for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
768            let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
769            request.cloid = Some(cloid);
770            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
771        }
772
773        let grouping =
774            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
775        let action = HyperliquidExecAction::Order {
776            orders: hyperliquid_orders,
777            grouping,
778            builder: signer.builder_attribution(),
779        };
780        let response = self.post_action_exec(signer, &action).await?;
781
782        ensure_ws_action_accepted(&response, "Order list submission")?;
783
784        // Past the verdict, a build failure is local; defer to WS, never reject
785        match signer.build_submit_orders_reports(orders, grouping, response) {
786            Ok(reports) => Ok(reports),
787            Err(e) => {
788                log::warn!(
789                    "Failed to build submit reports for order list: {e}; awaiting WS reconciliation"
790                );
791                Ok(Vec::new())
792            }
793        }
794    }
795
796    /// Cancel an order through the Hyperliquid WebSocket post API.
797    pub async fn cancel_order(
798        &self,
799        signer: &HyperliquidHttpClient,
800        instrument_id: InstrumentId,
801        client_order_id: Option<ClientOrderId>,
802        venue_order_id: Option<VenueOrderId>,
803    ) -> HyperliquidResult<()> {
804        let symbol = instrument_id.symbol.inner();
805        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
806            HyperliquidError::bad_request(format!(
807                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
808            ))
809        })?;
810        let action = if let Some(client_order_id) = client_order_id {
811            if let Some(cloid) = signer.cached_client_order_id_cloid(&client_order_id) {
812                HyperliquidExecAction::CancelByCloid {
813                    cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
814                    fast: None,
815                }
816            } else if let Some(oid) = venue_order_id {
817                let oid = oid
818                    .as_str()
819                    .parse::<u64>()
820                    .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
821                HyperliquidExecAction::Cancel {
822                    cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
823                    fast: None,
824                }
825            } else {
826                let cloid = signer.get_or_generate_client_order_id_cloid(client_order_id);
827                HyperliquidExecAction::CancelByCloid {
828                    cancels: vec![HyperliquidExecCancelByCloidRequest { asset, cloid }],
829                    fast: None,
830                }
831            }
832        } else if let Some(oid) = venue_order_id {
833            let oid = oid
834                .as_str()
835                .parse::<u64>()
836                .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?;
837            HyperliquidExecAction::Cancel {
838                cancels: vec![HyperliquidExecCancelOrderRequest { asset, oid }],
839                fast: None,
840            }
841        } else {
842            return Err(HyperliquidError::bad_request(
843                "Either client_order_id or venue_order_id must be provided",
844            ));
845        };
846        let response = self.post_action_exec(signer, &action).await?;
847
848        ensure_ws_action_accepted(&response, "Cancel order")
849    }
850
851    /// Cancel multiple orders through one Hyperliquid WebSocket post action.
852    pub async fn cancel_orders(
853        &self,
854        signer: &HyperliquidHttpClient,
855        cancels: &[(InstrumentId, ClientOrderId, Option<VenueOrderId>)],
856    ) -> HyperliquidResult<Vec<Option<String>>> {
857        let mut cloid_requests = Vec::new();
858        let mut cloid_indices = Vec::new();
859        let mut oid_requests = Vec::new();
860        let mut oid_indices = Vec::new();
861        let mut results = vec![None; cancels.len()];
862
863        for (index, (instrument_id, client_order_id, venue_order_id)) in cancels.iter().enumerate()
864        {
865            let symbol = instrument_id.symbol.inner();
866            let Some(asset) = signer.get_asset_index_for_symbol(symbol) else {
867                results[index] = Some(format!(
868                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
869                ));
870                continue;
871            };
872
873            if let Some(cloid) = signer.cached_client_order_id_cloid(client_order_id) {
874                cloid_requests.push(HyperliquidExecCancelByCloidRequest { asset, cloid });
875                cloid_indices.push(index);
876            } else if let Some(venue_order_id) = venue_order_id {
877                match venue_order_id.as_str().parse::<u64>() {
878                    Ok(oid) => {
879                        oid_requests.push(HyperliquidExecCancelOrderRequest { asset, oid });
880                        oid_indices.push(index);
881                    }
882                    Err(_) => {
883                        results[index] = Some("Invalid venue order ID format".to_string());
884                    }
885                }
886            } else {
887                let cloid = signer.get_or_generate_client_order_id_cloid(*client_order_id);
888                cloid_requests.push(HyperliquidExecCancelByCloidRequest { asset, cloid });
889                cloid_indices.push(index);
890            }
891        }
892
893        if cloid_requests.is_empty() && oid_requests.is_empty() {
894            return Ok(results);
895        }
896
897        if !cloid_requests.is_empty() {
898            let action = HyperliquidExecAction::CancelByCloid {
899                cancels: cloid_requests,
900                fast: None,
901            };
902            let errors = self
903                .post_cancel_action_errors(signer, &action, cloid_indices.len())
904                .await?;
905
906            for (index, error) in cloid_indices.into_iter().zip(errors) {
907                results[index] = error;
908            }
909        }
910
911        if !oid_requests.is_empty() {
912            let action = HyperliquidExecAction::Cancel {
913                cancels: oid_requests,
914                fast: None,
915            };
916            let errors = self
917                .post_cancel_action_errors(signer, &action, oid_indices.len())
918                .await?;
919
920            for (index, error) in oid_indices.into_iter().zip(errors) {
921                results[index] = error;
922            }
923        }
924
925        Ok(results)
926    }
927
928    async fn post_cancel_action_errors(
929        &self,
930        signer: &HyperliquidHttpClient,
931        action: &HyperliquidExecAction,
932        request_count: usize,
933    ) -> HyperliquidResult<Vec<Option<String>>> {
934        match self.post_cancel_action(signer, action).await {
935            Ok(response) if response.is_ok() => {
936                match cancel_errors_for_requests(extract_inner_errors(&response), request_count) {
937                    Ok(errors) => Ok(errors),
938                    Err(e) => Ok(vec![Some(e.to_string()); request_count]),
939                }
940            }
941            Ok(response) => Ok(vec![
942                Some(format!(
943                    "Cancel orders failed: {}",
944                    extract_error_message(&response)
945                ));
946                request_count
947            ]),
948            Err(e) => Err(e),
949        }
950    }
951
952    async fn post_cancel_action(
953        &self,
954        signer: &HyperliquidHttpClient,
955        action: &HyperliquidExecAction,
956    ) -> HyperliquidResult<HyperliquidExchangeResponse> {
957        let weight = exec_action_weight(action);
958        self.post_limiter.acquire(weight).await;
959
960        let payload = signer.sign_action_exec_request(action, None)?;
961        let response = self
962            .send_post_request(PostRequest::Action { payload }, self.post_timeout)
963            .await?;
964
965        match response.response {
966            PostResponsePayload::Action { payload } => {
967                serde_json::from_value(payload).map_err(HyperliquidError::Serde)
968            }
969            PostResponsePayload::Error { payload } => Err(map_post_payload_error(payload, weight)),
970            PostResponsePayload::Info { payload } => Err(HyperliquidError::decode(format!(
971                "expected action post response, received info payload: {payload}"
972            ))),
973        }
974    }
975
976    /// Modify an order through the Hyperliquid WebSocket post API.
977    #[allow(
978        clippy::too_many_arguments,
979        reason = "matches the Python and HTTP order modify surface"
980    )]
981    pub async fn modify_order(
982        &self,
983        signer: &HyperliquidHttpClient,
984        instrument_id: InstrumentId,
985        venue_order_id: Option<VenueOrderId>,
986        order_side: OrderSide,
987        order_type: OrderType,
988        price: Price,
989        quantity: Quantity,
990        trigger_price: Option<Price>,
991        reduce_only: bool,
992        post_only: bool,
993        time_in_force: TimeInForce,
994        client_order_id: Option<ClientOrderId>,
995    ) -> HyperliquidResult<()> {
996        let symbol = instrument_id.symbol.inner();
997        let asset = signer.get_asset_index_for_symbol(symbol).ok_or_else(|| {
998            HyperliquidError::bad_request(format!(
999                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1000            ))
1001        })?;
1002        let oid = match client_order_id
1003            .as_ref()
1004            .and_then(|id| signer.unique_cached_client_order_id_cloid(id))
1005        {
1006            Some(cloid) => HyperliquidExecModifyTarget::Cloid(cloid),
1007            None => {
1008                let Some(venue_order_id) = venue_order_id.as_ref() else {
1009                    return Err(HyperliquidError::bad_request(
1010                        "venue_order_id or unique cached CLOID is required for modify",
1011                    ));
1012                };
1013                HyperliquidExecModifyTarget::from_venue_order_id(venue_order_id)
1014                    .map_err(|_| HyperliquidError::bad_request("Invalid venue order ID format"))?
1015            }
1016        };
1017        let is_buy = matches!(order_side, OrderSide::Buy);
1018        let price_decimals = signer.get_price_precision_for_symbol(symbol).unwrap_or(2);
1019        let price = if signer.normalize_prices() {
1020            normalize_price(price.as_decimal(), price_decimals).normalize()
1021        } else {
1022            price.as_decimal().normalize()
1023        };
1024        let kind = hyperliquid_order_kind(
1025            order_type,
1026            time_in_force,
1027            post_only,
1028            trigger_price,
1029            signer.normalize_prices(),
1030            price_decimals,
1031        )?;
1032        let cloid =
1033            client_order_id.map(|id| (id, signer.get_or_generate_client_order_id_cloid(id)));
1034        let order = HyperliquidExecPlaceOrderRequest {
1035            asset,
1036            is_buy,
1037            price,
1038            size: quantity.as_decimal().normalize(),
1039            reduce_only,
1040            kind,
1041            cloid: cloid.map(|(_, cloid)| cloid),
1042        };
1043
1044        if let Some((client_order_id, cloid)) = cloid {
1045            self.cache_cloid_mapping(Ustr::from(&cloid.to_hex()), client_order_id);
1046        }
1047        let action = HyperliquidExecAction::Modify {
1048            modify: HyperliquidExecModifyOrderRequest { oid, order },
1049        };
1050        let response = self.post_action_exec(signer, &action).await?;
1051
1052        ensure_ws_action_accepted(&response, "Modify order")
1053    }
1054
1055    async fn send_post_request(
1056        &self,
1057        request: PostRequest,
1058        timeout: Duration,
1059    ) -> HyperliquidResult<PostResponse> {
1060        let id = self.post_ids.next();
1061
1062        match tokio::time::timeout(timeout, async {
1063            let rx = self.post_router.register(id).await?;
1064
1065            let send_result = self
1066                .cmd_tx
1067                .read()
1068                .await
1069                .send(HandlerCommand::Post { id, request });
1070
1071            if let Err(e) = send_result {
1072                self.post_router.cancel(id).await;
1073                return Err(HyperliquidError::transport(format!(
1074                    "post command channel closed: {e}"
1075                )));
1076            }
1077
1078            self.post_router.await_with_timeout(id, rx, timeout).await
1079        })
1080        .await
1081        {
1082            Ok(result) => result,
1083            Err(_elapsed) => {
1084                self.post_router.cancel(id).await;
1085                Err(HyperliquidError::Timeout)
1086            }
1087        }
1088    }
1089
1090    /// Returns true if the WebSocket is actively connected.
1091    pub fn is_active(&self) -> bool {
1092        let mode = self.connection_mode.load();
1093        mode.load(Ordering::Relaxed) == ConnectionMode::Active as u8
1094    }
1095
1096    /// Returns the URL of this WebSocket client.
1097    pub fn url(&self) -> &str {
1098        &self.url
1099    }
1100
1101    /// Caches multiple instruments.
1102    ///
1103    /// Clears the existing cache first, then adds all provided instruments.
1104    /// Instruments are keyed by their raw_symbol which is unique per instrument:
1105    /// - Perps use base currency (e.g., "BTC")
1106    /// - Spot uses @{pair_index} format (e.g., "@107") or slash format for PURR
1107    pub fn cache_instruments(&mut self, instruments: Vec<InstrumentAny>) {
1108        let mut map = AHashMap::new();
1109
1110        for inst in instruments {
1111            let coin = inst.raw_symbol().inner();
1112            map.insert(coin, inst);
1113        }
1114        let count = map.len();
1115        self.instruments.store(map);
1116        log::debug!("Hyperliquid instrument cache initialized with {count} instruments");
1117    }
1118
1119    /// Caches a single instrument.
1120    ///
1121    /// Any existing instrument with the same raw_symbol will be replaced.
1122    pub fn cache_instrument(&self, instrument: InstrumentAny) {
1123        let coin = instrument.raw_symbol().inner();
1124        self.instruments.insert(coin, instrument.clone());
1125
1126        // Before connect() the handler isn't running; this send will fail and that's expected
1127        // because connect() replays the instruments via InitializeInstruments
1128        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1129            let _ = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument));
1130        }
1131    }
1132
1133    /// Returns a shared reference to the instrument cache.
1134    #[must_use]
1135    pub fn instruments_cache(&self) -> Arc<AtomicMap<Ustr, InstrumentAny>> {
1136        self.instruments.clone()
1137    }
1138
1139    /// Caches spot fill coin mappings for instrument lookup.
1140    ///
1141    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1142    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1143    /// This mapping allows the handler to look up instruments from spot fills.
1144    pub fn cache_spot_fill_coins(&self, mapping: AHashMap<Ustr, Ustr>) {
1145        if let Ok(cmd_tx) = self.cmd_tx.try_read() {
1146            let _ = cmd_tx.send(HandlerCommand::CacheSpotFillCoins(mapping));
1147        }
1148    }
1149
1150    /// Caches a venue CLOID to client_order_id mapping for order/fill resolution.
1151    ///
1152    /// This mapping allows WebSocket order status and fill reports to be resolved back to
1153    /// the original client_order_id.
1154    ///
1155    /// This writes directly to a shared cache that the handler reads from, avoiding any
1156    /// race conditions between caching and WebSocket message processing.
1157    #[allow(
1158        clippy::missing_panics_doc,
1159        reason = "cloid cache mutex poisoning is not expected"
1160    )]
1161    pub fn cache_cloid_mapping(&self, cloid: Ustr, client_order_id: ClientOrderId) {
1162        log::debug!("Caching cloid mapping: {cloid} -> {client_order_id}");
1163        self.cloid_cache
1164            .lock()
1165            .expect(MUTEX_POISONED)
1166            .insert(cloid, client_order_id);
1167    }
1168
1169    /// Removes a cloid mapping from the cache.
1170    ///
1171    /// Called on terminal order state. The cache is FIFO-bounded so missed
1172    /// removals self-evict (see GH-3972 cancel-replace drain).
1173    #[allow(
1174        clippy::missing_panics_doc,
1175        reason = "cloid cache mutex poisoning is not expected"
1176    )]
1177    pub fn remove_cloid_mapping(&self, cloid: &Ustr) {
1178        if self
1179            .cloid_cache
1180            .lock()
1181            .expect(MUTEX_POISONED)
1182            .remove(cloid)
1183            .is_some()
1184        {
1185            log::debug!("Removed cloid mapping: {cloid}");
1186        }
1187    }
1188
1189    /// Clears all cloid mappings from the cache.
1190    ///
1191    /// Useful for cleanup during reconnection or shutdown.
1192    #[allow(
1193        clippy::missing_panics_doc,
1194        reason = "cloid cache mutex poisoning is not expected"
1195    )]
1196    pub fn clear_cloid_cache(&self) {
1197        let mut cache = self.cloid_cache.lock().expect(MUTEX_POISONED);
1198        let count = cache.len();
1199        cache.clear();
1200
1201        if count > 0 {
1202            log::debug!("Cleared {count} cloid mappings from cache");
1203        }
1204    }
1205
1206    /// Returns the number of cloid mappings in the cache.
1207    #[must_use]
1208    #[allow(
1209        clippy::missing_panics_doc,
1210        reason = "cloid cache mutex poisoning is not expected"
1211    )]
1212    pub fn cloid_cache_len(&self) -> usize {
1213        self.cloid_cache.lock().expect(MUTEX_POISONED).len()
1214    }
1215
1216    /// Looks up a client_order_id by its venue CLOID.
1217    ///
1218    /// Returns `Some(ClientOrderId)` if the mapping exists, `None` otherwise.
1219    #[must_use]
1220    #[allow(
1221        clippy::missing_panics_doc,
1222        reason = "cloid cache mutex poisoning is not expected"
1223    )]
1224    pub fn get_cloid_mapping(&self, cloid: &Ustr) -> Option<ClientOrderId> {
1225        self.cloid_cache
1226            .lock()
1227            .expect(MUTEX_POISONED)
1228            .get(cloid)
1229            .copied()
1230    }
1231
1232    /// Gets an instrument from the cache by ID.
1233    ///
1234    /// Searches the cache for a matching instrument ID.
1235    pub fn get_instrument(&self, id: &InstrumentId) -> Option<InstrumentAny> {
1236        self.instruments
1237            .load()
1238            .values()
1239            .find(|inst| inst.id() == *id)
1240            .cloned()
1241    }
1242
1243    /// Gets an instrument from the cache by raw_symbol (coin).
1244    pub fn get_instrument_by_symbol(&self, symbol: &Ustr) -> Option<InstrumentAny> {
1245        self.instruments.get_cloned(symbol)
1246    }
1247
1248    /// Returns the count of confirmed subscriptions.
1249    pub fn subscription_count(&self) -> usize {
1250        self.subscriptions.len()
1251    }
1252
1253    /// Gets a bar type from the cache by coin and interval.
1254    ///
1255    /// This looks up the subscription key created when subscribing to bars.
1256    pub fn get_bar_type(&self, coin: &str, interval: &str) -> Option<BarType> {
1257        // Use canonical key format matching subscribe_bars
1258        let key = format!("candle:{coin}:{interval}");
1259        self.bar_types.load().get(&key).copied()
1260    }
1261
1262    /// Subscribe to L2 order book for an instrument.
1263    pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1264        self.subscribe_book_with_options(instrument_id, None, None)
1265            .await
1266    }
1267
1268    /// Subscribe to L2 order book with optional `nSigFigs` / `mantissa`
1269    /// precision controls passed through to the venue's `l2Book` stream.
1270    ///
1271    /// One venue `l2Book` stream per coin is shared with depth10 snapshots;
1272    /// the first logical use opens the stream and its options win. Requesting
1273    /// different options while the stream is active logs a warning.
1274    pub async fn subscribe_book_with_options(
1275        &self,
1276        instrument_id: InstrumentId,
1277        n_sig_figs: Option<u32>,
1278        mantissa: Option<u32>,
1279    ) -> anyhow::Result<()> {
1280        let instrument = self
1281            .get_instrument(&instrument_id)
1282            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1283        let coin = instrument.raw_symbol().inner();
1284
1285        let cmd_tx = self.cmd_tx.read().await;
1286
1287        // Update the handler's coin→instrument mapping for this subscription
1288        cmd_tx
1289            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1290            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1291
1292        self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Deltas, n_sig_figs, mantissa)
1293    }
1294
1295    /// Subscribe to order book depth-10 snapshots.
1296    ///
1297    /// Reuses the same `l2Book` WebSocket subscription as
1298    /// [`Self::subscribe_book`] and flags the handler to additionally emit
1299    /// `NautilusWsMessage::Depth10` for this coin.
1300    pub async fn subscribe_book_depth10(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1301        self.subscribe_book_depth10_with_options(instrument_id, None, None)
1302            .await
1303    }
1304
1305    /// Subscribe to depth-10 snapshots with optional `nSigFigs` /
1306    /// `mantissa` precision controls.
1307    ///
1308    /// Shares the coin's `l2Book` stream with deltas subscribers; the first
1309    /// logical use opens the stream and its options win. Requesting different
1310    /// options while the stream is active logs a warning.
1311    pub async fn subscribe_book_depth10_with_options(
1312        &self,
1313        instrument_id: InstrumentId,
1314        n_sig_figs: Option<u32>,
1315        mantissa: Option<u32>,
1316    ) -> anyhow::Result<()> {
1317        let instrument = self
1318            .get_instrument(&instrument_id)
1319            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1320        let coin = instrument.raw_symbol().inner();
1321
1322        let cmd_tx = self.cmd_tx.read().await;
1323
1324        cmd_tx
1325            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1326            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1327
1328        cmd_tx
1329            .send(HandlerCommand::SetDepth10Sub {
1330                coin,
1331                subscribed: true,
1332            })
1333            .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1334
1335        self.send_book_stream_subscribe(&cmd_tx, coin, BookStreamUse::Depth10, n_sig_figs, mantissa)
1336    }
1337
1338    /// Unsubscribe from order book depth-10 snapshots.
1339    ///
1340    /// Clears the depth10 emission flag and tears down the underlying
1341    /// `l2Book` stream unless active deltas subscribers still need it.
1342    pub async fn unsubscribe_book_depth10(
1343        &self,
1344        instrument_id: InstrumentId,
1345    ) -> anyhow::Result<()> {
1346        let instrument = self
1347            .get_instrument(&instrument_id)
1348            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1349        let coin = instrument.raw_symbol().inner();
1350
1351        let cmd_tx = self.cmd_tx.read().await;
1352
1353        cmd_tx
1354            .send(HandlerCommand::SetDepth10Sub {
1355                coin,
1356                subscribed: false,
1357            })
1358            .map_err(|e| anyhow::anyhow!("Failed to send SetDepth10Sub command: {e}"))?;
1359
1360        self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Depth10)
1361    }
1362
1363    /// Subscribe to best bid/offer (BBO) quotes for an instrument.
1364    pub async fn subscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1365        let instrument = self
1366            .get_instrument(&instrument_id)
1367            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1368        let coin = instrument.raw_symbol().inner();
1369
1370        let cmd_tx = self.cmd_tx.read().await;
1371        self.quote_streams.insert(coin, ());
1372
1373        // Update the handler's coin→instrument mapping for this subscription
1374        if let Err(e) = cmd_tx.send(HandlerCommand::UpdateInstrument(instrument.clone())) {
1375            self.quote_streams.remove(&coin);
1376            anyhow::bail!("Failed to send UpdateInstrument command: {e}");
1377        }
1378
1379        let subscription = SubscriptionRequest::Bbo { coin };
1380
1381        if let Err(e) = cmd_tx.send(HandlerCommand::Subscribe {
1382            subscriptions: vec![subscription],
1383        }) {
1384            self.quote_streams.remove(&coin);
1385            anyhow::bail!("Failed to send subscribe command: {e}");
1386        }
1387        Ok(())
1388    }
1389
1390    /// Subscribe to all mid prices across markets.
1391    pub async fn subscribe_all_mids(&self) -> anyhow::Result<()> {
1392        self.subscribe_all_mids_with_dex(None).await
1393    }
1394
1395    /// Subscribe to aggregate asset contexts across all perp dexes.
1396    pub async fn subscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1397        self.cmd_tx
1398            .read()
1399            .await
1400            .send(HandlerCommand::Subscribe {
1401                subscriptions: vec![SubscriptionRequest::AllDexsAssetCtxs],
1402            })
1403            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1404        Ok(())
1405    }
1406
1407    /// Subscribe to all mid prices across markets, optionally scoped to a specific dex.
1408    pub async fn subscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1409        let cmd_tx = self.cmd_tx.read().await;
1410
1411        let subscription = SubscriptionRequest::AllMids {
1412            dex: dex.map(ToString::to_string),
1413        };
1414
1415        cmd_tx
1416            .send(HandlerCommand::Subscribe {
1417                subscriptions: vec![subscription],
1418            })
1419            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1420        Ok(())
1421    }
1422
1423    /// Unsubscribe from all mid prices across markets.
1424    pub async fn unsubscribe_all_mids(&self) -> anyhow::Result<()> {
1425        self.unsubscribe_all_mids_with_dex(None).await
1426    }
1427
1428    /// Unsubscribe from aggregate asset contexts across all perp dexes.
1429    pub async fn unsubscribe_all_dexs_asset_ctxs(&self) -> anyhow::Result<()> {
1430        self.cmd_tx
1431            .read()
1432            .await
1433            .send(HandlerCommand::Unsubscribe {
1434                subscriptions: vec![SubscriptionRequest::AllDexsAssetCtxs],
1435            })
1436            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1437        Ok(())
1438    }
1439
1440    /// Unsubscribe from all mid prices across markets, optionally scoped to a specific dex.
1441    pub async fn unsubscribe_all_mids_with_dex(&self, dex: Option<&str>) -> anyhow::Result<()> {
1442        let cmd_tx = self.cmd_tx.read().await;
1443
1444        let subscription = SubscriptionRequest::AllMids {
1445            dex: dex.map(ToString::to_string),
1446        };
1447
1448        cmd_tx
1449            .send(HandlerCommand::Unsubscribe {
1450                subscriptions: vec![subscription],
1451            })
1452            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1453        Ok(())
1454    }
1455
1456    /// Subscribe to trades for an instrument.
1457    pub async fn subscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1458        self.subscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1459            .await
1460    }
1461
1462    /// Subscribe to complete public trades for an instrument.
1463    pub async fn subscribe_public_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1464        self.subscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1465            .await
1466    }
1467
1468    async fn subscribe_trade_stream(
1469        &self,
1470        instrument_id: InstrumentId,
1471        stream_use: TradeStreamUse,
1472    ) -> anyhow::Result<()> {
1473        let instrument = self
1474            .get_instrument(&instrument_id)
1475            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1476        let coin = instrument.raw_symbol().inner();
1477
1478        let cmd_tx = self.cmd_tx.read().await;
1479
1480        // Update the handler's coin→instrument mapping for this subscription
1481        cmd_tx
1482            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1483            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1484
1485        // Keep registry mutations and their handler commands ordered across
1486        // concurrent generic/custom subscriptions for the same coin.
1487        let _trade_stream_guard = self.trade_stream_lock.lock().expect(MUTEX_POISONED);
1488        let registration = self.trade_streams.register(coin, stream_use);
1489        cmd_tx
1490            .send(HandlerCommand::UpdateTradeSubs {
1491                coin,
1492                uses: registration.uses,
1493            })
1494            .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1495
1496        if registration.subscribe {
1497            cmd_tx
1498                .send(HandlerCommand::Subscribe {
1499                    subscriptions: vec![SubscriptionRequest::Trades { coin }],
1500                })
1501                .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1502        }
1503        Ok(())
1504    }
1505
1506    /// Subscribe to mark price updates for an instrument.
1507    pub async fn subscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1508        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1509            .await
1510    }
1511
1512    /// Subscribe to index/oracle price updates for an instrument.
1513    pub async fn subscribe_index_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1514        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1515            .await
1516    }
1517
1518    /// Subscribe to candle/bar data for a specific coin and interval.
1519    pub async fn subscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1520        let instrument_id = bar_type.instrument_id();
1521        let instrument = self
1522            .get_instrument(&instrument_id)
1523            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1524        let coin = instrument.raw_symbol().inner();
1525        let interval = bar_type_to_interval(&bar_type)?;
1526        let subscription = SubscriptionRequest::Candle { coin, interval };
1527
1528        // Cache the bar type for parsing using canonical key
1529        let key = format!("candle:{coin}:{interval}");
1530        self.bar_types.insert(key.clone(), bar_type);
1531
1532        let cmd_tx = self.cmd_tx.read().await;
1533
1534        cmd_tx
1535            .send(HandlerCommand::UpdateInstrument(instrument.clone()))
1536            .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
1537
1538        cmd_tx
1539            .send(HandlerCommand::AddBarType { key, bar_type })
1540            .map_err(|e| anyhow::anyhow!("Failed to send AddBarType command: {e}"))?;
1541
1542        cmd_tx
1543            .send(HandlerCommand::Subscribe {
1544                subscriptions: vec![subscription],
1545            })
1546            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1547        Ok(())
1548    }
1549
1550    /// Subscribe to funding rate updates for an instrument.
1551    pub async fn subscribe_funding_rates(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1552        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1553            .await
1554    }
1555
1556    /// Subscribe to open interest updates for an instrument.
1557    pub async fn subscribe_open_interest(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1558        self.subscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1559            .await
1560    }
1561
1562    /// Subscribe to order updates for a specific user address.
1563    pub async fn subscribe_order_updates(&self, user: &str) -> anyhow::Result<()> {
1564        let subscription = SubscriptionRequest::OrderUpdates {
1565            user: user.to_string(),
1566        };
1567        self.cmd_tx
1568            .read()
1569            .await
1570            .send(HandlerCommand::Subscribe {
1571                subscriptions: vec![subscription],
1572            })
1573            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1574        Ok(())
1575    }
1576
1577    /// Subscribe to user events (fills, funding, liquidations) for a specific user address.
1578    pub async fn subscribe_user_events(&self, user: &str) -> anyhow::Result<()> {
1579        let subscription = SubscriptionRequest::UserEvents {
1580            user: user.to_string(),
1581        };
1582        self.cmd_tx
1583            .read()
1584            .await
1585            .send(HandlerCommand::Subscribe {
1586                subscriptions: vec![subscription],
1587            })
1588            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1589        Ok(())
1590    }
1591
1592    /// Subscribe to user fills for a specific user address.
1593    ///
1594    /// Note: This channel is redundant with `userEvents` which already includes fills.
1595    /// Prefer using `subscribe_user_events` or `subscribe_all_user_channels` instead.
1596    pub async fn subscribe_user_fills(&self, user: &str) -> anyhow::Result<()> {
1597        let subscription = SubscriptionRequest::UserFills {
1598            user: user.to_string(),
1599            aggregate_by_time: None,
1600        };
1601        self.cmd_tx
1602            .read()
1603            .await
1604            .send(HandlerCommand::Subscribe {
1605                subscriptions: vec![subscription],
1606            })
1607            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1608        Ok(())
1609    }
1610
1611    /// Subscribe to all user channels (order updates + user events) for convenience.
1612    ///
1613    /// Note: `userEvents` already includes fills, so we don't subscribe to `userFills`
1614    /// separately to avoid duplicate fill messages.
1615    ///
1616    /// This does **not** include opt-in TWAP custom-data channels
1617    /// (`userTwapHistory` / `userTwapSliceFills`).
1618    pub async fn subscribe_all_user_channels(&self, user: &str) -> anyhow::Result<()> {
1619        self.subscribe_order_updates(user).await?;
1620        self.subscribe_user_events(user).await?;
1621        Ok(())
1622    }
1623
1624    /// Subscribe to TWAP history for a user address (`userTwapHistory`).
1625    ///
1626    /// Opt-in custom data. The address need not be the adapter trading account.
1627    pub async fn subscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1628        let subscription = SubscriptionRequest::UserTwapHistory {
1629            user: user.to_string(),
1630        };
1631        self.cmd_tx
1632            .read()
1633            .await
1634            .send(HandlerCommand::Subscribe {
1635                subscriptions: vec![subscription],
1636            })
1637            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1638        Ok(())
1639    }
1640
1641    /// Unsubscribe from TWAP history for a user address.
1642    pub async fn unsubscribe_user_twap_history(&self, user: &str) -> anyhow::Result<()> {
1643        let subscription = SubscriptionRequest::UserTwapHistory {
1644            user: user.to_string(),
1645        };
1646        self.cmd_tx
1647            .read()
1648            .await
1649            .send(HandlerCommand::Unsubscribe {
1650                subscriptions: vec![subscription],
1651            })
1652            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1653        Ok(())
1654    }
1655
1656    /// Subscribe to TWAP slice fills for a user address (`userTwapSliceFills`).
1657    ///
1658    /// Opt-in custom data. The address need not be the adapter trading account.
1659    pub async fn subscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1660        let subscription = SubscriptionRequest::UserTwapSliceFills {
1661            user: user.to_string(),
1662        };
1663        self.cmd_tx
1664            .read()
1665            .await
1666            .send(HandlerCommand::Subscribe {
1667                subscriptions: vec![subscription],
1668            })
1669            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1670        Ok(())
1671    }
1672
1673    /// Unsubscribe from TWAP slice fills for a user address.
1674    pub async fn unsubscribe_user_twap_slice_fills(&self, user: &str) -> anyhow::Result<()> {
1675        let subscription = SubscriptionRequest::UserTwapSliceFills {
1676            user: user.to_string(),
1677        };
1678        self.cmd_tx
1679            .read()
1680            .await
1681            .send(HandlerCommand::Unsubscribe {
1682                subscriptions: vec![subscription],
1683            })
1684            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1685        Ok(())
1686    }
1687
1688    /// Unsubscribe from L2 order book for an instrument.
1689    ///
1690    /// Tears down the venue `l2Book` stream unless active depth10 subscribers
1691    /// still need it.
1692    pub async fn unsubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1693        let instrument = self
1694            .get_instrument(&instrument_id)
1695            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1696        let coin = instrument.raw_symbol().inner();
1697
1698        let cmd_tx = self.cmd_tx.read().await;
1699
1700        self.send_book_stream_unsubscribe(&cmd_tx, coin, BookStreamUse::Deltas)
1701    }
1702
1703    /// Resubscribes the venue `l2Book` stream for an instrument in place.
1704    ///
1705    /// Sends an unsubscribe immediately followed by a subscribe, both echoing
1706    /// the stream's original precision options (the venue matches unsubscribes
1707    /// by full payload). Registry state is left untouched so the logical
1708    /// deltas/depth10 uses and first-wins options survive the cycle. Used by
1709    /// stale-stream recovery, where a plain subscribe would be gated off by
1710    /// the existing registry entry.
1711    pub async fn resubscribe_book(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1712        let instrument = self
1713            .get_instrument(&instrument_id)
1714            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1715        let coin = instrument.raw_symbol().inner();
1716
1717        // Serialize the registry check with read-locked subscribe/unsubscribe senders
1718        let cmd_tx = self.cmd_tx.write().await;
1719
1720        let Some(options) = self.book_streams.options(&coin) else {
1721            log::debug!("Skipping l2Book resubscribe for {coin}: stream no longer registered");
1722            return Ok(());
1723        };
1724
1725        let subscription = SubscriptionRequest::L2Book {
1726            coin,
1727            mantissa: options.mantissa,
1728            n_sig_figs: options.n_sig_figs,
1729        };
1730
1731        Self::send_stream_resubscribe(&cmd_tx, subscription)
1732    }
1733
1734    fn send_book_stream_subscribe(
1735        &self,
1736        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1737        coin: Ustr,
1738        stream_use: BookStreamUse,
1739        n_sig_figs: Option<u32>,
1740        mantissa: Option<u32>,
1741    ) -> anyhow::Result<()> {
1742        let registration = self.book_streams.register(
1743            coin,
1744            stream_use,
1745            BookStreamOptions {
1746                n_sig_figs,
1747                mantissa,
1748            },
1749        );
1750
1751        if registration.options_mismatch {
1752            log::warn!(
1753                "Requested l2Book options for {coin} (n_sig_figs={n_sig_figs:?}, mantissa={mantissa:?}) \
1754                differ from the active stream ({:?}), keeping active options",
1755                registration.options,
1756            );
1757        }
1758
1759        if registration.subscribe {
1760            let subscription = SubscriptionRequest::L2Book {
1761                coin,
1762                mantissa: registration.options.mantissa,
1763                n_sig_figs: registration.options.n_sig_figs,
1764            };
1765
1766            cmd_tx
1767                .send(HandlerCommand::Subscribe {
1768                    subscriptions: vec![subscription],
1769                })
1770                .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1771        }
1772        Ok(())
1773    }
1774
1775    fn send_book_stream_unsubscribe(
1776        &self,
1777        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1778        coin: Ustr,
1779        stream_use: BookStreamUse,
1780    ) -> anyhow::Result<()> {
1781        match self.book_streams.release(&coin, stream_use) {
1782            BookStreamRelease::Unsubscribe(options) => {
1783                let subscription = SubscriptionRequest::L2Book {
1784                    coin,
1785                    mantissa: options.mantissa,
1786                    n_sig_figs: options.n_sig_figs,
1787                };
1788
1789                cmd_tx
1790                    .send(HandlerCommand::Unsubscribe {
1791                        subscriptions: vec![subscription],
1792                    })
1793                    .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1794            }
1795            BookStreamRelease::Retained => {
1796                let remaining_use = match stream_use {
1797                    BookStreamUse::Deltas => "depth10",
1798                    BookStreamUse::Depth10 => "deltas",
1799                };
1800                log::debug!("Keeping shared l2Book stream for {coin}: {remaining_use} use remains");
1801            }
1802        }
1803        Ok(())
1804    }
1805
1806    /// Unsubscribe from quote ticks for an instrument.
1807    pub async fn unsubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1808        let instrument = self
1809            .get_instrument(&instrument_id)
1810            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1811        let coin = instrument.raw_symbol().inner();
1812
1813        let subscription = SubscriptionRequest::Bbo { coin };
1814        let cmd_tx = self.cmd_tx.read().await;
1815
1816        self.quote_streams.remove(&coin);
1817
1818        cmd_tx
1819            .send(HandlerCommand::Unsubscribe {
1820                subscriptions: vec![subscription],
1821            })
1822            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1823        Ok(())
1824    }
1825
1826    /// Resubscribes the venue `bbo` stream for an instrument in place
1827    /// (unsubscribe immediately followed by subscribe). Used by stale-stream
1828    /// recovery.
1829    pub async fn resubscribe_quotes(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1830        let instrument = self
1831            .get_instrument(&instrument_id)
1832            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1833        let coin = instrument.raw_symbol().inner();
1834
1835        // Keep the registration check atomic with the resubscribe pair
1836        let cmd_tx = self.cmd_tx.write().await;
1837
1838        if !self.quote_streams.contains_key(&coin) {
1839            log::debug!("Skipping bbo resubscribe for {coin}: stream no longer registered");
1840            return Ok(());
1841        }
1842
1843        Self::send_stream_resubscribe(&cmd_tx, SubscriptionRequest::Bbo { coin })
1844    }
1845
1846    fn send_stream_resubscribe(
1847        cmd_tx: &tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
1848        subscription: SubscriptionRequest,
1849    ) -> anyhow::Result<()> {
1850        cmd_tx
1851            .send(HandlerCommand::Unsubscribe {
1852                subscriptions: vec![subscription.clone()],
1853            })
1854            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1855
1856        cmd_tx
1857            .send(HandlerCommand::Subscribe {
1858                subscriptions: vec![subscription],
1859            })
1860            .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
1861        Ok(())
1862    }
1863
1864    /// Unsubscribe from trades for an instrument.
1865    pub async fn unsubscribe_trades(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1866        self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::Ticks)
1867            .await
1868    }
1869
1870    /// Unsubscribe from complete public trades for an instrument.
1871    pub async fn unsubscribe_public_trades(
1872        &self,
1873        instrument_id: InstrumentId,
1874    ) -> anyhow::Result<()> {
1875        self.unsubscribe_trade_stream(instrument_id, TradeStreamUse::PublicTrades)
1876            .await
1877    }
1878
1879    async fn unsubscribe_trade_stream(
1880        &self,
1881        instrument_id: InstrumentId,
1882        stream_use: TradeStreamUse,
1883    ) -> anyhow::Result<()> {
1884        let instrument = self
1885            .get_instrument(&instrument_id)
1886            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1887        let coin = instrument.raw_symbol().inner();
1888
1889        let cmd_tx = self.cmd_tx.read().await;
1890        // Keep registry mutations and their handler commands ordered across
1891        // concurrent generic/custom unsubscriptions for the same coin.
1892        let _trade_stream_guard = self.trade_stream_lock.lock().expect(MUTEX_POISONED);
1893        let release = self.trade_streams.release(&coin, stream_use);
1894        cmd_tx
1895            .send(HandlerCommand::UpdateTradeSubs {
1896                coin,
1897                uses: release.uses,
1898            })
1899            .map_err(|e| anyhow::anyhow!("Failed to send UpdateTradeSubs command: {e}"))?;
1900
1901        if release.unsubscribe {
1902            cmd_tx
1903                .send(HandlerCommand::Unsubscribe {
1904                    subscriptions: vec![SubscriptionRequest::Trades { coin }],
1905                })
1906                .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1907        }
1908        Ok(())
1909    }
1910
1911    /// Unsubscribe from mark price updates for an instrument.
1912    pub async fn unsubscribe_mark_prices(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
1913        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::MarkPrice)
1914            .await
1915    }
1916
1917    /// Unsubscribe from index/oracle price updates for an instrument.
1918    pub async fn unsubscribe_index_prices(
1919        &self,
1920        instrument_id: InstrumentId,
1921    ) -> anyhow::Result<()> {
1922        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::IndexPrice)
1923            .await
1924    }
1925
1926    /// Unsubscribe from candle/bar data.
1927    pub async fn unsubscribe_bars(&self, bar_type: BarType) -> anyhow::Result<()> {
1928        let instrument_id = bar_type.instrument_id();
1929        let instrument = self
1930            .get_instrument(&instrument_id)
1931            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1932        let coin = instrument.raw_symbol().inner();
1933        let interval = bar_type_to_interval(&bar_type)?;
1934        let subscription = SubscriptionRequest::Candle { coin, interval };
1935
1936        let key = format!("candle:{coin}:{interval}");
1937        self.bar_types.remove(&key);
1938
1939        let cmd_tx = self.cmd_tx.read().await;
1940
1941        cmd_tx
1942            .send(HandlerCommand::RemoveBarType { key })
1943            .map_err(|e| anyhow::anyhow!("Failed to send RemoveBarType command: {e}"))?;
1944
1945        cmd_tx
1946            .send(HandlerCommand::Unsubscribe {
1947                subscriptions: vec![subscription],
1948            })
1949            .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
1950        Ok(())
1951    }
1952
1953    /// Unsubscribe from funding rate updates for an instrument.
1954    pub async fn unsubscribe_funding_rates(
1955        &self,
1956        instrument_id: InstrumentId,
1957    ) -> anyhow::Result<()> {
1958        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::FundingRate)
1959            .await
1960    }
1961
1962    /// Unsubscribe from open interest updates for an instrument.
1963    pub async fn unsubscribe_open_interest(
1964        &self,
1965        instrument_id: InstrumentId,
1966    ) -> anyhow::Result<()> {
1967        self.unsubscribe_asset_context_data(instrument_id, AssetContextDataType::OpenInterest)
1968            .await
1969    }
1970
1971    /// Cache the ordered instrument IDs required to normalize `allDexsAssetCtxs`.
1972    pub fn cache_all_dex_asset_ctxs_instrument_ids(
1973        &self,
1974        mapping: AHashMap<Ustr, Vec<Option<InstrumentId>>>,
1975    ) {
1976        self.all_dex_asset_ctxs_instrument_ids
1977            .store(mapping.clone());
1978
1979        if let Ok(cmd_tx) = self.cmd_tx.try_read()
1980            && let Err(e) = cmd_tx.send(HandlerCommand::CacheAllDexAssetCtxsInstrumentIds(mapping))
1981        {
1982            log::debug!(
1983                "Failed to send CacheAllDexAssetCtxsInstrumentIds command (handler may not be connected yet): {e}"
1984            );
1985        }
1986    }
1987
1988    async fn subscribe_asset_context_data(
1989        &self,
1990        instrument_id: InstrumentId,
1991        data_type: AssetContextDataType,
1992    ) -> anyhow::Result<()> {
1993        let instrument = self
1994            .get_instrument(&instrument_id)
1995            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1996        let coin = instrument.raw_symbol().inner();
1997
1998        let mut entry = self.asset_context_subs.entry(coin).or_default();
1999        let is_first_subscription = entry.is_empty();
2000        entry.insert(data_type);
2001        let data_types = entry.clone();
2002        drop(entry);
2003
2004        let cmd_tx = self.cmd_tx.read().await;
2005
2006        cmd_tx
2007            .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2008            .map_err(|e| anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}"))?;
2009
2010        if is_first_subscription {
2011            log::debug!(
2012                "First asset context subscription for coin '{coin}', subscribing to ActiveAssetCtx"
2013            );
2014            let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2015
2016            cmd_tx
2017                .send(HandlerCommand::UpdateInstrument(instrument.clone()))
2018                .map_err(|e| anyhow::anyhow!("Failed to send UpdateInstrument command: {e}"))?;
2019
2020            cmd_tx
2021                .send(HandlerCommand::Subscribe {
2022                    subscriptions: vec![subscription],
2023                })
2024                .map_err(|e| anyhow::anyhow!("Failed to send subscribe command: {e}"))?;
2025        } else {
2026            log::debug!(
2027                "Already subscribed to ActiveAssetCtx for coin '{coin}', adding {data_type:?} to tracked types"
2028            );
2029        }
2030
2031        Ok(())
2032    }
2033
2034    async fn unsubscribe_asset_context_data(
2035        &self,
2036        instrument_id: InstrumentId,
2037        data_type: AssetContextDataType,
2038    ) -> anyhow::Result<()> {
2039        let instrument = self
2040            .get_instrument(&instrument_id)
2041            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
2042        let coin = instrument.raw_symbol().inner();
2043
2044        if let Some(mut entry) = self.asset_context_subs.get_mut(&coin) {
2045            entry.remove(&data_type);
2046            let should_unsubscribe = entry.is_empty();
2047            let data_types = entry.clone();
2048            drop(entry);
2049
2050            let cmd_tx = self.cmd_tx.read().await;
2051
2052            if should_unsubscribe {
2053                self.asset_context_subs.remove(&coin);
2054
2055                log::debug!(
2056                    "Last asset context subscription removed for coin '{coin}', unsubscribing from ActiveAssetCtx"
2057                );
2058                let subscription = SubscriptionRequest::ActiveAssetCtx { coin };
2059
2060                cmd_tx
2061                    .send(HandlerCommand::UpdateAssetContextSubs {
2062                        coin,
2063                        data_types: AHashSet::new(),
2064                    })
2065                    .map_err(|e| {
2066                        anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2067                    })?;
2068
2069                cmd_tx
2070                    .send(HandlerCommand::Unsubscribe {
2071                        subscriptions: vec![subscription],
2072                    })
2073                    .map_err(|e| anyhow::anyhow!("Failed to send unsubscribe command: {e}"))?;
2074            } else {
2075                log::debug!(
2076                    "Removed {data_type:?} from tracked types for coin '{coin}', but keeping ActiveAssetCtx subscription"
2077                );
2078
2079                cmd_tx
2080                    .send(HandlerCommand::UpdateAssetContextSubs { coin, data_types })
2081                    .map_err(|e| {
2082                        anyhow::anyhow!("Failed to send UpdateAssetContextSubs command: {e}")
2083                    })?;
2084            }
2085        }
2086
2087        Ok(())
2088    }
2089
2090    /// Receives the next message from the WebSocket handler.
2091    ///
2092    /// Returns `None` if the handler has disconnected or the receiver was already taken.
2093    pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
2094        if let Some(ref mut rx) = self.out_rx {
2095            rx.recv().await
2096        } else {
2097            None
2098        }
2099    }
2100}
2101
2102fn cancel_errors_for_requests(
2103    errors: Vec<Option<String>>,
2104    request_count: usize,
2105) -> HyperliquidResult<Vec<Option<String>>> {
2106    if errors.is_empty() {
2107        return Ok(vec![None; request_count]);
2108    }
2109
2110    if errors.len() != request_count {
2111        return Err(HyperliquidError::exchange(format!(
2112            "Cancel orders returned {} statuses for {request_count} cancels",
2113            errors.len()
2114        )));
2115    }
2116
2117    Ok(errors)
2118}
2119
2120fn map_post_payload_error(payload: String, weight: u32) -> HyperliquidError {
2121    let lower = payload.to_ascii_lowercase();
2122    let message = format!("WebSocket post error: {payload}");
2123
2124    if starts_with_status(&lower, &["429"])
2125        || lower.contains("too many requests")
2126        || lower.contains("rate limit")
2127    {
2128        HyperliquidError::rate_limit("exchange", weight, None)
2129    } else if starts_with_status(&lower, &["401", "403"])
2130        || lower.contains("unauthorized")
2131        || lower.contains("forbidden")
2132        || lower.contains("authentication")
2133        || lower.contains("authorization")
2134        || lower.contains("invalid signature")
2135        || contains_word(&lower, "auth")
2136    {
2137        HyperliquidError::auth(message)
2138    } else if starts_with_status(&lower, &["400"]) || lower.contains("bad request") {
2139        HyperliquidError::bad_request(message)
2140    } else if starts_with_status(&lower, &["500", "502", "503", "504"]) {
2141        HyperliquidError::exchange(message)
2142    } else {
2143        HyperliquidError::exchange(payload)
2144    }
2145}
2146
2147fn hyperliquid_order_kind(
2148    order_type: OrderType,
2149    time_in_force: TimeInForce,
2150    post_only: bool,
2151    trigger_price: Option<Price>,
2152    normalize_prices_enabled: bool,
2153    price_precision: u8,
2154) -> HyperliquidResult<HyperliquidExecOrderKind> {
2155    match order_type {
2156        OrderType::Market => Ok(HyperliquidExecOrderKind::Limit {
2157            limit: HyperliquidExecLimitParams {
2158                tif: HyperliquidExecTif::Ioc,
2159            },
2160        }),
2161        OrderType::Limit => {
2162            let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2163                .map_err(|e| HyperliquidError::bad_request(format!("{e}")))?;
2164            Ok(HyperliquidExecOrderKind::Limit {
2165                limit: HyperliquidExecLimitParams { tif },
2166            })
2167        }
2168        OrderType::StopMarket
2169        | OrderType::StopLimit
2170        | OrderType::MarketIfTouched
2171        | OrderType::LimitIfTouched => {
2172            let trigger_price = trigger_price.ok_or_else(|| {
2173                HyperliquidError::bad_request("Trigger orders require a trigger price")
2174            })?;
2175            let trigger_px = if normalize_prices_enabled {
2176                normalize_price(trigger_price.as_decimal(), price_precision).normalize()
2177            } else {
2178                trigger_price.as_decimal().normalize()
2179            };
2180            let tpsl = match order_type {
2181                OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2182                OrderType::MarketIfTouched | OrderType::LimitIfTouched => HyperliquidExecTpSl::Tp,
2183                _ => unreachable!(),
2184            };
2185            let is_market = matches!(
2186                order_type,
2187                OrderType::StopMarket | OrderType::MarketIfTouched
2188            );
2189
2190            Ok(HyperliquidExecOrderKind::Trigger {
2191                trigger: HyperliquidExecTriggerParams {
2192                    is_market,
2193                    trigger_px,
2194                    tpsl,
2195                },
2196            })
2197        }
2198        _ => Err(HyperliquidError::bad_request(format!(
2199            "Order type {order_type:?} not supported"
2200        ))),
2201    }
2202}
2203
2204fn ensure_ws_action_accepted(
2205    response: &HyperliquidExchangeResponse,
2206    action_name: &str,
2207) -> HyperliquidResult<()> {
2208    if response.is_ok() {
2209        if let Some(error_msg) = extract_inner_errors(response).into_iter().flatten().next() {
2210            return Err(HyperliquidError::bad_request(format!(
2211                "{action_name} rejected: {error_msg}"
2212            )));
2213        }
2214
2215        if let Some(error_msg) = extract_inner_error(response) {
2216            return Err(HyperliquidError::bad_request(format!(
2217                "{action_name} rejected: {error_msg}"
2218            )));
2219        }
2220
2221        return Ok(());
2222    }
2223
2224    Err(HyperliquidError::bad_request(format!(
2225        "{action_name} failed: {}",
2226        extract_error_message(response)
2227    )))
2228}
2229
2230fn starts_with_status(payload: &str, statuses: &[&str]) -> bool {
2231    let trimmed = payload.trim_start();
2232    statuses
2233        .iter()
2234        .any(|status| starts_with_status_token(trimmed, status))
2235        || trimmed.strip_prefix("http").is_some_and(|rest| {
2236            let rest = rest
2237                .trim_start_matches(|c: char| c.is_ascii_whitespace() || matches!(c, ':' | '/'));
2238            statuses
2239                .iter()
2240                .any(|status| starts_with_status_token(rest, status))
2241        })
2242}
2243
2244fn starts_with_status_token(payload: &str, status: &str) -> bool {
2245    payload.strip_prefix(status).is_some_and(|rest| {
2246        rest.chars()
2247            .next()
2248            .is_none_or(|c| !c.is_ascii_alphanumeric())
2249    })
2250}
2251
2252fn contains_word(payload: &str, word: &str) -> bool {
2253    payload
2254        .split(|c: char| !c.is_ascii_alphanumeric())
2255        .any(|part| part == word)
2256}
2257
2258// Uses split_once/rsplit_once because coin names can contain colons
2259// (e.g., vault tokens `vntls:vCURSOR`)
2260fn subscription_from_topic(topic: &str) -> anyhow::Result<SubscriptionRequest> {
2261    let (kind, rest) = topic
2262        .split_once(':')
2263        .map_or((topic, None), |(k, r)| (k, Some(r)));
2264
2265    let channel = HyperliquidWsChannel::from_wire_str(kind)
2266        .ok_or_else(|| anyhow::anyhow!("Unknown subscription channel: {kind}"))?;
2267
2268    match channel {
2269        HyperliquidWsChannel::AllMids => Ok(SubscriptionRequest::AllMids {
2270            dex: rest.map(|s| s.to_string()),
2271        }),
2272        HyperliquidWsChannel::AllDexsAssetCtxs => Ok(SubscriptionRequest::AllDexsAssetCtxs),
2273        HyperliquidWsChannel::Notification => Ok(SubscriptionRequest::Notification {
2274            user: rest.context("Missing user")?.to_string(),
2275        }),
2276        HyperliquidWsChannel::WebData2 => Ok(SubscriptionRequest::WebData2 {
2277            user: rest.context("Missing user")?.to_string(),
2278        }),
2279        HyperliquidWsChannel::Candle => {
2280            // Format: candle:{coin}:{interval} - interval is last segment
2281            let rest = rest.context("Missing candle params")?;
2282            let (coin, interval_str) = rest.rsplit_once(':').context("Missing interval")?;
2283            let interval = HyperliquidBarInterval::from_str(interval_str)?;
2284            Ok(SubscriptionRequest::Candle {
2285                coin: Ustr::from(coin),
2286                interval,
2287            })
2288        }
2289        HyperliquidWsChannel::L2Book => Ok(SubscriptionRequest::L2Book {
2290            coin: Ustr::from(rest.context("Missing coin")?),
2291            mantissa: None,
2292            n_sig_figs: None,
2293        }),
2294        HyperliquidWsChannel::Trades => Ok(SubscriptionRequest::Trades {
2295            coin: Ustr::from(rest.context("Missing coin")?),
2296        }),
2297        HyperliquidWsChannel::OrderUpdates => Ok(SubscriptionRequest::OrderUpdates {
2298            user: rest.context("Missing user")?.to_string(),
2299        }),
2300        HyperliquidWsChannel::UserEvents => Ok(SubscriptionRequest::UserEvents {
2301            user: rest.context("Missing user")?.to_string(),
2302        }),
2303        HyperliquidWsChannel::UserFills => Ok(SubscriptionRequest::UserFills {
2304            user: rest.context("Missing user")?.to_string(),
2305            aggregate_by_time: None,
2306        }),
2307        HyperliquidWsChannel::UserFundings => Ok(SubscriptionRequest::UserFundings {
2308            user: rest.context("Missing user")?.to_string(),
2309        }),
2310        HyperliquidWsChannel::UserNonFundingLedgerUpdates => {
2311            Ok(SubscriptionRequest::UserNonFundingLedgerUpdates {
2312                user: rest.context("Missing user")?.to_string(),
2313            })
2314        }
2315        HyperliquidWsChannel::ActiveAssetCtx => Ok(SubscriptionRequest::ActiveAssetCtx {
2316            coin: Ustr::from(rest.context("Missing coin")?),
2317        }),
2318        HyperliquidWsChannel::ActiveSpotAssetCtx => Ok(SubscriptionRequest::ActiveSpotAssetCtx {
2319            coin: Ustr::from(rest.context("Missing coin")?),
2320        }),
2321        HyperliquidWsChannel::ActiveAssetData => {
2322            // Format: activeAssetData:{user}:{coin} - user is eth addr (no colons)
2323            let rest = rest.context("Missing params")?;
2324            let (user, coin) = rest.split_once(':').context("Missing coin")?;
2325            Ok(SubscriptionRequest::ActiveAssetData {
2326                user: user.to_string(),
2327                coin: coin.to_string(),
2328            })
2329        }
2330        HyperliquidWsChannel::UserTwapSliceFills => Ok(SubscriptionRequest::UserTwapSliceFills {
2331            user: rest.context("Missing user")?.to_string(),
2332        }),
2333        HyperliquidWsChannel::UserTwapHistory => Ok(SubscriptionRequest::UserTwapHistory {
2334            user: rest.context("Missing user")?.to_string(),
2335        }),
2336        HyperliquidWsChannel::Bbo => Ok(SubscriptionRequest::Bbo {
2337            coin: Ustr::from(rest.context("Missing coin")?),
2338        }),
2339
2340        // Response-only channels are not valid subscription topics
2341        HyperliquidWsChannel::SubscriptionResponse
2342        | HyperliquidWsChannel::User
2343        | HyperliquidWsChannel::Post
2344        | HyperliquidWsChannel::Pong
2345        | HyperliquidWsChannel::Error => {
2346            anyhow::bail!("Not a subscription channel: {kind}")
2347        }
2348    }
2349}
2350
2351#[cfg(test)]
2352mod tests {
2353    use nautilus_common::clients::{
2354        SocketReconnectHandle, SocketReconnectRegistry, SocketReconnectRequestOutcome,
2355    };
2356    use rstest::rstest;
2357    use ustr::Ustr;
2358
2359    use super::*;
2360    use crate::{
2361        common::{consts::INFLIGHT_MAX, enums::HyperliquidBarInterval},
2362        websocket::handler::subscription_to_key,
2363    };
2364
2365    /// Generates a unique topic key for a subscription request.
2366    fn subscription_topic(sub: &SubscriptionRequest) -> String {
2367        subscription_to_key(sub)
2368    }
2369
2370    #[rstest]
2371    #[case(SubscriptionRequest::Trades { coin: "BTC".into() }, "trades:BTC")]
2372    #[case(SubscriptionRequest::Bbo { coin: "BTC".into() }, "bbo:BTC")]
2373    #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() }, "orderUpdates:0x123")]
2374    #[case(SubscriptionRequest::UserEvents { user: "0xabc".to_string() }, "userEvents:0xabc")]
2375    fn test_subscription_topic_generation(
2376        #[case] subscription: SubscriptionRequest,
2377        #[case] expected_topic: &str,
2378    ) {
2379        assert_eq!(subscription_topic(&subscription), expected_topic);
2380    }
2381
2382    #[rstest]
2383    fn test_subscription_topics_unique() {
2384        let sub1 = SubscriptionRequest::Trades { coin: "BTC".into() };
2385        let sub2 = SubscriptionRequest::Bbo { coin: "BTC".into() };
2386
2387        let topic1 = subscription_topic(&sub1);
2388        let topic2 = subscription_topic(&sub2);
2389
2390        assert_ne!(topic1, topic2);
2391    }
2392
2393    #[rstest]
2394    #[case(SubscriptionRequest::Trades { coin: "BTC".into() })]
2395    #[case(SubscriptionRequest::Bbo { coin: "ETH".into() })]
2396    #[case(SubscriptionRequest::Candle { coin: "SOL".into(), interval: HyperliquidBarInterval::OneHour })]
2397    #[case(SubscriptionRequest::OrderUpdates { user: "0x123".to_string() })]
2398    #[case(SubscriptionRequest::Trades { coin: "vntls:vCURSOR".into() })]
2399    #[case(SubscriptionRequest::L2Book { coin: "vntls:vCURSOR".into(), mantissa: None, n_sig_figs: None })]
2400    #[case(SubscriptionRequest::Candle { coin: "vntls:vCURSOR".into(), interval: HyperliquidBarInterval::OneHour })]
2401    fn test_subscription_reconstruction(#[case] subscription: SubscriptionRequest) {
2402        let topic = subscription_topic(&subscription);
2403        let reconstructed = subscription_from_topic(&topic).expect("Failed to reconstruct");
2404        assert_eq!(subscription_topic(&reconstructed), topic);
2405    }
2406
2407    #[rstest]
2408    fn test_subscription_topic_candle() {
2409        let sub = SubscriptionRequest::Candle {
2410            coin: "BTC".into(),
2411            interval: HyperliquidBarInterval::OneHour,
2412        };
2413
2414        let topic = subscription_topic(&sub);
2415        assert_eq!(topic, "candle:BTC:1h");
2416    }
2417
2418    #[rstest]
2419    fn with_state_sink_survives_clone() {
2420        let client = HyperliquidWebSocketClient::new(
2421            None,
2422            HyperliquidEnvironment::Testnet,
2423            None,
2424            TransportBackend::default(),
2425            None,
2426        )
2427        .with_state_sink(SocketStateSink::new(|_| {}));
2428
2429        let cloned = client.clone();
2430        assert!(client.socket_sink.is_some());
2431        assert!(cloned.socket_sink.is_some());
2432    }
2433
2434    #[rstest]
2435    fn clone_does_not_take_socket_registration() {
2436        let registry = SocketReconnectRegistry::default();
2437        let endpoint = Ustr::from("hyperliquid-data-streams");
2438        let mut client = HyperliquidWebSocketClient::new(
2439            None,
2440            HyperliquidEnvironment::Testnet,
2441            None,
2442            TransportBackend::default(),
2443            None,
2444        );
2445        client.socket_registration = Some(registry.register(
2446            endpoint,
2447            SocketReconnectHandle::new(|| SocketReconnectRequestOutcome::Accepted),
2448        ));
2449
2450        let cloned = client.clone();
2451        assert!(cloned.socket_registration.is_none());
2452        assert!(client.socket_registration.is_some());
2453        assert!(registry.get(endpoint).is_some());
2454
2455        drop(cloned);
2456        assert!(registry.get(endpoint).is_some());
2457
2458        drop(client);
2459        assert!(registry.get(endpoint).is_none());
2460    }
2461
2462    #[rstest]
2463    fn set_post_timeout_updates_client_and_clone() {
2464        let mut client = HyperliquidWebSocketClient::new(
2465            None,
2466            HyperliquidEnvironment::Testnet,
2467            None,
2468            TransportBackend::default(),
2469            None,
2470        );
2471        let timeout = std::time::Duration::from_secs(7);
2472
2473        client.set_post_timeout(timeout);
2474
2475        assert_eq!(client.post_timeout, timeout);
2476        assert_eq!(client.clone().post_timeout, timeout);
2477    }
2478
2479    #[rstest]
2480    #[tokio::test(flavor = "multi_thread")]
2481    async fn send_post_request_times_out_while_waiting_for_inflight_slot() {
2482        let client = HyperliquidWebSocketClient::new(
2483            None,
2484            HyperliquidEnvironment::Testnet,
2485            None,
2486            TransportBackend::default(),
2487            None,
2488        );
2489        let mut receivers = Vec::with_capacity(INFLIGHT_MAX);
2490        for offset in 0..INFLIGHT_MAX {
2491            receivers.push(
2492                client
2493                    .post_router
2494                    .register(10_000 + offset as u64)
2495                    .await
2496                    .unwrap(),
2497            );
2498        }
2499
2500        let err = client
2501            .send_post_request(
2502                PostRequest::Info {
2503                    payload: serde_json::json!({"type": "clearinghouseState", "user": "0x0"}),
2504                },
2505                std::time::Duration::from_millis(25),
2506            )
2507            .await
2508            .expect_err("request should timeout before acquiring an inflight slot");
2509
2510        assert!(matches!(err, HyperliquidError::Timeout));
2511        assert_eq!(receivers.len(), INFLIGHT_MAX);
2512    }
2513
2514    #[rstest]
2515    fn cancel_errors_for_requests_accepts_empty_as_success() {
2516        let errors = cancel_errors_for_requests(Vec::new(), 2).unwrap();
2517
2518        assert_eq!(errors, vec![None, None]);
2519    }
2520
2521    #[rstest]
2522    fn cancel_errors_for_requests_rejects_status_count_mismatch() {
2523        let err = cancel_errors_for_requests(vec![None], 2).expect_err("mismatch should fail");
2524
2525        assert!(
2526            err.to_string()
2527                .contains("returned 1 statuses for 2 cancels")
2528        );
2529    }
2530
2531    #[rstest]
2532    fn test_post_payload_error_maps_rate_limit() {
2533        let err = map_post_payload_error("429 Too Many Requests".to_string(), 3);
2534
2535        assert!(matches!(
2536            err,
2537            HyperliquidError::RateLimit {
2538                scope: "exchange",
2539                weight: 3,
2540                retry_after_ms: None,
2541            }
2542        ));
2543    }
2544
2545    #[rstest]
2546    #[case("401 Unauthorized")]
2547    #[case("HTTP 403: forbidden")]
2548    #[case("invalid signature")]
2549    #[case("authentication failed")]
2550    fn test_post_payload_error_maps_auth(#[case] payload: &str) {
2551        let err = map_post_payload_error(payload.to_string(), 1);
2552
2553        assert!(matches!(err, HyperliquidError::Auth(_)));
2554    }
2555
2556    #[rstest]
2557    #[case("400 Bad Request")]
2558    #[case("HTTP 400: malformed payload")]
2559    #[case("bad request: missing action")]
2560    fn test_post_payload_error_maps_bad_request(#[case] payload: &str) {
2561        let err = map_post_payload_error(payload.to_string(), 1);
2562
2563        assert!(matches!(err, HyperliquidError::BadRequest(_)));
2564    }
2565
2566    #[rstest]
2567    #[case("500 Internal Server Error")]
2568    #[case("HTTP 503: service unavailable")]
2569    fn test_post_payload_error_maps_exchange_status(#[case] payload: &str) {
2570        let err = map_post_payload_error(payload.to_string(), 1);
2571
2572        assert!(matches!(err, HyperliquidError::Exchange(_)));
2573    }
2574
2575    #[rstest]
2576    #[case("order 429001 rejected")]
2577    #[case("asset 5001 is not tradable")]
2578    #[case("authoritative nonce window exceeded")]
2579    fn test_post_payload_error_does_not_match_embedded_codes_or_words(#[case] payload: &str) {
2580        let err = map_post_payload_error(payload.to_string(), 1);
2581
2582        assert!(matches!(err, HyperliquidError::Exchange(_)));
2583    }
2584}