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