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    env,
26    num::NonZeroU32,
27    sync::{Arc, LazyLock},
28    time::Duration,
29};
30
31use ahash::AHashMap;
32use anyhow::Context;
33use nautilus_core::{
34    AtomicMap, UUID4, UnixNanos,
35    consts::NAUTILUS_USER_AGENT,
36    time::{AtomicTime, get_atomic_clock_realtime},
37};
38use nautilus_model::{
39    data::{Bar, BarType},
40    enums::{
41        AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
42        TriggerType,
43    },
44    events::AccountState,
45    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
46    instruments::{CurrencyPair, Instrument, InstrumentAny},
47    orders::{Order, OrderAny},
48    reports::{FillReport, OrderStatusReport, PositionStatusReport},
49    types::{AccountBalance, Currency, Price, Quantity},
50};
51use nautilus_network::{
52    http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
53    ratelimiter::quota::Quota,
54};
55use rust_decimal::Decimal;
56use serde_json::Value;
57use ustr::Ustr;
58
59use crate::{
60    common::{
61        consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
62        credential::{Secrets, VaultAddress},
63        enums::{
64            HyperliquidBarInterval, HyperliquidEnvironment,
65            HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
66        },
67        parse::{
68            bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
69            determine_order_list_grouping, extract_inner_error, normalize_price,
70            order_to_hyperliquid_request_with_asset, parse_combined_account_balances_and_margins,
71            parse_spot_account_balances, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
72        },
73    },
74    data::candle_to_bar,
75    http::{
76        error::{Error, Result},
77        models::{
78            ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
79            HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
80            HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
81            HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecModifyOrderRequest,
82            HyperliquidExecOrderKind, HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
83            HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
84            HyperliquidExecTriggerParams, HyperliquidFills, HyperliquidFundingHistoryEntry,
85            HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs,
86            RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
87        },
88        parse::{
89            HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
90            parse_order_status_report_from_basic, parse_perp_instruments,
91            parse_position_status_report, parse_spot_instruments,
92            parse_spot_position_status_report,
93        },
94        query::{ExchangeAction, InfoRequest},
95        rate_limits::{
96            RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
97            info_base_weight, info_extra_weight,
98        },
99    },
100    signing::{
101        HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
102    },
103    websocket::messages::WsBasicOrderData,
104};
105
106// https://hyperliquid.xyz/docs/api#rate-limits
107pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
108    LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
109
110/// Provides a raw HTTP client for low-level Hyperliquid REST API operations.
111///
112/// This client handles HTTP infrastructure, request signing, and raw API calls
113/// that closely match Hyperliquid endpoint specifications.
114#[derive(Debug, Clone)]
115#[cfg_attr(
116    feature = "python",
117    pyo3::pyclass(
118        module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
119        from_py_object
120    )
121)]
122#[cfg_attr(
123    feature = "python",
124    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
125)]
126pub struct HyperliquidRawHttpClient {
127    client: HttpClient,
128    environment: HyperliquidEnvironment,
129    base_info: String,
130    base_exchange: String,
131    signer: Option<HyperliquidEip712Signer>,
132    nonce_manager: Option<Arc<NonceManager>>,
133    vault_address: Option<VaultAddress>,
134    rest_limiter: Arc<WeightedLimiter>,
135    rate_limit_backoff_base: Duration,
136    rate_limit_backoff_cap: Duration,
137    rate_limit_max_attempts_info: u32,
138}
139
140impl HyperliquidRawHttpClient {
141    /// Creates a new [`HyperliquidRawHttpClient`] for public endpoints only.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the HTTP client cannot be created.
146    pub fn new(
147        environment: HyperliquidEnvironment,
148        timeout_secs: u64,
149        proxy_url: Option<String>,
150    ) -> std::result::Result<Self, HttpClientError> {
151        Ok(Self {
152            client: HttpClient::new(
153                Self::default_headers(),
154                vec![],
155                vec![],
156                Some(*HYPERLIQUID_REST_QUOTA),
157                Some(timeout_secs),
158                proxy_url,
159            )?,
160            environment,
161            base_info: info_url(environment).to_string(),
162            base_exchange: exchange_url(environment).to_string(),
163            signer: None,
164            nonce_manager: None,
165            vault_address: None,
166            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
167            rate_limit_backoff_base: Duration::from_millis(125),
168            rate_limit_backoff_cap: Duration::from_secs(5),
169            rate_limit_max_attempts_info: 3,
170        })
171    }
172
173    /// Creates a new [`HyperliquidRawHttpClient`] configured with credentials
174    /// for authenticated requests.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the HTTP client cannot be created.
179    pub fn with_credentials(
180        secrets: &Secrets,
181        timeout_secs: u64,
182        proxy_url: Option<String>,
183    ) -> std::result::Result<Self, HttpClientError> {
184        let signer = HyperliquidEip712Signer::new(&secrets.private_key)
185            .map_err(|e| HttpClientError::from(e.to_string()))?;
186        let nonce_manager = Arc::new(NonceManager::new());
187
188        Ok(Self {
189            client: HttpClient::new(
190                Self::default_headers(),
191                vec![],
192                vec![],
193                Some(*HYPERLIQUID_REST_QUOTA),
194                Some(timeout_secs),
195                proxy_url,
196            )?,
197            environment: secrets.environment,
198            base_info: info_url(secrets.environment).to_string(),
199            base_exchange: exchange_url(secrets.environment).to_string(),
200            signer: Some(signer),
201            nonce_manager: Some(nonce_manager),
202            vault_address: secrets.vault_address,
203            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
204            rate_limit_backoff_base: Duration::from_millis(125),
205            rate_limit_backoff_cap: Duration::from_secs(5),
206            rate_limit_max_attempts_info: 3,
207        })
208    }
209
210    /// Overrides the base info URL (for testing with mock servers).
211    pub fn set_base_info_url(&mut self, url: String) {
212        self.base_info = url;
213    }
214
215    /// Overrides the base exchange URL (for testing with mock servers).
216    pub fn set_base_exchange_url(&mut self, url: String) {
217        self.base_exchange = url;
218    }
219
220    /// Creates an authenticated client from environment variables for the specified network.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`Error::Auth`] if required environment variables are not set.
225    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
226        let secrets = Secrets::from_env(environment)
227            .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
228        Self::with_credentials(&secrets, 60, None)
229            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
230    }
231
232    /// Creates a new [`HyperliquidRawHttpClient`] configured with explicit credentials.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
237    pub fn from_credentials(
238        private_key: &str,
239        vault_address: Option<&str>,
240        environment: HyperliquidEnvironment,
241        timeout_secs: u64,
242        proxy_url: Option<String>,
243    ) -> Result<Self> {
244        let secrets = Secrets::from_private_key(private_key, vault_address, environment)
245            .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
246        Self::with_credentials(&secrets, timeout_secs, proxy_url)
247            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
248    }
249
250    /// Configure rate limiting parameters (chainable).
251    #[must_use]
252    pub fn with_rate_limits(mut self) -> Self {
253        self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
254        self.rate_limit_backoff_base = Duration::from_millis(125);
255        self.rate_limit_backoff_cap = Duration::from_secs(5);
256        self.rate_limit_max_attempts_info = 3;
257        self
258    }
259
260    /// Returns the configured environment.
261    #[must_use]
262    pub fn environment(&self) -> HyperliquidEnvironment {
263        self.environment
264    }
265
266    /// Returns whether this client is configured for testnet.
267    #[must_use]
268    pub fn is_testnet(&self) -> bool {
269        self.environment == HyperliquidEnvironment::Testnet
270    }
271
272    /// Gets the user address derived from the private key (if client has credentials).
273    ///
274    /// # Errors
275    ///
276    /// Returns [`Error::Auth`] if the client has no signer configured.
277    pub fn get_user_address(&self) -> Result<String> {
278        self.signer
279            .as_ref()
280            .ok_or_else(|| Error::auth("No signer configured"))?
281            .address()
282    }
283
284    /// Returns `true` if a vault address is configured.
285    #[must_use]
286    pub fn has_vault_address(&self) -> bool {
287        self.vault_address.is_some()
288    }
289
290    /// Gets the account address for queries: vault address if configured,
291    /// otherwise the user (EOA) address.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`Error::Auth`] if the client has no signer configured.
296    pub fn get_account_address(&self) -> Result<String> {
297        if let Some(vault) = &self.vault_address {
298            Ok(vault.to_hex())
299        } else {
300            self.get_user_address()
301        }
302    }
303
304    fn default_headers() -> HashMap<String, String> {
305        HashMap::from([
306            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
307            ("Content-Type".to_string(), "application/json".to_string()),
308        ])
309    }
310
311    fn signer_id(&self) -> SignerId {
312        SignerId("hyperliquid:default".into())
313    }
314
315    fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
316        let retry_after = headers.get("retry-after")?;
317        retry_after.parse::<u64>().ok().map(|s| s * 1000) // convert seconds to ms
318    }
319
320    /// Get metadata about available markets.
321    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
322        let request = InfoRequest::meta();
323        let response = self.send_info_request(&request).await?;
324        serde_json::from_value(response).map_err(Error::Serde)
325    }
326
327    /// Get complete spot metadata (tokens and pairs).
328    pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
329        let request = InfoRequest::spot_meta();
330        let response = self.send_info_request(&request).await?;
331        serde_json::from_value(response).map_err(Error::Serde)
332    }
333
334    /// Get perpetuals metadata with asset contexts (for price precision refinement).
335    pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
336        let request = InfoRequest::meta_and_asset_ctxs();
337        let response = self.send_info_request(&request).await?;
338        serde_json::from_value(response).map_err(Error::Serde)
339    }
340
341    /// Get spot metadata with asset contexts (for price precision refinement).
342    pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
343        let request = InfoRequest::spot_meta_and_asset_ctxs();
344        let response = self.send_info_request(&request).await?;
345        serde_json::from_value(response).map_err(Error::Serde)
346    }
347
348    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
349        let request = InfoRequest::meta();
350        let response = self.send_info_request(&request).await?;
351        serde_json::from_value(response).map_err(Error::Serde)
352    }
353
354    /// Get metadata for all perp dexes (standard + HIP-3).
355    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
356        let request = InfoRequest::all_perp_metas();
357        let response = self.send_info_request(&request).await?;
358        serde_json::from_value(response).map_err(Error::Serde)
359    }
360
361    /// Get L2 order book for a coin.
362    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
363        let request = InfoRequest::l2_book(coin);
364        let response = self.send_info_request(&request).await?;
365        serde_json::from_value(response).map_err(Error::Serde)
366    }
367
368    /// Get user fills (trading history).
369    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
370        let request = InfoRequest::user_fills(user);
371        let response = self.send_info_request(&request).await?;
372        serde_json::from_value(response).map_err(Error::Serde)
373    }
374
375    /// Get order status for a user.
376    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
377        let request = InfoRequest::order_status(user, oid);
378        let response = self.send_info_request(&request).await?;
379        serde_json::from_value(response).map_err(Error::Serde)
380    }
381
382    /// Get all open orders for a user.
383    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
384        let request = InfoRequest::open_orders(user);
385        self.send_info_request(&request).await
386    }
387
388    /// Get frontend open orders (includes more detail) for a user.
389    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
390        let request = InfoRequest::frontend_open_orders(user);
391        self.send_info_request(&request).await
392    }
393
394    /// Get clearinghouse state (balances, positions, margin) for a user.
395    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
396        let request = InfoRequest::clearinghouse_state(user);
397        self.send_info_request(&request).await
398    }
399
400    /// Get spot clearinghouse state (per-token spot balances) for a user.
401    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
402        let request = InfoRequest::spot_clearinghouse_state(user);
403        self.send_info_request(&request).await
404    }
405
406    /// Get user fee schedule and effective rates.
407    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
408        let request = InfoRequest::user_fees(user);
409        self.send_info_request(&request).await
410    }
411
412    /// Get candle/bar data for a coin.
413    pub async fn info_candle_snapshot(
414        &self,
415        coin: &str,
416        interval: HyperliquidBarInterval,
417        start_time: u64,
418        end_time: u64,
419    ) -> Result<HyperliquidCandleSnapshot> {
420        let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
421        let response = self.send_info_request(&request).await?;
422
423        log::trace!(
424            "Candle snapshot raw response (len={}): {:?}",
425            response.as_array().map_or(0, |a| a.len()),
426            response
427        );
428
429        serde_json::from_value(response).map_err(Error::Serde)
430    }
431
432    /// Get historical funding rates for a coin.
433    ///
434    /// `start_time` and `end_time` are Unix milliseconds. `end_time` is optional;
435    /// if omitted, the venue returns entries up to the most recent funding.
436    pub async fn info_funding_history(
437        &self,
438        coin: &str,
439        start_time: u64,
440        end_time: Option<u64>,
441    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
442        let request = InfoRequest::funding_history(coin, start_time, end_time);
443        let response = self.send_info_request(&request).await?;
444        serde_json::from_value(response).map_err(Error::Serde)
445    }
446
447    /// Generic info request method that returns raw JSON (useful for new endpoints and testing).
448    pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
449        self.send_info_request(request).await
450    }
451
452    async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
453        let base_w = info_base_weight(request);
454        self.rest_limiter.acquire(base_w).await;
455
456        let mut attempt = 0u32;
457
458        loop {
459            let response = self.http_roundtrip_info(request).await?;
460
461            if response.status.is_success() {
462                // decode once to count items, then materialize T
463                let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
464                let extra = info_extra_weight(request, &val);
465                if extra > 0 {
466                    self.rest_limiter.debit_extra(extra).await;
467                    log::debug!(
468                        "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
469                    );
470                }
471                return Ok(val);
472            }
473
474            // 429 → respect Retry-After; else jittered backoff. Retry Info only.
475            if response.status.as_u16() == 429 {
476                if attempt >= self.rate_limit_max_attempts_info {
477                    let ra = self.parse_retry_after_simple(&response.headers);
478                    return Err(Error::rate_limit("info", base_w, ra));
479                }
480                let delay = self
481                    .parse_retry_after_simple(&response.headers)
482                    .map_or_else(
483                        || {
484                            backoff_full_jitter(
485                                attempt,
486                                self.rate_limit_backoff_base,
487                                self.rate_limit_backoff_cap,
488                            )
489                        },
490                        Duration::from_millis,
491                    );
492                log::warn!(
493                    "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
494                    delay.as_millis()
495                );
496                attempt += 1;
497                tokio::time::sleep(delay).await;
498                // tiny re-acquire to avoid stampede exactly on minute boundary
499                self.rest_limiter.acquire(1).await;
500                continue;
501            }
502
503            // transient 5xx: treat like retryable Info (bounded)
504            if (response.status.is_server_error() || response.status.as_u16() == 408)
505                && attempt < self.rate_limit_max_attempts_info
506            {
507                let delay = backoff_full_jitter(
508                    attempt,
509                    self.rate_limit_backoff_base,
510                    self.rate_limit_backoff_cap,
511                );
512                log::warn!(
513                    "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
514                    response.status.as_u16(),
515                    delay.as_millis()
516                );
517                attempt += 1;
518                tokio::time::sleep(delay).await;
519                continue;
520            }
521
522            // non-retryable or exhausted
523            let error_body = String::from_utf8_lossy(&response.body);
524            return Err(Error::http(
525                response.status.as_u16(),
526                error_body.to_string(),
527            ));
528        }
529    }
530
531    async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
532        let url = &self.base_info;
533        let body = serde_json::to_value(request).map_err(Error::Serde)?;
534        let body_bytes = serde_json::to_string(&body)
535            .map_err(Error::Serde)?
536            .into_bytes();
537
538        self.client
539            .request(
540                Method::POST,
541                url.clone(),
542                None,
543                None,
544                Some(body_bytes),
545                None,
546                None,
547            )
548            .await
549            .map_err(Error::from_http_client)
550    }
551
552    /// Send a signed action to the exchange.
553    pub async fn post_action(
554        &self,
555        action: &ExchangeAction,
556    ) -> Result<HyperliquidExchangeResponse> {
557        let w = exchange_weight(action);
558        self.rest_limiter.acquire(w).await;
559
560        let signer = self
561            .signer
562            .as_ref()
563            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
564
565        let nonce_manager = self
566            .nonce_manager
567            .as_ref()
568            .ok_or_else(|| Error::auth("nonce manager missing"))?;
569
570        let signer_id = self.signer_id();
571        let time_nonce = nonce_manager.next(signer_id)?;
572
573        let action_value = serde_json::to_value(action)
574            .context("serialize exchange action")
575            .map_err(|e| Error::bad_request(e.to_string()))?;
576
577        // Serialize the original action struct with MessagePack for L1 signing
578        let action_bytes = rmp_serde::to_vec_named(action)
579            .context("serialize action with MessagePack")
580            .map_err(|e| Error::bad_request(e.to_string()))?;
581
582        let sign_request = SignRequest {
583            action: action_value,
584            action_bytes: Some(action_bytes),
585            time_nonce,
586            action_type: HyperliquidActionType::L1,
587            is_testnet: self.is_testnet(),
588            vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
589        };
590
591        let sig = signer.sign(&sign_request)?.signature;
592
593        let nonce_u64 = time_nonce.as_millis() as u64;
594
595        let request = if let Some(vault) = self.vault_address {
596            HyperliquidExchangeRequest::with_vault(
597                action.clone(),
598                nonce_u64,
599                sig,
600                vault.to_string(),
601            )
602        } else {
603            HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
604        };
605
606        let response = self.http_roundtrip_exchange(&request).await?;
607
608        if response.status.is_success() {
609            let parsed_response: HyperliquidExchangeResponse =
610                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
611
612            // Check if the response contains an error status
613            match &parsed_response {
614                HyperliquidExchangeResponse::Status {
615                    status,
616                    response: response_data,
617                } if status == "err" => {
618                    let error_msg = response_data
619                        .as_str()
620                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
621                    log::error!("Hyperliquid API returned error: {error_msg}");
622                    Err(Error::bad_request(format!("API error: {error_msg}")))
623                }
624                HyperliquidExchangeResponse::Error { error } => {
625                    log::error!("Hyperliquid API returned error: {error}");
626                    Err(Error::bad_request(format!("API error: {error}")))
627                }
628                _ => Ok(parsed_response),
629            }
630        } else if response.status.as_u16() == 429 {
631            let ra = self.parse_retry_after_simple(&response.headers);
632            Err(Error::rate_limit("exchange", w, ra))
633        } else {
634            let error_body = String::from_utf8_lossy(&response.body);
635            log::error!(
636                "Exchange API error (status {}): {}",
637                response.status.as_u16(),
638                error_body
639            );
640            Err(Error::http(
641                response.status.as_u16(),
642                error_body.to_string(),
643            ))
644        }
645    }
646
647    /// Send a signed action to the exchange using the typed HyperliquidExecAction enum.
648    ///
649    /// This is the preferred method for placing orders as it uses properly typed
650    /// structures that match Hyperliquid's API expectations exactly.
651    pub async fn post_action_exec(
652        &self,
653        action: &HyperliquidExecAction,
654    ) -> Result<HyperliquidExchangeResponse> {
655        let w = match action {
656            HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
657            HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
658            HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
659            HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
660            _ => 1,
661        };
662        self.rest_limiter.acquire(w).await;
663
664        let signer = self
665            .signer
666            .as_ref()
667            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
668
669        let nonce_manager = self
670            .nonce_manager
671            .as_ref()
672            .ok_or_else(|| Error::auth("nonce manager missing"))?;
673
674        let signer_id = self.signer_id();
675        let time_nonce = nonce_manager.next(signer_id)?;
676        // No need to validate - next() guarantees a valid, unused nonce
677
678        let action_value = serde_json::to_value(action)
679            .context("serialize exchange action")
680            .map_err(|e| Error::bad_request(e.to_string()))?;
681
682        // Serialize the original action struct with MessagePack for L1 signing
683        let action_bytes = rmp_serde::to_vec_named(action)
684            .context("serialize action with MessagePack")
685            .map_err(|e| Error::bad_request(e.to_string()))?;
686
687        let sig = signer
688            .sign(&SignRequest {
689                action: action_value,
690                action_bytes: Some(action_bytes),
691                time_nonce,
692                action_type: HyperliquidActionType::L1,
693                is_testnet: self.is_testnet(),
694                vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
695            })?
696            .signature;
697
698        let request = if let Some(vault) = self.vault_address {
699            HyperliquidExchangeRequest::with_vault(
700                action.clone(),
701                time_nonce.as_millis() as u64,
702                sig,
703                vault.to_string(),
704            )
705        } else {
706            HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
707        };
708
709        let response = self.http_roundtrip_exchange(&request).await?;
710
711        if response.status.is_success() {
712            let parsed_response: HyperliquidExchangeResponse =
713                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
714
715            // Check if the response contains an error status
716            match &parsed_response {
717                HyperliquidExchangeResponse::Status {
718                    status,
719                    response: response_data,
720                } if status == "err" => {
721                    let error_msg = response_data
722                        .as_str()
723                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
724                    log::error!("Hyperliquid API returned error: {error_msg}");
725                    Err(Error::bad_request(format!("API error: {error_msg}")))
726                }
727                HyperliquidExchangeResponse::Error { error } => {
728                    log::error!("Hyperliquid API returned error: {error}");
729                    Err(Error::bad_request(format!("API error: {error}")))
730                }
731                _ => Ok(parsed_response),
732            }
733        } else if response.status.as_u16() == 429 {
734            let ra = self.parse_retry_after_simple(&response.headers);
735            Err(Error::rate_limit("exchange", w, ra))
736        } else {
737            let error_body = String::from_utf8_lossy(&response.body);
738            Err(Error::http(
739                response.status.as_u16(),
740                error_body.to_string(),
741            ))
742        }
743    }
744
745    /// Submit a single order to the Hyperliquid exchange.
746    ///
747    pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
748        self.rest_limiter.snapshot().await
749    }
750    async fn http_roundtrip_exchange<T>(
751        &self,
752        request: &HyperliquidExchangeRequest<T>,
753    ) -> Result<HttpResponse>
754    where
755        T: serde::Serialize,
756    {
757        let url = &self.base_exchange;
758        let body = serde_json::to_string(&request).map_err(Error::Serde)?;
759        let body_bytes = body.into_bytes();
760
761        let response = self
762            .client
763            .request(
764                Method::POST,
765                url.clone(),
766                None,
767                None,
768                Some(body_bytes),
769                None,
770                None,
771            )
772            .await
773            .map_err(Error::from_http_client)?;
774
775        Ok(response)
776    }
777}
778
779/// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
780///
781/// This domain client wraps [`HyperliquidRawHttpClient`] and provides methods that work
782/// with Nautilus domain types. It maintains an instrument cache and handles conversions
783/// between Hyperliquid API responses and Nautilus domain models.
784#[derive(Debug, Clone)]
785#[cfg_attr(
786    feature = "python",
787    pyo3::pyclass(
788        module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
789        from_py_object
790    )
791)]
792#[cfg_attr(
793    feature = "python",
794    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
795)]
796pub struct HyperliquidHttpClient {
797    pub(crate) inner: Arc<HyperliquidRawHttpClient>,
798    clock: &'static AtomicTime,
799    instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
800    instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
801    /// Mapping from symbol to asset index for order submission.
802    asset_indices: Arc<AtomicMap<Ustr, u32>>,
803    /// Mapping from spot fill coin (`@{pair_index}`) to instrument symbol.
804    spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
805    account_id: Option<AccountId>,
806    /// Optional override address for queries (agent wallet / API sub-key support).
807    /// When set, used for balance queries, position reports, and WS subscriptions
808    /// instead of the address derived from the private key.
809    account_address: Option<String>,
810    normalize_prices: bool,
811    market_order_slippage_bps: u32,
812}
813
814impl Default for HyperliquidHttpClient {
815    fn default() -> Self {
816        Self::new(HyperliquidEnvironment::Mainnet, 60, None)
817            .expect("Failed to create default Hyperliquid HTTP client")
818    }
819}
820
821impl HyperliquidHttpClient {
822    /// Creates a new [`HyperliquidHttpClient`] for public endpoints only.
823    ///
824    /// # Errors
825    ///
826    /// Returns an error if the HTTP client cannot be created.
827    pub fn new(
828        environment: HyperliquidEnvironment,
829        timeout_secs: u64,
830        proxy_url: Option<String>,
831    ) -> std::result::Result<Self, HttpClientError> {
832        let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
833        Ok(Self::from_raw(raw_client))
834    }
835
836    /// Creates a new [`HyperliquidHttpClient`] configured with a [`Secrets`] struct.
837    ///
838    /// # Errors
839    ///
840    /// Returns an error if the HTTP client cannot be created.
841    pub fn with_secrets(
842        secrets: &Secrets,
843        timeout_secs: u64,
844        proxy_url: Option<String>,
845    ) -> std::result::Result<Self, HttpClientError> {
846        let raw_client =
847            HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
848        Ok(Self::from_raw(raw_client))
849    }
850
851    fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
852        Self {
853            inner: Arc::new(raw_client),
854            clock: get_atomic_clock_realtime(),
855            instruments: Arc::new(AtomicMap::new()),
856            instruments_by_coin: Arc::new(AtomicMap::new()),
857            asset_indices: Arc::new(AtomicMap::new()),
858            spot_fill_coins: Arc::new(AtomicMap::new()),
859            account_id: None,
860            account_address: None,
861            normalize_prices: true,
862            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
863        }
864    }
865
866    /// Overrides the base info URL (for testing with mock servers).
867    ///
868    /// # Panics
869    ///
870    /// Panics if the inner `Arc` has multiple references.
871    pub fn set_base_info_url(&mut self, url: String) {
872        Arc::get_mut(&mut self.inner)
873            .expect("cannot override URL: Arc has multiple references")
874            .set_base_info_url(url);
875    }
876
877    /// Overrides the base exchange URL (for testing with mock servers).
878    ///
879    /// # Panics
880    ///
881    /// Panics if the inner `Arc` has multiple references.
882    pub fn set_base_exchange_url(&mut self, url: String) {
883        Arc::get_mut(&mut self.inner)
884            .expect("cannot override URL: Arc has multiple references")
885            .set_base_exchange_url(url);
886    }
887
888    /// Creates an authenticated client from environment variables for the specified network.
889    ///
890    /// # Errors
891    ///
892    /// Returns [`Error::Auth`] if required environment variables are not set.
893    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
894        let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
895        Ok(Self {
896            inner: Arc::new(raw_client),
897            clock: get_atomic_clock_realtime(),
898            instruments: Arc::new(AtomicMap::new()),
899            instruments_by_coin: Arc::new(AtomicMap::new()),
900            asset_indices: Arc::new(AtomicMap::new()),
901            spot_fill_coins: Arc::new(AtomicMap::new()),
902            account_id: None,
903            account_address: None,
904            normalize_prices: true,
905            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
906        })
907    }
908
909    /// Creates a new [`HyperliquidHttpClient`] configured with credentials.
910    ///
911    /// If credentials are not provided, falls back to environment variables:
912    /// - Testnet: `HYPERLIQUID_TESTNET_PK`, `HYPERLIQUID_TESTNET_VAULT`
913    /// - Mainnet: `HYPERLIQUID_PK`, `HYPERLIQUID_VAULT`
914    ///
915    /// If no credentials are provided and no environment variables are set,
916    /// creates an unauthenticated client for public endpoints only.
917    ///
918    /// # Errors
919    ///
920    /// Returns [`Error::Auth`] if credentials are invalid.
921    pub fn with_credentials(
922        private_key: Option<String>,
923        vault_address: Option<String>,
924        account_address: Option<String>,
925        environment: HyperliquidEnvironment,
926        timeout_secs: u64,
927        proxy_url: Option<String>,
928    ) -> Result<Self> {
929        let (pk_env_var, vault_env_var) =
930            crate::common::credential::credential_env_vars(environment);
931
932        // Resolve private key: explicit value -> env var -> None (unauthenticated)
933        let resolved_pk = match private_key {
934            Some(pk) => Some(pk),
935            None => env::var(pk_env_var).ok(),
936        };
937
938        // Resolve vault address: explicit value -> env var -> None
939        let resolved_vault = match vault_address {
940            Some(vault) => Some(vault),
941            None => env::var(vault_env_var).ok(),
942        };
943
944        // Resolve account address: explicit value -> env var -> None
945        let resolved_account_address = match account_address {
946            Some(addr) => Some(addr),
947            None => env::var("HYPERLIQUID_ACCOUNT_ADDRESS").ok(),
948        };
949
950        match resolved_pk {
951            Some(pk) => {
952                let raw_client = HyperliquidRawHttpClient::from_credentials(
953                    &pk,
954                    resolved_vault.as_deref(),
955                    environment,
956                    timeout_secs,
957                    proxy_url,
958                )?;
959                Ok(Self {
960                    inner: Arc::new(raw_client),
961                    clock: get_atomic_clock_realtime(),
962                    instruments: Arc::new(AtomicMap::new()),
963                    instruments_by_coin: Arc::new(AtomicMap::new()),
964                    asset_indices: Arc::new(AtomicMap::new()),
965                    spot_fill_coins: Arc::new(AtomicMap::new()),
966                    account_id: None,
967                    account_address: resolved_account_address,
968                    normalize_prices: true,
969                    market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
970                })
971            }
972            None => {
973                // No credentials available, create unauthenticated client
974                Self::new(environment, timeout_secs, proxy_url)
975                    .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
976            }
977        }
978    }
979
980    /// Creates a new [`HyperliquidHttpClient`] configured with explicit credentials.
981    ///
982    /// # Errors
983    ///
984    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
985    pub fn from_credentials(
986        private_key: &str,
987        vault_address: Option<&str>,
988        environment: HyperliquidEnvironment,
989        timeout_secs: u64,
990        proxy_url: Option<String>,
991    ) -> Result<Self> {
992        let raw_client = HyperliquidRawHttpClient::from_credentials(
993            private_key,
994            vault_address,
995            environment,
996            timeout_secs,
997            proxy_url,
998        )?;
999        Ok(Self {
1000            inner: Arc::new(raw_client),
1001            clock: get_atomic_clock_realtime(),
1002            instruments: Arc::new(AtomicMap::new()),
1003            instruments_by_coin: Arc::new(AtomicMap::new()),
1004            asset_indices: Arc::new(AtomicMap::new()),
1005            spot_fill_coins: Arc::new(AtomicMap::new()),
1006            account_id: None,
1007            account_address: None,
1008            normalize_prices: true,
1009            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1010        })
1011    }
1012
1013    /// Returns whether this client is configured for testnet.
1014    #[must_use]
1015    pub fn is_testnet(&self) -> bool {
1016        self.inner.is_testnet()
1017    }
1018
1019    /// Returns whether order price normalization is enabled.
1020    #[must_use]
1021    pub fn normalize_prices(&self) -> bool {
1022        self.normalize_prices
1023    }
1024
1025    /// Sets whether to normalize order prices to 5 significant figures.
1026    pub fn set_normalize_prices(&mut self, value: bool) {
1027        self.normalize_prices = value;
1028    }
1029
1030    /// Returns the MARKET-order slippage buffer in basis points.
1031    #[must_use]
1032    pub fn market_order_slippage_bps(&self) -> u32 {
1033        self.market_order_slippage_bps
1034    }
1035
1036    /// Sets the MARKET-order slippage buffer in basis points.
1037    pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1038        self.market_order_slippage_bps = value;
1039    }
1040
1041    /// Gets the user address derived from the private key (if client has credentials).
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns [`Error::Auth`] if the client has no signer configured.
1046    pub fn get_user_address(&self) -> Result<String> {
1047        self.inner.get_user_address()
1048    }
1049
1050    /// Returns `true` if a vault address is configured.
1051    #[must_use]
1052    pub fn has_vault_address(&self) -> bool {
1053        self.inner.has_vault_address()
1054    }
1055
1056    /// Gets the account address for queries: account_address if configured
1057    /// (agent wallet), then vault address, otherwise the user (EOA) address.
1058    ///
1059    /// # Errors
1060    ///
1061    /// Returns [`Error::Auth`] if the client has no signer configured and
1062    /// no account_address override is set.
1063    pub fn get_account_address(&self) -> Result<String> {
1064        if let Some(addr) = &self.account_address {
1065            return Ok(addr.clone());
1066        }
1067        self.inner.get_account_address()
1068    }
1069
1070    /// Sets the account address override for queries (agent wallet support).
1071    pub fn set_account_address(&mut self, address: Option<String>) {
1072        self.account_address = address;
1073    }
1074
1075    /// Caches a single instrument.
1076    ///
1077    /// This is required for parsing orders, fills, and positions into reports.
1078    /// Any existing instrument with the same symbol will be replaced.
1079    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1080        let full_symbol = instrument.symbol().inner();
1081        let coin = instrument.raw_symbol().inner();
1082
1083        self.instruments.rcu(|m| {
1084            m.insert(full_symbol, instrument.clone());
1085            // HTTP responses only include coins, external code may lookup by coin
1086            m.insert(coin, instrument.clone());
1087        });
1088
1089        // Composite key allows disambiguating same coin across PERP and SPOT
1090        if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1091            self.instruments_by_coin.rcu(|m| {
1092                m.insert((coin, product_type), instrument.clone());
1093
1094                // Index the leading symbol component (the part before the first
1095                // `-`) as a secondary key for two distinct callers:
1096                //
1097                // * Spot raw_symbols are either `@{pair_index}` or slash format
1098                //   (e.g., "PURR/USDC"); spot balance/position reconciliation
1099                //   maps the venue token name (e.g., "PURR") to instruments via
1100                //   this alias.
1101                // * Order submission paths split `instrument_id.symbol` on `-`
1102                //   to derive a coin key. For HIP-3 perps with wildcard-bearing
1103                //   venue names, the sanitized base in `instrument_id.symbol`
1104                //   (e.g., "dex:STREAMABCDxxxx") differs from `raw_symbol` /
1105                //   `coin` (e.g., "dex:STREAMABCD****"), so an alias on the
1106                //   sanitized base lets that lookup resolve.
1107                //
1108                // First-write-wins guards against non-canonical spot pairs that
1109                // share a base token overwriting the canonical instrument; the
1110                // spot loader sorts canonical pairs first so the alias resolves
1111                // to the canonical one. For standard perps `base == coin`, so
1112                // the alias is a no-op.
1113                if let Some(base) = full_symbol.as_str().split('-').next() {
1114                    let base_ustr = Ustr::from(base);
1115                    let key = (base_ustr, product_type);
1116                    if base_ustr != coin && !m.contains_key(&key) {
1117                        m.insert(key, instrument.clone());
1118                    }
1119                }
1120            });
1121        } else {
1122            log::warn!("Unable to determine product type for symbol: {full_symbol}");
1123        }
1124    }
1125
1126    fn get_or_create_instrument(
1127        &self,
1128        coin: &Ustr,
1129        product_type: Option<HyperliquidProductType>,
1130    ) -> Option<InstrumentAny> {
1131        if let Some(pt) = product_type
1132            && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1133        {
1134            return Some(instrument.clone());
1135        }
1136
1137        // HTTP responses lack product type context, try PERP then SPOT
1138        if product_type.is_none() {
1139            let guard = self.instruments_by_coin.load();
1140
1141            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1142                return Some(instrument.clone());
1143            }
1144
1145            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1146                return Some(instrument.clone());
1147            }
1148        }
1149
1150        // Spot fills use @{pair_index} format, translate to full symbol and look up
1151        if coin.as_str().starts_with('@')
1152            && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1153        {
1154            // Look up by full symbol in instruments map (not instruments_by_coin
1155            // which uses raw_symbol)
1156            if let Some(instrument) = self.instruments.load().get(symbol) {
1157                return Some(instrument.clone());
1158            }
1159        }
1160
1161        // Vault tokens aren't in standard API, create synthetic instruments
1162        if coin.as_str().starts_with("vntls:") {
1163            log::info!("Creating synthetic instrument for vault token: {coin}");
1164
1165            let ts_event = self.clock.get_time_ns();
1166
1167            // Create synthetic vault token instrument
1168            let symbol_str = format!("{coin}-USDC-SPOT");
1169            let symbol = Symbol::new(&symbol_str);
1170            let venue = *HYPERLIQUID_VENUE;
1171            let instrument_id = InstrumentId::new(symbol, venue);
1172
1173            // Create currencies
1174            let base_currency = Currency::new(
1175                coin.as_str(),
1176                8, // precision
1177                0, // ISO code (not applicable)
1178                coin.as_str(),
1179                CurrencyType::Crypto,
1180            );
1181
1182            let quote_currency = Currency::new(
1183                "USDC",
1184                6, // USDC standard precision
1185                0,
1186                "USDC",
1187                CurrencyType::Crypto,
1188            );
1189
1190            let price_increment = Price::from("0.00000001");
1191            let size_increment = Quantity::from("0.00000001");
1192
1193            let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1194                instrument_id,
1195                symbol,
1196                base_currency,
1197                quote_currency,
1198                8, // price_precision
1199                8, // size_precision
1200                price_increment,
1201                size_increment,
1202                None, // multiplier
1203                None, // lot_size
1204                None, // max_quantity
1205                None, // min_quantity
1206                None, // max_notional
1207                None, // min_notional
1208                None, // max_price
1209                None, // min_price
1210                None, // margin_init
1211                None, // margin_maint
1212                None, // maker_fee
1213                None, // taker_fee
1214                None, // info
1215                ts_event,
1216                ts_event,
1217            ));
1218
1219            self.cache_instrument(&instrument);
1220
1221            Some(instrument)
1222        } else {
1223            // For non-vault tokens, log warning and return None
1224            log::warn!("Instrument not found in cache: {coin}");
1225            None
1226        }
1227    }
1228
1229    /// Set the account ID for this client.
1230    ///
1231    /// This is required for generating reports with the correct account ID.
1232    pub fn set_account_id(&mut self, account_id: AccountId) {
1233        self.account_id = Some(account_id);
1234    }
1235
1236    /// Fetch and parse all instrument definitions, populating the asset indices cache.
1237    pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1238        let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1239
1240        // Load all perp dexes: index 0 = standard, index 1+ = HIP-3
1241        match self.inner.load_all_perp_metas().await {
1242            Ok(all_metas) => {
1243                for (dex_index, meta) in all_metas.iter().enumerate() {
1244                    let base = perp_dex_asset_index_base(dex_index);
1245
1246                    match parse_perp_instruments(meta, base) {
1247                        Ok(perp_defs) => {
1248                            log::debug!(
1249                                "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1250                                perp_defs.len(),
1251                            );
1252                            defs.extend(perp_defs);
1253                        }
1254                        Err(e) => {
1255                            log::warn!("Failed to parse perp instruments for dex {dex_index}: {e}");
1256                        }
1257                    }
1258                }
1259            }
1260            Err(e) => {
1261                log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1262
1263                match self.inner.load_perp_meta().await {
1264                    Ok(perp_meta) => match parse_perp_instruments(&perp_meta, 0) {
1265                        Ok(perp_defs) => {
1266                            log::debug!(
1267                                "Loaded Hyperliquid perp defs via fallback: count={}",
1268                                perp_defs.len(),
1269                            );
1270                            defs.extend(perp_defs);
1271                        }
1272                        Err(e) => {
1273                            log::warn!("Failed to parse perp instruments: {e}");
1274                        }
1275                    },
1276                    Err(e) => {
1277                        log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1278                    }
1279                }
1280            }
1281        }
1282
1283        match self.inner.get_spot_meta().await {
1284            Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1285                Ok(spot_defs) => {
1286                    log::debug!(
1287                        "Loaded Hyperliquid spot definitions: count={}",
1288                        spot_defs.len(),
1289                    );
1290                    defs.extend(spot_defs);
1291                }
1292                Err(e) => {
1293                    log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1294                }
1295            },
1296            Err(e) => {
1297                log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1298            }
1299        }
1300
1301        // Drop defs whose Nautilus-internal symbol collides with one already
1302        // accepted. This guards the HIP-3 case where two distinct venue names
1303        // (e.g. `dex:FOO*` and `dex:FOO?`) sanitize onto the same internal
1304        // symbol; without this filter the second def would silently overwrite
1305        // the first in `asset_indices`, which would route orders to the wrong
1306        // asset. First-write-wins matches the spot canonical-pair ordering.
1307        let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1308        let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1309        for def in defs {
1310            if seen_symbols.insert(def.symbol) {
1311                deduped.push(def);
1312            } else {
1313                log::warn!(
1314                    "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1315                    def.symbol,
1316                    def.raw_symbol,
1317                );
1318            }
1319        }
1320        let defs = deduped;
1321
1322        // Populate asset indices for all instruments (including filtered HIP-3)
1323        self.asset_indices.rcu(|m| {
1324            for def in &defs {
1325                m.insert(def.symbol, def.asset_index);
1326            }
1327        });
1328        log::debug!(
1329            "Populated asset indices map (count={})",
1330            self.asset_indices.len()
1331        );
1332
1333        Ok(defs)
1334    }
1335
1336    /// Converts instrument definitions into Nautilus instruments.
1337    pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1338        let ts_init = self.clock.get_time_ns();
1339        instruments_from_defs_owned(defs, ts_init)
1340    }
1341
1342    /// Fetch and parse all available instrument definitions from Hyperliquid.
1343    pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1344        let defs = self.request_instrument_defs().await?;
1345        Ok(self.convert_defs(defs))
1346    }
1347
1348    /// Get asset index for a symbol from the cached map.
1349    ///
1350    /// For perps: index in meta.universe (0, 1, 2, ...).
1351    /// For spot: 10_000 + index in spotMeta.universe.
1352    /// For HIP-3: 100_000 + dex_index * 10_000 + index in dex meta.universe.
1353    ///
1354    /// Returns `None` if the symbol is not found in the map.
1355    pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1356        self.asset_indices.load().get(&Ustr::from(symbol)).copied()
1357    }
1358
1359    /// Get the price precision for a cached instrument by symbol.
1360    pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1361        self.instruments
1362            .load()
1363            .get(&Ustr::from(symbol))
1364            .map(|inst| inst.price_precision())
1365    }
1366
1367    /// Get mapping from spot fill coin identifiers to instrument symbols.
1368    ///
1369    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1370    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1371    /// This mapping allows looking up the instrument from a spot fill.
1372    ///
1373    /// This method also caches the mapping internally for use by fill parsing methods.
1374    #[must_use]
1375    pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1376        const SPOT_INDEX_OFFSET: u32 = 10_000;
1377        const BUILDER_PERP_OFFSET: u32 = 100_000;
1378
1379        let guard = self.asset_indices.load();
1380
1381        let mut mapping = AHashMap::new();
1382
1383        for (symbol, &asset_index) in guard.iter() {
1384            // Spot instruments: asset_index in [10_000, 100_000)
1385            if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1386                let pair_index = asset_index - SPOT_INDEX_OFFSET;
1387                let fill_coin = Ustr::from(&format!("@{pair_index}"));
1388                mapping.insert(fill_coin, *symbol);
1389            }
1390        }
1391
1392        // Cache the mapping internally for fill parsing
1393        self.spot_fill_coins.store(mapping.clone());
1394
1395        mapping
1396    }
1397
1398    /// Get perpetuals metadata (internal helper).
1399    #[allow(dead_code)]
1400    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1401        self.inner.load_perp_meta().await
1402    }
1403
1404    /// Get metadata for all perp dexes (standard + HIP-3).
1405    #[allow(dead_code)]
1406    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1407        self.inner.load_all_perp_metas().await
1408    }
1409
1410    /// Get spot metadata (internal helper).
1411    #[allow(dead_code)]
1412    pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1413        self.inner.get_spot_meta().await
1414    }
1415
1416    /// Get L2 order book for a coin.
1417    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1418        self.inner.info_l2_book(coin).await
1419    }
1420
1421    /// Get user fills (trading history).
1422    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1423        self.inner.info_user_fills(user).await
1424    }
1425
1426    /// Get order status for a user.
1427    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1428        self.inner.info_order_status(user, oid).await
1429    }
1430
1431    /// Get all open orders for a user.
1432    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1433        self.inner.info_open_orders(user).await
1434    }
1435
1436    /// Get frontend open orders (includes more detail) for a user.
1437    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1438        self.inner.info_frontend_open_orders(user).await
1439    }
1440
1441    /// Get clearinghouse state (balances, positions, margin) for a user.
1442    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1443        self.inner.info_clearinghouse_state(user).await
1444    }
1445
1446    /// Get spot clearinghouse state (per-token spot balances) for a user.
1447    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1448        self.inner.info_spot_clearinghouse_state(user).await
1449    }
1450
1451    /// Get user fee schedule and effective rates.
1452    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1453        self.inner.info_user_fees(user).await
1454    }
1455
1456    /// Get candle/bar data for a coin.
1457    pub async fn info_candle_snapshot(
1458        &self,
1459        coin: &str,
1460        interval: HyperliquidBarInterval,
1461        start_time: u64,
1462        end_time: u64,
1463    ) -> Result<HyperliquidCandleSnapshot> {
1464        self.inner
1465            .info_candle_snapshot(coin, interval, start_time, end_time)
1466            .await
1467    }
1468
1469    /// Get historical funding rates for a coin.
1470    pub async fn info_funding_history(
1471        &self,
1472        coin: &str,
1473        start_time: u64,
1474        end_time: Option<u64>,
1475    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1476        self.inner
1477            .info_funding_history(coin, start_time, end_time)
1478            .await
1479    }
1480
1481    /// Post an action to the exchange endpoint (low-level delegation).
1482    pub async fn post_action(
1483        &self,
1484        action: &ExchangeAction,
1485    ) -> Result<HyperliquidExchangeResponse> {
1486        self.inner.post_action(action).await
1487    }
1488
1489    /// Post an execution action (low-level delegation).
1490    pub async fn post_action_exec(
1491        &self,
1492        action: &HyperliquidExecAction,
1493    ) -> Result<HyperliquidExchangeResponse> {
1494        self.inner.post_action_exec(action).await
1495    }
1496
1497    /// Get metadata about available markets (low-level delegation).
1498    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1499        self.inner.info_meta().await
1500    }
1501
1502    /// Cancel an order on the Hyperliquid exchange.
1503    ///
1504    /// Can cancel either by venue order ID or client order ID.
1505    /// At least one ID must be provided.
1506    ///
1507    /// # Errors
1508    ///
1509    /// Returns an error if credentials are missing, no order ID is provided,
1510    /// or the API returns an error.
1511    pub async fn cancel_order(
1512        &self,
1513        instrument_id: InstrumentId,
1514        client_order_id: Option<ClientOrderId>,
1515        venue_order_id: Option<VenueOrderId>,
1516    ) -> Result<()> {
1517        // Get asset ID from cached indices map
1518        let symbol = instrument_id.symbol.as_str();
1519        let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1520            Error::bad_request(format!(
1521                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1522            ))
1523        })?;
1524
1525        // Create cancel action based on which ID we have
1526        let action = if let Some(cloid) = client_order_id {
1527            // Hash the client order ID to CLOID (same as order submission)
1528            let cloid_hash = Cloid::from_client_order_id(cloid);
1529            let cancel_req = HyperliquidExecCancelByCloidRequest {
1530                asset: asset_id,
1531                cloid: cloid_hash,
1532            };
1533            HyperliquidExecAction::CancelByCloid {
1534                cancels: vec![cancel_req],
1535            }
1536        } else if let Some(oid) = venue_order_id {
1537            let oid_u64 = oid
1538                .as_str()
1539                .parse::<u64>()
1540                .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1541            let cancel_req = HyperliquidExecCancelOrderRequest {
1542                asset: asset_id,
1543                oid: oid_u64,
1544            };
1545            HyperliquidExecAction::Cancel {
1546                cancels: vec![cancel_req],
1547            }
1548        } else {
1549            return Err(Error::bad_request(
1550                "Either client_order_id or venue_order_id must be provided",
1551            ));
1552        };
1553
1554        // Submit cancellation
1555        let response = self.inner.post_action_exec(&action).await?;
1556
1557        // Check response - only check for error status
1558        match response {
1559            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1560            HyperliquidExchangeResponse::Status {
1561                status,
1562                response: error_data,
1563            } => Err(Error::bad_request(format!(
1564                "Cancel order failed: status={status}, error={error_data}"
1565            ))),
1566            HyperliquidExchangeResponse::Error { error } => {
1567                Err(Error::bad_request(format!("Cancel order error: {error}")))
1568            }
1569        }
1570    }
1571
1572    /// Modify an order on the Hyperliquid exchange.
1573    ///
1574    /// The HL modify API requires a full replacement order spec plus the
1575    /// venue order ID. The caller must provide all order fields.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns an error if the asset index is not found, the venue order ID
1580    /// is invalid, or the API returns an error.
1581    #[expect(clippy::too_many_arguments)]
1582    pub async fn modify_order(
1583        &self,
1584        instrument_id: InstrumentId,
1585        venue_order_id: VenueOrderId,
1586        order_side: OrderSide,
1587        order_type: OrderType,
1588        price: Price,
1589        quantity: Quantity,
1590        trigger_price: Option<Price>,
1591        reduce_only: bool,
1592        post_only: bool,
1593        time_in_force: TimeInForce,
1594        client_order_id: Option<ClientOrderId>,
1595    ) -> Result<()> {
1596        let symbol = instrument_id.symbol.as_str();
1597        let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1598            Error::bad_request(format!(
1599                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1600            ))
1601        })?;
1602
1603        let oid: u64 = venue_order_id
1604            .as_str()
1605            .parse()
1606            .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1607
1608        let is_buy = matches!(order_side, OrderSide::Buy);
1609        let decimals = self.get_price_precision(symbol).unwrap_or(2);
1610
1611        let normalized_price = if self.normalize_prices {
1612            normalize_price(price.as_decimal(), decimals).normalize()
1613        } else {
1614            price.as_decimal().normalize()
1615        };
1616
1617        let size = quantity.as_decimal().normalize();
1618        let cloid = client_order_id.map(Cloid::from_client_order_id);
1619
1620        let kind = match order_type {
1621            OrderType::Market => HyperliquidExecOrderKind::Limit {
1622                limit: HyperliquidExecLimitParams {
1623                    tif: HyperliquidExecTif::Ioc,
1624                },
1625            },
1626            OrderType::Limit => {
1627                let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1628                    .map_err(|e| Error::bad_request(format!("{e}")))?;
1629                HyperliquidExecOrderKind::Limit {
1630                    limit: HyperliquidExecLimitParams { tif },
1631                }
1632            }
1633            OrderType::StopMarket
1634            | OrderType::StopLimit
1635            | OrderType::MarketIfTouched
1636            | OrderType::LimitIfTouched => {
1637                if let Some(trig_px) = trigger_price {
1638                    let trigger_price_decimal = if self.normalize_prices {
1639                        normalize_price(trig_px.as_decimal(), decimals).normalize()
1640                    } else {
1641                        trig_px.as_decimal().normalize()
1642                    };
1643                    let tpsl = match order_type {
1644                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1645                        _ => HyperliquidExecTpSl::Tp,
1646                    };
1647                    let is_market = matches!(
1648                        order_type,
1649                        OrderType::StopMarket | OrderType::MarketIfTouched
1650                    );
1651                    HyperliquidExecOrderKind::Trigger {
1652                        trigger: HyperliquidExecTriggerParams {
1653                            is_market,
1654                            trigger_px: trigger_price_decimal,
1655                            tpsl,
1656                        },
1657                    }
1658                } else {
1659                    return Err(Error::bad_request("Trigger orders require a trigger price"));
1660                }
1661            }
1662            _ => {
1663                return Err(Error::bad_request(format!(
1664                    "Order type {order_type:?} not supported for modify"
1665                )));
1666            }
1667        };
1668
1669        let order = HyperliquidExecPlaceOrderRequest {
1670            asset: asset_id,
1671            is_buy,
1672            price: normalized_price,
1673            size,
1674            reduce_only,
1675            kind,
1676            cloid,
1677        };
1678
1679        let action = HyperliquidExecAction::Modify {
1680            modify: HyperliquidExecModifyOrderRequest { oid, order },
1681        };
1682
1683        let response = self.inner.post_action_exec(&action).await?;
1684
1685        match response {
1686            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1687                if let Some(inner_error) = extract_inner_error(&response) {
1688                    Err(Error::bad_request(format!(
1689                        "Modify order rejected: {inner_error}",
1690                    )))
1691                } else {
1692                    Ok(())
1693                }
1694            }
1695            HyperliquidExchangeResponse::Status {
1696                status,
1697                response: error_data,
1698            } => Err(Error::bad_request(format!(
1699                "Modify order failed: status={status}, error={error_data}"
1700            ))),
1701            HyperliquidExchangeResponse::Error { error } => {
1702                Err(Error::bad_request(format!("Modify order error: {error}")))
1703            }
1704        }
1705    }
1706
1707    /// Request order status reports for a user.
1708    ///
1709    /// Fetches open orders via `info_frontend_open_orders` and parses them into OrderStatusReports.
1710    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1711    ///
1712    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1713    /// will be created automatically.
1714    ///
1715    /// # Errors
1716    ///
1717    /// Returns an error if the API request fails or parsing fails.
1718    pub async fn request_order_status_reports(
1719        &self,
1720        user: &str,
1721        instrument_id: Option<InstrumentId>,
1722    ) -> Result<Vec<OrderStatusReport>> {
1723        let account_id = self
1724            .account_id
1725            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1726        let response = self.info_frontend_open_orders(user).await?;
1727
1728        // Parse the JSON response into a vector of orders
1729        let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1730            .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1731
1732        let mut reports = Vec::new();
1733        let ts_init = self.clock.get_time_ns();
1734
1735        for order_value in orders {
1736            // Parse the order data
1737            let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
1738                Ok(o) => o,
1739                Err(e) => {
1740                    log::warn!("Failed to parse order: {e}");
1741                    continue;
1742                }
1743            };
1744
1745            // Get instrument from cache or create synthetic for vault tokens
1746            let instrument = match self.get_or_create_instrument(&order.coin, None) {
1747                Some(inst) => inst,
1748                None => continue, // Skip if instrument not found
1749            };
1750
1751            // Filter by instrument_id if specified
1752            if let Some(filter_id) = instrument_id
1753                && instrument.id() != filter_id
1754            {
1755                continue;
1756            }
1757
1758            // Determine status from order data - orders from frontend_open_orders are open
1759            let status = HyperliquidOrderStatusEnum::Open;
1760
1761            // Parse to OrderStatusReport
1762            match parse_order_status_report_from_basic(
1763                &order,
1764                &status,
1765                &instrument,
1766                account_id,
1767                ts_init,
1768            ) {
1769                Ok(report) => reports.push(report),
1770                Err(e) => log::error!("Failed to parse order status report: {e}"),
1771            }
1772        }
1773
1774        Ok(reports)
1775    }
1776
1777    /// Request a single order status report by venue order ID.
1778    ///
1779    /// Queries `info_frontend_open_orders` and filters for the given oid so the
1780    /// result includes trigger metadata (trigger_px, tpsl, trailing_stop, etc.).
1781    /// Falls back to `info_order_status` when the order is no longer open.
1782    ///
1783    /// # Errors
1784    ///
1785    /// Returns an error if the API request fails or parsing fails.
1786    pub async fn request_order_status_report(
1787        &self,
1788        user: &str,
1789        oid: u64,
1790    ) -> Result<Option<OrderStatusReport>> {
1791        let account_id = self
1792            .account_id
1793            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1794
1795        let ts_init = self.clock.get_time_ns();
1796
1797        // Try open orders first (returns full WsBasicOrderData with trigger fields).
1798        // A transport error here must not abort the call: the oid fallback to
1799        // info_order_status below still covers closed orders, so a transient
1800        // frontendOpenOrders outage is downgraded to a warning.
1801        let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
1802            Ok(response) => match serde_json::from_value(response) {
1803                Ok(v) => v,
1804                Err(e) => {
1805                    log::warn!("Failed to parse frontend open orders response: {e}");
1806                    Vec::new()
1807                }
1808            },
1809            Err(e) => {
1810                log::warn!(
1811                    "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
1812                );
1813                Vec::new()
1814            }
1815        };
1816
1817        if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
1818            let instrument = match self.get_or_create_instrument(&order.coin, None) {
1819                Some(inst) => inst,
1820                None => return Ok(None),
1821            };
1822
1823            let status = if order.trigger_activated == Some(true) {
1824                HyperliquidOrderStatusEnum::Triggered
1825            } else {
1826                HyperliquidOrderStatusEnum::Open
1827            };
1828
1829            return match parse_order_status_report_from_basic(
1830                &order,
1831                &status,
1832                &instrument,
1833                account_id,
1834                ts_init,
1835            ) {
1836                Ok(report) => Ok(Some(report)),
1837                Err(e) => {
1838                    log::error!("Failed to parse order status report for oid {oid}: {e}");
1839                    Ok(None)
1840                }
1841            };
1842        }
1843
1844        // Order not in open set: query by oid (returns limited HyperliquidOrderInfo)
1845        let response = self.info_order_status(user, oid).await?;
1846        let entry = match response.into_order() {
1847            Some(e) => e,
1848            None => return Ok(None),
1849        };
1850
1851        let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
1852            Some(inst) => inst,
1853            None => return Ok(None),
1854        };
1855
1856        // The info_order_status endpoint returns limited HyperliquidOrderInfo
1857        // without trigger fields (trigger_px, tpsl, is_market, trailing_stop).
1858        // Closed trigger orders will report as Limit type. This is an exchange
1859        // API limitation: trigger metadata is only available on open orders.
1860        let basic = WsBasicOrderData {
1861            coin: entry.order.coin,
1862            side: entry.order.side,
1863            limit_px: entry.order.limit_px,
1864            sz: entry.order.sz,
1865            oid: entry.order.oid,
1866            timestamp: entry.order.timestamp,
1867            orig_sz: entry.order.orig_sz,
1868            cloid: entry.order.cloid,
1869            trigger_px: None,
1870            is_market: None,
1871            tpsl: None,
1872            trigger_activated: None,
1873            trailing_stop: None,
1874        };
1875
1876        match parse_order_status_report_from_basic(
1877            &basic,
1878            &entry.status,
1879            &instrument,
1880            account_id,
1881            ts_init,
1882        ) {
1883            Ok(mut report) => {
1884                // Use status_timestamp for ts_last when available (more accurate
1885                // than the order creation timestamp for filled/canceled orders)
1886                if entry.status_timestamp > 0 {
1887                    report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
1888                }
1889                Ok(Some(report))
1890            }
1891            Err(e) => {
1892                log::error!("Failed to parse order status report for oid {oid}: {e}");
1893                Ok(None)
1894            }
1895        }
1896    }
1897
1898    /// Request a single order status report by client order ID.
1899    ///
1900    /// Searches `info_frontend_open_orders` for an order whose cloid matches the
1901    /// keccak256 hash of the given client order ID. Only finds open orders.
1902    ///
1903    /// # Errors
1904    ///
1905    /// Returns an error if the API request fails or parsing fails.
1906    pub async fn request_order_status_report_by_client_order_id(
1907        &self,
1908        user: &str,
1909        client_order_id: &ClientOrderId,
1910    ) -> Result<Option<OrderStatusReport>> {
1911        let account_id = self
1912            .account_id
1913            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1914
1915        let ts_init = self.clock.get_time_ns();
1916
1917        let cloid_hex = Cloid::from_client_order_id(*client_order_id).to_hex();
1918
1919        let response = self.info_frontend_open_orders(user).await?;
1920        let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
1921            Ok(v) => v,
1922            Err(e) => {
1923                log::warn!("Failed to parse frontend open orders response: {e}");
1924                return Ok(None);
1925            }
1926        };
1927
1928        let order = match orders
1929            .into_iter()
1930            .find(|o| o.cloid.as_ref().is_some_and(|c| c == &cloid_hex))
1931        {
1932            Some(o) => o,
1933            None => return Ok(None),
1934        };
1935
1936        let instrument = match self.get_or_create_instrument(&order.coin, None) {
1937            Some(inst) => inst,
1938            None => return Ok(None),
1939        };
1940
1941        let status = if order.trigger_activated == Some(true) {
1942            HyperliquidOrderStatusEnum::Triggered
1943        } else {
1944            HyperliquidOrderStatusEnum::Open
1945        };
1946
1947        match parse_order_status_report_from_basic(
1948            &order,
1949            &status,
1950            &instrument,
1951            account_id,
1952            ts_init,
1953        ) {
1954            Ok(mut report) => {
1955                report.client_order_id = Some(*client_order_id);
1956                Ok(Some(report))
1957            }
1958            Err(e) => {
1959                log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
1960                Ok(None)
1961            }
1962        }
1963    }
1964
1965    /// Request fill reports for a user.
1966    ///
1967    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
1968    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1969    ///
1970    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1971    /// will be created automatically.
1972    ///
1973    /// # Errors
1974    ///
1975    /// Returns an error if the API request fails or parsing fails.
1976    ///
1977    /// Returns an error if `account_id` is not set on the client.
1978    pub async fn request_fill_reports(
1979        &self,
1980        user: &str,
1981        instrument_id: Option<InstrumentId>,
1982    ) -> Result<Vec<FillReport>> {
1983        let account_id = self
1984            .account_id
1985            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1986        let fills_response = self.info_user_fills(user).await?;
1987
1988        let mut reports = Vec::new();
1989        let ts_init = self.clock.get_time_ns();
1990
1991        for fill in fills_response {
1992            // Get instrument from cache or create synthetic for vault tokens
1993            let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1994                Some(inst) => inst,
1995                None => continue, // Skip if instrument not found
1996            };
1997
1998            // Filter by instrument_id if specified
1999            if let Some(filter_id) = instrument_id
2000                && instrument.id() != filter_id
2001            {
2002                continue;
2003            }
2004
2005            // Parse to FillReport
2006            match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2007                Ok(report) => reports.push(report),
2008                Err(e) => log::error!("Failed to parse fill report: {e}"),
2009            }
2010        }
2011
2012        Ok(reports)
2013    }
2014
2015    /// Request position status reports for a user.
2016    ///
2017    /// Fetches perp clearinghouse state and spot clearinghouse state, then returns
2018    /// the union of perp asset positions (short/long with PnL) and spot holdings
2019    /// (long only). This method requires instruments to be added to the client
2020    /// cache via `cache_instrument()`.
2021    ///
2022    /// When `instrument_id` resolves to a specific product type, the opposite
2023    /// product's endpoint is skipped to avoid wasted round trips and make
2024    /// filtered queries independent of the unused endpoint's availability.
2025    ///
2026    /// For vault tokens (starting with "vntls:") that are not in the cache,
2027    /// synthetic instruments will be created automatically. Spot balances whose
2028    /// base token has no cached instrument are skipped with a debug log.
2029    ///
2030    /// # Errors
2031    ///
2032    /// Returns an error if either clearinghouse request fails (when that
2033    /// product is in scope) or parsing fails.
2034    ///
2035    /// Returns an error if `account_id` has not been set on the client.
2036    pub async fn request_position_status_reports(
2037        &self,
2038        user: &str,
2039        instrument_id: Option<InstrumentId>,
2040    ) -> Result<Vec<PositionStatusReport>> {
2041        let account_id = self
2042            .account_id
2043            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2044
2045        let filter_product = instrument_id
2046            .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2047        let fetch_perp = filter_product != Some(HyperliquidProductType::Spot);
2048        let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2049
2050        let mut reports = Vec::new();
2051        let ts_init = self.clock.get_time_ns();
2052
2053        if !fetch_perp {
2054            let spot_reports = self
2055                .request_spot_position_status_reports(user, instrument_id)
2056                .await?;
2057            reports.extend(spot_reports);
2058            return Ok(reports);
2059        }
2060
2061        let state_response = self.info_clearinghouse_state(user).await?;
2062
2063        // Extract asset positions from the clearinghouse state
2064        let asset_positions: Vec<serde_json::Value> = state_response
2065            .get("assetPositions")
2066            .and_then(|v| v.as_array())
2067            .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2068            .clone();
2069
2070        for position_value in asset_positions {
2071            // Extract coin from position data
2072            let coin = position_value
2073                .get("position")
2074                .and_then(|p| p.get("coin"))
2075                .and_then(|c| c.as_str())
2076                .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2077
2078            // Get instrument from cache - convert &str to Ustr for lookup
2079            let coin_ustr = Ustr::from(coin);
2080            let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2081                Some(inst) => inst,
2082                None => continue, // Skip if instrument not found
2083            };
2084
2085            // Filter by instrument_id if specified
2086            if let Some(filter_id) = instrument_id
2087                && instrument.id() != filter_id
2088            {
2089                continue;
2090            }
2091
2092            // Parse to PositionStatusReport
2093            match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2094                Ok(report) => reports.push(report),
2095                Err(e) => log::error!("Failed to parse position status report: {e}"),
2096            }
2097        }
2098
2099        // Spot positions are part of the report truth; propagate fetch errors
2100        // rather than silently omitting spot holdings from reconciliation.
2101        if fetch_spot {
2102            let spot_reports = self
2103                .request_spot_position_status_reports(user, instrument_id)
2104                .await?;
2105            reports.extend(spot_reports);
2106        }
2107
2108        Ok(reports)
2109    }
2110
2111    /// Request account state (balances and margins) for a user.
2112    ///
2113    /// Fetches perp and spot clearinghouse state from Hyperliquid and merges them
2114    /// into a single [`AccountState`]. USDC is taken from the perp margin summary
2115    /// when present (to avoid double-counting combined `withdrawable`); non-USDC
2116    /// tokens are appended from the spot balances.
2117    ///
2118    /// # Errors
2119    ///
2120    /// Returns an error if `account_id` is not set, or if either the perp or
2121    /// spot clearinghouse request fails. Spot failures are propagated so the
2122    /// caller sees real API errors instead of a silently truncated snapshot.
2123    pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2124        let account_id = self
2125            .account_id
2126            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2127        let state_response = self.info_clearinghouse_state(user).await?;
2128        let ts_init = self.clock.get_time_ns();
2129
2130        log::trace!("Clearinghouse state response: {state_response}");
2131
2132        let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2133            .map_err(|e| {
2134                log::error!("Failed to parse clearinghouse state: {e}");
2135                log::debug!("Raw response: {state_response}");
2136                Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2137            })?;
2138
2139        // Spot must not be silently dropped: a 429 or parse error would
2140        // otherwise make non-USDC holdings look like they vanished.
2141        let spot_response = self.info_spot_clearinghouse_state(user).await?;
2142        let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2143            .map_err(|e| {
2144                log::error!("Failed to parse spot clearinghouse state: {e}");
2145                log::debug!("Raw spot response: {spot_response}");
2146                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2147            })?;
2148
2149        let (balances, margins) =
2150            parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2151                .map_err(|e| Error::decode(e.to_string()))?;
2152
2153        Ok(AccountState::new(
2154            account_id,
2155            AccountType::Margin,
2156            balances,
2157            margins,
2158            true, // reported
2159            UUID4::new(),
2160            ts_init,
2161            ts_init,
2162            None,
2163        ))
2164    }
2165
2166    /// Request spot token balances for a user.
2167    ///
2168    /// Fetches `spotClearinghouseState` and returns one [`AccountBalance`] per
2169    /// non-zero token. USDC is included as a separate balance entry when present;
2170    /// callers that also report perp margin state must dedupe currencies before
2171    /// emitting an [`AccountState`].
2172    ///
2173    /// # Errors
2174    ///
2175    /// Returns an error if the API request fails or the response cannot be parsed.
2176    pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2177        let response = self.info_spot_clearinghouse_state(user).await?;
2178
2179        log::trace!("Spot clearinghouse state response: {response}");
2180
2181        let state: SpotClearinghouseState =
2182            serde_json::from_value(response.clone()).map_err(|e| {
2183                log::error!("Failed to parse spot clearinghouse state: {e}");
2184                log::debug!("Raw response: {response}");
2185                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2186            })?;
2187
2188        parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2189    }
2190
2191    /// Request spot position status reports for a user.
2192    ///
2193    /// Each non-zero spot balance is reported as a Long position against its
2194    /// `{BASE}-{QUOTE}-SPOT` instrument. Balances whose base token has no
2195    /// matching instrument in the cache are skipped with a debug log (callers
2196    /// should ensure [`request_instruments`](Self::request_instruments) has run
2197    /// first).
2198    ///
2199    /// # Errors
2200    ///
2201    /// Returns an error if `account_id` has not been set or the API request fails.
2202    pub async fn request_spot_position_status_reports(
2203        &self,
2204        user: &str,
2205        instrument_id: Option<InstrumentId>,
2206    ) -> Result<Vec<PositionStatusReport>> {
2207        let account_id = self
2208            .account_id
2209            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2210        let response = self.info_spot_clearinghouse_state(user).await?;
2211
2212        let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2213            log::error!("Failed to parse spot clearinghouse state: {e}");
2214            Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2215        })?;
2216
2217        let ts_init = self.clock.get_time_ns();
2218        let mut reports = Vec::with_capacity(state.balances.len());
2219
2220        for balance in &state.balances {
2221            if balance.total.is_zero() {
2222                continue;
2223            }
2224
2225            // USDC is the universal quote for Hyperliquid spot: it funds every
2226            // pair and has no `USDC-*-SPOT` instrument. Skip it so the loop
2227            // does not trigger a misleading cache-miss WARN. Revisit if
2228            // Hyperliquid ever introduces a USDC-base spot pair.
2229            if balance.coin.as_str() == "USDC" {
2230                continue;
2231            }
2232
2233            let instrument = match self
2234                .get_or_create_instrument(&balance.coin, Some(HyperliquidProductType::Spot))
2235            {
2236                Some(inst) => inst,
2237                None => continue,
2238            };
2239
2240            if let Some(filter_id) = instrument_id
2241                && instrument.id() != filter_id
2242            {
2243                continue;
2244            }
2245
2246            match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2247                Ok(report) => reports.push(report),
2248                Err(e) => log::error!(
2249                    "Failed to parse spot position status report for {}: {e}",
2250                    balance.coin,
2251                ),
2252            }
2253        }
2254
2255        Ok(reports)
2256    }
2257
2258    /// Request historical bars for an instrument.
2259    ///
2260    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
2261    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
2262    ///
2263    /// # Errors
2264    ///
2265    /// Returns an error if:
2266    /// - The instrument is not found in cache.
2267    /// - The bar aggregation is unsupported by Hyperliquid.
2268    /// - The API request fails.
2269    /// - Parsing fails.
2270    ///
2271    /// # References
2272    ///
2273    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
2274    pub async fn request_bars(
2275        &self,
2276        bar_type: BarType,
2277        start: Option<chrono::DateTime<chrono::Utc>>,
2278        end: Option<chrono::DateTime<chrono::Utc>>,
2279        limit: Option<u32>,
2280    ) -> Result<Vec<Bar>> {
2281        let instrument_id = bar_type.instrument_id();
2282        let symbol = instrument_id.symbol;
2283
2284        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2285
2286        // Extract base currency for lookup, then use raw_symbol for the API call
2287        let base = Ustr::from(
2288            symbol
2289                .as_str()
2290                .split('-')
2291                .next()
2292                .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
2293        );
2294
2295        let instrument = self
2296            .get_or_create_instrument(&base, product_type)
2297            .ok_or_else(|| {
2298                Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
2299            })?;
2300
2301        // Use raw_symbol which has the correct Hyperliquid API format:
2302        // - Perps: base currency (e.g., "BTC")
2303        // - Spot PURR: slash format (e.g., "PURR/USDC")
2304        // - Spot others: @{index} format (e.g., "@107")
2305        let coin = instrument.raw_symbol().inner();
2306
2307        let price_precision = instrument.price_precision();
2308        let size_precision = instrument.size_precision();
2309
2310        let interval =
2311            bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2312
2313        // Hyperliquid uses millisecond timestamps
2314        let now = chrono::Utc::now();
2315        let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2316        let start_time = if let Some(start) = start {
2317            start.timestamp_millis() as u64
2318        } else {
2319            // Default to 1000 bars before end_time
2320            let spec = bar_type.spec();
2321            let step_ms = match spec.aggregation {
2322                BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2323                BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2324                BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2325                BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2326                BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2327                _ => 60_000,
2328            };
2329            end_time.saturating_sub(1000 * step_ms)
2330        };
2331
2332        let candles = self
2333            .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2334            .await?;
2335
2336        // Filter out incomplete bars where end_timestamp >= current time
2337        let now_ms = now.timestamp_millis() as u64;
2338
2339        let mut bars: Vec<Bar> = candles
2340            .iter()
2341            .filter(|candle| candle.end_timestamp < now_ms)
2342            .enumerate()
2343            .filter_map(|(i, candle)| {
2344                candle_to_bar(candle, bar_type, price_precision, size_precision)
2345                    .map_err(|e| {
2346                        log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2347                        e
2348                    })
2349                    .ok()
2350            })
2351            .collect();
2352
2353        // 0 means no limit
2354        if let Some(limit) = limit
2355            && limit > 0
2356            && bars.len() > limit as usize
2357        {
2358            bars.truncate(limit as usize);
2359        }
2360
2361        log::debug!(
2362            "Received {} bars for {} (filtered {} incomplete)",
2363            bars.len(),
2364            bar_type,
2365            candles.len() - bars.len()
2366        );
2367        Ok(bars)
2368    }
2369
2370    /// Submits an order to the exchange.
2371    ///
2372    /// # Errors
2373    ///
2374    /// Returns an error if credentials are missing, order validation fails, serialization fails,
2375    /// or the API returns an error.
2376    #[expect(clippy::too_many_arguments)]
2377    pub async fn submit_order(
2378        &self,
2379        instrument_id: InstrumentId,
2380        client_order_id: ClientOrderId,
2381        order_side: OrderSide,
2382        order_type: OrderType,
2383        quantity: Quantity,
2384        time_in_force: TimeInForce,
2385        price: Option<Price>,
2386        trigger_price: Option<Price>,
2387        post_only: bool,
2388        reduce_only: bool,
2389    ) -> Result<OrderStatusReport> {
2390        let symbol = instrument_id.symbol.as_str();
2391        let asset = self.get_asset_index(symbol).ok_or_else(|| {
2392            Error::bad_request(format!(
2393                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2394            ))
2395        })?;
2396
2397        let is_buy = matches!(order_side, OrderSide::Buy);
2398        let price_precision = self.get_price_precision(symbol).unwrap_or(2);
2399
2400        let price_decimal = match price {
2401            Some(px) if self.normalize_prices => {
2402                normalize_price(px.as_decimal(), price_precision).normalize()
2403            }
2404            Some(px) => px.as_decimal().normalize(),
2405            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2406            None if matches!(
2407                order_type,
2408                OrderType::StopMarket | OrderType::MarketIfTouched
2409            ) =>
2410            {
2411                match trigger_price {
2412                    Some(tp) => {
2413                        let derived = derive_limit_from_trigger(
2414                            tp.as_decimal().normalize(),
2415                            is_buy,
2416                            self.market_order_slippage_bps,
2417                        );
2418                        let sig_rounded = round_to_sig_figs(derived, 5);
2419                        clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2420                    }
2421                    None => Decimal::ZERO,
2422                }
2423            }
2424            None => return Err(Error::bad_request("Limit orders require a price")),
2425        };
2426
2427        let size_decimal = quantity.as_decimal().normalize();
2428
2429        let kind = match order_type {
2430            OrderType::Market => HyperliquidExecOrderKind::Limit {
2431                limit: HyperliquidExecLimitParams {
2432                    tif: HyperliquidExecTif::Ioc,
2433                },
2434            },
2435            OrderType::Limit => {
2436                let tif = if post_only {
2437                    HyperliquidExecTif::Alo
2438                } else {
2439                    match time_in_force {
2440                        TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2441                        TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2442                        TimeInForce::Fok
2443                        | TimeInForce::Day
2444                        | TimeInForce::Gtd
2445                        | TimeInForce::AtTheOpen
2446                        | TimeInForce::AtTheClose => {
2447                            return Err(Error::bad_request(format!(
2448                                "Time in force {time_in_force:?} not supported"
2449                            )));
2450                        }
2451                    }
2452                };
2453                HyperliquidExecOrderKind::Limit {
2454                    limit: HyperliquidExecLimitParams { tif },
2455                }
2456            }
2457            OrderType::StopMarket
2458            | OrderType::StopLimit
2459            | OrderType::MarketIfTouched
2460            | OrderType::LimitIfTouched => {
2461                if let Some(trig_px) = trigger_price {
2462                    let trigger_price_decimal = if self.normalize_prices {
2463                        normalize_price(trig_px.as_decimal(), price_precision).normalize()
2464                    } else {
2465                        trig_px.as_decimal().normalize()
2466                    };
2467
2468                    // Determine TP/SL type based on order type
2469                    // StopMarket/StopLimit are always Sl (protective stops)
2470                    // MarketIfTouched/LimitIfTouched are always Tp (profit-taking/entry)
2471                    let tpsl = match order_type {
2472                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2473                        OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2474                            HyperliquidExecTpSl::Tp
2475                        }
2476                        _ => unreachable!(),
2477                    };
2478
2479                    let is_market = matches!(
2480                        order_type,
2481                        OrderType::StopMarket | OrderType::MarketIfTouched
2482                    );
2483
2484                    HyperliquidExecOrderKind::Trigger {
2485                        trigger: HyperliquidExecTriggerParams {
2486                            is_market,
2487                            trigger_px: trigger_price_decimal,
2488                            tpsl,
2489                        },
2490                    }
2491                } else {
2492                    return Err(Error::bad_request("Trigger orders require a trigger price"));
2493                }
2494            }
2495            _ => {
2496                return Err(Error::bad_request(format!(
2497                    "Order type {order_type:?} not supported"
2498                )));
2499            }
2500        };
2501
2502        let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2503            asset,
2504            is_buy,
2505            price: price_decimal,
2506            size: size_decimal,
2507            reduce_only,
2508            kind,
2509            cloid: Some(Cloid::from_client_order_id(client_order_id)),
2510        };
2511
2512        let builder = if self.has_vault_address() {
2513            None
2514        } else {
2515            Some(HyperliquidExecBuilderFee {
2516                address: NAUTILUS_BUILDER_ADDRESS.to_string(),
2517                fee_tenths_bp: 0,
2518            })
2519        };
2520
2521        let action = HyperliquidExecAction::Order {
2522            orders: vec![hyperliquid_order],
2523            grouping: HyperliquidExecGrouping::Na,
2524            builder,
2525        };
2526
2527        let response = self.inner.post_action_exec(&action).await?;
2528
2529        match response {
2530            HyperliquidExchangeResponse::Status {
2531                status,
2532                response: response_data,
2533            } if status == RESPONSE_STATUS_OK => {
2534                let data_value = if let Some(data) = response_data.get("data") {
2535                    data.clone()
2536                } else {
2537                    response_data
2538                };
2539
2540                let order_response: HyperliquidExecOrderResponseData =
2541                    serde_json::from_value(data_value).map_err(|e| {
2542                        Error::bad_request(format!("Failed to parse order response: {e}"))
2543                    })?;
2544
2545                let order_status = order_response
2546                    .statuses
2547                    .first()
2548                    .ok_or_else(|| Error::bad_request("No order status in response"))?;
2549
2550                let symbol_str = instrument_id.symbol.as_str();
2551                let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
2552
2553                // Extract base coin from symbol (first segment before '-')
2554                let asset_str = symbol_str.split('-').next().unwrap_or(symbol_str);
2555                let instrument = self
2556                    .get_or_create_instrument(&Ustr::from(asset_str), product_type)
2557                    .ok_or_else(|| {
2558                        Error::bad_request(format!("Instrument not found for {asset_str}"))
2559                    })?;
2560
2561                let account_id = self
2562                    .account_id
2563                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2564                let ts_init = self.clock.get_time_ns();
2565
2566                match order_status {
2567                    HyperliquidExecOrderStatus::Resting { resting } => Ok(self
2568                        .create_order_status_report(
2569                            instrument_id,
2570                            Some(client_order_id),
2571                            VenueOrderId::new(resting.oid.to_string()),
2572                            order_side,
2573                            order_type,
2574                            quantity,
2575                            time_in_force,
2576                            price,
2577                            trigger_price,
2578                            OrderStatus::Accepted,
2579                            Quantity::new(0.0, instrument.size_precision()),
2580                            &instrument,
2581                            account_id,
2582                            ts_init,
2583                        )),
2584                    HyperliquidExecOrderStatus::Filled { filled } => {
2585                        let filled_qty = Quantity::new(
2586                            filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2587                            instrument.size_precision(),
2588                        );
2589                        Ok(self.create_order_status_report(
2590                            instrument_id,
2591                            Some(client_order_id),
2592                            VenueOrderId::new(filled.oid.to_string()),
2593                            order_side,
2594                            order_type,
2595                            quantity,
2596                            time_in_force,
2597                            price,
2598                            trigger_price,
2599                            OrderStatus::Filled,
2600                            filled_qty,
2601                            &instrument,
2602                            account_id,
2603                            ts_init,
2604                        ))
2605                    }
2606                    HyperliquidExecOrderStatus::Error { error } => {
2607                        Err(Error::bad_request(format!("Order rejected: {error}")))
2608                    }
2609                }
2610            }
2611            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2612                "Order submission failed: {error}"
2613            ))),
2614            _ => Err(Error::bad_request("Unexpected response format")),
2615        }
2616    }
2617
2618    /// Submit an order using an OrderAny object.
2619    ///
2620    /// This is a convenience method that wraps submit_order.
2621    pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2622        self.submit_order(
2623            order.instrument_id(),
2624            order.client_order_id(),
2625            order.order_side(),
2626            order.order_type(),
2627            order.quantity(),
2628            order.time_in_force(),
2629            order.price(),
2630            order.trigger_price(),
2631            order.is_post_only(),
2632            order.is_reduce_only(),
2633        )
2634        .await
2635    }
2636
2637    #[expect(clippy::too_many_arguments)]
2638    fn create_order_status_report(
2639        &self,
2640        instrument_id: InstrumentId,
2641        client_order_id: Option<ClientOrderId>,
2642        venue_order_id: VenueOrderId,
2643        order_side: OrderSide,
2644        order_type: OrderType,
2645        quantity: Quantity,
2646        time_in_force: TimeInForce,
2647        price: Option<Price>,
2648        trigger_price: Option<Price>,
2649        order_status: OrderStatus,
2650        filled_qty: Quantity,
2651        _instrument: &InstrumentAny,
2652        account_id: AccountId,
2653        ts_init: UnixNanos,
2654    ) -> OrderStatusReport {
2655        let ts_accepted = self.clock.get_time_ns();
2656        let ts_last = ts_accepted;
2657        let report_id = UUID4::new();
2658
2659        let mut report = OrderStatusReport::new(
2660            account_id,
2661            instrument_id,
2662            client_order_id,
2663            venue_order_id,
2664            order_side,
2665            order_type,
2666            time_in_force,
2667            order_status,
2668            quantity,
2669            filled_qty,
2670            ts_accepted,
2671            ts_last,
2672            ts_init,
2673            Some(report_id),
2674        );
2675
2676        if let Some(px) = price {
2677            report = report.with_price(px);
2678        }
2679
2680        if let Some(trig_px) = trigger_price {
2681            report = report
2682                .with_trigger_price(trig_px)
2683                .with_trigger_type(TriggerType::Default);
2684        }
2685
2686        report
2687    }
2688
2689    /// Submit multiple orders to the Hyperliquid exchange in a single request.
2690    ///
2691    /// # Errors
2692    ///
2693    /// Returns an error if credentials are missing, order validation fails, serialization fails,
2694    /// or the API returns an error.
2695    pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
2696        // Convert orders using asset indices from the cached map
2697        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
2698
2699        for order in orders {
2700            let instrument_id = order.instrument_id();
2701            let symbol = instrument_id.symbol.as_str();
2702            let asset = self.get_asset_index(symbol).ok_or_else(|| {
2703                Error::bad_request(format!(
2704                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2705                ))
2706            })?;
2707            let price_decimals = self.get_price_precision(symbol).unwrap_or(2);
2708            let request = order_to_hyperliquid_request_with_asset(
2709                order,
2710                asset,
2711                price_decimals,
2712                self.normalize_prices,
2713                self.market_order_slippage_bps,
2714            )
2715            .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
2716            hyperliquid_orders.push(request);
2717        }
2718
2719        let builder = if self.has_vault_address() {
2720            None
2721        } else {
2722            Some(HyperliquidExecBuilderFee {
2723                address: NAUTILUS_BUILDER_ADDRESS.to_string(),
2724                fee_tenths_bp: 0,
2725            })
2726        };
2727
2728        let grouping =
2729            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
2730
2731        let action = HyperliquidExecAction::Order {
2732            orders: hyperliquid_orders,
2733            grouping,
2734            builder,
2735        };
2736
2737        // Submit to exchange using the typed exec endpoint
2738        let response = self.inner.post_action_exec(&action).await?;
2739
2740        // Parse the response to extract order statuses
2741        match response {
2742            HyperliquidExchangeResponse::Status {
2743                status,
2744                response: response_data,
2745            } if status == RESPONSE_STATUS_OK => {
2746                // Extract the 'data' field from the response if it exists (new format)
2747                // Otherwise use response_data directly (old format)
2748                let data_value = if let Some(data) = response_data.get("data") {
2749                    data.clone()
2750                } else {
2751                    response_data
2752                };
2753
2754                // Parse the response data to extract order statuses
2755                let order_response: HyperliquidExecOrderResponseData =
2756                    serde_json::from_value(data_value).map_err(|e| {
2757                        Error::bad_request(format!("Failed to parse order response: {e}"))
2758                    })?;
2759
2760                let account_id = self
2761                    .account_id
2762                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2763                let ts_init = self.clock.get_time_ns();
2764
2765                // For grouped orders (NormalTpsl/PositionTpsl), the exchange
2766                // returns a single status for the whole group. Only enforce
2767                // 1:1 matching for ungrouped (Na) submissions.
2768                if grouping == HyperliquidExecGrouping::Na
2769                    && order_response.statuses.len() != orders.len()
2770                {
2771                    return Err(Error::bad_request(format!(
2772                        "Mismatch between submitted orders ({}) and response statuses ({})",
2773                        orders.len(),
2774                        order_response.statuses.len()
2775                    )));
2776                }
2777
2778                let mut reports = Vec::new();
2779
2780                // Create OrderStatusReport for each order with a matching
2781                // status. For grouped submissions the exchange may return
2782                // fewer statuses; remaining orders are confirmed via WS.
2783                for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
2784                    // Extract asset from instrument symbol
2785                    let instrument_id = order.instrument_id();
2786                    let symbol = instrument_id.symbol.as_str();
2787                    let product_type = HyperliquidProductType::from_symbol(symbol).ok();
2788
2789                    // Extract base coin from symbol (first segment before '-')
2790                    let asset = symbol.split('-').next().unwrap_or(symbol);
2791                    let instrument = self
2792                        .get_or_create_instrument(&Ustr::from(asset), product_type)
2793                        .ok_or_else(|| {
2794                            Error::bad_request(format!("Instrument not found for {asset}"))
2795                        })?;
2796
2797                    // Create OrderStatusReport based on the order status
2798                    let report = match order_status {
2799                        HyperliquidExecOrderStatus::Resting { resting } => {
2800                            // Order is resting on the order book
2801                            self.create_order_status_report(
2802                                order.instrument_id(),
2803                                Some(order.client_order_id()),
2804                                VenueOrderId::new(resting.oid.to_string()),
2805                                order.order_side(),
2806                                order.order_type(),
2807                                order.quantity(),
2808                                order.time_in_force(),
2809                                order.price(),
2810                                order.trigger_price(),
2811                                OrderStatus::Accepted,
2812                                Quantity::new(0.0, instrument.size_precision()),
2813                                &instrument,
2814                                account_id,
2815                                ts_init,
2816                            )
2817                        }
2818                        HyperliquidExecOrderStatus::Filled { filled } => {
2819                            // Order was filled immediately
2820                            let filled_qty = Quantity::new(
2821                                filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2822                                instrument.size_precision(),
2823                            );
2824                            self.create_order_status_report(
2825                                order.instrument_id(),
2826                                Some(order.client_order_id()),
2827                                VenueOrderId::new(filled.oid.to_string()),
2828                                order.order_side(),
2829                                order.order_type(),
2830                                order.quantity(),
2831                                order.time_in_force(),
2832                                order.price(),
2833                                order.trigger_price(),
2834                                OrderStatus::Filled,
2835                                filled_qty,
2836                                &instrument,
2837                                account_id,
2838                                ts_init,
2839                            )
2840                        }
2841                        HyperliquidExecOrderStatus::Error { error } => {
2842                            return Err(Error::bad_request(format!(
2843                                "Order {} rejected: {error}",
2844                                order.client_order_id()
2845                            )));
2846                        }
2847                    };
2848
2849                    reports.push(report);
2850                }
2851
2852                Ok(reports)
2853            }
2854            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2855                "Order submission failed: {error}"
2856            ))),
2857            _ => Err(Error::bad_request("Unexpected response format")),
2858        }
2859    }
2860}
2861
2862/// Returns the asset index base for a perp dex.
2863///
2864/// Standard perps (dex 0) start at 0. HIP-3 dexes start at
2865/// 100_000 + dex_index * 10_000.
2866fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
2867    if dex_index == 0 {
2868        0
2869    } else {
2870        100_000 + dex_index as u32 * 10_000
2871    }
2872}
2873
2874#[cfg(test)]
2875mod tests {
2876    use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
2877    use nautilus_model::{
2878        currencies::CURRENCY_MAP,
2879        enums::CurrencyType,
2880        identifiers::{InstrumentId, Symbol},
2881        instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
2882        types::{Currency, Price, Quantity},
2883    };
2884    use rstest::rstest;
2885    use ustr::Ustr;
2886
2887    use super::HyperliquidHttpClient;
2888    use crate::{
2889        common::{
2890            consts::HYPERLIQUID_VENUE,
2891            enums::{HyperliquidEnvironment, HyperliquidProductType},
2892        },
2893        http::query::InfoRequest,
2894    };
2895
2896    #[rstest]
2897    fn stable_json_roundtrips() {
2898        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
2899        let s = serde_json::to_string(&v).unwrap();
2900        // Parse back to ensure JSON structure is correct, regardless of field order
2901        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
2902        assert_eq!(parsed["type"], "l2Book");
2903        assert_eq!(parsed["coin"], "BTC");
2904        assert_eq!(parsed, v);
2905    }
2906
2907    #[rstest]
2908    fn info_pretty_shape() {
2909        let r = InfoRequest::l2_book("BTC");
2910        let val = serde_json::to_value(&r).unwrap();
2911        let pretty = serde_json::to_string_pretty(&val).unwrap();
2912        assert!(pretty.contains("\"type\": \"l2Book\""));
2913        assert!(pretty.contains("\"coin\": \"BTC\""));
2914    }
2915
2916    #[rstest]
2917    fn test_cache_instrument_by_raw_symbol() {
2918        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
2919
2920        // Create a test instrument with base currency "vntls:vCURSOR"
2921        let base_code = "vntls:vCURSOR";
2922        let quote_code = "USDC";
2923
2924        // Register the custom currency
2925        {
2926            let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
2927            if !currency_map.contains_key(base_code) {
2928                currency_map.insert(
2929                    base_code.to_string(),
2930                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
2931                );
2932            }
2933        }
2934
2935        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
2936        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
2937
2938        // Nautilus symbol is "vntls:vCURSOR-USDC-SPOT"
2939        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
2940        let venue = *HYPERLIQUID_VENUE;
2941        let instrument_id = InstrumentId::new(symbol, venue);
2942
2943        // raw_symbol is set to the base currency "vntls:vCURSOR" (see parse.rs)
2944        let raw_symbol = Symbol::new(base_code);
2945
2946        let clock = get_atomic_clock_realtime();
2947        let ts = clock.get_time_ns();
2948
2949        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
2950            instrument_id,
2951            raw_symbol,
2952            base_currency,
2953            quote_currency,
2954            8,
2955            8,
2956            Price::from("0.00000001"),
2957            Quantity::from("0.00000001"),
2958            None,
2959            None,
2960            None,
2961            None,
2962            None,
2963            None,
2964            None,
2965            None,
2966            None,
2967            None,
2968            None,
2969            None, // taker_fee
2970            None, // info
2971            ts,
2972            ts,
2973        ));
2974
2975        // Cache the instrument
2976        client.cache_instrument(&instrument);
2977
2978        // Verify it can be looked up by full symbol
2979        let instruments = client.instruments.load();
2980        let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2981        assert!(
2982            by_full_symbol.is_some(),
2983            "Instrument should be accessible by full symbol"
2984        );
2985        assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2986
2987        // Verify it can be looked up by raw_symbol (coin) - backward compatibility
2988        let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2989        assert!(
2990            by_raw_symbol.is_some(),
2991            "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2992        );
2993        assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2994        drop(instruments);
2995
2996        // Verify it can be looked up by composite key (coin, product_type)
2997        let instruments_by_coin = client.instruments_by_coin.load();
2998        let by_coin =
2999            instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
3000        assert!(
3001            by_coin.is_some(),
3002            "Instrument should be accessible by coin and product type"
3003        );
3004        assert_eq!(by_coin.unwrap().id(), instrument.id());
3005        drop(instruments_by_coin);
3006
3007        // Verify get_or_create_instrument works with product type
3008        let retrieved_with_type = client.get_or_create_instrument(
3009            &Ustr::from("vntls:vCURSOR"),
3010            Some(HyperliquidProductType::Spot),
3011        );
3012        assert!(retrieved_with_type.is_some());
3013        assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
3014
3015        // Verify get_or_create_instrument works without product type (fallback)
3016        let retrieved_without_type =
3017            client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
3018        assert!(retrieved_without_type.is_some());
3019        assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
3020    }
3021
3022    #[rstest]
3023    fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
3024        // Two spot pairs share the base token "HYPE": the canonical pair is
3025        // cached first; a subsequent non-canonical pair must not overwrite the
3026        // base-token alias so lookups by "HYPE" keep resolving to the canonical
3027        // instrument.
3028        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3029
3030        let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
3031        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3032        let clock = get_atomic_clock_realtime();
3033        let ts = clock.get_time_ns();
3034
3035        let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3036            InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3037            Symbol::new("@107"),
3038            hype,
3039            usdc,
3040            5,
3041            2,
3042            Price::from("0.00001"),
3043            Quantity::from("0.01"),
3044            None,
3045            None,
3046            None,
3047            None,
3048            None,
3049            None,
3050            None,
3051            None,
3052            None,
3053            None,
3054            None,
3055            None,
3056            None,
3057            ts,
3058            ts,
3059        ));
3060
3061        let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3062            InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3063            Symbol::new("@999"),
3064            hype,
3065            usdc,
3066            5,
3067            2,
3068            Price::from("0.00001"),
3069            Quantity::from("0.01"),
3070            None,
3071            None,
3072            None,
3073            None,
3074            None,
3075            None,
3076            None,
3077            None,
3078            None,
3079            None,
3080            None,
3081            None,
3082            None,
3083            ts,
3084            ts,
3085        ));
3086
3087        client.cache_instrument(&canonical);
3088        client.cache_instrument(&non_canonical);
3089
3090        let instruments_by_coin = client.instruments_by_coin.load();
3091        let by_base = instruments_by_coin
3092            .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
3093            .expect("base alias must resolve");
3094        assert_eq!(
3095            by_base.raw_symbol().inner().as_str(),
3096            "@107",
3097            "base alias must point to the canonical pair, not the one cached later",
3098        );
3099    }
3100
3101    #[rstest]
3102    fn test_cache_instrument_perp_aliases_sanitized_base() {
3103        // HIP-3 perp with wildcard-bearing venue name: `instrument_id.symbol`
3104        // is sanitized but order paths derive a coin key by splitting that
3105        // sanitized symbol on `-`. The cache must alias on the sanitized base
3106        // so those lookups resolve to the same instrument cached under
3107        // `raw_symbol` (the venue-official name).
3108        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3109
3110        let base_currency = Currency::new(
3111            "dex:STREAMABCD****",
3112            8,
3113            0,
3114            "dex:STREAMABCD****",
3115            CurrencyType::Crypto,
3116        );
3117        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3118        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3119        let clock = get_atomic_clock_realtime();
3120        let ts = clock.get_time_ns();
3121
3122        let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3123            InstrumentId::new(
3124                Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
3125                *HYPERLIQUID_VENUE,
3126            ),
3127            Symbol::new("dex:STREAMABCD****"),
3128            base_currency,
3129            usd,
3130            usdc,
3131            false,
3132            6,
3133            3,
3134            Price::from("0.000001"),
3135            Quantity::from("0.001"),
3136            None,
3137            None,
3138            None,
3139            None,
3140            None,
3141            None,
3142            None,
3143            None,
3144            None,
3145            None,
3146            None,
3147            None,
3148            None,
3149            ts,
3150            ts,
3151        ));
3152
3153        client.cache_instrument(&hip3);
3154
3155        let instruments_by_coin = client.instruments_by_coin.load();
3156        let by_raw = instruments_by_coin
3157            .get(&(
3158                Ustr::from("dex:STREAMABCD****"),
3159                HyperliquidProductType::Perp,
3160            ))
3161            .expect("venue coin lookup must resolve");
3162        assert_eq!(by_raw.id(), hip3.id());
3163
3164        let by_sanitized = instruments_by_coin
3165            .get(&(
3166                Ustr::from("dex:STREAMABCDxxxx"),
3167                HyperliquidProductType::Perp,
3168            ))
3169            .expect("sanitized base lookup must resolve");
3170        assert_eq!(by_sanitized.id(), hip3.id());
3171        drop(instruments_by_coin);
3172
3173        // Confirm the order-submission lookup path resolves through the alias.
3174        let resolved = client
3175            .get_or_create_instrument(
3176                &Ustr::from("dex:STREAMABCDxxxx"),
3177                Some(HyperliquidProductType::Perp),
3178            )
3179            .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
3180        assert_eq!(resolved.id(), hip3.id());
3181    }
3182}