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