Skip to main content

nautilus_hyperliquid/http/
client.rs

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