Skip to main content

nautilus_hyperliquid/websocket/
client.rs

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