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