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