Skip to main content

nautilus_hyperliquid/http/
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
16//! Provides the HTTP client integration for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
17//!
18//! This module defines and implements a [`HyperliquidHttpClient`] for sending requests to various
19//! Hyperliquid endpoints. It handles request signing (when credentials are provided), constructs
20//! valid HTTP requests using the [`HttpClient`], and parses the responses back into structured
21//! data or an [`Error`].
22
23use std::{
24    collections::HashMap,
25    num::NonZeroU32,
26    sync::{Arc, LazyLock},
27    time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34    AtomicMap, UUID4, UnixNanos,
35    consts::NAUTILUS_USER_AGENT,
36    datetime::datetime_to_unix_nanos,
37    time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_model::{
40    data::{Bar, BarType},
41    enums::{
42        AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
43        TriggerType,
44    },
45    events::AccountState,
46    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
47    instruments::{CurrencyPair, Instrument, InstrumentAny},
48    orders::{Order, OrderAny},
49    reports::{FillReport, OrderStatusReport, PositionStatusReport},
50    types::{AccountBalance, Currency, Price, Quantity},
51};
52use nautilus_network::{
53    http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
54    ratelimiter::quota::Quota,
55};
56use parking_lot::Mutex;
57use rust_decimal::Decimal;
58use serde_json::Value;
59use ustr::Ustr;
60
61use crate::{
62    account::resolve_execution_account_address,
63    common::{
64        consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
65        credential::{Secrets, VaultAddress, credential_env_vars},
66        enums::{
67            HyperliquidBarInterval, HyperliquidEnvironment,
68            HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
69        },
70        parse::{
71            bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
72            derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
73            normalize_price, order_to_hyperliquid_request_with_asset_and_cloid,
74            parse_combined_account_balances_and_margins, parse_spot_account_balances,
75            parse_trigger_order_type, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
76        },
77    },
78    data::candle_to_bar,
79    data_types::HyperliquidPublicTrade,
80    http::{
81        error::{Error, Result},
82        models::{
83            ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeAction,
84            HyperliquidExchangeBuilderFee, HyperliquidExchangeCancelByCloidRequest,
85            HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
86            HyperliquidExchangeLimitParams, HyperliquidExchangeMergeOutcomeParams,
87            HyperliquidExchangeMergeQuestionParams, HyperliquidExchangeModifyOrderRequest,
88            HyperliquidExchangeModifyTarget, HyperliquidExchangeNegateOutcomeParams,
89            HyperliquidExchangeOrderKind, HyperliquidExchangeOrderResponseData,
90            HyperliquidExchangeOrderStatus, HyperliquidExchangePlaceOrderRequest,
91            HyperliquidExchangeRequest, HyperliquidExchangeResponse,
92            HyperliquidExchangeSplitOutcomeParams, HyperliquidExchangeTif, HyperliquidExchangeTpSl,
93            HyperliquidExchangeTriggerParams, HyperliquidExchangeUserOutcomeOp, HyperliquidFills,
94            HyperliquidFundingHistoryEntry, HyperliquidL2Book, HyperliquidMeta,
95            HyperliquidOrderStatus, HyperliquidOrderStatusEntry, HyperliquidRecentTrade,
96            OutcomeMeta, PerpDex, PerpMeta, PerpMetaAndCtxs, RESPONSE_STATUS_OK,
97            SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
98        },
99        parse::{
100            HyperliquidInstrumentDef, filter_recent_public_trades, instruments_from_defs_owned,
101            parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
102            parse_perp_instruments_with_settlement, parse_position_status_report,
103            parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
104            resolve_perp_settlement_currency,
105        },
106        query::{ExchangeAction, InfoRequest},
107        rate_limits::{
108            RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
109            exec_action_weight, info_base_weight, info_extra_weight,
110        },
111    },
112    signing::{
113        HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
114    },
115    websocket::messages::WsBasicOrderData,
116};
117
118fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
119    let mut best_by_venue_order_id = AHashMap::new();
120
121    for candidate in reports {
122        let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
123            best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
124            continue;
125        };
126        let (mut best, other) = if historical_report_is_more_advanced(&candidate, &current) {
127            (candidate, current)
128        } else {
129            (current, candidate)
130        };
131
132        if matches!(
133            best.order_type,
134            OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
135        ) {
136            best.price = best.price.or(other.price);
137        }
138        best.trigger_price = best.trigger_price.or(other.trigger_price);
139        best_by_venue_order_id.insert(best.venue_order_id, best);
140    }
141
142    best_by_venue_order_id.into_values().collect()
143}
144
145fn historical_report_is_more_advanced(
146    candidate: &OrderStatusReport,
147    current: &OrderStatusReport,
148) -> bool {
149    candidate.filled_qty > current.filled_qty
150        || (candidate.filled_qty == current.filled_qty
151            && (historical_status_priority(candidate.order_status)
152                > historical_status_priority(current.order_status)
153                || (candidate.order_status == current.order_status
154                    && candidate.ts_last > current.ts_last)))
155}
156
157const fn historical_status_priority(status: OrderStatus) -> u8 {
158    match status {
159        OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
160        OrderStatus::Released | OrderStatus::Denied => 1,
161        OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
162        OrderStatus::Triggered => 3,
163        OrderStatus::PartiallyFilled => 4,
164        OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
165        OrderStatus::Filled | OrderStatus::Voided => 6,
166    }
167}
168
169// https://hyperliquid.xyz/docs/api#rate-limits
170pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
171    LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
172
173const HYPERLIQUID_RECENT_HISTORY_LIMIT: usize = 2_000;
174const VAULT_TOKEN_PREFIX: &str = "vntls:";
175
176/// Provides a raw HTTP client for low-level Hyperliquid REST API operations.
177///
178/// This client handles HTTP infrastructure, request signing, and raw API calls
179/// that closely match Hyperliquid endpoint specifications.
180#[derive(Debug, Clone)]
181#[cfg_attr(
182    feature = "python",
183    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
184)]
185pub struct HyperliquidRawHttpClient {
186    client: HttpClient,
187    environment: HyperliquidEnvironment,
188    base_info: String,
189    base_exchange: String,
190    signer: Option<HyperliquidEip712Signer>,
191    nonce_manager: Option<Arc<NonceManager>>,
192    vault_address: Option<VaultAddress>,
193    rest_limiter: Arc<WeightedLimiter>,
194    rate_limit_backoff_base: Duration,
195    rate_limit_backoff_cap: Duration,
196    rate_limit_max_attempts_info: u32,
197}
198
199impl HyperliquidRawHttpClient {
200    /// Creates a new [`HyperliquidRawHttpClient`] for public endpoints only.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the HTTP client cannot be created.
205    pub fn new(
206        environment: HyperliquidEnvironment,
207        timeout_secs: u64,
208        proxy_url: Option<String>,
209    ) -> std::result::Result<Self, HttpClientError> {
210        Ok(Self {
211            client: HttpClient::builder()
212                .headers(Self::default_headers())
213                .default_quota(*HYPERLIQUID_REST_QUOTA)
214                .timeout_secs(timeout_secs)
215                .maybe_proxy_url(proxy_url)
216                .build()?,
217            environment,
218            base_info: info_url(environment).to_string(),
219            base_exchange: exchange_url(environment).to_string(),
220            signer: None,
221            nonce_manager: None,
222            vault_address: None,
223            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
224            rate_limit_backoff_base: Duration::from_millis(125),
225            rate_limit_backoff_cap: Duration::from_secs(5),
226            rate_limit_max_attempts_info: 3,
227        })
228    }
229
230    /// Creates a new [`HyperliquidRawHttpClient`] configured with credentials
231    /// for authenticated requests.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the HTTP client cannot be created.
236    pub fn with_credentials(
237        secrets: &Secrets,
238        timeout_secs: u64,
239        proxy_url: Option<String>,
240    ) -> std::result::Result<Self, HttpClientError> {
241        let signer = HyperliquidEip712Signer::new(&secrets.private_key)
242            .map_err(|e| HttpClientError::from(e.to_string()))?;
243        let nonce_manager = Arc::new(NonceManager::new());
244
245        Ok(Self {
246            client: HttpClient::builder()
247                .headers(Self::default_headers())
248                .default_quota(*HYPERLIQUID_REST_QUOTA)
249                .timeout_secs(timeout_secs)
250                .maybe_proxy_url(proxy_url)
251                .build()?,
252            environment: secrets.environment,
253            base_info: info_url(secrets.environment).to_string(),
254            base_exchange: exchange_url(secrets.environment).to_string(),
255            signer: Some(signer),
256            nonce_manager: Some(nonce_manager),
257            vault_address: secrets.vault_address,
258            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
259            rate_limit_backoff_base: Duration::from_millis(125),
260            rate_limit_backoff_cap: Duration::from_secs(5),
261            rate_limit_max_attempts_info: 3,
262        })
263    }
264
265    /// Overrides the base info URL (for testing with mock servers).
266    pub fn set_base_info_url(&mut self, url: String) {
267        self.base_info = url;
268    }
269
270    /// Overrides the base exchange URL (for testing with mock servers).
271    pub fn set_base_exchange_url(&mut self, url: String) {
272        self.base_exchange = url;
273    }
274
275    /// Creates an authenticated client from environment variables for the specified network.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`Error::Auth`] if required environment variables are not set.
280    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
281        let secrets = Secrets::from_env(environment)
282            .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
283        Self::with_credentials(&secrets, 60, None)
284            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
285    }
286
287    /// Creates a new [`HyperliquidRawHttpClient`] configured with explicit credentials.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
292    pub fn from_credentials(
293        private_key: &str,
294        vault_address: Option<&str>,
295        environment: HyperliquidEnvironment,
296        timeout_secs: u64,
297        proxy_url: Option<String>,
298    ) -> Result<Self> {
299        let secrets = Secrets::from_private_key(private_key, vault_address, environment)
300            .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
301        Self::with_credentials(&secrets, timeout_secs, proxy_url)
302            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
303    }
304
305    /// Configure rate limiting parameters (chainable).
306    #[must_use]
307    pub fn with_rate_limits(mut self) -> Self {
308        self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
309        self.rate_limit_backoff_base = Duration::from_millis(125);
310        self.rate_limit_backoff_cap = Duration::from_secs(5);
311        self.rate_limit_max_attempts_info = 3;
312        self
313    }
314
315    /// Returns the configured environment.
316    #[must_use]
317    pub fn environment(&self) -> HyperliquidEnvironment {
318        self.environment
319    }
320
321    /// Returns whether this client is configured for testnet.
322    #[must_use]
323    pub fn is_testnet(&self) -> bool {
324        self.environment == HyperliquidEnvironment::Testnet
325    }
326
327    /// Gets the user address derived from the private key (if client has credentials).
328    ///
329    /// # Errors
330    ///
331    /// Returns [`Error::Auth`] if the client has no signer configured.
332    pub fn get_user_address(&self) -> Result<String> {
333        self.signer
334            .as_ref()
335            .ok_or_else(|| Error::auth("No signer configured"))?
336            .address()
337    }
338
339    /// Returns `true` if a vault address is configured.
340    #[must_use]
341    pub fn has_vault_address(&self) -> bool {
342        self.vault_address.is_some()
343    }
344
345    /// Gets the account address for queries: vault address if configured,
346    /// otherwise the user (EOA) address.
347    ///
348    /// # Errors
349    ///
350    /// Returns [`Error::Auth`] if the client has no signer configured.
351    pub fn get_account_address(&self) -> Result<String> {
352        if let Some(vault) = &self.vault_address {
353            Ok(vault.to_hex())
354        } else {
355            self.get_user_address()
356        }
357    }
358
359    fn default_headers() -> HashMap<String, String> {
360        HashMap::from([
361            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
362            ("Content-Type".to_string(), "application/json".to_string()),
363        ])
364    }
365
366    fn signer_id(&self) -> SignerId {
367        SignerId("hyperliquid:default".into())
368    }
369
370    fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
371        let retry_after = headers.get("retry-after")?;
372        retry_after.parse::<u64>().ok().map(|s| s * 1000) // convert seconds to ms
373    }
374
375    /// Get metadata about available markets.
376    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
377        let request = InfoRequest::meta();
378        let response = self.send_info_request(&request).await?;
379        serde_json::from_value(response).map_err(Error::Serde)
380    }
381
382    /// Get complete spot metadata (tokens and pairs).
383    pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
384        let request = InfoRequest::spot_meta();
385        let response = self.send_info_request(&request).await?;
386        serde_json::from_value(response).map_err(Error::Serde)
387    }
388
389    /// Get perpetuals metadata with asset contexts (for price precision refinement).
390    pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
391        let request = InfoRequest::meta_and_asset_ctxs();
392        let response = self.send_info_request(&request).await?;
393        serde_json::from_value(response).map_err(Error::Serde)
394    }
395
396    /// Get spot metadata with asset contexts (for price precision refinement).
397    pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
398        let request = InfoRequest::spot_meta_and_asset_ctxs();
399        let response = self.send_info_request(&request).await?;
400        serde_json::from_value(response).map_err(Error::Serde)
401    }
402
403    /// Get outcome metadata.
404    pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
405        let request = InfoRequest::outcome_meta();
406        let response = self.send_info_request(&request).await?;
407        serde_json::from_value(response).map_err(Error::Serde)
408    }
409
410    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
411        let request = InfoRequest::meta();
412        let response = self.send_info_request(&request).await?;
413        serde_json::from_value(response).map_err(Error::Serde)
414    }
415
416    /// Get metadata for all perp dexes (standard + HIP-3).
417    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
418        let request = InfoRequest::all_perp_metas();
419        let response = self.send_info_request(&request).await?;
420        serde_json::from_value(response).map_err(Error::Serde)
421    }
422
423    /// Get the list of perp dex names aligned by dex index.
424    pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
425        let request = InfoRequest::perp_dexs();
426        let response = self.send_info_request(&request).await?;
427        serde_json::from_value(response).map_err(Error::Serde)
428    }
429
430    /// Get L2 order book for a coin.
431    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
432        let request = InfoRequest::l2_book(coin);
433        let response = self.send_info_request(&request).await?;
434        serde_json::from_value(response).map_err(Error::Serde)
435    }
436
437    /// Get recent public trades for a coin.
438    ///
439    /// Returns a recent snapshot (newest first) with no time range. Depends on the
440    /// Hyperliquid indexer: self-hosted `/info` nodes return HTTP 422.
441    pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
442        let request = InfoRequest::recent_trades(coin);
443        let response = self.send_info_request(&request).await?;
444        serde_json::from_value(response).map_err(Error::Serde)
445    }
446
447    /// Get user fills (trading history).
448    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
449        let request = InfoRequest::user_fills(user);
450        let response = self.send_info_request(&request).await?;
451        serde_json::from_value(response).map_err(Error::Serde)
452    }
453
454    /// Get order status for a user.
455    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
456        let request = InfoRequest::order_status(user, oid);
457        let response = self.send_info_request(&request).await?;
458        serde_json::from_value(response).map_err(Error::Serde)
459    }
460
461    /// Get all open orders for a user.
462    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
463        let request = InfoRequest::open_orders(user);
464        self.send_info_request(&request).await
465    }
466
467    /// Get frontend open orders (includes more detail) for a user.
468    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
469        self.info_frontend_open_orders_for_dex(user, None).await
470    }
471
472    async fn info_frontend_open_orders_for_dex(
473        &self,
474        user: &str,
475        dex: Option<&str>,
476    ) -> Result<Value> {
477        let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
478        self.send_info_request(&request).await
479    }
480
481    /// Get the most recent historical orders for a user.
482    pub async fn info_historical_orders(
483        &self,
484        user: &str,
485    ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
486        let request = InfoRequest::historical_orders(user);
487        let response = self.send_info_request(&request).await?;
488        serde_json::from_value(response).map_err(Error::Serde)
489    }
490
491    /// Get clearinghouse state (balances, positions, margin) for a user.
492    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
493        self.info_clearinghouse_state_for_dex(user, None).await
494    }
495
496    async fn info_clearinghouse_state_for_dex(
497        &self,
498        user: &str,
499        dex: Option<&str>,
500    ) -> Result<Value> {
501        let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
502        self.send_info_request(&request).await
503    }
504
505    /// Get spot clearinghouse state (per-token spot balances) for a user.
506    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
507        let request = InfoRequest::spot_clearinghouse_state(user);
508        self.send_info_request(&request).await
509    }
510
511    /// Get user fee schedule and effective rates.
512    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
513        let request = InfoRequest::user_fees(user);
514        self.send_info_request(&request).await
515    }
516
517    /// Get candle/bar data for a coin.
518    pub async fn info_candle_snapshot(
519        &self,
520        coin: &str,
521        interval: HyperliquidBarInterval,
522        start_time: u64,
523        end_time: u64,
524    ) -> Result<HyperliquidCandleSnapshot> {
525        let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
526        let response = self.send_info_request(&request).await?;
527
528        log::trace!(
529            "Candle snapshot raw response (len={}): {:?}",
530            response.as_array().map_or(0, |a| a.len()),
531            response
532        );
533
534        serde_json::from_value(response).map_err(Error::Serde)
535    }
536
537    /// Get historical funding rates for a coin.
538    ///
539    /// `start_time` and `end_time` are Unix milliseconds. `end_time` is optional;
540    /// if omitted, the venue returns entries up to the most recent funding.
541    pub async fn info_funding_history(
542        &self,
543        coin: &str,
544        start_time: u64,
545        end_time: Option<u64>,
546    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
547        let request = InfoRequest::funding_history(coin, start_time, end_time);
548        let response = self.send_info_request(&request).await?;
549        serde_json::from_value(response).map_err(Error::Serde)
550    }
551
552    /// Generic info request method that returns raw JSON (useful for new endpoints and testing).
553    pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
554        self.send_info_request(request).await
555    }
556
557    async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
558        let base_w = info_base_weight(request);
559        self.rest_limiter.acquire(base_w).await;
560
561        let mut attempt = 0u32;
562
563        loop {
564            let response = self.http_roundtrip_info(request).await?;
565
566            if response.status.is_success() {
567                // decode once to count items, then materialize T
568                let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
569                let extra = info_extra_weight(request, &val);
570                if extra > 0 {
571                    self.rest_limiter.debit_extra(extra).await;
572                    log::debug!(
573                        "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
574                    );
575                }
576                return Ok(val);
577            }
578
579            // 429 → respect Retry-After; else jittered backoff. Retry Info only.
580            if response.status.as_u16() == 429 {
581                if attempt >= self.rate_limit_max_attempts_info {
582                    let ra = self.parse_retry_after_simple(&response.headers);
583                    return Err(Error::rate_limit("info", base_w, ra));
584                }
585                let delay = self
586                    .parse_retry_after_simple(&response.headers)
587                    .map_or_else(
588                        || {
589                            backoff_full_jitter(
590                                attempt,
591                                self.rate_limit_backoff_base,
592                                self.rate_limit_backoff_cap,
593                            )
594                        },
595                        Duration::from_millis,
596                    );
597                log::warn!(
598                    "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
599                    delay.as_millis()
600                );
601                attempt += 1;
602                tokio::time::sleep(delay).await;
603                // tiny re-acquire to avoid stampede exactly on minute boundary
604                self.rest_limiter.acquire(1).await;
605                continue;
606            }
607
608            // transient 5xx: treat like retryable Info (bounded)
609            if (response.status.is_server_error() || response.status.as_u16() == 408)
610                && attempt < self.rate_limit_max_attempts_info
611            {
612                let delay = backoff_full_jitter(
613                    attempt,
614                    self.rate_limit_backoff_base,
615                    self.rate_limit_backoff_cap,
616                );
617                log::warn!(
618                    "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
619                    response.status.as_u16(),
620                    delay.as_millis()
621                );
622                attempt += 1;
623                tokio::time::sleep(delay).await;
624                continue;
625            }
626
627            // non-retryable or exhausted
628            let error_body = String::from_utf8_lossy(&response.body);
629            return Err(Error::http(
630                response.status.as_u16(),
631                error_body.to_string(),
632            ));
633        }
634    }
635
636    async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
637        let url = &self.base_info;
638        let body = serde_json::to_value(request).map_err(Error::Serde)?;
639        let body_bytes = serde_json::to_string(&body)
640            .map_err(Error::Serde)?
641            .into_bytes();
642
643        self.client
644            .request(
645                Method::POST,
646                url.clone(),
647                None,
648                None,
649                Some(body_bytes),
650                None,
651                None,
652            )
653            .await
654            .map_err(Error::from_http_client)
655    }
656
657    /// Send a signed action to the exchange.
658    pub async fn post_action(
659        &self,
660        action: &ExchangeAction,
661    ) -> Result<HyperliquidExchangeResponse> {
662        let w = exchange_weight(action);
663        self.rest_limiter.acquire(w).await;
664
665        let signer = self
666            .signer
667            .as_ref()
668            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
669
670        let nonce_manager = self
671            .nonce_manager
672            .as_ref()
673            .ok_or_else(|| Error::auth("nonce manager missing"))?;
674
675        let signer_id = self.signer_id();
676        let time_nonce = nonce_manager.next(signer_id)?;
677
678        // L1 signing uses `action_bytes` only; skip the JSON value to save work
679        let action_bytes = rmp_serde::to_vec_named(action)
680            .context("serialize action with MessagePack")
681            .map_err(|e| Error::bad_request(e.to_string()))?;
682
683        let sign_request = SignRequest {
684            action: None,
685            action_bytes: Some(action_bytes),
686            time_nonce,
687            action_type: HyperliquidActionType::L1,
688            is_testnet: self.is_testnet(),
689            vault_address: self.vault_address,
690            expires_after: None,
691        };
692
693        let sig = signer.sign(&sign_request)?.signature;
694
695        let nonce_u64 = time_nonce.as_millis() as u64;
696
697        let request = if let Some(vault) = self.vault_address {
698            HyperliquidExchangeRequest::with_vault(
699                action.clone(),
700                nonce_u64,
701                sig,
702                vault.to_string(),
703            )
704        } else {
705            HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
706        };
707
708        let response = self.http_roundtrip_exchange(&request).await?;
709
710        if response.status.is_success() {
711            let parsed_response: HyperliquidExchangeResponse =
712                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
713
714            // Check if the response contains an error status
715            match &parsed_response {
716                HyperliquidExchangeResponse::Status {
717                    status,
718                    response: response_data,
719                } if status == "err" => {
720                    let error_msg = response_data
721                        .as_str()
722                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
723                    log::error!("Hyperliquid API returned error: {error_msg}");
724                    Err(Error::bad_request(format!("API error: {error_msg}")))
725                }
726                HyperliquidExchangeResponse::Error { error } => {
727                    log::error!("Hyperliquid API returned error: {error}");
728                    Err(Error::bad_request(format!("API error: {error}")))
729                }
730                _ => Ok(parsed_response),
731            }
732        } else if response.status.as_u16() == 429 {
733            let ra = self.parse_retry_after_simple(&response.headers);
734            Err(Error::rate_limit("exchange", w, ra))
735        } else {
736            let error_body = String::from_utf8_lossy(&response.body);
737            log::error!(
738                "Exchange API error (status {}): {}",
739                response.status.as_u16(),
740                error_body
741            );
742            Err(Error::http(
743                response.status.as_u16(),
744                error_body.to_string(),
745            ))
746        }
747    }
748
749    /// Build a signed exchange request using the typed HyperliquidExchangeAction enum.
750    pub fn sign_action_exec_request(
751        &self,
752        action: &HyperliquidExchangeAction,
753        expires_after: Option<u64>,
754    ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
755        let signer = self
756            .signer
757            .as_ref()
758            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
759
760        let nonce_manager = self
761            .nonce_manager
762            .as_ref()
763            .ok_or_else(|| Error::auth("nonce manager missing"))?;
764
765        let signer_id = self.signer_id();
766        let time_nonce = nonce_manager.next(signer_id)?;
767        // No need to validate - next() guarantees a valid, unused nonce
768
769        // L1 signing uses `action_bytes` only; skip the JSON value to save work
770        let action_bytes = rmp_serde::to_vec_named(action)
771            .context("serialize action with MessagePack")
772            .map_err(|e| Error::bad_request(e.to_string()))?;
773
774        let sig = signer
775            .sign(&SignRequest {
776                action: None,
777                action_bytes: Some(action_bytes),
778                time_nonce,
779                action_type: HyperliquidActionType::L1,
780                is_testnet: self.is_testnet(),
781                vault_address: self.vault_address,
782                expires_after,
783            })?
784            .signature;
785
786        let mut request = if let Some(vault) = self.vault_address {
787            HyperliquidExchangeRequest::with_vault(
788                action.clone(),
789                time_nonce.as_millis() as u64,
790                sig,
791                vault.to_string(),
792            )
793        } else {
794            HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
795        };
796        request.expires_after = expires_after;
797        Ok(request)
798    }
799
800    /// Send a signed action to the exchange using the typed HyperliquidExchangeAction enum.
801    ///
802    /// This is the preferred method for placing orders as it uses properly typed
803    /// structures that match Hyperliquid's API expectations exactly.
804    pub async fn post_action_exec(
805        &self,
806        action: &HyperliquidExchangeAction,
807    ) -> Result<HyperliquidExchangeResponse> {
808        let w = exec_action_weight(action);
809        self.rest_limiter.acquire(w).await;
810
811        let request = self.sign_action_exec_request(action, None)?;
812
813        let response = self.http_roundtrip_exchange(&request).await?;
814
815        if response.status.is_success() {
816            let parsed_response: HyperliquidExchangeResponse =
817                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
818
819            // Check if the response contains an error status
820            match &parsed_response {
821                HyperliquidExchangeResponse::Status {
822                    status,
823                    response: response_data,
824                } if status == "err" => {
825                    let error_msg = response_data
826                        .as_str()
827                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
828                    log::error!("Hyperliquid API returned error: {error_msg}");
829                    Err(Error::bad_request(format!("API error: {error_msg}")))
830                }
831                HyperliquidExchangeResponse::Error { error } => {
832                    log::error!("Hyperliquid API returned error: {error}");
833                    Err(Error::bad_request(format!("API error: {error}")))
834                }
835                _ => Ok(parsed_response),
836            }
837        } else if response.status.as_u16() == 429 {
838            let ra = self.parse_retry_after_simple(&response.headers);
839            Err(Error::rate_limit("exchange", w, ra))
840        } else {
841            let error_body = String::from_utf8_lossy(&response.body);
842            Err(Error::http(
843                response.status.as_u16(),
844                error_body.to_string(),
845            ))
846        }
847    }
848
849    /// Submit a single order to the Hyperliquid exchange.
850    pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
851        self.rest_limiter.snapshot().await
852    }
853    async fn http_roundtrip_exchange<T>(
854        &self,
855        request: &HyperliquidExchangeRequest<T>,
856    ) -> Result<HttpResponse>
857    where
858        T: serde::Serialize,
859    {
860        let url = &self.base_exchange;
861        let body = serde_json::to_string(&request).map_err(Error::Serde)?;
862        let body_bytes = body.into_bytes();
863
864        let response = self
865            .client
866            .request(
867                Method::POST,
868                url.clone(),
869                None,
870                None,
871                Some(body_bytes),
872                None,
873                None,
874            )
875            .await
876            .map_err(Error::from_http_client)?;
877
878        Ok(response)
879    }
880}
881
882/// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
883///
884/// This domain client wraps [`HyperliquidRawHttpClient`] and provides methods that work
885/// with Nautilus domain types. It maintains an instrument cache and handles conversions
886/// between Hyperliquid API responses and Nautilus domain models.
887#[derive(Debug, Clone)]
888#[cfg_attr(
889    feature = "python",
890    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
891)]
892#[cfg_attr(
893    feature = "python",
894    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
895)]
896pub struct HyperliquidHttpClient {
897    pub(crate) inner: Arc<HyperliquidRawHttpClient>,
898    clock: &'static AtomicTime,
899    instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
900    instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
901    /// Mapping from symbol to asset index for order submission.
902    asset_indices: Arc<AtomicMap<Ustr, u32>>,
903    /// Mapping from spot fill coin (`@{pair_index}`) to instrument symbol.
904    spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
905    client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
906    account_id: Option<AccountId>,
907    /// Optional override address for queries (agent wallet / API sub-key support).
908    /// When set, used for balance queries, position reports, and WS subscriptions
909    /// instead of the address derived from the private key.
910    account_address: Option<String>,
911    normalize_prices: bool,
912    market_order_slippage_bps: u32,
913    include_builder_attribution: bool,
914}
915
916impl Default for HyperliquidHttpClient {
917    fn default() -> Self {
918        Self::new(HyperliquidEnvironment::Mainnet, 60, None)
919            .expect("Failed to create default Hyperliquid HTTP client")
920    }
921}
922
923impl HyperliquidHttpClient {
924    /// Creates a new [`HyperliquidHttpClient`] for public endpoints only.
925    ///
926    /// # Errors
927    ///
928    /// Returns an error if the HTTP client cannot be created.
929    pub fn new(
930        environment: HyperliquidEnvironment,
931        timeout_secs: u64,
932        proxy_url: Option<String>,
933    ) -> std::result::Result<Self, HttpClientError> {
934        let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
935        Ok(Self::from_raw(raw_client))
936    }
937
938    /// Creates a new [`HyperliquidHttpClient`] configured with a [`Secrets`] struct.
939    ///
940    /// # Errors
941    ///
942    /// Returns an error if the HTTP client cannot be created.
943    pub fn with_secrets(
944        secrets: &Secrets,
945        timeout_secs: u64,
946        proxy_url: Option<String>,
947    ) -> std::result::Result<Self, HttpClientError> {
948        let raw_client =
949            HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
950        Ok(Self::from_raw(raw_client))
951    }
952
953    fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
954        Self {
955            inner: Arc::new(raw_client),
956            clock: get_atomic_clock_realtime(),
957            instruments: Arc::new(AtomicMap::new()),
958            instruments_by_coin: Arc::new(AtomicMap::new()),
959            asset_indices: Arc::new(AtomicMap::new()),
960            spot_fill_coins: Arc::new(AtomicMap::new()),
961            client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
962            account_id: None,
963            account_address: None,
964            normalize_prices: true,
965            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
966            include_builder_attribution: true,
967        }
968    }
969
970    /// Returns the cached CLOID for a client order ID, or derives and caches it.
971    #[must_use]
972    pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
973        let mut cloids = self.client_order_id_cloids.lock();
974        *cloids
975            .entry(client_order_id)
976            .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
977    }
978
979    /// Caches a CLOID for a client order ID if one is not already cached.
980    pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
981        self.client_order_id_cloids
982            .lock()
983            .entry(client_order_id)
984            .or_insert(cloid);
985    }
986
987    /// Returns the cached CLOID for a client order ID.
988    #[must_use]
989    pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
990        self.client_order_id_cloids
991            .lock()
992            .get(client_order_id)
993            .copied()
994    }
995
996    /// Returns the cached CLOID for a client order ID when no other client
997    /// order ID maps to the same CLOID.
998    #[must_use]
999    pub(crate) fn unique_cached_client_order_id_cloid(
1000        &self,
1001        client_order_id: &ClientOrderId,
1002    ) -> Option<Cloid> {
1003        let cloids = self.client_order_id_cloids.lock();
1004        let cloid = cloids.get(client_order_id).copied()?;
1005        let mapping_count = cloids
1006            .values()
1007            .filter(|cached_cloid| **cached_cloid == cloid)
1008            .count();
1009
1010        (mapping_count == 1).then_some(cloid)
1011    }
1012
1013    /// Removes the cached CLOID for a client order ID.
1014    pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1015        self.client_order_id_cloids.lock().remove(client_order_id)
1016    }
1017
1018    /// Overrides the base info URL (for testing with mock servers).
1019    ///
1020    /// # Panics
1021    ///
1022    /// Panics if the inner `Arc` has multiple references.
1023    pub fn set_base_info_url(&mut self, url: String) {
1024        Arc::get_mut(&mut self.inner)
1025            .expect("cannot override URL: Arc has multiple references")
1026            .set_base_info_url(url);
1027    }
1028
1029    /// Overrides the base exchange URL (for testing with mock servers).
1030    ///
1031    /// # Panics
1032    ///
1033    /// Panics if the inner `Arc` has multiple references.
1034    pub fn set_base_exchange_url(&mut self, url: String) {
1035        Arc::get_mut(&mut self.inner)
1036            .expect("cannot override URL: Arc has multiple references")
1037            .set_base_exchange_url(url);
1038    }
1039
1040    /// Creates an authenticated client from environment variables for the specified network.
1041    ///
1042    /// # Errors
1043    ///
1044    /// Returns [`Error::Auth`] if required environment variables are not set.
1045    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1046        let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1047        Ok(Self {
1048            inner: Arc::new(raw_client),
1049            clock: get_atomic_clock_realtime(),
1050            instruments: Arc::new(AtomicMap::new()),
1051            instruments_by_coin: Arc::new(AtomicMap::new()),
1052            asset_indices: Arc::new(AtomicMap::new()),
1053            spot_fill_coins: Arc::new(AtomicMap::new()),
1054            client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1055            account_id: None,
1056            account_address: None,
1057            normalize_prices: true,
1058            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1059            include_builder_attribution: true,
1060        })
1061    }
1062
1063    /// Creates a new [`HyperliquidHttpClient`] configured with credentials.
1064    ///
1065    /// If credentials are not provided, falls back to environment variables:
1066    /// - Testnet: `HYPERLIQUID_TESTNET_PK`, `HYPERLIQUID_TESTNET_VAULT`
1067    /// - Mainnet: `HYPERLIQUID_PK`, `HYPERLIQUID_VAULT`
1068    ///
1069    /// If no credentials are provided and no environment variables are set,
1070    /// creates an unauthenticated client for public endpoints only.
1071    ///
1072    /// # Errors
1073    ///
1074    /// Returns [`Error::Auth`] if credentials are invalid.
1075    pub fn with_credentials(
1076        private_key: Option<String>,
1077        vault_address: Option<String>,
1078        account_address: Option<&str>,
1079        environment: HyperliquidEnvironment,
1080        timeout_secs: u64,
1081        proxy_url: Option<String>,
1082    ) -> Result<Self> {
1083        let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1084
1085        let resolved_account_address = resolve_execution_account_address(
1086            private_key.as_deref(),
1087            vault_address.as_deref(),
1088            account_address,
1089            environment,
1090        )?;
1091
1092        // Resolve private key: explicit value -> env var -> None (unauthenticated)
1093        let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1094
1095        // Resolve vault address: explicit value -> env var -> None
1096        let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1097
1098        Self::from_resolved_credentials(
1099            resolved_pk,
1100            resolved_vault.as_deref(),
1101            resolved_account_address,
1102            environment,
1103            timeout_secs,
1104            proxy_url,
1105        )
1106    }
1107
1108    fn from_resolved_credentials(
1109        private_key: Option<String>,
1110        vault_address: Option<&str>,
1111        account_address: Option<String>,
1112        environment: HyperliquidEnvironment,
1113        timeout_secs: u64,
1114        proxy_url: Option<String>,
1115    ) -> Result<Self> {
1116        match private_key {
1117            Some(pk) => {
1118                let raw_client = HyperliquidRawHttpClient::from_credentials(
1119                    &pk,
1120                    vault_address,
1121                    environment,
1122                    timeout_secs,
1123                    proxy_url,
1124                )?;
1125                Ok(Self {
1126                    inner: Arc::new(raw_client),
1127                    clock: get_atomic_clock_realtime(),
1128                    instruments: Arc::new(AtomicMap::new()),
1129                    instruments_by_coin: Arc::new(AtomicMap::new()),
1130                    asset_indices: Arc::new(AtomicMap::new()),
1131                    spot_fill_coins: Arc::new(AtomicMap::new()),
1132                    client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1133                    account_id: None,
1134                    account_address,
1135                    normalize_prices: true,
1136                    market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1137                    include_builder_attribution: true,
1138                })
1139            }
1140            None => {
1141                // No credentials available, create unauthenticated client
1142                let mut client = Self::new(environment, timeout_secs, proxy_url)
1143                    .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1144                client.set_account_address(account_address);
1145                Ok(client)
1146            }
1147        }
1148    }
1149
1150    /// Creates a new [`HyperliquidHttpClient`] configured with explicit credentials.
1151    ///
1152    /// # Errors
1153    ///
1154    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
1155    pub fn from_credentials(
1156        private_key: &str,
1157        vault_address: Option<&str>,
1158        environment: HyperliquidEnvironment,
1159        timeout_secs: u64,
1160        proxy_url: Option<String>,
1161    ) -> Result<Self> {
1162        let raw_client = HyperliquidRawHttpClient::from_credentials(
1163            private_key,
1164            vault_address,
1165            environment,
1166            timeout_secs,
1167            proxy_url,
1168        )?;
1169        Ok(Self {
1170            inner: Arc::new(raw_client),
1171            clock: get_atomic_clock_realtime(),
1172            instruments: Arc::new(AtomicMap::new()),
1173            instruments_by_coin: Arc::new(AtomicMap::new()),
1174            asset_indices: Arc::new(AtomicMap::new()),
1175            spot_fill_coins: Arc::new(AtomicMap::new()),
1176            client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1177            account_id: None,
1178            account_address: None,
1179            normalize_prices: true,
1180            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1181            include_builder_attribution: true,
1182        })
1183    }
1184
1185    /// Returns whether this client is configured for testnet.
1186    #[must_use]
1187    pub fn is_testnet(&self) -> bool {
1188        self.inner.is_testnet()
1189    }
1190
1191    /// Returns whether order price normalization is enabled.
1192    #[must_use]
1193    pub fn normalize_prices(&self) -> bool {
1194        self.normalize_prices
1195    }
1196
1197    /// Sets whether to normalize order prices to 5 significant figures.
1198    pub fn set_normalize_prices(&mut self, value: bool) {
1199        self.normalize_prices = value;
1200    }
1201
1202    /// Returns the MARKET-order slippage buffer in basis points.
1203    #[must_use]
1204    pub fn market_order_slippage_bps(&self) -> u32 {
1205        self.market_order_slippage_bps
1206    }
1207
1208    /// Sets the MARKET-order slippage buffer in basis points.
1209    pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1210        self.market_order_slippage_bps = value;
1211    }
1212
1213    /// Returns whether eligible mainnet orders include builder attribution.
1214    #[must_use]
1215    pub fn include_builder_attribution(&self) -> bool {
1216        self.include_builder_attribution
1217    }
1218
1219    /// Sets whether eligible mainnet orders include builder attribution.
1220    pub fn set_include_builder_attribution(&mut self, value: bool) {
1221        self.include_builder_attribution = value;
1222    }
1223
1224    /// Gets the user address derived from the private key (if client has credentials).
1225    ///
1226    /// # Errors
1227    ///
1228    /// Returns [`Error::Auth`] if the client has no signer configured.
1229    pub fn get_user_address(&self) -> Result<String> {
1230        self.inner.get_user_address()
1231    }
1232
1233    /// Returns `true` if a vault address is configured.
1234    #[must_use]
1235    pub fn has_vault_address(&self) -> bool {
1236        self.inner.has_vault_address()
1237    }
1238
1239    /// Returns the builder-attribution fee to attach to outgoing orders.
1240    ///
1241    /// Returns `None` when attribution is disabled, or when Hyperliquid does
1242    /// not support it for the current request context (vault orders and testnet).
1243    #[must_use]
1244    pub fn builder_attribution(&self) -> Option<HyperliquidExchangeBuilderFee> {
1245        if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1246            None
1247        } else {
1248            Some(HyperliquidExchangeBuilderFee {
1249                address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1250                fee_tenths_bp: 0,
1251            })
1252        }
1253    }
1254
1255    /// Gets the account address for queries: account_address if configured
1256    /// (agent wallet), then vault address, otherwise the user (EOA) address.
1257    ///
1258    /// # Errors
1259    ///
1260    /// Returns [`Error::Auth`] if the client has no signer configured and
1261    /// no account_address override is set.
1262    pub fn get_account_address(&self) -> Result<String> {
1263        if let Some(addr) = &self.account_address {
1264            return Ok(addr.clone());
1265        }
1266        self.inner.get_account_address()
1267    }
1268
1269    /// Sets the account address override for queries (agent wallet support).
1270    pub fn set_account_address(&mut self, address: Option<String>) {
1271        self.account_address = address;
1272    }
1273
1274    /// Caches a single instrument.
1275    ///
1276    /// This is required for parsing orders, fills, and positions into reports.
1277    /// Any existing instrument with the same symbol will be replaced.
1278    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1279        let full_symbol = instrument.symbol().inner();
1280        let coin = instrument.raw_symbol().inner();
1281
1282        self.instruments.rcu(|m| {
1283            m.insert(full_symbol, instrument.clone());
1284            // HTTP responses only include coins, external code may lookup by coin
1285            m.insert(coin, instrument.clone());
1286        });
1287
1288        // Composite key allows disambiguating same coin across PERP and SPOT
1289        if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1290            self.instruments_by_coin.rcu(|m| {
1291                m.insert((coin, product_type), instrument.clone());
1292
1293                // Secondary alias key for two distinct callers:
1294                //
1295                // * Spot raw_symbols are either `@{pair_index}` or slash format
1296                //   (e.g., "PURR/USDC"); spot balance/position reconciliation
1297                //   maps the venue token name (e.g., "PURR") to instruments via
1298                //   this alias.
1299                // * Order submission paths split `instrument_id.symbol` on `-`
1300                //   to derive a coin key. For HIP-3 perps with wildcard-bearing
1301                //   venue names, the sanitized base in `instrument_id.symbol`
1302                //   (e.g., "dex:STREAMABCDxxxx") differs from `raw_symbol` /
1303                //   `coin` (e.g., "dex:STREAMABCD****"), so an alias on the
1304                //   sanitized base lets that lookup resolve.
1305                //
1306                // For outcomes the alias is the `+<encoding>` token form
1307                // (matching the `coin` field on `spotClearinghouseState`);
1308                // for perps / spots it is the leading symbol segment.
1309                // `cache_alias_for_symbol` keeps the two rules co-located so
1310                // every caller derives the same key.
1311                //
1312                // First-write-wins guards against non-canonical spot pairs that
1313                // share a base token overwriting the canonical instrument; the
1314                // spot loader sorts canonical pairs first so the alias resolves
1315                // to the canonical one. For standard perps `base == coin`, so
1316                // the alias is a no-op.
1317                if let Some(alias_ustr) = cache_alias_for_symbol(full_symbol.as_str())
1318                    .map(|alias| Ustr::from(alias.as_str()))
1319                {
1320                    let key = (alias_ustr, product_type);
1321                    if alias_ustr != coin && !m.contains_key(&key) {
1322                        m.insert(key, instrument.clone());
1323                    }
1324                }
1325            });
1326        } else {
1327            log::warn!("Unable to determine product type for symbol: {full_symbol}");
1328        }
1329    }
1330
1331    fn get_or_create_instrument(
1332        &self,
1333        coin: &Ustr,
1334        product_type: Option<HyperliquidProductType>,
1335    ) -> Option<InstrumentAny> {
1336        if let Some(pt) = product_type
1337            && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1338        {
1339            return Some(instrument.clone());
1340        }
1341
1342        // HTTP responses lack product type context. HIP-4 outcome coins
1343        // (`#E`/`+E`) are checked first because they never collide with
1344        // perp or spot symbols, then perp, then spot.
1345        if product_type.is_none() {
1346            let guard = self.instruments_by_coin.load();
1347
1348            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1349                return Some(instrument.clone());
1350            }
1351
1352            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1353                return Some(instrument.clone());
1354            }
1355
1356            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1357                return Some(instrument.clone());
1358            }
1359        }
1360
1361        // Spot fills use @{pair_index} format, translate to full symbol and look up
1362        if coin.as_str().starts_with('@')
1363            && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1364        {
1365            // Look up by full symbol in instruments map (not instruments_by_coin
1366            // which uses raw_symbol)
1367            if let Some(instrument) = self.instruments.load().get(symbol) {
1368                return Some(instrument.clone());
1369            }
1370        }
1371
1372        // Vault tokens aren't in standard API, create synthetic instruments
1373        if coin.as_str().starts_with(VAULT_TOKEN_PREFIX) {
1374            log::debug!("Creating synthetic instrument for vault token: {coin}");
1375
1376            let ts_event = self.clock.get_time_ns();
1377
1378            // Create synthetic vault token instrument
1379            let symbol_str = format!("{coin}-USDC-SPOT");
1380            let symbol = Symbol::new(&symbol_str);
1381            let venue = *HYPERLIQUID_VENUE;
1382            let instrument_id = InstrumentId::new(symbol, venue);
1383
1384            // Create currencies
1385            let base_currency = Currency::new(
1386                coin.as_str(),
1387                8, // precision
1388                0, // ISO code (not applicable)
1389                coin.as_str(),
1390                CurrencyType::Crypto,
1391            );
1392
1393            let quote_currency = Currency::new(
1394                "USDC",
1395                6, // USDC standard precision
1396                0,
1397                "USDC",
1398                CurrencyType::Crypto,
1399            );
1400
1401            let price_increment = Price::from("0.00000001");
1402            let size_increment = Quantity::from("0.00000001");
1403
1404            let instrument = InstrumentAny::CurrencyPair(
1405                CurrencyPair::builder()
1406                    .instrument_id(instrument_id)
1407                    .raw_symbol(symbol)
1408                    .base_currency(base_currency)
1409                    .quote_currency(quote_currency)
1410                    .price_precision(8)
1411                    .size_precision(8)
1412                    .price_increment(price_increment)
1413                    .size_increment(size_increment)
1414                    .ts_event(ts_event)
1415                    .ts_init(ts_event)
1416                    .build()
1417                    .unwrap(),
1418            );
1419
1420            self.cache_instrument(&instrument);
1421
1422            Some(instrument)
1423        } else {
1424            // For non-vault tokens, log warning and return None
1425            log::warn!("Instrument not found in cache: {coin}");
1426            None
1427        }
1428    }
1429
1430    /// Set the account ID for this client.
1431    ///
1432    /// This is required for generating reports with the correct account ID.
1433    pub fn set_account_id(&mut self, account_id: AccountId) {
1434        self.account_id = Some(account_id);
1435    }
1436
1437    /// Fetch and parse all instrument definitions, populating the asset indices cache.
1438    pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1439        let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1440        let spot_meta = match self.inner.get_spot_meta().await {
1441            Ok(spot_meta) => Some(spot_meta),
1442            Err(e) => {
1443                log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1444                None
1445            }
1446        };
1447
1448        // Load all perp dexes: index 0 = standard, index 1+ = HIP-3
1449        match self.inner.load_all_perp_metas().await {
1450            Ok(all_metas) => {
1451                for (dex_index, meta) in all_metas.iter().enumerate() {
1452                    let base = perp_dex_asset_index_base(dex_index);
1453                    let settlement_currency = match resolve_perp_settlement_currency(
1454                        meta,
1455                        spot_meta.as_ref(),
1456                    ) {
1457                        Ok(settlement_currency) => settlement_currency,
1458                        Err(e) => {
1459                            return Err(Error::decode(format!(
1460                                "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1461                            )));
1462                        }
1463                    };
1464
1465                    let perp_defs = parse_perp_instruments_with_settlement(
1466                        meta,
1467                        base,
1468                        settlement_currency.as_str(),
1469                    );
1470                    log::debug!(
1471                        "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1472                        perp_defs.len(),
1473                    );
1474                    defs.extend(perp_defs);
1475                }
1476            }
1477            Err(e) => {
1478                log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1479
1480                match self.inner.load_perp_meta().await {
1481                    Ok(perp_meta) => {
1482                        match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1483                            Ok(settlement_currency) => {
1484                                let perp_defs = parse_perp_instruments_with_settlement(
1485                                    &perp_meta,
1486                                    0,
1487                                    settlement_currency.as_str(),
1488                                );
1489                                log::debug!(
1490                                    "Loaded Hyperliquid perp defs via fallback: count={}",
1491                                    perp_defs.len(),
1492                                );
1493                                defs.extend(perp_defs);
1494                            }
1495                            Err(e) => {
1496                                return Err(Error::decode(format!(
1497                                    "failed to resolve fallback perp settlement currency: {e}",
1498                                )));
1499                            }
1500                        }
1501                    }
1502                    Err(e) => {
1503                        log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1504                    }
1505                }
1506            }
1507        }
1508
1509        if let Some(spot_meta) = spot_meta.as_ref() {
1510            match parse_spot_instruments(spot_meta) {
1511                Ok(spot_defs) => {
1512                    log::debug!(
1513                        "Loaded Hyperliquid spot definitions: count={}",
1514                        spot_defs.len(),
1515                    );
1516                    defs.extend(spot_defs);
1517                }
1518                Err(e) => {
1519                    log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1520                }
1521            }
1522        }
1523
1524        // HIP-4 outcome metadata is best-effort: the venue may not expose it
1525        // and the response shape is still firming up. Treat any error as a
1526        // soft skip so missing outcomes do not break perp/spot loading.
1527        match self.inner.get_outcome_meta().await {
1528            Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1529                Ok(outcome_defs) => {
1530                    log::debug!(
1531                        "Loaded Hyperliquid outcome definitions: count={}",
1532                        outcome_defs.len(),
1533                    );
1534                    defs.extend(outcome_defs);
1535                }
1536                Err(e) => {
1537                    log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1538                }
1539            },
1540            Err(e) => {
1541                log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1542            }
1543        }
1544
1545        // Drop defs whose Nautilus-internal symbol collides with one already
1546        // accepted. This guards the HIP-3 case where two distinct venue names
1547        // (e.g. `dex:FOO*` and `dex:FOO?`) sanitize onto the same internal
1548        // symbol; without this filter the second def would silently overwrite
1549        // the first in `asset_indices`, which would route orders to the wrong
1550        // asset. First-write-wins matches the spot canonical-pair ordering.
1551        let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1552        let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1553        for def in defs {
1554            if seen_symbols.insert(def.symbol) {
1555                deduped.push(def);
1556            } else {
1557                log::warn!(
1558                    "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1559                    def.symbol,
1560                    def.raw_symbol,
1561                );
1562            }
1563        }
1564        let defs = deduped;
1565
1566        // Populate asset indices for all instruments (including filtered HIP-3)
1567        self.asset_indices.rcu(|m| {
1568            for def in &defs {
1569                m.insert(def.symbol, def.asset_index);
1570            }
1571        });
1572        log::debug!(
1573            "Populated asset indices map (count={})",
1574            self.asset_indices.len()
1575        );
1576
1577        Ok(defs)
1578    }
1579
1580    /// Converts instrument definitions into Nautilus instruments.
1581    pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1582        let ts_init = self.clock.get_time_ns();
1583        instruments_from_defs_owned(defs, ts_init)
1584    }
1585
1586    /// Fetch and parse all available instrument definitions from Hyperliquid.
1587    pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1588        let defs = self.request_instrument_defs().await?;
1589        Ok(self.convert_defs(defs))
1590    }
1591
1592    /// Builds the `allDexsAssetCtxs` normalization map from dex name to ordered instrument IDs.
1593    ///
1594    /// The order of instrument IDs must match the venue universe ordering for each perp dex so
1595    /// incoming `ctxs` arrays can be normalized without leaking raw positional payloads.
1596    pub async fn build_all_dex_asset_ctxs_instrument_ids(
1597        &self,
1598    ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1599        let all_metas = match self.inner.load_all_perp_metas().await {
1600            Ok(all_metas) => all_metas,
1601            Err(e) => {
1602                log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1603                vec![self.inner.load_perp_meta().await?]
1604            }
1605        };
1606
1607        let perp_dexs = match self.inner.load_perp_dexs().await {
1608            Ok(dexs) => Some(dexs),
1609            Err(e) => {
1610                log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1611                None
1612            }
1613        };
1614
1615        let raw_symbol_to_id =
1616            self.instruments
1617                .load()
1618                .values()
1619                .fold(AHashMap::new(), |mut acc, instrument| {
1620                    acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1621                    acc
1622                });
1623
1624        let mut mapping = AHashMap::new();
1625
1626        for (dex_index, meta) in all_metas.iter().enumerate() {
1627            let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1628            let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1629
1630            for asset in &meta.universe {
1631                if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1632                    instrument_ids.push(Some(*instrument_id));
1633                } else {
1634                    log::warn!(
1635                        "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1636                        dex_name,
1637                        asset.name
1638                    );
1639                    instrument_ids.push(None);
1640                }
1641            }
1642
1643            mapping.insert(dex_name, instrument_ids);
1644        }
1645
1646        Ok(mapping)
1647    }
1648
1649    /// Get asset index for a symbol from the cached map.
1650    ///
1651    /// For perps: index in meta.universe (0, 1, 2, ...).
1652    /// For spot: 10_000 + index in spotMeta.universe.
1653    /// For HIP-3: 100_000 + dex_index * 10_000 + index in dex meta.universe.
1654    ///
1655    /// Returns `None` if the symbol is not found in the map.
1656    pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1657        self.get_asset_index_for_symbol(Ustr::from(symbol))
1658    }
1659
1660    /// Get asset index for an already-interned symbol from the cached map.
1661    ///
1662    /// Returns `None` if the symbol is not found in the map.
1663    pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1664        self.asset_indices.load().get(&symbol).copied()
1665    }
1666
1667    /// Get the price precision for a cached instrument by symbol.
1668    pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1669        self.get_price_precision_for_symbol(Ustr::from(symbol))
1670    }
1671
1672    /// Get the price precision for a cached instrument by interned symbol.
1673    pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1674        self.instruments
1675            .load()
1676            .get(&symbol)
1677            .map(|inst| inst.price_precision())
1678    }
1679
1680    /// Get mapping from spot fill coin identifiers to instrument symbols.
1681    ///
1682    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1683    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1684    /// This mapping allows looking up the instrument from a spot fill.
1685    ///
1686    /// This method also caches the mapping internally for use by fill parsing methods.
1687    #[must_use]
1688    pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1689        const SPOT_INDEX_OFFSET: u32 = 10_000;
1690        const BUILDER_PERP_OFFSET: u32 = 100_000;
1691
1692        let guard = self.asset_indices.load();
1693
1694        let mut mapping = AHashMap::new();
1695
1696        for (symbol, &asset_index) in guard.iter() {
1697            // Spot instruments: asset_index in [10_000, 100_000)
1698            if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1699                let pair_index = asset_index - SPOT_INDEX_OFFSET;
1700                let fill_coin = Ustr::from(&format!("@{pair_index}"));
1701                mapping.insert(fill_coin, *symbol);
1702            }
1703        }
1704
1705        // Cache the mapping internally for fill parsing
1706        self.spot_fill_coins.store(mapping.clone());
1707
1708        mapping
1709    }
1710
1711    /// Get perpetuals metadata (internal helper).
1712    #[allow(dead_code)]
1713    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1714        self.inner.load_perp_meta().await
1715    }
1716
1717    /// Get metadata for all perp dexes (standard + HIP-3).
1718    #[allow(dead_code)]
1719    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1720        self.inner.load_all_perp_metas().await
1721    }
1722
1723    /// Get spot metadata (internal helper).
1724    #[allow(dead_code)]
1725    pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1726        self.inner.get_spot_meta().await
1727    }
1728
1729    /// Get outcome metadata (internal helper).
1730    pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1731        self.inner.get_outcome_meta().await
1732    }
1733
1734    /// Get L2 order book for a coin.
1735    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1736        self.inner.info_l2_book(coin).await
1737    }
1738
1739    /// Get recent public trades for a coin.
1740    pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1741        self.inner.info_recent_trades(coin).await
1742    }
1743
1744    /// Get user fills (trading history).
1745    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1746        self.inner.info_user_fills(user).await
1747    }
1748
1749    /// Get order status for a user.
1750    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1751        self.inner.info_order_status(user, oid).await
1752    }
1753
1754    /// Get all open orders for a user.
1755    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1756        self.inner.info_open_orders(user).await
1757    }
1758
1759    /// Get frontend open orders (includes more detail) for a user.
1760    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1761        self.inner.info_frontend_open_orders(user).await
1762    }
1763
1764    async fn info_frontend_open_orders_for_dex(
1765        &self,
1766        user: &str,
1767        dex: Option<&str>,
1768    ) -> Result<Value> {
1769        self.inner
1770            .info_frontend_open_orders_for_dex(user, dex)
1771            .await
1772    }
1773
1774    /// Get the most recent historical orders for a user.
1775    pub async fn info_historical_orders(
1776        &self,
1777        user: &str,
1778    ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
1779        self.inner.info_historical_orders(user).await
1780    }
1781
1782    /// Get clearinghouse state (balances, positions, margin) for a user.
1783    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1784        self.inner.info_clearinghouse_state(user).await
1785    }
1786
1787    async fn info_clearinghouse_state_for_dex(
1788        &self,
1789        user: &str,
1790        dex: Option<&str>,
1791    ) -> Result<Value> {
1792        self.inner.info_clearinghouse_state_for_dex(user, dex).await
1793    }
1794
1795    /// Get spot clearinghouse state (per-token spot balances) for a user.
1796    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1797        self.inner.info_spot_clearinghouse_state(user).await
1798    }
1799
1800    /// Get user fee schedule and effective rates.
1801    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1802        self.inner.info_user_fees(user).await
1803    }
1804
1805    /// Get candle/bar data for a coin.
1806    pub async fn info_candle_snapshot(
1807        &self,
1808        coin: &str,
1809        interval: HyperliquidBarInterval,
1810        start_time: u64,
1811        end_time: u64,
1812    ) -> Result<HyperliquidCandleSnapshot> {
1813        self.inner
1814            .info_candle_snapshot(coin, interval, start_time, end_time)
1815            .await
1816    }
1817
1818    /// Get historical funding rates for a coin.
1819    pub async fn info_funding_history(
1820        &self,
1821        coin: &str,
1822        start_time: u64,
1823        end_time: Option<u64>,
1824    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1825        self.inner
1826            .info_funding_history(coin, start_time, end_time)
1827            .await
1828    }
1829
1830    /// Post an action to the exchange endpoint (low-level delegation).
1831    pub async fn post_action(
1832        &self,
1833        action: &ExchangeAction,
1834    ) -> Result<HyperliquidExchangeResponse> {
1835        self.inner.post_action(action).await
1836    }
1837
1838    /// Post an execution action (low-level delegation).
1839    pub async fn post_action_exec(
1840        &self,
1841        action: &HyperliquidExchangeAction,
1842    ) -> Result<HyperliquidExchangeResponse> {
1843        self.inner.post_action_exec(action).await
1844    }
1845
1846    /// Build the signed exchange request used by both HTTP and WebSocket post transports.
1847    pub fn sign_action_exec_request(
1848        &self,
1849        action: &HyperliquidExchangeAction,
1850        expires_after: Option<u64>,
1851    ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
1852        self.inner.sign_action_exec_request(action, expires_after)
1853    }
1854
1855    /// Get metadata about available markets (low-level delegation).
1856    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1857        self.inner.info_meta().await
1858    }
1859
1860    /// Cancel an order on the Hyperliquid exchange.
1861    ///
1862    /// Can cancel either by venue order ID or client order ID.
1863    /// At least one ID must be provided.
1864    ///
1865    /// # Errors
1866    ///
1867    /// Returns an error if credentials are missing, no order ID is provided,
1868    /// or the API returns an error.
1869    pub async fn cancel_order(
1870        &self,
1871        instrument_id: InstrumentId,
1872        client_order_id: Option<ClientOrderId>,
1873        venue_order_id: Option<VenueOrderId>,
1874    ) -> Result<()> {
1875        // Get asset ID from cached indices map
1876        let symbol = instrument_id.symbol.inner();
1877        let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1878            Error::bad_request(format!(
1879                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1880            ))
1881        })?;
1882
1883        let action = if let Some(client_order_id) = client_order_id {
1884            if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
1885                HyperliquidExchangeAction::CancelByCloid {
1886                    cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1887                        asset: asset_id,
1888                        cloid,
1889                    }],
1890                    fast: None,
1891                }
1892            } else if let Some(oid) = venue_order_id {
1893                let oid_u64 = oid
1894                    .as_str()
1895                    .parse::<u64>()
1896                    .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1897                HyperliquidExchangeAction::Cancel {
1898                    cancels: vec![HyperliquidExchangeCancelOrderRequest {
1899                        asset: asset_id,
1900                        oid: oid_u64,
1901                    }],
1902                    fast: None,
1903                }
1904            } else {
1905                let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
1906                HyperliquidExchangeAction::CancelByCloid {
1907                    cancels: vec![HyperliquidExchangeCancelByCloidRequest {
1908                        asset: asset_id,
1909                        cloid,
1910                    }],
1911                    fast: None,
1912                }
1913            }
1914        } else if let Some(oid) = venue_order_id {
1915            let oid_u64 = oid
1916                .as_str()
1917                .parse::<u64>()
1918                .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1919            HyperliquidExchangeAction::Cancel {
1920                cancels: vec![HyperliquidExchangeCancelOrderRequest {
1921                    asset: asset_id,
1922                    oid: oid_u64,
1923                }],
1924                fast: None,
1925            }
1926        } else {
1927            return Err(Error::bad_request(
1928                "Either client_order_id or venue_order_id must be provided",
1929            ));
1930        };
1931
1932        // Submit cancellation
1933        let response = self.inner.post_action_exec(&action).await?;
1934
1935        // Check response - only check for error status
1936        match response {
1937            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1938            HyperliquidExchangeResponse::Status {
1939                status,
1940                response: error_data,
1941            } => Err(Error::bad_request(format!(
1942                "Cancel order failed: status={status}, error={error_data}"
1943            ))),
1944            HyperliquidExchangeResponse::Error { error } => {
1945                Err(Error::bad_request(format!("Cancel order error: {error}")))
1946            }
1947        }
1948    }
1949
1950    /// Modify an order on the Hyperliquid exchange.
1951    ///
1952    /// The HL modify API requires a full replacement order spec plus a venue
1953    /// order ID or cached CLOID target. The caller must provide all order fields.
1954    ///
1955    /// # Errors
1956    ///
1957    /// Returns an error if the asset index is not found, no safe modify target
1958    /// exists, the venue order ID is invalid, or the API returns an error.
1959    #[expect(clippy::too_many_arguments)]
1960    pub async fn modify_order(
1961        &self,
1962        instrument_id: InstrumentId,
1963        venue_order_id: Option<VenueOrderId>,
1964        order_side: OrderSide,
1965        order_type: OrderType,
1966        price: Price,
1967        quantity: Quantity,
1968        trigger_price: Option<Price>,
1969        reduce_only: bool,
1970        post_only: bool,
1971        time_in_force: TimeInForce,
1972        client_order_id: Option<ClientOrderId>,
1973    ) -> Result<()> {
1974        let symbol = instrument_id.symbol.inner();
1975        let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
1976            Error::bad_request(format!(
1977                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1978            ))
1979        })?;
1980
1981        let oid = match client_order_id
1982            .as_ref()
1983            .and_then(|id| self.unique_cached_client_order_id_cloid(id))
1984        {
1985            Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
1986            None => {
1987                let Some(venue_order_id) = venue_order_id.as_ref() else {
1988                    return Err(Error::bad_request(
1989                        "venue_order_id or unique cached CLOID is required for modify",
1990                    ));
1991                };
1992                HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
1993                    .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
1994            }
1995        };
1996
1997        let is_buy = matches!(order_side, OrderSide::Buy);
1998        let decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
1999
2000        let normalized_price = if self.normalize_prices {
2001            normalize_price(price.as_decimal(), decimals).normalize()
2002        } else {
2003            price.as_decimal().normalize()
2004        };
2005
2006        let size = quantity.as_decimal().normalize();
2007
2008        let kind = match order_type {
2009            OrderType::Market => HyperliquidExchangeOrderKind::Limit {
2010                limit: HyperliquidExchangeLimitParams {
2011                    tif: HyperliquidExchangeTif::Ioc,
2012                },
2013            },
2014            OrderType::Limit => {
2015                let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2016                    .map_err(|e| Error::bad_request(format!("{e}")))?;
2017                HyperliquidExchangeOrderKind::Limit {
2018                    limit: HyperliquidExchangeLimitParams { tif },
2019                }
2020            }
2021            OrderType::StopMarket
2022            | OrderType::StopLimit
2023            | OrderType::MarketIfTouched
2024            | OrderType::LimitIfTouched => {
2025                if let Some(trig_px) = trigger_price {
2026                    let trigger_price_decimal = if self.normalize_prices {
2027                        normalize_price(trig_px.as_decimal(), decimals).normalize()
2028                    } else {
2029                        trig_px.as_decimal().normalize()
2030                    };
2031                    let tpsl = match order_type {
2032                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2033                        _ => HyperliquidExchangeTpSl::Tp,
2034                    };
2035                    let is_market = matches!(
2036                        order_type,
2037                        OrderType::StopMarket | OrderType::MarketIfTouched
2038                    );
2039                    HyperliquidExchangeOrderKind::Trigger {
2040                        trigger: HyperliquidExchangeTriggerParams {
2041                            is_market,
2042                            trigger_px: trigger_price_decimal,
2043                            tpsl,
2044                        },
2045                    }
2046                } else {
2047                    return Err(Error::bad_request("Trigger orders require a trigger price"));
2048                }
2049            }
2050            _ => {
2051                return Err(Error::bad_request(format!(
2052                    "Order type {order_type:?} not supported for modify"
2053                )));
2054            }
2055        };
2056        let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2057
2058        let order = HyperliquidExchangePlaceOrderRequest {
2059            asset: asset_id,
2060            is_buy,
2061            price: normalized_price,
2062            size,
2063            reduce_only,
2064            kind,
2065            cloid,
2066        };
2067
2068        let action = HyperliquidExchangeAction::Modify {
2069            modify: HyperliquidExchangeModifyOrderRequest { oid, order },
2070        };
2071
2072        let response = self.inner.post_action_exec(&action).await?;
2073
2074        match response {
2075            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2076                if let Some(inner_error) = extract_inner_error(&response) {
2077                    Err(Error::bad_request(format!(
2078                        "Modify order rejected: {inner_error}",
2079                    )))
2080                } else {
2081                    Ok(())
2082                }
2083            }
2084            HyperliquidExchangeResponse::Status {
2085                status,
2086                response: error_data,
2087            } => Err(Error::bad_request(format!(
2088                "Modify order failed: status={status}, error={error_data}"
2089            ))),
2090            HyperliquidExchangeResponse::Error { error } => {
2091                Err(Error::bad_request(format!("Modify order error: {error}")))
2092            }
2093        }
2094    }
2095
2096    /// Split an HIP-4 outcome's quote tokens into matched Yes and No side tokens.
2097    ///
2098    /// Submits a `userOutcome` exchange action with the `splitOutcome` operation:
2099    /// debits `amount` quote tokens (USDH) and credits `amount` Yes plus `amount`
2100    /// No side tokens for the given `outcome` index. Ordinary directional
2101    /// buys and sells on outcome instruments go through the standard order path
2102    /// without calling this; the action is for dual-side market making and
2103    /// inventory creation.
2104    ///
2105    /// # Errors
2106    ///
2107    /// Returns an error if credentials are missing, the venue rejects the
2108    /// action, or the response cannot be parsed.
2109    pub async fn submit_split_outcome(
2110        &self,
2111        outcome: u32,
2112        amount: Decimal,
2113    ) -> Result<HyperliquidExchangeResponse> {
2114        let action = HyperliquidExchangeAction::UserOutcome {
2115            op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
2116                HyperliquidExchangeSplitOutcomeParams { outcome, amount },
2117            ),
2118        };
2119        self.inner.post_action_exec(&action).await
2120    }
2121
2122    /// Merge matched Yes + No side-token pairs of an HIP-4 outcome back into quote tokens.
2123    ///
2124    /// Submits a `userOutcome` action with the `mergeOutcome` operation. Pass
2125    /// `amount = None` to merge the maximum mergeable balance (venue-side
2126    /// `null`).
2127    ///
2128    /// # Errors
2129    ///
2130    /// Returns an error if credentials are missing, the venue rejects the
2131    /// action, or the response cannot be parsed.
2132    pub async fn submit_merge_outcome(
2133        &self,
2134        outcome: u32,
2135        amount: Option<Decimal>,
2136    ) -> Result<HyperliquidExchangeResponse> {
2137        let action = HyperliquidExchangeAction::UserOutcome {
2138            op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
2139                HyperliquidExchangeMergeOutcomeParams { outcome, amount },
2140            ),
2141        };
2142        self.inner.post_action_exec(&action).await
2143    }
2144
2145    /// Merge `Yes` shares of every outcome in a multi-outcome question into quote tokens.
2146    ///
2147    /// Submits a `userOutcome` action with the `mergeQuestion` operation. Pass
2148    /// `amount = None` to merge the maximum balance.
2149    ///
2150    /// # Errors
2151    ///
2152    /// Returns an error if credentials are missing, the venue rejects the
2153    /// action, or the response cannot be parsed.
2154    pub async fn submit_merge_question(
2155        &self,
2156        question: u32,
2157        amount: Option<Decimal>,
2158    ) -> Result<HyperliquidExchangeResponse> {
2159        let action = HyperliquidExchangeAction::UserOutcome {
2160            op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
2161                HyperliquidExchangeMergeQuestionParams { question, amount },
2162            ),
2163        };
2164        self.inner.post_action_exec(&action).await
2165    }
2166
2167    /// Swap `No` shares of one outcome into `Yes` shares of every other outcome.
2168    ///
2169    /// Submits a `userOutcome` action with the `negateOutcome` operation. Both
2170    /// outcomes must belong to the same multi-outcome `question`.
2171    ///
2172    /// # Errors
2173    ///
2174    /// Returns an error if credentials are missing, the venue rejects the
2175    /// action, or the response cannot be parsed.
2176    pub async fn submit_negate_outcome(
2177        &self,
2178        question: u32,
2179        outcome: u32,
2180        amount: Decimal,
2181    ) -> Result<HyperliquidExchangeResponse> {
2182        let action = HyperliquidExchangeAction::UserOutcome {
2183            op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
2184                HyperliquidExchangeNegateOutcomeParams {
2185                    question,
2186                    outcome,
2187                    amount,
2188                },
2189            ),
2190        };
2191        self.inner.post_action_exec(&action).await
2192    }
2193
2194    /// Request order status reports for a user.
2195    ///
2196    /// Fetches frontend open orders from the default and all cached builder dexes when unfiltered,
2197    /// or from the dex selected by an instrument filter, then parses them into OrderStatusReports.
2198    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
2199    ///
2200    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
2201    /// will be created automatically.
2202    ///
2203    /// # Errors
2204    ///
2205    /// Returns an error if the API request fails or parsing fails.
2206    pub async fn request_order_status_reports(
2207        &self,
2208        user: &str,
2209        instrument_id: Option<InstrumentId>,
2210    ) -> Result<Vec<OrderStatusReport>> {
2211        let dexes = self.reconciliation_dexes(instrument_id);
2212        self.request_order_status_reports_for_dexes(user, instrument_id, &dexes)
2213            .await
2214    }
2215
2216    pub(crate) async fn request_order_status_reports_for_dexes(
2217        &self,
2218        user: &str,
2219        instrument_id: Option<InstrumentId>,
2220        dexes: &[Option<Ustr>],
2221    ) -> Result<Vec<OrderStatusReport>> {
2222        let account_id = self
2223            .account_id
2224            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2225        let mut reports = Vec::new();
2226        let ts_init = self.clock.get_time_ns();
2227
2228        for dex in dexes {
2229            let response = self
2230                .info_frontend_open_orders_for_dex(user, dex.as_deref())
2231                .await?;
2232            let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2233                .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2234
2235            for order_value in orders {
2236                let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2237                    Ok(order) => order,
2238                    Err(e) => {
2239                        log::warn!("Failed to parse order: {e}");
2240                        continue;
2241                    }
2242                };
2243
2244                let instrument = match self.get_or_create_instrument(&order.coin, None) {
2245                    Some(instrument) => instrument,
2246                    None => continue,
2247                };
2248
2249                if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2250                    continue;
2251                }
2252
2253                match parse_order_status_report_from_basic(
2254                    &order,
2255                    &HyperliquidOrderStatusEnum::Open,
2256                    &instrument,
2257                    account_id,
2258                    ts_init,
2259                ) {
2260                    Ok(report) => reports.push(report),
2261                    Err(e) => log::error!("Failed to parse order status report: {e}"),
2262                }
2263            }
2264        }
2265
2266        Ok(reports)
2267    }
2268
2269    /// Request historical order status reports for a user.
2270    ///
2271    /// The venue bounds this endpoint to its 2,000 most recent historical
2272    /// orders. Mass-status reconciliation narrows these reports to venue order
2273    /// IDs represented by the retained fill window.
2274    pub async fn request_historical_order_status_reports(
2275        &self,
2276        user: &str,
2277        instrument_id: Option<InstrumentId>,
2278    ) -> Result<Vec<OrderStatusReport>> {
2279        let entries = self.info_historical_orders(user).await?;
2280        self.historical_order_status_reports_from_response(entries, instrument_id)
2281    }
2282
2283    pub(crate) fn historical_order_status_reports_from_response(
2284        &self,
2285        entries: Vec<HyperliquidOrderStatusEntry>,
2286        instrument_id: Option<InstrumentId>,
2287    ) -> Result<Vec<OrderStatusReport>> {
2288        let account_id = self
2289            .account_id
2290            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2291        let mut reports = Vec::new();
2292        let ts_init = self.clock.get_time_ns();
2293
2294        for entry in entries {
2295            let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2296                Some(instrument) => instrument,
2297                None => continue,
2298            };
2299
2300            if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2301                continue;
2302            }
2303
2304            let order_type = entry.order.order_type.as_deref().unwrap_or_default();
2305            let tpsl = if order_type.starts_with("Take Profit") {
2306                Some(crate::common::enums::HyperliquidTpSl::Tp)
2307            } else if order_type.starts_with("Stop") {
2308                Some(crate::common::enums::HyperliquidTpSl::Sl)
2309            } else {
2310                None
2311            };
2312            let is_market = entry
2313                .order
2314                .order_type
2315                .as_deref()
2316                .is_some_and(|label| label.ends_with("Market"));
2317            let historical_order_type = match tpsl.as_ref() {
2318                Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2319                None if is_market => OrderType::Market,
2320                None => OrderType::Limit,
2321            };
2322            let order = WsBasicOrderData {
2323                coin: entry.order.coin,
2324                side: entry.order.side,
2325                limit_px: entry.order.limit_px,
2326                sz: entry.order.sz,
2327                oid: entry.order.oid,
2328                timestamp: entry.order.timestamp,
2329                orig_sz: entry.order.orig_sz,
2330                cloid: entry.order.cloid,
2331                tif: entry.order.tif,
2332                reduce_only: entry.order.reduce_only,
2333                trigger_px: entry
2334                    .order
2335                    .trigger_px
2336                    .filter(|price| *price != Decimal::ZERO),
2337                is_market: tpsl.is_some().then_some(is_market),
2338                tpsl,
2339                trigger_activated: None,
2340                trailing_stop: None,
2341            };
2342
2343            match parse_order_status_report_from_basic(
2344                &order,
2345                &entry.status,
2346                &instrument,
2347                account_id,
2348                ts_init,
2349            ) {
2350                Ok(mut report) => {
2351                    report.order_type = historical_order_type;
2352                    report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2353                    reports.push(report);
2354                }
2355                Err(e) => log::error!("Failed to parse historical order status report: {e}"),
2356            }
2357        }
2358
2359        Ok(deduplicate_historical_order_reports(reports))
2360    }
2361
2362    /// Request a single order status report by venue order ID.
2363    ///
2364    /// Queries `info_frontend_open_orders` and filters for the given oid so the
2365    /// result includes trigger metadata (trigger_px, tpsl, trailing_stop, etc.).
2366    /// Falls back to `info_order_status` when the order is no longer open.
2367    ///
2368    /// # Errors
2369    ///
2370    /// Returns an error if the API request fails or parsing fails.
2371    pub async fn request_order_status_report(
2372        &self,
2373        user: &str,
2374        oid: u64,
2375    ) -> Result<Option<OrderStatusReport>> {
2376        let account_id = self
2377            .account_id
2378            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2379
2380        let ts_init = self.clock.get_time_ns();
2381
2382        // Try open orders first (returns full WsBasicOrderData with trigger fields).
2383        // A transport error here must not abort the call: the oid fallback to
2384        // info_order_status below still covers closed orders, so a transient
2385        // frontendOpenOrders outage is downgraded to a warning.
2386        let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2387            Ok(response) => match serde_json::from_value(response) {
2388                Ok(v) => v,
2389                Err(e) => {
2390                    log::warn!("Failed to parse frontend open orders response: {e}");
2391                    Vec::new()
2392                }
2393            },
2394            Err(e) => {
2395                log::warn!(
2396                    "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2397                );
2398                Vec::new()
2399            }
2400        };
2401
2402        if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2403            let instrument = match self.get_or_create_instrument(&order.coin, None) {
2404                Some(inst) => inst,
2405                None => return Ok(None),
2406            };
2407
2408            let status = if order.trigger_activated == Some(true) {
2409                HyperliquidOrderStatusEnum::Triggered
2410            } else {
2411                HyperliquidOrderStatusEnum::Open
2412            };
2413
2414            return match parse_order_status_report_from_basic(
2415                &order,
2416                &status,
2417                &instrument,
2418                account_id,
2419                ts_init,
2420            ) {
2421                Ok(report) => Ok(Some(report)),
2422                Err(e) => {
2423                    log::error!("Failed to parse order status report for oid {oid}: {e}");
2424                    Ok(None)
2425                }
2426            };
2427        }
2428
2429        // Order not in open set: query by oid (returns limited HyperliquidOrderInfo)
2430        let response = self.info_order_status(user, oid).await?;
2431        let entry = match response.into_order() {
2432            Some(e) => e,
2433            None => return Ok(None),
2434        };
2435
2436        let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2437            Some(inst) => inst,
2438            None => return Ok(None),
2439        };
2440
2441        // The info_order_status endpoint returns limited HyperliquidOrderInfo
2442        // without trigger fields (trigger_px, tpsl, is_market, trailing_stop).
2443        // Closed trigger orders will report as Limit type. This is an exchange
2444        // API limitation: trigger metadata is only available on open orders.
2445        let basic = WsBasicOrderData {
2446            coin: entry.order.coin,
2447            side: entry.order.side,
2448            limit_px: entry.order.limit_px,
2449            sz: entry.order.sz,
2450            oid: entry.order.oid,
2451            timestamp: entry.order.timestamp,
2452            orig_sz: entry.order.orig_sz,
2453            cloid: entry.order.cloid,
2454            tif: None,
2455            reduce_only: None,
2456            trigger_px: None,
2457            is_market: None,
2458            tpsl: None,
2459            trigger_activated: None,
2460            trailing_stop: None,
2461        };
2462
2463        match parse_order_status_report_from_basic(
2464            &basic,
2465            &entry.status,
2466            &instrument,
2467            account_id,
2468            ts_init,
2469        ) {
2470            Ok(mut report) => {
2471                // Use status_timestamp for ts_last when available (more accurate
2472                // than the order creation timestamp for filled/canceled orders)
2473                if entry.status_timestamp > 0 {
2474                    report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2475                }
2476                Ok(Some(report))
2477            }
2478            Err(e) => {
2479                log::error!("Failed to parse order status report for oid {oid}: {e}");
2480                Ok(None)
2481            }
2482        }
2483    }
2484
2485    /// Request a single order status report by client order ID.
2486    ///
2487    /// Searches `info_frontend_open_orders` for an order whose cloid matches the
2488    /// cached CLOID or the generated CLOID. Only finds open orders.
2489    ///
2490    /// # Errors
2491    ///
2492    /// Returns an error if the API request fails or parsing fails.
2493    pub async fn request_order_status_report_by_client_order_id(
2494        &self,
2495        user: &str,
2496        client_order_id: &ClientOrderId,
2497    ) -> Result<Option<OrderStatusReport>> {
2498        let account_id = self
2499            .account_id
2500            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2501
2502        let ts_init = self.clock.get_time_ns();
2503
2504        let cached_cloid_hex = self
2505            .cached_client_order_id_cloid(client_order_id)
2506            .map(|cloid| cloid.to_hex());
2507        let cloid = Cloid::from_client_order_id(*client_order_id);
2508        let cloid_hex = cloid.to_hex();
2509
2510        let response = self.info_frontend_open_orders(user).await?;
2511        let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2512            Ok(v) => v,
2513            Err(e) => {
2514                log::warn!("Failed to parse frontend open orders response: {e}");
2515                return Ok(None);
2516            }
2517        };
2518
2519        let order = match orders.into_iter().find(|o| {
2520            o.cloid
2521                .as_ref()
2522                .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2523        }) {
2524            Some(o) => o,
2525            None => return Ok(None),
2526        };
2527
2528        let instrument = match self.get_or_create_instrument(&order.coin, None) {
2529            Some(inst) => inst,
2530            None => return Ok(None),
2531        };
2532
2533        let status = if order.trigger_activated == Some(true) {
2534            HyperliquidOrderStatusEnum::Triggered
2535        } else {
2536            HyperliquidOrderStatusEnum::Open
2537        };
2538
2539        match parse_order_status_report_from_basic(
2540            &order,
2541            &status,
2542            &instrument,
2543            account_id,
2544            ts_init,
2545        ) {
2546            Ok(mut report) => {
2547                report.client_order_id = Some(*client_order_id);
2548                Ok(Some(report))
2549            }
2550            Err(e) => {
2551                log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2552                Ok(None)
2553            }
2554        }
2555    }
2556
2557    /// Request fill reports for a user.
2558    ///
2559    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
2560    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
2561    ///
2562    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
2563    /// will be created automatically.
2564    ///
2565    /// # Errors
2566    ///
2567    /// Returns an error if the API request fails or parsing fails.
2568    ///
2569    /// Returns an error if `account_id` is not set on the client.
2570    pub async fn request_fill_reports(
2571        &self,
2572        user: &str,
2573        instrument_id: Option<InstrumentId>,
2574    ) -> Result<Vec<FillReport>> {
2575        let fills_response = self.info_user_fills(user).await?;
2576        self.fill_reports_from_response(fills_response, instrument_id)
2577    }
2578
2579    pub(crate) fn fill_reports_from_response(
2580        &self,
2581        fills_response: HyperliquidFills,
2582        instrument_id: Option<InstrumentId>,
2583    ) -> Result<Vec<FillReport>> {
2584        let account_id = self
2585            .account_id
2586            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2587
2588        let mut reports = Vec::new();
2589        let ts_init = self.clock.get_time_ns();
2590
2591        for fill in fills_response {
2592            // Get instrument from cache or create synthetic for vault tokens
2593            let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2594                Some(inst) => inst,
2595                None => continue, // Skip if instrument not found
2596            };
2597
2598            // Filter by instrument_id if specified
2599            if let Some(filter_id) = instrument_id
2600                && instrument.id() != filter_id
2601            {
2602                continue;
2603            }
2604
2605            // Parse to FillReport
2606            match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2607                Ok(report) => reports.push(report),
2608                Err(e) => log::error!("Failed to parse fill report: {e}"),
2609            }
2610        }
2611
2612        Ok(reports)
2613    }
2614
2615    /// Request position status reports for a user.
2616    ///
2617    /// Fetches clearinghouse state from the default and all cached builder dexes when unfiltered,
2618    /// plus spot clearinghouse state, then returns the union of perp asset positions (short/long
2619    /// with PnL) and spot holdings (long only). This method requires instruments to be added to the
2620    /// client cache via `cache_instrument()`.
2621    ///
2622    /// When `instrument_id` resolves to a specific product type, the opposite
2623    /// product's endpoint is skipped to avoid wasted round trips and make
2624    /// filtered queries independent of the unused endpoint's availability.
2625    /// HIP-4 outcomes live in `spotClearinghouseState`, so an outcome filter
2626    /// is routed like a spot filter (perp leg skipped).
2627    ///
2628    /// For vault tokens (starting with "vntls:") that are not in the cache,
2629    /// synthetic instruments will be created automatically. Spot balances whose
2630    /// base token has no cached instrument are skipped with a debug log.
2631    ///
2632    /// # Errors
2633    ///
2634    /// Returns an error if any clearinghouse request fails (when that product or dex is in scope)
2635    /// or parsing fails.
2636    ///
2637    /// Returns an error if `account_id` has not been set on the client.
2638    pub async fn request_position_status_reports(
2639        &self,
2640        user: &str,
2641        instrument_id: Option<InstrumentId>,
2642    ) -> Result<Vec<PositionStatusReport>> {
2643        let dexes = self.reconciliation_dexes(instrument_id);
2644        self.request_position_status_reports_for_dexes(user, instrument_id, &dexes)
2645            .await
2646    }
2647
2648    pub(crate) async fn request_position_status_reports_for_dexes(
2649        &self,
2650        user: &str,
2651        instrument_id: Option<InstrumentId>,
2652        dexes: &[Option<Ustr>],
2653    ) -> Result<Vec<PositionStatusReport>> {
2654        let account_id = self
2655            .account_id
2656            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2657
2658        let filter_product = instrument_id
2659            .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2660
2661        let fetch_perp = !matches!(
2662            filter_product,
2663            Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2664        );
2665        let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2666
2667        let mut reports = Vec::new();
2668        let ts_init = self.clock.get_time_ns();
2669
2670        if !fetch_perp {
2671            let spot_reports = self
2672                .request_spot_position_status_reports(user, instrument_id)
2673                .await?;
2674            reports.extend(spot_reports);
2675            return Ok(reports);
2676        }
2677
2678        for dex in dexes {
2679            let state_response = self
2680                .info_clearinghouse_state_for_dex(user, dex.as_deref())
2681                .await?;
2682            let asset_positions: Vec<serde_json::Value> = state_response
2683                .get("assetPositions")
2684                .and_then(|value| value.as_array())
2685                .ok_or_else(|| {
2686                    Error::bad_request("assetPositions not found in clearinghouse state")
2687                })?
2688                .clone();
2689
2690            for position_value in asset_positions {
2691                let coin = position_value
2692                    .get("position")
2693                    .and_then(|position| position.get("coin"))
2694                    .and_then(|coin| coin.as_str())
2695                    .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2696
2697                let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
2698                    Some(instrument) => instrument,
2699                    None => continue,
2700                };
2701
2702                if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2703                    continue;
2704                }
2705
2706                match parse_position_status_report(
2707                    &position_value,
2708                    &instrument,
2709                    account_id,
2710                    ts_init,
2711                ) {
2712                    Ok(report) => reports.push(report),
2713                    Err(e) => log::error!("Failed to parse position status report: {e}"),
2714                }
2715            }
2716        }
2717
2718        // Spot positions are part of the report truth; propagate fetch errors
2719        // rather than silently omitting spot holdings from reconciliation.
2720        if fetch_spot {
2721            let spot_reports = self
2722                .request_spot_position_status_reports(user, instrument_id)
2723                .await?;
2724            reports.extend(spot_reports);
2725        }
2726
2727        Ok(reports)
2728    }
2729
2730    /// Request account state (balances and margins) for a user.
2731    ///
2732    /// Fetches perp and spot clearinghouse state from Hyperliquid and merges them
2733    /// into a single [`AccountState`]. USDC comes from the perp margin summary only
2734    /// when that summary reflects non-zero collateral, margin used, or withdrawable
2735    /// balance; if the summary is absent or zeroed, spot USDC is used instead. Non-USDC
2736    /// tokens are always appended from the spot balances.
2737    ///
2738    /// # Errors
2739    ///
2740    /// Returns an error if `account_id` is not set, or if either the perp or
2741    /// spot clearinghouse request fails. Spot failures are propagated so the
2742    /// caller sees real API errors instead of a silently truncated snapshot.
2743    pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2744        let account_id = self
2745            .account_id
2746            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2747        let state_response = self.info_clearinghouse_state(user).await?;
2748        let ts_init = self.clock.get_time_ns();
2749
2750        log::trace!("Clearinghouse state response: {state_response}");
2751
2752        let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2753            .map_err(|e| {
2754                log::error!("Failed to parse clearinghouse state: {e}");
2755                log::debug!("Raw response: {state_response}");
2756                Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2757            })?;
2758
2759        // Spot must not be silently dropped: a 429 or parse error would
2760        // otherwise make non-USDC holdings look like they vanished.
2761        let spot_response = self.info_spot_clearinghouse_state(user).await?;
2762        let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2763            .map_err(|e| {
2764                log::error!("Failed to parse spot clearinghouse state: {e}");
2765                log::debug!("Raw spot response: {spot_response}");
2766                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2767            })?;
2768
2769        let (balances, margins) =
2770            parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2771                .map_err(|e| Error::decode(e.to_string()))?;
2772
2773        Ok(AccountState::new(
2774            account_id,
2775            AccountType::Margin,
2776            balances,
2777            margins,
2778            true, // reported
2779            UUID4::new(),
2780            ts_init,
2781            ts_init,
2782            None,
2783        ))
2784    }
2785
2786    /// Request spot token balances for a user.
2787    ///
2788    /// Fetches `spotClearinghouseState` and returns one [`AccountBalance`] per
2789    /// non-zero token. USDC is included as a separate balance entry when present;
2790    /// callers that also report perp margin state must dedupe currencies before
2791    /// emitting an [`AccountState`].
2792    ///
2793    /// # Errors
2794    ///
2795    /// Returns an error if the API request fails or the response cannot be parsed.
2796    pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2797        let response = self.info_spot_clearinghouse_state(user).await?;
2798
2799        log::trace!("Spot clearinghouse state response: {response}");
2800
2801        let state: SpotClearinghouseState =
2802            serde_json::from_value(response.clone()).map_err(|e| {
2803                log::error!("Failed to parse spot clearinghouse state: {e}");
2804                log::debug!("Raw response: {response}");
2805                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2806            })?;
2807
2808        parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2809    }
2810
2811    /// Request spot position status reports for a user.
2812    ///
2813    /// Each non-zero spot balance is reported as a Long position against its
2814    /// `{BASE}-{QUOTE}-SPOT` instrument. HIP-4 outcome side tokens arrive on
2815    /// this same endpoint with `coin` set to the `+<encoding>` token form;
2816    /// those balances are resolved against the matching Outcome instrument so
2817    /// outcome holdings surface as positions through the standard reconcile
2818    /// path. Balances whose base token has no matching instrument in the
2819    /// cache are skipped with a debug log (callers should ensure
2820    /// [`request_instruments`](Self::request_instruments) has run first).
2821    ///
2822    /// # Errors
2823    ///
2824    /// Returns an error if `account_id` has not been set or the API request fails.
2825    pub async fn request_spot_position_status_reports(
2826        &self,
2827        user: &str,
2828        instrument_id: Option<InstrumentId>,
2829    ) -> Result<Vec<PositionStatusReport>> {
2830        let account_id = self
2831            .account_id
2832            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2833        let response = self.info_spot_clearinghouse_state(user).await?;
2834
2835        let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2836            log::error!("Failed to parse spot clearinghouse state: {e}");
2837            Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2838        })?;
2839
2840        let ts_init = self.clock.get_time_ns();
2841        let mut reports = Vec::with_capacity(state.balances.len());
2842
2843        for balance in &state.balances {
2844            if balance.total.is_zero() {
2845                continue;
2846            }
2847
2848            // USDC is the universal quote for Hyperliquid spot: it funds every
2849            // pair and has no `USDC-*-SPOT` instrument. Skip it so the loop
2850            // does not trigger a misleading cache-miss WARN. Revisit if
2851            // Hyperliquid ever introduces a USDC-base spot pair.
2852            if balance.coin.as_str() == "USDC" {
2853                continue;
2854            }
2855
2856            let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2857                Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2858                _ => HyperliquidProductType::Spot,
2859            };
2860
2861            let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2862            {
2863                Some(inst) => inst,
2864                None => continue,
2865            };
2866
2867            if let Some(filter_id) = instrument_id
2868                && instrument.id() != filter_id
2869            {
2870                continue;
2871            }
2872
2873            match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2874                Ok(report) => reports.push(report),
2875                Err(e) => log::error!(
2876                    "Failed to parse spot position status report for {}: {e}",
2877                    balance.coin,
2878                ),
2879            }
2880        }
2881
2882        Ok(reports)
2883    }
2884
2885    /// Request historical bars for an instrument.
2886    ///
2887    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
2888    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
2889    ///
2890    /// # Errors
2891    ///
2892    /// Returns an error if:
2893    /// - The instrument is not found in cache.
2894    /// - The bar aggregation is unsupported by Hyperliquid.
2895    /// - The API request fails.
2896    /// - Parsing fails.
2897    ///
2898    /// # References
2899    ///
2900    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
2901    pub async fn request_bars(
2902        &self,
2903        bar_type: BarType,
2904        start: Option<jiff::Timestamp>,
2905        end: Option<jiff::Timestamp>,
2906        limit: Option<u32>,
2907    ) -> Result<Vec<Bar>> {
2908        let instrument_id = bar_type.instrument_id();
2909        let symbol = instrument_id.symbol;
2910
2911        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2912
2913        // `cache_alias_for_symbol` mirrors how `cache_instrument` stores the
2914        // secondary key (token form `+<encoding>` for outcomes, leading
2915        // segment for perps / spots), so this lookup stays in sync.
2916        let alias = cache_alias_for_symbol(symbol.as_str())
2917            .map(|alias| Ustr::from(alias.as_str()))
2918            .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
2919
2920        let instrument = self
2921            .get_or_create_instrument(&alias, product_type)
2922            .ok_or_else(|| {
2923                Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
2924            })?;
2925
2926        // Use raw_symbol which has the correct Hyperliquid API format:
2927        // - Perps: base currency (e.g., "BTC")
2928        // - Spot PURR: slash format (e.g., "PURR/USDC")
2929        // - Spot others: @{index} format (e.g., "@107")
2930        let coin = instrument.raw_symbol().inner();
2931
2932        let price_precision = instrument.price_precision();
2933        let size_precision = instrument.size_precision();
2934
2935        let interval =
2936            bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2937
2938        // Hyperliquid uses millisecond timestamps
2939        let now = jiff::Timestamp::now();
2940        let end_time = end.unwrap_or(now).as_millisecond() as u64;
2941        let start_time = if let Some(start) = start {
2942            start.as_millisecond() as u64
2943        } else {
2944            // Default to 1000 bars before end_time
2945            let spec = bar_type.spec();
2946            let step_ms = match spec.aggregation {
2947                BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2948                BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2949                BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2950                BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2951                BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2952                _ => 60_000,
2953            };
2954            end_time.saturating_sub(1000 * step_ms)
2955        };
2956
2957        let candles = self
2958            .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2959            .await?;
2960
2961        // Filter out incomplete bars where end_timestamp >= current time
2962        let now_ms = now.as_millisecond() as u64;
2963
2964        let mut bars: Vec<Bar> = candles
2965            .iter()
2966            .filter(|candle| candle.end_timestamp < now_ms)
2967            .enumerate()
2968            .filter_map(|(i, candle)| {
2969                candle_to_bar(candle, bar_type, price_precision, size_precision)
2970                    .map_err(|e| {
2971                        log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2972                        e
2973                    })
2974                    .ok()
2975            })
2976            .collect();
2977
2978        // 0 means no limit
2979        if let Some(limit) = limit
2980            && limit > 0
2981            && bars.len() > limit as usize
2982        {
2983            bars.truncate(limit as usize);
2984        }
2985
2986        log::debug!(
2987            "Received {} bars for {} (filtered {} incomplete)",
2988            bars.len(),
2989            bar_type,
2990            candles.len() - bars.len()
2991        );
2992        Ok(bars)
2993    }
2994
2995    /// Request the recent public trade snapshot for an instrument.
2996    ///
2997    /// Hyperliquid's `recentTrades` endpoint is a bounded newest-first snapshot,
2998    /// rather than a range-query endpoint. The returned trades are normalized to
2999    /// ascending event time and then constrained to the requested window.
3000    ///
3001    /// A self-hosted node without the indexer responds with HTTP 422. This is
3002    /// treated as no available coverage so requests can still complete.
3003    pub async fn request_public_trades(
3004        &self,
3005        instrument_id: InstrumentId,
3006        start: Option<jiff::Timestamp>,
3007        end: Option<jiff::Timestamp>,
3008        limit: Option<usize>,
3009    ) -> Result<Vec<HyperliquidPublicTrade>> {
3010        let symbol = instrument_id.symbol;
3011        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3012        let alias = cache_alias_for_symbol(symbol.as_str())
3013            .map(|alias| Ustr::from(alias.as_str()))
3014            .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3015        let instrument = self
3016            .get_or_create_instrument(&alias, product_type)
3017            .ok_or_else(|| {
3018                Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3019            })?;
3020
3021        let raw_trades = match self
3022            .info_recent_trades(instrument.raw_symbol().as_ref())
3023            .await
3024        {
3025            Ok(trades) => trades,
3026            Err(e) if e.is_unprocessable_entity() => {
3027                log::warn!(
3028                    "Recent public trades endpoint unavailable for {instrument_id} \
3029                     (requires the Hyperliquid indexer); returning empty response"
3030                );
3031                Vec::new()
3032            }
3033            Err(e) => return Err(e),
3034        };
3035
3036        let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3037            .iter()
3038            .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3039                Ok(trade) => Some(trade),
3040                Err(e) => {
3041                    log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3042                    None
3043                }
3044            })
3045            .collect();
3046        trades.sort_by_key(|trade| trade.ts_event);
3047
3048        Ok(filter_recent_public_trades(
3049            trades,
3050            datetime_to_unix_nanos(start),
3051            datetime_to_unix_nanos(end),
3052            limit.filter(|limit| *limit > 0),
3053            instrument_id,
3054        ))
3055    }
3056
3057    /// Submits an order to the exchange.
3058    ///
3059    /// # Errors
3060    ///
3061    /// Returns an error if credentials are missing, order validation fails, serialization fails,
3062    /// or the API returns an error.
3063    #[expect(clippy::too_many_arguments)]
3064    pub async fn submit_order(
3065        &self,
3066        instrument_id: InstrumentId,
3067        client_order_id: ClientOrderId,
3068        order_side: OrderSide,
3069        order_type: OrderType,
3070        quantity: Quantity,
3071        time_in_force: TimeInForce,
3072        price: Option<Price>,
3073        trigger_price: Option<Price>,
3074        post_only: bool,
3075        reduce_only: bool,
3076    ) -> Result<OrderStatusReport> {
3077        let symbol = instrument_id.symbol.inner();
3078        let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3079            Error::bad_request(format!(
3080                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3081            ))
3082        })?;
3083
3084        let is_buy = matches!(order_side, OrderSide::Buy);
3085        let price_precision = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3086
3087        let price_decimal = match price {
3088            Some(px) if self.normalize_prices => {
3089                normalize_price(px.as_decimal(), price_precision).normalize()
3090            }
3091            Some(px) => px.as_decimal().normalize(),
3092            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3093            None if matches!(
3094                order_type,
3095                OrderType::StopMarket | OrderType::MarketIfTouched
3096            ) =>
3097            {
3098                match trigger_price {
3099                    Some(tp) => {
3100                        let derived = derive_limit_from_trigger(
3101                            tp.as_decimal().normalize(),
3102                            is_buy,
3103                            self.market_order_slippage_bps,
3104                        );
3105                        let sig_rounded = round_to_sig_figs(derived, 5);
3106                        clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
3107                    }
3108                    None => Decimal::ZERO,
3109                }
3110            }
3111            None => return Err(Error::bad_request("Limit orders require a price")),
3112        };
3113
3114        let size_decimal = quantity.as_decimal().normalize();
3115
3116        let kind = match order_type {
3117            OrderType::Market => HyperliquidExchangeOrderKind::Limit {
3118                limit: HyperliquidExchangeLimitParams {
3119                    tif: HyperliquidExchangeTif::Ioc,
3120                },
3121            },
3122            OrderType::Limit => {
3123                let tif = if post_only {
3124                    HyperliquidExchangeTif::Alo
3125                } else {
3126                    match time_in_force {
3127                        TimeInForce::Gtc => HyperliquidExchangeTif::Gtc,
3128                        TimeInForce::Ioc => HyperliquidExchangeTif::Ioc,
3129                        TimeInForce::Fok
3130                        | TimeInForce::Day
3131                        | TimeInForce::Gtd
3132                        | TimeInForce::AtTheOpen
3133                        | TimeInForce::AtTheClose => {
3134                            return Err(Error::bad_request(format!(
3135                                "Time in force {time_in_force:?} not supported"
3136                            )));
3137                        }
3138                    }
3139                };
3140                HyperliquidExchangeOrderKind::Limit {
3141                    limit: HyperliquidExchangeLimitParams { tif },
3142                }
3143            }
3144            OrderType::StopMarket
3145            | OrderType::StopLimit
3146            | OrderType::MarketIfTouched
3147            | OrderType::LimitIfTouched => {
3148                if let Some(trig_px) = trigger_price {
3149                    let trigger_price_decimal = if self.normalize_prices {
3150                        normalize_price(trig_px.as_decimal(), price_precision).normalize()
3151                    } else {
3152                        trig_px.as_decimal().normalize()
3153                    };
3154
3155                    // Determine TP/SL type based on order type
3156                    // StopMarket/StopLimit are always Sl (protective stops)
3157                    // MarketIfTouched/LimitIfTouched are always Tp (profit-taking/entry)
3158                    let tpsl = match order_type {
3159                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
3160                        OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3161                            HyperliquidExchangeTpSl::Tp
3162                        }
3163                        _ => unreachable!(),
3164                    };
3165
3166                    let is_market = matches!(
3167                        order_type,
3168                        OrderType::StopMarket | OrderType::MarketIfTouched
3169                    );
3170
3171                    HyperliquidExchangeOrderKind::Trigger {
3172                        trigger: HyperliquidExchangeTriggerParams {
3173                            is_market,
3174                            trigger_px: trigger_price_decimal,
3175                            tpsl,
3176                        },
3177                    }
3178                } else {
3179                    return Err(Error::bad_request("Trigger orders require a trigger price"));
3180                }
3181            }
3182            _ => {
3183                return Err(Error::bad_request(format!(
3184                    "Order type {order_type:?} not supported"
3185                )));
3186            }
3187        };
3188
3189        let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3190        let hyperliquid_order = HyperliquidExchangePlaceOrderRequest {
3191            asset,
3192            is_buy,
3193            price: price_decimal,
3194            size: size_decimal,
3195            reduce_only,
3196            kind,
3197            cloid: Some(cloid),
3198        };
3199
3200        let builder = self.builder_attribution();
3201
3202        let action = HyperliquidExchangeAction::Order {
3203            orders: vec![hyperliquid_order],
3204            grouping: HyperliquidExchangeGrouping::Na,
3205            builder,
3206        };
3207
3208        let response = self.inner.post_action_exec(&action).await?;
3209
3210        // A single (non-bracket) order should return an actionable status;
3211        // `None` (a deferred `Tag` child) is unexpected on this HTTP path.
3212        self.build_submit_order_report(
3213            instrument_id,
3214            client_order_id,
3215            order_side,
3216            order_type,
3217            quantity,
3218            time_in_force,
3219            price,
3220            trigger_price,
3221            response,
3222        )?
3223        .ok_or_else(|| {
3224            Error::bad_request(
3225                "Single-order submission returned no actionable status (deferred trigger child)",
3226            )
3227        })
3228    }
3229
3230    /// Submit an order using an OrderAny object.
3231    ///
3232    /// This is a convenience method that wraps submit_order.
3233    pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3234        self.submit_order(
3235            order.instrument_id(),
3236            order.client_order_id(),
3237            order.order_side(),
3238            order.order_type(),
3239            order.quantity(),
3240            order.time_in_force(),
3241            order.price(),
3242            order.trigger_price(),
3243            order.is_post_only(),
3244            order.is_reduce_only(),
3245        )
3246        .await
3247    }
3248
3249    #[expect(clippy::too_many_arguments)]
3250    fn create_order_status_report(
3251        &self,
3252        instrument_id: InstrumentId,
3253        client_order_id: Option<ClientOrderId>,
3254        venue_order_id: VenueOrderId,
3255        order_side: OrderSide,
3256        order_type: OrderType,
3257        quantity: Quantity,
3258        time_in_force: TimeInForce,
3259        price: Option<Price>,
3260        trigger_price: Option<Price>,
3261        order_status: OrderStatus,
3262        filled_qty: Quantity,
3263        _instrument: &InstrumentAny,
3264        account_id: AccountId,
3265        ts_init: UnixNanos,
3266    ) -> OrderStatusReport {
3267        let ts_accepted = self.clock.get_time_ns();
3268        let ts_last = ts_accepted;
3269        let report_id = UUID4::new();
3270
3271        let mut report = OrderStatusReport::new(
3272            account_id,
3273            instrument_id,
3274            client_order_id,
3275            venue_order_id,
3276            order_side.into(),
3277            order_type,
3278            time_in_force,
3279            order_status,
3280            quantity,
3281            filled_qty,
3282            ts_accepted,
3283            ts_last,
3284            ts_init,
3285            Some(report_id),
3286        );
3287
3288        if let Some(px) = price {
3289            report = report.with_price(px);
3290        }
3291
3292        if let Some(trig_px) = trigger_price {
3293            report = report
3294                .with_trigger_price(trig_px)
3295                .with_trigger_type(TriggerType::Default);
3296        }
3297
3298        report
3299    }
3300
3301    /// Submit multiple orders to the Hyperliquid exchange in a single request.
3302    ///
3303    /// # Errors
3304    ///
3305    /// Returns an error if credentials are missing, order validation fails, serialization fails,
3306    /// or the API returns an error.
3307    pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3308        // Convert orders using asset indices from the cached map
3309        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3310        let mut client_order_ids = Vec::with_capacity(orders.len());
3311
3312        for order in orders {
3313            let instrument_id = order.instrument_id();
3314            let symbol = instrument_id.symbol.inner();
3315            let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3316                Error::bad_request(format!(
3317                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3318                ))
3319            })?;
3320            let price_decimals = self.get_price_precision_for_symbol(symbol).unwrap_or(2);
3321            let request = order_to_hyperliquid_request_with_asset_and_cloid(
3322                order,
3323                asset,
3324                price_decimals,
3325                self.normalize_prices,
3326                self.market_order_slippage_bps,
3327                None,
3328            )
3329            .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3330            client_order_ids.push(order.client_order_id());
3331            hyperliquid_orders.push(request);
3332        }
3333
3334        for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3335            request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3336        }
3337
3338        let builder = self.builder_attribution();
3339
3340        let grouping =
3341            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3342
3343        let action = HyperliquidExchangeAction::Order {
3344            orders: hyperliquid_orders,
3345            grouping,
3346            builder,
3347        };
3348
3349        // Submit to exchange using the typed exec endpoint
3350        let response = self.inner.post_action_exec(&action).await?;
3351
3352        self.build_submit_orders_reports(orders, grouping, response)
3353    }
3354
3355    /// Parses a Hyperliquid exchange order response for a single-order submit
3356    /// into an [`OrderStatusReport`].
3357    ///
3358    /// Returns `Ok(None)` when the venue returned an empty `statuses` array or
3359    /// when the only status is a deferred `Tag` child (for example
3360    /// `waitingForFill`): the venue accepted the order but has not assigned an
3361    /// oid yet, so the order stays `SUBMITTED` until the user-events stream
3362    /// drives the first `OrderAccepted` with the real oid.
3363    ///
3364    /// Shared by the HTTP and WebSocket single-submit paths.
3365    ///
3366    /// # Errors
3367    ///
3368    /// Returns an error if account credentials are missing, the response is
3369    /// malformed, or the order returned an `error` status.
3370    #[expect(clippy::too_many_arguments)]
3371    pub fn build_submit_order_report(
3372        &self,
3373        instrument_id: InstrumentId,
3374        client_order_id: ClientOrderId,
3375        order_side: OrderSide,
3376        order_type: OrderType,
3377        quantity: Quantity,
3378        time_in_force: TimeInForce,
3379        price: Option<Price>,
3380        trigger_price: Option<Price>,
3381        response: HyperliquidExchangeResponse,
3382    ) -> Result<Option<OrderStatusReport>> {
3383        let order_response = parse_order_response(response)?;
3384
3385        let Some(order_status) = order_response.statuses.first() else {
3386            return Ok(None);
3387        };
3388
3389        let account_id = self
3390            .account_id
3391            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3392        let ts_init = self.clock.get_time_ns();
3393
3394        self.build_status_report(
3395            instrument_id,
3396            client_order_id,
3397            order_side,
3398            order_type,
3399            quantity,
3400            time_in_force,
3401            price,
3402            trigger_price,
3403            order_status,
3404            account_id,
3405            ts_init,
3406        )
3407    }
3408
3409    /// Parses a Hyperliquid exchange order response into per-order
3410    /// [`OrderStatusReport`]s, paired positionally with `orders`.
3411    ///
3412    /// Shared by the HTTP and WebSocket batch-submit paths since the response
3413    /// envelope is identical regardless of transport. Deferred `Tag` children
3414    /// (for example `waitingForFill`) are elided from the result; those orders
3415    /// stay `SUBMITTED` until the user-events stream delivers an `OrderAccepted`
3416    /// with the real oid.
3417    ///
3418    /// # Errors
3419    ///
3420    /// Returns an error if account credentials are missing, the response is
3421    /// malformed, an order returned an `error` status, or, for ungrouped
3422    /// submissions, the response status count diverges from the order count.
3423    pub fn build_submit_orders_reports(
3424        &self,
3425        orders: &[&OrderAny],
3426        grouping: HyperliquidExchangeGrouping,
3427        response: HyperliquidExchangeResponse,
3428    ) -> Result<Vec<OrderStatusReport>> {
3429        let order_response = parse_order_response(response)?;
3430
3431        let account_id = self
3432            .account_id
3433            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3434        let ts_init = self.clock.get_time_ns();
3435
3436        // For grouped orders (NormalTpsl/PositionTpsl) the exchange returns a
3437        // single status for the whole group, so only enforce 1:1 matching for
3438        // ungrouped (Na) submissions.
3439        if grouping == HyperliquidExchangeGrouping::Na
3440            && order_response.statuses.len() != orders.len()
3441        {
3442            return Err(Error::bad_request(format!(
3443                "Mismatch between submitted orders ({}) and response statuses ({})",
3444                orders.len(),
3445                order_response.statuses.len()
3446            )));
3447        }
3448
3449        // The exchange returns statuses in submission order, so pair each order
3450        // with its status positionally.
3451        let mut reports = Vec::with_capacity(order_response.statuses.len());
3452        for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
3453            if let Some(report) = self.build_status_report(
3454                order.instrument_id(),
3455                order.client_order_id(),
3456                order.order_side(),
3457                order.order_type(),
3458                order.quantity(),
3459                order.time_in_force(),
3460                order.price(),
3461                order.trigger_price(),
3462                order_status,
3463                account_id,
3464                ts_init,
3465            )? {
3466                reports.push(report);
3467            }
3468        }
3469
3470        Ok(reports)
3471    }
3472
3473    /// Builds an [`OrderStatusReport`] from a single venue status, or `Ok(None)`
3474    /// for a deferred `Tag` child that has no oid yet.
3475    ///
3476    /// `Tag` rows are elided rather than given a synthetic placeholder venue id:
3477    /// an earlier placeholder accept was deduped against the later real accept,
3478    /// so the cache never picked up the real oid and cancel/modify by venue id
3479    /// broke on bracket children.
3480    #[expect(clippy::too_many_arguments)]
3481    fn build_status_report(
3482        &self,
3483        instrument_id: InstrumentId,
3484        client_order_id: ClientOrderId,
3485        order_side: OrderSide,
3486        order_type: OrderType,
3487        quantity: Quantity,
3488        time_in_force: TimeInForce,
3489        price: Option<Price>,
3490        trigger_price: Option<Price>,
3491        order_status: &HyperliquidExchangeOrderStatus,
3492        account_id: AccountId,
3493        ts_init: UnixNanos,
3494    ) -> Result<Option<OrderStatusReport>> {
3495        if matches!(order_status, HyperliquidExchangeOrderStatus::Tag(_)) {
3496            return Ok(None);
3497        }
3498
3499        let symbol = instrument_id.symbol.as_str();
3500        let product_type = HyperliquidProductType::from_symbol(symbol).ok();
3501
3502        // Mirror the alias `cache_instrument` stored (token form for outcomes,
3503        // leading segment for perps / spots).
3504        let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
3505        let instrument = self
3506            .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
3507            .ok_or_else(|| {
3508                Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3509            })?;
3510
3511        let report = match order_status {
3512            HyperliquidExchangeOrderStatus::Resting { resting } => self.create_order_status_report(
3513                instrument_id,
3514                Some(client_order_id),
3515                VenueOrderId::new(resting.oid.to_string()),
3516                order_side,
3517                order_type,
3518                quantity,
3519                time_in_force,
3520                price,
3521                trigger_price,
3522                OrderStatus::Accepted,
3523                Quantity::zero(instrument.size_precision()),
3524                &instrument,
3525                account_id,
3526                ts_init,
3527            ),
3528            HyperliquidExchangeOrderStatus::Filled { filled } => {
3529                let filled_qty =
3530                    Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
3531                        .map_err(|e| {
3532                            Error::bad_request(format!(
3533                                "Invalid filled size {}: {e}",
3534                                filled.total_sz
3535                            ))
3536                        })?;
3537                self.create_order_status_report(
3538                    instrument_id,
3539                    Some(client_order_id),
3540                    VenueOrderId::new(filled.oid.to_string()),
3541                    order_side,
3542                    order_type,
3543                    quantity,
3544                    time_in_force,
3545                    price,
3546                    trigger_price,
3547                    OrderStatus::Filled,
3548                    filled_qty,
3549                    &instrument,
3550                    account_id,
3551                    ts_init,
3552                )
3553            }
3554            HyperliquidExchangeOrderStatus::Error { error } => {
3555                return Err(Error::bad_request(format!(
3556                    "Order {client_order_id} rejected: {error}"
3557                )));
3558            }
3559            HyperliquidExchangeOrderStatus::Tag(_) => unreachable!("handled above"),
3560        };
3561
3562        Ok(Some(report))
3563    }
3564
3565    fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
3566        if let Some(instrument_id) = instrument_id {
3567            return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
3568        }
3569
3570        let cached = self.instruments.load();
3571        reconciliation_dexes_from_builders(
3572            cached
3573                .keys()
3574                .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str())),
3575        )
3576    }
3577
3578    pub(crate) async fn reconciliation_dexes_from_activity(
3579        &self,
3580        historical_orders: &[HyperliquidOrderStatusEntry],
3581        fills: &HyperliquidFills,
3582    ) -> Result<Vec<Option<Ustr>>> {
3583        if historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3584            || fills.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
3585        {
3586            let builder_dexes = self
3587                .inner
3588                .load_perp_dexs()
3589                .await?
3590                .into_iter()
3591                .flatten()
3592                .filter(|dex| !dex.name.is_empty())
3593                .map(|dex| Ustr::from(dex.name.as_str()));
3594            return Ok(reconciliation_dexes_from_builders(builder_dexes));
3595        }
3596
3597        let builder_dexes = historical_orders
3598            .iter()
3599            .map(|entry| entry.order.coin.as_str())
3600            .chain(fills.iter().map(|fill| fill.coin.as_str()))
3601            .filter_map(perp_dex_from_activity_coin);
3602
3603        Ok(reconciliation_dexes_from_builders(builder_dexes))
3604    }
3605}
3606
3607fn reconciliation_dexes_from_builders(
3608    builder_dexes: impl IntoIterator<Item = Ustr>,
3609) -> Vec<Option<Ustr>> {
3610    let mut builder_dexes = builder_dexes.into_iter().collect::<Vec<_>>();
3611    builder_dexes.sort_unstable_by(|a, b| a.as_str().cmp(b.as_str()));
3612    builder_dexes.dedup();
3613
3614    let mut dexes = Vec::with_capacity(builder_dexes.len() + 1);
3615    dexes.push(None);
3616    dexes.extend(builder_dexes.into_iter().map(Some));
3617    dexes
3618}
3619
3620fn perp_dex_from_activity_coin(coin: &str) -> Option<Ustr> {
3621    if coin.starts_with(VAULT_TOKEN_PREFIX) {
3622        return None;
3623    }
3624
3625    let (dex, _) = coin.split_once(':')?;
3626    (!dex.is_empty()).then(|| Ustr::from(dex))
3627}
3628
3629fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
3630    symbol
3631        .strip_suffix("-PERP")?
3632        .split_once(':')
3633        .map(|(dex, _)| Ustr::from(dex))
3634}
3635
3636/// Extracts the order-status payload from an exchange response.
3637///
3638/// The newer response format nests the statuses under `data`; the older format
3639/// places them directly in the response body.
3640fn parse_order_response(
3641    response: HyperliquidExchangeResponse,
3642) -> Result<HyperliquidExchangeOrderResponseData> {
3643    let response_data = match response {
3644        HyperliquidExchangeResponse::Status {
3645            status,
3646            response: response_data,
3647        } if status == RESPONSE_STATUS_OK => response_data,
3648        HyperliquidExchangeResponse::Error { error } => {
3649            return Err(Error::bad_request(format!(
3650                "Order submission failed: {error}"
3651            )));
3652        }
3653        _ => return Err(Error::bad_request("Unexpected response format")),
3654    };
3655
3656    let data_value = if let Some(data) = response_data.get("data") {
3657        data.clone()
3658    } else {
3659        response_data
3660    };
3661
3662    serde_json::from_value(data_value)
3663        .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
3664}
3665
3666fn resolve_perp_dex_name(
3667    dex_index: usize,
3668    meta: &PerpMeta,
3669    perp_dexs: Option<&[Option<PerpDex>]>,
3670) -> String {
3671    if dex_index == 0 {
3672        return String::new();
3673    }
3674
3675    if let Some(dex_name) = perp_dexs
3676        .and_then(|dexs| dexs.get(dex_index))
3677        .and_then(|dex| dex.as_ref())
3678        .map(|dex| dex.name.clone())
3679    {
3680        return dex_name;
3681    }
3682
3683    meta.universe
3684        .iter()
3685        .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
3686        .unwrap_or_default()
3687}
3688
3689/// Returns the asset index base for a perp dex.
3690///
3691/// Standard perps (dex 0) start at 0. HIP-3 dexes start at
3692/// 100_000 + dex_index * 10_000.
3693fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3694    if dex_index == 0 {
3695        0
3696    } else {
3697        100_000 + dex_index as u32 * 10_000
3698    }
3699}
3700
3701#[cfg(test)]
3702mod tests {
3703    use std::{net::SocketAddr, sync::Arc};
3704
3705    use axum::{
3706        Router,
3707        extract::State,
3708        http::StatusCode,
3709        response::{IntoResponse, Json, Response},
3710        routing::post,
3711    };
3712    use nautilus_core::time::get_atomic_clock_realtime;
3713    use nautilus_model::{
3714        currencies::CURRENCY_MAP,
3715        enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce},
3716        identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol},
3717        instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3718        types::{Currency, Price, Quantity},
3719    };
3720    use rstest::rstest;
3721    use rust_decimal_macros::dec;
3722    use serde_json::{Value, json};
3723    use ustr::Ustr;
3724
3725    use super::{HyperliquidHttpClient, resolve_perp_dex_name};
3726    use crate::{
3727        common::{
3728            consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
3729            enums::{HyperliquidEnvironment, HyperliquidProductType},
3730        },
3731        http::{
3732            models::{Cloid, HyperliquidExchangeResponse, PerpAsset, PerpDex, PerpMeta},
3733            query::InfoRequest,
3734        },
3735    };
3736
3737    const TEST_PRIVATE_KEY: &str =
3738        "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
3739
3740    fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
3741        PerpMeta {
3742            universe: names
3743                .iter()
3744                .map(|name| PerpAsset {
3745                    name: (*name).to_string(),
3746                    ..Default::default()
3747                })
3748                .collect(),
3749            margin_tables: Vec::new(),
3750            collateral_token: None,
3751        }
3752    }
3753
3754    #[rstest]
3755    fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
3756        let meta = perp_meta_with_assets(&["BTC", "ETH"]);
3757        assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
3758    }
3759
3760    #[rstest]
3761    fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
3762        let meta = perp_meta_with_assets(&["xyz:TSLA"]);
3763        let perp_dexs = vec![
3764            None,
3765            Some(PerpDex {
3766                name: "xyz".to_string(),
3767            }),
3768        ];
3769        assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
3770    }
3771
3772    #[rstest]
3773    fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
3774        let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
3775        assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
3776    }
3777
3778    #[rstest]
3779    fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
3780        // A `Tag` status (for example the `waitingForFill` trigger child of a
3781        // `normalTpsl` bracket) must surface as `Ok(None)` so the caller leaves
3782        // the order SUBMITTED until the user-events stream confirms a real oid.
3783        let mut client =
3784            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3785        client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3786
3787        let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3788            "status": "ok",
3789            "response": {
3790                "type": "order",
3791                "data": {
3792                    "statuses": ["waitingForFill"]
3793                }
3794            }
3795        }))
3796        .unwrap();
3797
3798        let result = client
3799            .build_submit_order_report(
3800                InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3801                ClientOrderId::from("O-WAITING-CHILD"),
3802                OrderSide::Buy,
3803                OrderType::StopMarket,
3804                Quantity::from("100"),
3805                TimeInForce::Gtc,
3806                None,
3807                Some(Price::from("0.16136")),
3808                response,
3809            )
3810            .unwrap();
3811
3812        assert!(
3813            result.is_none(),
3814            "Tag status must elide so the order stays SUBMITTED, was {result:?}"
3815        );
3816    }
3817
3818    #[rstest]
3819    fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
3820        // An atomic `filled` submit response must surface as a FILLED report
3821        // carrying the venue oid and the total filled size built from the
3822        // Decimal `totalSz` at the instrument's size precision.
3823        let mut client =
3824            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3825        client.set_account_id(AccountId::from("HYPERLIQUID-001"));
3826
3827        let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
3828        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3829        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3830        let clock = get_atomic_clock_realtime();
3831        let ts = clock.get_time_ns();
3832        let perp = InstrumentAny::CryptoPerpetual(
3833            CryptoPerpetual::builder()
3834                .instrument_id(InstrumentId::new(
3835                    Symbol::new("ARB-USD-PERP"),
3836                    *HYPERLIQUID_VENUE,
3837                ))
3838                .raw_symbol(Symbol::new("ARB"))
3839                .base_currency(base)
3840                .quote_currency(usd)
3841                .settlement_currency(usdc)
3842                .is_inverse(false)
3843                .price_precision(5)
3844                .size_precision(2)
3845                .price_increment(Price::from("0.00001"))
3846                .size_increment(Quantity::from("0.01"))
3847                .ts_event(ts)
3848                .ts_init(ts)
3849                .build()
3850                .unwrap(),
3851        );
3852        client.cache_instrument(&perp);
3853
3854        let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
3855            "status": "ok",
3856            "response": {
3857                "type": "order",
3858                "data": {
3859                    "statuses": [{
3860                        "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
3861                    }]
3862                }
3863            }
3864        }))
3865        .unwrap();
3866
3867        let report = client
3868            .build_submit_order_report(
3869                InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
3870                ClientOrderId::from("O-FILLED-001"),
3871                OrderSide::Buy,
3872                OrderType::Market,
3873                Quantity::from("0.5"),
3874                TimeInForce::Ioc,
3875                None,
3876                None,
3877                response,
3878            )
3879            .unwrap()
3880            .expect("filled status must produce a report");
3881
3882        assert_eq!(report.order_status, OrderStatus::Filled);
3883        assert_eq!(report.venue_order_id.as_str(), "778899");
3884        assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
3885    }
3886
3887    #[derive(Clone, Default)]
3888    struct OutcomeMetaServerState {
3889        last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3890    }
3891
3892    async fn handle_outcome_meta_info(
3893        State(state): State<OutcomeMetaServerState>,
3894        body: axum::body::Bytes,
3895    ) -> Response {
3896        let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3897            return (
3898                StatusCode::BAD_REQUEST,
3899                Json(json!({"error": "Invalid JSON body"})),
3900            )
3901                .into_response();
3902        };
3903
3904        *state.last_request_body.lock().await = Some(request_body.clone());
3905
3906        if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3907            return (
3908                StatusCode::BAD_REQUEST,
3909                Json(json!({"error": "Expected outcomeMeta request"})),
3910            )
3911                .into_response();
3912        }
3913
3914        Json(json!({
3915            "outcomes": [
3916                {
3917                    "outcome": 123,
3918                    "name": "Recurring",
3919                    "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3920                    "sideSpecs": [
3921                        {"name": "Yes"},
3922                        {"name": "No"}
3923                    ]
3924                }
3925            ]
3926        }))
3927        .into_response()
3928    }
3929
3930    async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3931        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3932        let addr = listener.local_addr().unwrap();
3933        let router = Router::new()
3934            .route("/info", post(handle_outcome_meta_info))
3935            .with_state(state);
3936
3937        tokio::spawn(async move {
3938            axum::serve(listener, router).await.unwrap();
3939        });
3940
3941        addr
3942    }
3943
3944    async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
3945        let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3946            return (
3947                StatusCode::BAD_REQUEST,
3948                Json(json!({"error": "Invalid JSON body"})),
3949            )
3950                .into_response();
3951        };
3952
3953        match request_body.get("type").and_then(|value| value.as_str()) {
3954            Some("spotMeta") => (
3955                StatusCode::INTERNAL_SERVER_ERROR,
3956                Json(json!({"error": "spot metadata unavailable"})),
3957            )
3958                .into_response(),
3959            Some("allPerpMetas") => Json(json!([
3960                {
3961                    "collateralToken": 360,
3962                    "marginTables": [],
3963                    "universe": [
3964                        {
3965                            "maxLeverage": 20,
3966                            "name": "km:US500",
3967                            "szDecimals": 3
3968                        }
3969                    ]
3970                }
3971            ]))
3972            .into_response(),
3973            _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
3974        }
3975    }
3976
3977    async fn start_unresolved_collateral_server() -> SocketAddr {
3978        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3979        let addr = listener.local_addr().unwrap();
3980        let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
3981
3982        tokio::spawn(async move {
3983            axum::serve(listener, router).await.unwrap();
3984        });
3985
3986        addr
3987    }
3988
3989    #[rstest]
3990    fn stable_json_roundtrips() {
3991        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3992        let s = serde_json::to_string(&v).unwrap();
3993        // Parse back to ensure JSON structure is correct, regardless of field order
3994        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3995        assert_eq!(parsed["type"], "l2Book");
3996        assert_eq!(parsed["coin"], "BTC");
3997        assert_eq!(parsed, v);
3998    }
3999
4000    #[rstest]
4001    fn info_pretty_shape() {
4002        let r = InfoRequest::l2_book("BTC");
4003        let val = serde_json::to_value(&r).unwrap();
4004        let pretty = serde_json::to_string_pretty(&val).unwrap();
4005        assert!(pretty.contains("\"type\": \"l2Book\""));
4006        assert!(pretty.contains("\"coin\": \"BTC\""));
4007    }
4008
4009    #[rstest]
4010    fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
4011        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4012        let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
4013        let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
4014        let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
4015        let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
4016
4017        let first = client.get_or_generate_client_order_id_cloid(client_order_id);
4018        let second = client.get_or_generate_client_order_id_cloid(client_order_id);
4019        client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
4020        client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
4021        client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
4022
4023        assert_eq!(first, Cloid::from_client_order_id(client_order_id));
4024        assert_eq!(first, second);
4025        assert_eq!(
4026            client.cached_client_order_id_cloid(&client_order_id),
4027            Some(first),
4028            "cache insert must not overwrite an existing generated CLOID",
4029        );
4030        assert_eq!(
4031            client.unique_cached_client_order_id_cloid(&client_order_id),
4032            Some(first),
4033        );
4034        assert_eq!(
4035            client.cached_client_order_id_cloid(&other_client_order_id),
4036            Some(explicit_cloid),
4037        );
4038        assert_eq!(
4039            client.unique_cached_client_order_id_cloid(&other_client_order_id),
4040            None,
4041            "duplicate CLOID mappings are not safe modify targets",
4042        );
4043        assert_eq!(
4044            client.remove_client_order_id_cloid(&client_order_id),
4045            Some(first),
4046        );
4047        assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
4048    }
4049
4050    #[rstest]
4051    fn test_builder_attribution_defaults_to_mainnet_builder() {
4052        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4053        let builder = client
4054            .builder_attribution()
4055            .expect("mainnet client should include builder attribution by default");
4056
4057        assert!(client.include_builder_attribution());
4058        assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
4059        assert_eq!(builder.fee_tenths_bp, 0);
4060    }
4061
4062    #[rstest]
4063    fn test_builder_attribution_disabled_returns_none() {
4064        let mut client =
4065            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4066        client.set_include_builder_attribution(false);
4067
4068        assert!(!client.include_builder_attribution());
4069        assert!(client.builder_attribution().is_none());
4070    }
4071
4072    #[rstest]
4073    fn test_builder_attribution_omitted_on_testnet() {
4074        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4075
4076        assert!(client.include_builder_attribution());
4077        assert!(client.builder_attribution().is_none());
4078    }
4079
4080    #[rstest]
4081    #[tokio::test]
4082    async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
4083        let state = OutcomeMetaServerState::default();
4084        let addr = start_outcome_meta_server(state.clone()).await;
4085        let mut client =
4086            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4087        client.set_base_info_url(format!("http://{addr}/info"));
4088
4089        let meta = client.get_outcome_meta().await.unwrap();
4090        let request_body = state.last_request_body.lock().await.clone().unwrap();
4091
4092        assert_eq!(request_body, json!({"type": "outcomeMeta"}));
4093        assert_eq!(meta.outcomes.len(), 1);
4094        assert_eq!(meta.outcomes[0].outcome, 123);
4095        assert_eq!(meta.outcomes[0].name, "Recurring");
4096        assert_eq!(meta.outcomes[0].side_specs.len(), 2);
4097        assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
4098        assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
4099    }
4100
4101    #[rstest]
4102    #[tokio::test]
4103    async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
4104        let addr = start_unresolved_collateral_server().await;
4105        let mut client =
4106            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4107        client.set_base_info_url(format!("http://{addr}/info"));
4108
4109        let err = client.request_instrument_defs().await.unwrap_err();
4110
4111        assert_eq!(
4112            err.to_string(),
4113            "decode error: failed to resolve perp settlement currency for dex 0: \
4114             Spot metadata required to resolve perp collateral token 360",
4115        );
4116    }
4117
4118    #[rstest]
4119    fn test_with_credentials_preserves_explicit_account_address() {
4120        let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4121        let client = HyperliquidHttpClient::with_credentials(
4122            Some(TEST_PRIVATE_KEY.to_string()),
4123            None,
4124            Some(account_address),
4125            HyperliquidEnvironment::Mainnet,
4126            60,
4127            None,
4128        )
4129        .unwrap();
4130
4131        assert_eq!(client.get_account_address().unwrap(), account_address);
4132    }
4133
4134    #[rstest]
4135    fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
4136        let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
4137        let client = HyperliquidHttpClient::from_resolved_credentials(
4138            None,
4139            None,
4140            Some(account_address.to_string()),
4141            HyperliquidEnvironment::Mainnet,
4142            60,
4143            None,
4144        )
4145        .unwrap();
4146
4147        assert_eq!(client.get_account_address().unwrap(), account_address);
4148    }
4149
4150    #[rstest]
4151    fn test_cache_instrument_by_raw_symbol() {
4152        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4153
4154        // Create a test instrument with base currency "vntls:vCURSOR"
4155        let base_code = "vntls:vCURSOR";
4156        let quote_code = "USDC";
4157
4158        // Register the custom currency
4159        {
4160            let mut currency_map = CURRENCY_MAP.lock();
4161            if !currency_map.contains_key(base_code) {
4162                currency_map.insert(
4163                    base_code.to_string(),
4164                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
4165                );
4166            }
4167        }
4168
4169        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
4170        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
4171
4172        // Nautilus symbol is "vntls:vCURSOR-USDC-SPOT"
4173        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
4174        let venue = *HYPERLIQUID_VENUE;
4175        let instrument_id = InstrumentId::new(symbol, venue);
4176
4177        // raw_symbol is set to the base currency "vntls:vCURSOR" (see parse.rs)
4178        let raw_symbol = Symbol::new(base_code);
4179
4180        let clock = get_atomic_clock_realtime();
4181        let ts = clock.get_time_ns();
4182
4183        let instrument = InstrumentAny::CurrencyPair(
4184            CurrencyPair::builder()
4185                .instrument_id(instrument_id)
4186                .raw_symbol(raw_symbol)
4187                .base_currency(base_currency)
4188                .quote_currency(quote_currency)
4189                .price_precision(8)
4190                .size_precision(8)
4191                .price_increment(Price::from("0.00000001"))
4192                .size_increment(Quantity::from("0.00000001"))
4193                .ts_event(ts)
4194                .ts_init(ts)
4195                .build()
4196                .unwrap(),
4197        );
4198
4199        // Cache the instrument
4200        client.cache_instrument(&instrument);
4201
4202        // Verify it can be looked up by full symbol
4203        let instruments = client.instruments.load();
4204        let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
4205        assert!(
4206            by_full_symbol.is_some(),
4207            "Instrument should be accessible by full symbol"
4208        );
4209        assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
4210
4211        // Verify it can be looked up by raw_symbol (coin) - backward compatibility
4212        let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
4213        assert!(
4214            by_raw_symbol.is_some(),
4215            "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
4216        );
4217        assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
4218        drop(instruments);
4219
4220        // Verify it can be looked up by composite key (coin, product_type)
4221        let instruments_by_coin = client.instruments_by_coin.load();
4222        let by_coin =
4223            instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
4224        assert!(
4225            by_coin.is_some(),
4226            "Instrument should be accessible by coin and product type"
4227        );
4228        assert_eq!(by_coin.unwrap().id(), instrument.id());
4229        drop(instruments_by_coin);
4230
4231        // Verify get_or_create_instrument works with product type
4232        let retrieved_with_type = client.get_or_create_instrument(
4233            &Ustr::from("vntls:vCURSOR"),
4234            Some(HyperliquidProductType::Spot),
4235        );
4236        assert!(retrieved_with_type.is_some());
4237        assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
4238
4239        // Verify get_or_create_instrument works without product type (fallback)
4240        let retrieved_without_type =
4241            client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
4242        assert!(retrieved_without_type.is_some());
4243        assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
4244    }
4245
4246    #[rstest]
4247    fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
4248        // HTTP fill payloads for HIP-4 outcomes arrive with `coin = "#E"` and
4249        // no product-type context, so the no-product fallback in
4250        // `get_or_create_instrument` must check the Outcome bucket. Without
4251        // this, venue Settlement and userOutcome fills are silently dropped
4252        // from request_fill_reports / request_order_status_reports.
4253        use nautilus_core::time::get_atomic_clock_realtime;
4254        use nautilus_model::{
4255            enums::AssetClass,
4256            identifiers::{InstrumentId, Symbol},
4257            instruments::{BinaryOption, InstrumentAny},
4258            types::{Currency, Price, Quantity},
4259        };
4260
4261        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4262        let coin = "#500";
4263        let token = "+500";
4264
4265        let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
4266        let symbol = Symbol::new(token);
4267        let raw_symbol = Symbol::new(coin);
4268        let venue = *HYPERLIQUID_VENUE;
4269        let instrument_id = InstrumentId::new(symbol, venue);
4270
4271        let clock = get_atomic_clock_realtime();
4272        let ts = clock.get_time_ns();
4273
4274        let binary = InstrumentAny::BinaryOption(
4275            BinaryOption::builder()
4276                .instrument_id(instrument_id)
4277                .raw_symbol(raw_symbol)
4278                .asset_class(AssetClass::Alternative)
4279                .currency(usdh)
4280                .activation_ns(Default::default())
4281                .expiration_ns(Default::default())
4282                .price_precision(4)
4283                .size_precision(2)
4284                .price_increment(Price::from("0.0001"))
4285                .size_increment(Quantity::from("0.01"))
4286                .ts_event(ts)
4287                .ts_init(ts)
4288                .build()
4289                .unwrap(),
4290        );
4291
4292        client.cache_instrument(&binary);
4293
4294        let with_type = client
4295            .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
4296        assert!(with_type.is_some());
4297        assert_eq!(with_type.unwrap().id(), instrument_id);
4298
4299        let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
4300        assert!(
4301            no_type.is_some(),
4302            "Outcome coin must resolve through the no-product fallback",
4303        );
4304        assert_eq!(no_type.unwrap().id(), instrument_id);
4305
4306        let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
4307        assert!(missing.is_none());
4308    }
4309
4310    #[rstest]
4311    fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
4312        // Two spot pairs share the base token "HYPE": the canonical pair is
4313        // cached first; a subsequent non-canonical pair must not overwrite the
4314        // base-token alias so lookups by "HYPE" keep resolving to the canonical
4315        // instrument.
4316        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4317
4318        let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
4319        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4320        let clock = get_atomic_clock_realtime();
4321        let ts = clock.get_time_ns();
4322
4323        let canonical = InstrumentAny::CurrencyPair(
4324            CurrencyPair::builder()
4325                .instrument_id(InstrumentId::new(
4326                    Symbol::new("HYPE-USDC-SPOT"),
4327                    *HYPERLIQUID_VENUE,
4328                ))
4329                .raw_symbol(Symbol::new("@107"))
4330                .base_currency(hype)
4331                .quote_currency(usdc)
4332                .price_precision(5)
4333                .size_precision(2)
4334                .price_increment(Price::from("0.00001"))
4335                .size_increment(Quantity::from("0.01"))
4336                .ts_event(ts)
4337                .ts_init(ts)
4338                .build()
4339                .unwrap(),
4340        );
4341
4342        let non_canonical = InstrumentAny::CurrencyPair(
4343            CurrencyPair::builder()
4344                .instrument_id(InstrumentId::new(
4345                    Symbol::new("HYPE-USDC-SPOT"),
4346                    *HYPERLIQUID_VENUE,
4347                ))
4348                .raw_symbol(Symbol::new("@999"))
4349                .base_currency(hype)
4350                .quote_currency(usdc)
4351                .price_precision(5)
4352                .size_precision(2)
4353                .price_increment(Price::from("0.00001"))
4354                .size_increment(Quantity::from("0.01"))
4355                .ts_event(ts)
4356                .ts_init(ts)
4357                .build()
4358                .unwrap(),
4359        );
4360
4361        client.cache_instrument(&canonical);
4362        client.cache_instrument(&non_canonical);
4363
4364        let instruments_by_coin = client.instruments_by_coin.load();
4365        let by_base = instruments_by_coin
4366            .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
4367            .expect("base alias must resolve");
4368        assert_eq!(
4369            by_base.raw_symbol().inner().as_str(),
4370            "@107",
4371            "base alias must point to the canonical pair, not the one cached later",
4372        );
4373    }
4374
4375    #[rstest]
4376    fn test_cache_instrument_perp_aliases_sanitized_base() {
4377        // HIP-3 perp with wildcard-bearing venue name: `instrument_id.symbol`
4378        // is sanitized but order paths derive a coin key by splitting that
4379        // sanitized symbol on `-`. The cache must alias on the sanitized base
4380        // so those lookups resolve to the same instrument cached under
4381        // `raw_symbol` (the venue-official name).
4382        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4383
4384        let base_currency = Currency::new(
4385            "dex:STREAMABCD****",
4386            8,
4387            0,
4388            "dex:STREAMABCD****",
4389            CurrencyType::Crypto,
4390        );
4391        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4392        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4393        let clock = get_atomic_clock_realtime();
4394        let ts = clock.get_time_ns();
4395
4396        let hip3 = InstrumentAny::CryptoPerpetual(
4397            CryptoPerpetual::builder()
4398                .instrument_id(InstrumentId::new(
4399                    Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
4400                    *HYPERLIQUID_VENUE,
4401                ))
4402                .raw_symbol(Symbol::new("dex:STREAMABCD****"))
4403                .base_currency(base_currency)
4404                .quote_currency(usd)
4405                .settlement_currency(usdc)
4406                .is_inverse(false)
4407                .price_precision(6)
4408                .size_precision(3)
4409                .price_increment(Price::from("0.000001"))
4410                .size_increment(Quantity::from("0.001"))
4411                .ts_event(ts)
4412                .ts_init(ts)
4413                .build()
4414                .unwrap(),
4415        );
4416
4417        client.cache_instrument(&hip3);
4418
4419        let instruments_by_coin = client.instruments_by_coin.load();
4420        let by_raw = instruments_by_coin
4421            .get(&(
4422                Ustr::from("dex:STREAMABCD****"),
4423                HyperliquidProductType::Perp,
4424            ))
4425            .expect("venue coin lookup must resolve");
4426        assert_eq!(by_raw.id(), hip3.id());
4427
4428        let by_sanitized = instruments_by_coin
4429            .get(&(
4430                Ustr::from("dex:STREAMABCDxxxx"),
4431                HyperliquidProductType::Perp,
4432            ))
4433            .expect("sanitized base lookup must resolve");
4434        assert_eq!(by_sanitized.id(), hip3.id());
4435        drop(instruments_by_coin);
4436
4437        // Confirm the order-submission lookup path resolves through the alias.
4438        let resolved = client
4439            .get_or_create_instrument(
4440                &Ustr::from("dex:STREAMABCDxxxx"),
4441                Some(HyperliquidProductType::Perp),
4442            )
4443            .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
4444        assert_eq!(resolved.id(), hip3.id());
4445    }
4446}