nautilus_hyperliquid/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides the HTTP client integration for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
17//!
18//! This module defines and implements a [`HyperliquidHttpClient`] for sending requests to various
19//! Hyperliquid endpoints. It handles request signing (when credentials are provided), constructs
20//! valid HTTP requests using the [`HttpClient`], and parses the responses back into structured
21//! data or an [`Error`].
22
23use std::{
24    collections::HashMap,
25    num::NonZeroU32,
26    sync::{Arc, LazyLock, RwLock},
27    time::Duration,
28};
29
30use ahash::AHashMap;
31use anyhow::Context;
32use nautilus_core::{
33    UUID4, UnixNanos, consts::NAUTILUS_USER_AGENT, time::get_atomic_clock_realtime,
34};
35use nautilus_model::{
36    data::{Bar, BarType},
37    enums::{
38        BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType,
39    },
40    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
41    instruments::{CurrencyPair, Instrument, InstrumentAny},
42    orders::{Order, OrderAny},
43    reports::{FillReport, OrderStatusReport, PositionStatusReport},
44    types::{Currency, Price, Quantity},
45};
46use nautilus_network::{
47    http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
48    ratelimiter::quota::Quota,
49};
50use rust_decimal::Decimal;
51use serde_json::Value;
52use ustr::Ustr;
53
54use crate::{
55    common::{
56        consts::{HYPERLIQUID_VENUE, exchange_url, info_url},
57        credential::{Secrets, VaultAddress},
58        enums::{
59            HyperliquidBarInterval, HyperliquidOrderStatus as HyperliquidOrderStatusEnum,
60            HyperliquidProductType,
61        },
62        parse::{
63            bar_type_to_interval, extract_asset_id_from_symbol, orders_to_hyperliquid_requests,
64        },
65    },
66    http::{
67        error::{Error, Result},
68        models::{
69            Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
70            HyperliquidExchangeResponse, HyperliquidExecAction,
71            HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
72            HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecOrderKind,
73            HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
74            HyperliquidExecPlaceOrderRequest, HyperliquidExecTif, HyperliquidExecTpSl,
75            HyperliquidExecTriggerParams, HyperliquidFills, HyperliquidL2Book, HyperliquidMeta,
76            HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs, SpotMeta, SpotMetaAndCtxs,
77        },
78        parse::{
79            HyperliquidInstrumentDef, instruments_from_defs_owned, parse_perp_instruments,
80            parse_spot_instruments,
81        },
82        query::{ExchangeAction, InfoRequest},
83        rate_limits::{
84            RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
85            info_base_weight, info_extra_weight,
86        },
87    },
88    signing::{
89        HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
90    },
91};
92
93// https://hyperliquid.xyz/docs/api#rate-limits
94pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
95    LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
96
97/// Provides a raw HTTP client for low-level Hyperliquid REST API operations.
98///
99/// This client handles HTTP infrastructure, request signing, and raw API calls
100/// that closely match Hyperliquid endpoint specifications.
101#[derive(Debug, Clone)]
102#[cfg_attr(
103    feature = "python",
104    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.hyperliquid")
105)]
106pub struct HyperliquidRawHttpClient {
107    client: HttpClient,
108    is_testnet: bool,
109    base_info: String,
110    base_exchange: String,
111    signer: Option<HyperliquidEip712Signer>,
112    nonce_manager: Option<Arc<NonceManager>>,
113    vault_address: Option<VaultAddress>,
114    rest_limiter: Arc<WeightedLimiter>,
115    rate_limit_backoff_base: Duration,
116    rate_limit_backoff_cap: Duration,
117    rate_limit_max_attempts_info: u32,
118}
119
120impl HyperliquidRawHttpClient {
121    /// Creates a new [`HyperliquidRawHttpClient`] for public endpoints only.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the HTTP client cannot be created.
126    pub fn new(
127        is_testnet: bool,
128        timeout_secs: Option<u64>,
129        proxy_url: Option<String>,
130    ) -> std::result::Result<Self, HttpClientError> {
131        Ok(Self {
132            client: HttpClient::new(
133                Self::default_headers(),
134                vec![],
135                vec![],
136                Some(*HYPERLIQUID_REST_QUOTA),
137                timeout_secs,
138                proxy_url,
139            )?,
140            is_testnet,
141            base_info: info_url(is_testnet).to_string(),
142            base_exchange: exchange_url(is_testnet).to_string(),
143            signer: None,
144            nonce_manager: None,
145            vault_address: None,
146            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
147            rate_limit_backoff_base: Duration::from_millis(125),
148            rate_limit_backoff_cap: Duration::from_secs(5),
149            rate_limit_max_attempts_info: 3,
150        })
151    }
152
153    /// Creates a new [`HyperliquidRawHttpClient`] configured with credentials
154    /// for authenticated requests.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the HTTP client cannot be created.
159    pub fn with_credentials(
160        secrets: &Secrets,
161        timeout_secs: Option<u64>,
162        proxy_url: Option<String>,
163    ) -> std::result::Result<Self, HttpClientError> {
164        let signer = HyperliquidEip712Signer::new(secrets.private_key.clone());
165        let nonce_manager = Arc::new(NonceManager::new());
166
167        Ok(Self {
168            client: HttpClient::new(
169                Self::default_headers(),
170                vec![],
171                vec![],
172                Some(*HYPERLIQUID_REST_QUOTA),
173                timeout_secs,
174                proxy_url,
175            )?,
176            is_testnet: secrets.is_testnet,
177            base_info: info_url(secrets.is_testnet).to_string(),
178            base_exchange: exchange_url(secrets.is_testnet).to_string(),
179            signer: Some(signer),
180            nonce_manager: Some(nonce_manager),
181            vault_address: secrets.vault_address,
182            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
183            rate_limit_backoff_base: Duration::from_millis(125),
184            rate_limit_backoff_cap: Duration::from_secs(5),
185            rate_limit_max_attempts_info: 3,
186        })
187    }
188
189    /// Creates an authenticated client from environment variables.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`Error::Auth`] if required environment variables are not set.
194    pub fn from_env() -> Result<Self> {
195        let secrets =
196            Secrets::from_env().map_err(|_| Error::auth("missing credentials in environment"))?;
197        Self::with_credentials(&secrets, None, None)
198            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
199    }
200
201    /// Creates a new [`HyperliquidRawHttpClient`] configured with explicit credentials.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
206    pub fn from_credentials(
207        private_key: &str,
208        vault_address: Option<&str>,
209        is_testnet: bool,
210        timeout_secs: Option<u64>,
211        proxy_url: Option<String>,
212    ) -> Result<Self> {
213        let secrets = Secrets::from_private_key(private_key, vault_address, is_testnet)
214            .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
215        Self::with_credentials(&secrets, timeout_secs, proxy_url)
216            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
217    }
218
219    /// Configure rate limiting parameters (chainable).
220    #[must_use]
221    pub fn with_rate_limits(mut self) -> Self {
222        self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
223        self.rate_limit_backoff_base = Duration::from_millis(125);
224        self.rate_limit_backoff_cap = Duration::from_secs(5);
225        self.rate_limit_max_attempts_info = 3;
226        self
227    }
228
229    /// Returns whether this client is configured for testnet.
230    #[must_use]
231    pub fn is_testnet(&self) -> bool {
232        self.is_testnet
233    }
234
235    /// Gets the user address derived from the private key (if client has credentials).
236    ///
237    /// # Errors
238    ///
239    /// Returns [`Error::Auth`] if the client has no signer configured.
240    pub fn get_user_address(&self) -> Result<String> {
241        self.signer
242            .as_ref()
243            .ok_or_else(|| Error::auth("No signer configured"))?
244            .address()
245    }
246
247    /// Builds the default headers to include with each request (e.g., `User-Agent`).
248    fn default_headers() -> HashMap<String, String> {
249        HashMap::from([
250            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
251            ("Content-Type".to_string(), "application/json".to_string()),
252        ])
253    }
254
255    fn signer_id(&self) -> Result<SignerId> {
256        Ok(SignerId("hyperliquid:default".into()))
257    }
258
259    /// Parse Retry-After from response headers
260    fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
261        let retry_after = headers.get("retry-after")?;
262        retry_after.parse::<u64>().ok().map(|s| s * 1000) // convert seconds to ms
263    }
264
265    /// Get metadata about available markets.
266    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
267        let request = InfoRequest::meta();
268        let response = self.send_info_request(&request).await?;
269        serde_json::from_value(response).map_err(Error::Serde)
270    }
271
272    /// Get complete spot metadata (tokens and pairs).
273    pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
274        let request = InfoRequest::spot_meta();
275        let response = self.send_info_request(&request).await?;
276        serde_json::from_value(response).map_err(Error::Serde)
277    }
278
279    /// Get perpetuals metadata with asset contexts (for price precision refinement).
280    pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
281        let request = InfoRequest::meta_and_asset_ctxs();
282        let response = self.send_info_request(&request).await?;
283        serde_json::from_value(response).map_err(Error::Serde)
284    }
285
286    /// Get spot metadata with asset contexts (for price precision refinement).
287    pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
288        let request = InfoRequest::spot_meta_and_asset_ctxs();
289        let response = self.send_info_request(&request).await?;
290        serde_json::from_value(response).map_err(Error::Serde)
291    }
292
293    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
294        let request = InfoRequest::meta();
295        let response = self.send_info_request(&request).await?;
296        serde_json::from_value(response).map_err(Error::Serde)
297    }
298
299    /// Get L2 order book for a coin.
300    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
301        let request = InfoRequest::l2_book(coin);
302        let response = self.send_info_request(&request).await?;
303        serde_json::from_value(response).map_err(Error::Serde)
304    }
305
306    /// Get user fills (trading history).
307    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
308        let request = InfoRequest::user_fills(user);
309        let response = self.send_info_request(&request).await?;
310        serde_json::from_value(response).map_err(Error::Serde)
311    }
312
313    /// Get order status for a user.
314    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
315        let request = InfoRequest::order_status(user, oid);
316        let response = self.send_info_request(&request).await?;
317        serde_json::from_value(response).map_err(Error::Serde)
318    }
319
320    /// Get all open orders for a user.
321    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
322        let request = InfoRequest::open_orders(user);
323        self.send_info_request(&request).await
324    }
325
326    /// Get frontend open orders (includes more detail) for a user.
327    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
328        let request = InfoRequest::frontend_open_orders(user);
329        self.send_info_request(&request).await
330    }
331
332    /// Get clearinghouse state (balances, positions, margin) for a user.
333    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
334        let request = InfoRequest::clearinghouse_state(user);
335        self.send_info_request(&request).await
336    }
337
338    /// Get candle/bar data for a coin.
339    pub async fn info_candle_snapshot(
340        &self,
341        coin: &str,
342        interval: HyperliquidBarInterval,
343        start_time: u64,
344        end_time: u64,
345    ) -> Result<HyperliquidCandleSnapshot> {
346        let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
347        let response = self.send_info_request(&request).await?;
348
349        log::trace!(
350            "Candle snapshot raw response (len={}): {:?}",
351            response.as_array().map_or(0, |a| a.len()),
352            response
353        );
354
355        serde_json::from_value(response).map_err(Error::Serde)
356    }
357
358    /// Generic info request method that returns raw JSON (useful for new endpoints and testing).
359    pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
360        self.send_info_request(request).await
361    }
362
363    /// Send a raw info request and return the JSON response.
364    async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
365        let base_w = info_base_weight(request);
366        self.rest_limiter.acquire(base_w).await;
367
368        let mut attempt = 0u32;
369        loop {
370            let response = self.http_roundtrip_info(request).await?;
371
372            if response.status.is_success() {
373                // decode once to count items, then materialize T
374                let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
375                let extra = info_extra_weight(request, &val);
376                if extra > 0 {
377                    self.rest_limiter.debit_extra(extra).await;
378                    log::debug!(
379                        "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
380                    );
381                }
382                return Ok(val);
383            }
384
385            // 429 → respect Retry-After; else jittered backoff. Retry Info only.
386            if response.status.as_u16() == 429 {
387                if attempt >= self.rate_limit_max_attempts_info {
388                    let ra = self.parse_retry_after_simple(&response.headers);
389                    return Err(Error::rate_limit("info", base_w, ra));
390                }
391                let delay = self
392                    .parse_retry_after_simple(&response.headers)
393                    .map_or_else(
394                        || {
395                            backoff_full_jitter(
396                                attempt,
397                                self.rate_limit_backoff_base,
398                                self.rate_limit_backoff_cap,
399                            )
400                        },
401                        Duration::from_millis,
402                    );
403                log::warn!(
404                    "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
405                    delay.as_millis()
406                );
407                attempt += 1;
408                tokio::time::sleep(delay).await;
409                // tiny re-acquire to avoid stampede exactly on minute boundary
410                self.rest_limiter.acquire(1).await;
411                continue;
412            }
413
414            // transient 5xx: treat like retryable Info (bounded)
415            if (response.status.is_server_error() || response.status.as_u16() == 408)
416                && attempt < self.rate_limit_max_attempts_info
417            {
418                let delay = backoff_full_jitter(
419                    attempt,
420                    self.rate_limit_backoff_base,
421                    self.rate_limit_backoff_cap,
422                );
423                log::warn!(
424                    "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
425                    response.status.as_u16(),
426                    delay.as_millis()
427                );
428                attempt += 1;
429                tokio::time::sleep(delay).await;
430                continue;
431            }
432
433            // non-retryable or exhausted
434            let error_body = String::from_utf8_lossy(&response.body);
435            return Err(Error::http(
436                response.status.as_u16(),
437                error_body.to_string(),
438            ));
439        }
440    }
441
442    /// Raw HTTP roundtrip for info requests - returns the original HttpResponse.
443    async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
444        let url = &self.base_info;
445        let body = serde_json::to_value(request).map_err(Error::Serde)?;
446        let body_bytes = serde_json::to_string(&body)
447            .map_err(Error::Serde)?
448            .into_bytes();
449
450        self.client
451            .request(
452                Method::POST,
453                url.clone(),
454                None,
455                None,
456                Some(body_bytes),
457                None,
458                None,
459            )
460            .await
461            .map_err(Error::from_http_client)
462    }
463
464    /// Send a signed action to the exchange.
465    pub async fn post_action(
466        &self,
467        action: &ExchangeAction,
468    ) -> Result<HyperliquidExchangeResponse> {
469        let w = exchange_weight(action);
470        self.rest_limiter.acquire(w).await;
471
472        let signer = self
473            .signer
474            .as_ref()
475            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
476
477        let nonce_manager = self
478            .nonce_manager
479            .as_ref()
480            .ok_or_else(|| Error::auth("nonce manager missing"))?;
481
482        let signer_id = self.signer_id()?;
483        let time_nonce = nonce_manager.next(signer_id)?;
484
485        let action_value = serde_json::to_value(action)
486            .context("serialize exchange action")
487            .map_err(|e| Error::bad_request(e.to_string()))?;
488
489        // Serialize the original action struct with MessagePack for L1 signing
490        let action_bytes = rmp_serde::to_vec_named(action)
491            .context("serialize action with MessagePack")
492            .map_err(|e| Error::bad_request(e.to_string()))?;
493
494        let sign_request = SignRequest {
495            action: action_value.clone(),
496            action_bytes: Some(action_bytes),
497            time_nonce,
498            action_type: HyperliquidActionType::L1,
499            is_testnet: self.is_testnet,
500            vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
501        };
502
503        let sig = signer.sign(&sign_request)?.signature;
504
505        let nonce_u64 = time_nonce.as_millis() as u64;
506
507        let request = if let Some(vault) = self.vault_address {
508            HyperliquidExchangeRequest::with_vault(
509                action.clone(),
510                nonce_u64,
511                sig,
512                vault.to_string(),
513            )
514            .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
515        } else {
516            HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
517                .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
518        };
519
520        let response = self.http_roundtrip_exchange(&request).await?;
521
522        if response.status.is_success() {
523            let parsed_response: HyperliquidExchangeResponse =
524                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
525
526            // Check if the response contains an error status
527            match &parsed_response {
528                HyperliquidExchangeResponse::Status {
529                    status,
530                    response: response_data,
531                } if status == "err" => {
532                    let error_msg = response_data
533                        .as_str()
534                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
535                    log::error!("Hyperliquid API returned error: {error_msg}");
536                    Err(Error::bad_request(format!("API error: {error_msg}")))
537                }
538                HyperliquidExchangeResponse::Error { error } => {
539                    log::error!("Hyperliquid API returned error: {error}");
540                    Err(Error::bad_request(format!("API error: {error}")))
541                }
542                _ => Ok(parsed_response),
543            }
544        } else if response.status.as_u16() == 429 {
545            let ra = self.parse_retry_after_simple(&response.headers);
546            Err(Error::rate_limit("exchange", w, ra))
547        } else {
548            let error_body = String::from_utf8_lossy(&response.body);
549            log::error!(
550                "Exchange API error (status {}): {}",
551                response.status.as_u16(),
552                error_body
553            );
554            Err(Error::http(
555                response.status.as_u16(),
556                error_body.to_string(),
557            ))
558        }
559    }
560
561    /// Send a signed action to the exchange using the typed HyperliquidExecAction enum.
562    ///
563    /// This is the preferred method for placing orders as it uses properly typed
564    /// structures that match Hyperliquid's API expectations exactly.
565    pub async fn post_action_exec(
566        &self,
567        action: &HyperliquidExecAction,
568    ) -> Result<HyperliquidExchangeResponse> {
569        let w = match action {
570            HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
571            HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
572            HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
573            HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
574            _ => 1,
575        };
576        self.rest_limiter.acquire(w).await;
577
578        let signer = self
579            .signer
580            .as_ref()
581            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
582
583        let nonce_manager = self
584            .nonce_manager
585            .as_ref()
586            .ok_or_else(|| Error::auth("nonce manager missing"))?;
587
588        let signer_id = self.signer_id()?;
589        let time_nonce = nonce_manager.next(signer_id)?;
590        // No need to validate - next() guarantees a valid, unused nonce
591
592        let action_value = serde_json::to_value(action)
593            .context("serialize exchange action")
594            .map_err(|e| Error::bad_request(e.to_string()))?;
595
596        // Serialize the original action struct with MessagePack for L1 signing
597        let action_bytes = rmp_serde::to_vec_named(action)
598            .context("serialize action with MessagePack")
599            .map_err(|e| Error::bad_request(e.to_string()))?;
600
601        let sig = signer
602            .sign(&SignRequest {
603                action: action_value.clone(),
604                action_bytes: Some(action_bytes),
605                time_nonce,
606                action_type: HyperliquidActionType::L1,
607                is_testnet: self.is_testnet,
608                vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
609            })?
610            .signature;
611
612        let request = if let Some(vault) = self.vault_address {
613            HyperliquidExchangeRequest::with_vault(
614                action.clone(),
615                time_nonce.as_millis() as u64,
616                sig,
617                vault.to_string(),
618            )
619            .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
620        } else {
621            HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
622                .map_err(|e| Error::bad_request(format!("Failed to create request: {e}")))?
623        };
624
625        let response = self.http_roundtrip_exchange(&request).await?;
626
627        if response.status.is_success() {
628            let parsed_response: HyperliquidExchangeResponse =
629                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
630
631            // Check if the response contains an error status
632            match &parsed_response {
633                HyperliquidExchangeResponse::Status {
634                    status,
635                    response: response_data,
636                } if status == "err" => {
637                    let error_msg = response_data
638                        .as_str()
639                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
640                    log::error!("Hyperliquid API returned error: {error_msg}");
641                    Err(Error::bad_request(format!("API error: {error_msg}")))
642                }
643                HyperliquidExchangeResponse::Error { error } => {
644                    log::error!("Hyperliquid API returned error: {error}");
645                    Err(Error::bad_request(format!("API error: {error}")))
646                }
647                _ => Ok(parsed_response),
648            }
649        } else if response.status.as_u16() == 429 {
650            let ra = self.parse_retry_after_simple(&response.headers);
651            Err(Error::rate_limit("exchange", w, ra))
652        } else {
653            let error_body = String::from_utf8_lossy(&response.body);
654            Err(Error::http(
655                response.status.as_u16(),
656                error_body.to_string(),
657            ))
658        }
659    }
660
661    /// Submit a single order to the Hyperliquid exchange.
662    ///
663    pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
664        self.rest_limiter.snapshot().await
665    }
666    async fn http_roundtrip_exchange<T>(
667        &self,
668        request: &HyperliquidExchangeRequest<T>,
669    ) -> Result<HttpResponse>
670    where
671        T: serde::Serialize,
672    {
673        let url = &self.base_exchange;
674        let body = serde_json::to_string(&request).map_err(Error::Serde)?;
675        let body_bytes = body.into_bytes();
676
677        let response = self
678            .client
679            .request(
680                Method::POST,
681                url.clone(),
682                None,
683                None,
684                Some(body_bytes),
685                None,
686                None,
687            )
688            .await
689            .map_err(Error::from_http_client)?;
690
691        Ok(response)
692    }
693}
694
695/// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
696///
697/// This domain client wraps [`HyperliquidRawHttpClient`] and provides methods that work
698/// with Nautilus domain types. It maintains an instrument cache and handles conversions
699/// between Hyperliquid API responses and Nautilus domain models.
700#[derive(Debug, Clone)]
701#[cfg_attr(
702    feature = "python",
703    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.hyperliquid")
704)]
705pub struct HyperliquidHttpClient {
706    pub(crate) inner: Arc<HyperliquidRawHttpClient>,
707    instruments: Arc<RwLock<AHashMap<Ustr, InstrumentAny>>>,
708    instruments_by_coin: Arc<RwLock<AHashMap<(Ustr, HyperliquidProductType), InstrumentAny>>>,
709    account_id: Option<AccountId>,
710}
711
712impl Default for HyperliquidHttpClient {
713    fn default() -> Self {
714        Self::new(true, None, None).expect("Failed to create default Hyperliquid HTTP client")
715    }
716}
717
718impl HyperliquidHttpClient {
719    /// Creates a new [`HyperliquidHttpClient`] for public endpoints only.
720    ///
721    /// # Errors
722    ///
723    /// Returns an error if the HTTP client cannot be created.
724    pub fn new(
725        is_testnet: bool,
726        timeout_secs: Option<u64>,
727        proxy_url: Option<String>,
728    ) -> std::result::Result<Self, HttpClientError> {
729        let raw_client = HyperliquidRawHttpClient::new(is_testnet, timeout_secs, proxy_url)?;
730        Ok(Self {
731            inner: Arc::new(raw_client),
732            instruments: Arc::new(RwLock::new(AHashMap::new())),
733            instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
734            account_id: None,
735        })
736    }
737
738    /// Creates a new [`HyperliquidHttpClient`] configured with credentials.
739    ///
740    /// # Errors
741    ///
742    /// Returns an error if the HTTP client cannot be created.
743    pub fn with_credentials(
744        secrets: &Secrets,
745        timeout_secs: Option<u64>,
746        proxy_url: Option<String>,
747    ) -> std::result::Result<Self, HttpClientError> {
748        let raw_client =
749            HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
750        Ok(Self {
751            inner: Arc::new(raw_client),
752            instruments: Arc::new(RwLock::new(AHashMap::new())),
753            instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
754            account_id: None,
755        })
756    }
757
758    /// Creates an authenticated client from environment variables.
759    ///
760    /// # Errors
761    ///
762    /// Returns [`Error::Auth`] if required environment variables are not set.
763    pub fn from_env() -> Result<Self> {
764        let raw_client = HyperliquidRawHttpClient::from_env()?;
765        Ok(Self {
766            inner: Arc::new(raw_client),
767            instruments: Arc::new(RwLock::new(AHashMap::new())),
768            instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
769            account_id: None,
770        })
771    }
772
773    /// Creates a new [`HyperliquidHttpClient`] configured with explicit credentials.
774    ///
775    /// # Errors
776    ///
777    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
778    pub fn from_credentials(
779        private_key: &str,
780        vault_address: Option<&str>,
781        is_testnet: bool,
782        timeout_secs: Option<u64>,
783        proxy_url: Option<String>,
784    ) -> Result<Self> {
785        let raw_client = HyperliquidRawHttpClient::from_credentials(
786            private_key,
787            vault_address,
788            is_testnet,
789            timeout_secs,
790            proxy_url,
791        )?;
792        Ok(Self {
793            inner: Arc::new(raw_client),
794            instruments: Arc::new(RwLock::new(AHashMap::new())),
795            instruments_by_coin: Arc::new(RwLock::new(AHashMap::new())),
796            account_id: None,
797        })
798    }
799
800    /// Returns whether this client is configured for testnet.
801    #[must_use]
802    pub fn is_testnet(&self) -> bool {
803        self.inner.is_testnet()
804    }
805
806    /// Gets the user address derived from the private key (if client has credentials).
807    ///
808    /// # Errors
809    ///
810    /// Returns [`Error::Auth`] if the client has no signer configured.
811    pub fn get_user_address(&self) -> Result<String> {
812        self.inner.get_user_address()
813    }
814
815    /// Caches a single instrument.
816    ///
817    /// This is required for parsing orders, fills, and positions into reports.
818    /// Any existing instrument with the same symbol will be replaced.
819    ///
820    /// # Panics
821    ///
822    /// Panics if the instrument lock cannot be acquired.
823    pub fn cache_instrument(&self, instrument: InstrumentAny) {
824        let full_symbol = instrument.symbol().inner();
825        let coin = instrument.raw_symbol().inner();
826
827        {
828            let mut instruments = self
829                .instruments
830                .write()
831                .expect("Failed to acquire write lock");
832
833            instruments.insert(full_symbol, instrument.clone());
834
835            // HTTP responses only include coins, external code may lookup by coin
836            instruments.insert(coin, instrument.clone());
837        }
838
839        // Composite key allows disambiguating same coin across PERP and SPOT
840        if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
841            let mut instruments_by_coin = self
842                .instruments_by_coin
843                .write()
844                .expect("Failed to acquire write lock");
845            instruments_by_coin.insert((coin, product_type), instrument);
846        } else {
847            log::warn!("Unable to determine product type for symbol: {full_symbol}");
848        }
849    }
850
851    /// Get an instrument from cache, or create a synthetic one for vault tokens.
852    ///
853    /// Vault tokens (starting with "vntls:") are not available in the standard spotMeta API.
854    /// This method creates synthetic CurrencyPair instruments for vault tokens on-the-fly
855    /// to allow order/fill/position parsing to continue.
856    ///
857    /// For non-vault tokens that are not in cache, returns None and logs a warning.
858    /// This can happen if instruments weren't loaded properly or if there are new instruments
859    /// that weren't present during initialization.
860    ///
861    /// The synthetic instruments use reasonable defaults:
862    /// - Quote currency: USDC (most common quote for vault tokens)
863    /// - Price/size decimals: 8 (standard precision)
864    /// - Price increment: 0.00000001
865    /// - Size increment: 0.00000001
866    fn get_or_create_instrument(
867        &self,
868        coin: &Ustr,
869        product_type: Option<HyperliquidProductType>,
870    ) -> Option<InstrumentAny> {
871        if let Some(pt) = product_type {
872            let instruments_by_coin = self
873                .instruments_by_coin
874                .read()
875                .expect("Failed to acquire read lock");
876
877            if let Some(instrument) = instruments_by_coin.get(&(*coin, pt)) {
878                return Some(instrument.clone());
879            }
880        }
881
882        // HTTP responses lack product type context, try PERP then SPOT
883        if product_type.is_none() {
884            let instruments_by_coin = self
885                .instruments_by_coin
886                .read()
887                .expect("Failed to acquire read lock");
888
889            if let Some(instrument) =
890                instruments_by_coin.get(&(*coin, HyperliquidProductType::Perp))
891            {
892                return Some(instrument.clone());
893            }
894            if let Some(instrument) =
895                instruments_by_coin.get(&(*coin, HyperliquidProductType::Spot))
896            {
897                return Some(instrument.clone());
898            }
899        }
900
901        // Vault tokens aren't in standard API, create synthetic instruments
902        if coin.as_str().starts_with("vntls:") {
903            log::info!("Creating synthetic instrument for vault token: {coin}");
904
905            let clock = nautilus_core::time::get_atomic_clock_realtime();
906            let ts_event = clock.get_time_ns();
907
908            // Create synthetic vault token instrument
909            let symbol_str = format!("{coin}-USDC-SPOT");
910            let symbol = Symbol::new(&symbol_str);
911            let venue = *HYPERLIQUID_VENUE;
912            let instrument_id = InstrumentId::new(symbol, venue);
913
914            // Create currencies
915            let base_currency = Currency::new(
916                coin.as_str(),
917                8, // precision
918                0, // ISO code (not applicable)
919                coin.as_str(),
920                CurrencyType::Crypto,
921            );
922
923            let quote_currency = Currency::new(
924                "USDC",
925                6, // USDC standard precision
926                0,
927                "USDC",
928                CurrencyType::Crypto,
929            );
930
931            let price_increment = Price::from("0.00000001");
932            let size_increment = Quantity::from("0.00000001");
933
934            let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
935                instrument_id,
936                symbol,
937                base_currency,
938                quote_currency,
939                8, // price_precision
940                8, // size_precision
941                price_increment,
942                size_increment,
943                None, // price_increment
944                None, // size_increment
945                None, // maker_fee
946                None, // taker_fee
947                None, // margin_init
948                None, // margin_maint
949                None, // lot_size
950                None, // max_quantity
951                None, // min_quantity
952                None, // max_notional
953                None, // min_notional
954                None, // max_price
955                ts_event,
956                ts_event,
957            ));
958
959            self.cache_instrument(instrument.clone());
960
961            Some(instrument)
962        } else {
963            // For non-vault tokens, log warning and return None
964            log::warn!("Instrument not found in cache: {coin}");
965            None
966        }
967    }
968
969    /// Set the account ID for this client.
970    ///
971    /// This is required for generating reports with the correct account ID.
972    pub fn set_account_id(&mut self, account_id: AccountId) {
973        self.account_id = Some(account_id);
974    }
975
976    /// Fetch and parse all available instrument definitions from Hyperliquid.
977    pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
978        let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
979
980        match self.inner.load_perp_meta().await {
981            Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
982                Ok(perp_defs) => {
983                    log::debug!(
984                        "Loaded Hyperliquid perp definitions: count={}",
985                        perp_defs.len(),
986                    );
987                    defs.extend(perp_defs);
988                }
989                Err(e) => {
990                    log::warn!("Failed to parse Hyperliquid perp instruments: {e}");
991                }
992            },
993            Err(e) => {
994                log::warn!("Failed to load Hyperliquid perp metadata: {e}");
995            }
996        }
997
998        match self.inner.get_spot_meta().await {
999            Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1000                Ok(spot_defs) => {
1001                    log::debug!(
1002                        "Loaded Hyperliquid spot definitions: count={}",
1003                        spot_defs.len(),
1004                    );
1005                    defs.extend(spot_defs);
1006                }
1007                Err(e) => {
1008                    log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1009                }
1010            },
1011            Err(e) => {
1012                log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1013            }
1014        }
1015
1016        Ok(instruments_from_defs_owned(defs))
1017    }
1018
1019    /// Get perpetuals metadata (internal helper).
1020    #[allow(dead_code)]
1021    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1022        self.inner.load_perp_meta().await
1023    }
1024
1025    /// Get spot metadata (internal helper).
1026    #[allow(dead_code)]
1027    pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1028        self.inner.get_spot_meta().await
1029    }
1030
1031    /// Get L2 order book for a coin.
1032    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1033        self.inner.info_l2_book(coin).await
1034    }
1035
1036    /// Get user fills (trading history).
1037    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1038        self.inner.info_user_fills(user).await
1039    }
1040
1041    /// Get order status for a user.
1042    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1043        self.inner.info_order_status(user, oid).await
1044    }
1045
1046    /// Get all open orders for a user.
1047    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1048        self.inner.info_open_orders(user).await
1049    }
1050
1051    /// Get frontend open orders (includes more detail) for a user.
1052    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1053        self.inner.info_frontend_open_orders(user).await
1054    }
1055
1056    /// Get clearinghouse state (balances, positions, margin) for a user.
1057    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1058        self.inner.info_clearinghouse_state(user).await
1059    }
1060
1061    /// Get candle/bar data for a coin.
1062    pub async fn info_candle_snapshot(
1063        &self,
1064        coin: &str,
1065        interval: HyperliquidBarInterval,
1066        start_time: u64,
1067        end_time: u64,
1068    ) -> Result<HyperliquidCandleSnapshot> {
1069        self.inner
1070            .info_candle_snapshot(coin, interval, start_time, end_time)
1071            .await
1072    }
1073
1074    /// Post an action to the exchange endpoint (low-level delegation).
1075    pub async fn post_action(
1076        &self,
1077        action: &ExchangeAction,
1078    ) -> Result<HyperliquidExchangeResponse> {
1079        self.inner.post_action(action).await
1080    }
1081
1082    /// Post an execution action (low-level delegation).
1083    pub async fn post_action_exec(
1084        &self,
1085        action: &HyperliquidExecAction,
1086    ) -> Result<HyperliquidExchangeResponse> {
1087        self.inner.post_action_exec(action).await
1088    }
1089
1090    /// Get metadata about available markets (low-level delegation).
1091    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1092        self.inner.info_meta().await
1093    }
1094
1095    /// Cancel an order on the Hyperliquid exchange.
1096    ///
1097    /// Can cancel either by venue order ID or client order ID.
1098    /// At least one ID must be provided.
1099    ///
1100    /// # Errors
1101    ///
1102    /// Returns an error if credentials are missing, no order ID is provided,
1103    /// or the API returns an error.
1104    pub async fn cancel_order(
1105        &self,
1106        instrument_id: InstrumentId,
1107        client_order_id: Option<ClientOrderId>,
1108        venue_order_id: Option<VenueOrderId>,
1109    ) -> Result<()> {
1110        // Extract asset ID from instrument symbol
1111        let symbol = instrument_id.symbol.as_str();
1112        let asset_id = extract_asset_id_from_symbol(symbol)
1113            .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1114
1115        // Create cancel action based on which ID we have
1116        let action = if let Some(cloid) = client_order_id {
1117            let cloid_hex = Cloid::from_hex(cloid)
1118                .map_err(|e| Error::bad_request(format!("Invalid client order ID format: {e}")))?;
1119            let cancel_req = HyperliquidExecCancelByCloidRequest {
1120                asset: asset_id,
1121                cloid: cloid_hex,
1122            };
1123            HyperliquidExecAction::CancelByCloid {
1124                cancels: vec![cancel_req],
1125            }
1126        } else if let Some(oid) = venue_order_id {
1127            let oid_u64 = oid
1128                .as_str()
1129                .parse::<u64>()
1130                .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1131            let cancel_req = HyperliquidExecCancelOrderRequest {
1132                asset: asset_id,
1133                oid: oid_u64,
1134            };
1135            HyperliquidExecAction::Cancel {
1136                cancels: vec![cancel_req],
1137            }
1138        } else {
1139            return Err(Error::bad_request(
1140                "Either client_order_id or venue_order_id must be provided",
1141            ));
1142        };
1143
1144        // Submit cancellation
1145        let response = self.inner.post_action_exec(&action).await?;
1146
1147        // Check response - only check for error status
1148        match response {
1149            HyperliquidExchangeResponse::Status { status, .. } if status == "ok" => Ok(()),
1150            HyperliquidExchangeResponse::Status {
1151                status,
1152                response: error_data,
1153            } => Err(Error::bad_request(format!(
1154                "Cancel order failed: status={status}, error={error_data}"
1155            ))),
1156            HyperliquidExchangeResponse::Error { error } => {
1157                Err(Error::bad_request(format!("Cancel order error: {error}")))
1158            }
1159        }
1160    }
1161
1162    /// Request order status reports for a user.
1163    ///
1164    /// Fetches open orders via `info_frontend_open_orders` and parses them into OrderStatusReports.
1165    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1166    ///
1167    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1168    /// will be created automatically.
1169    ///
1170    /// # Errors
1171    ///
1172    /// Returns an error if the API request fails or parsing fails.
1173    ///
1174    /// # Panics
1175    ///
1176    /// Panics if `account_id` is not set on the client.
1177    pub async fn request_order_status_reports(
1178        &self,
1179        user: &str,
1180        instrument_id: Option<InstrumentId>,
1181    ) -> Result<Vec<OrderStatusReport>> {
1182        let response = self.info_frontend_open_orders(user).await?;
1183
1184        // Parse the JSON response into a vector of orders
1185        let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1186            .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1187
1188        let mut reports = Vec::new();
1189        let ts_init = UnixNanos::default();
1190
1191        for order_value in orders {
1192            // Parse the order data
1193            let order: crate::websocket::messages::WsBasicOrderData =
1194                match serde_json::from_value(order_value.clone()) {
1195                    Ok(o) => o,
1196                    Err(e) => {
1197                        log::warn!("Failed to parse order: {e}");
1198                        continue;
1199                    }
1200                };
1201
1202            // Get instrument from cache or create synthetic for vault tokens
1203            let instrument = match self.get_or_create_instrument(&order.coin, None) {
1204                Some(inst) => inst,
1205                None => continue, // Skip if instrument not found
1206            };
1207
1208            // Filter by instrument_id if specified
1209            if let Some(filter_id) = instrument_id
1210                && instrument.id() != filter_id
1211            {
1212                continue;
1213            }
1214
1215            // Determine status from order data - orders from frontend_open_orders are open
1216            let status = HyperliquidOrderStatusEnum::Open;
1217
1218            // Parse to OrderStatusReport
1219            match crate::http::parse::parse_order_status_report_from_basic(
1220                &order,
1221                &status,
1222                &instrument,
1223                self.account_id.expect("account_id not set"),
1224                ts_init,
1225            ) {
1226                Ok(report) => reports.push(report),
1227                Err(e) => log::error!("Failed to parse order status report: {e}"),
1228            }
1229        }
1230
1231        Ok(reports)
1232    }
1233
1234    /// Request fill reports for a user.
1235    ///
1236    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
1237    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1238    ///
1239    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1240    /// will be created automatically.
1241    ///
1242    /// # Errors
1243    ///
1244    /// Returns an error if the API request fails or parsing fails.
1245    ///
1246    /// # Panics
1247    ///
1248    /// Panics if `account_id` is not set on the client.
1249    pub async fn request_fill_reports(
1250        &self,
1251        user: &str,
1252        instrument_id: Option<InstrumentId>,
1253    ) -> Result<Vec<FillReport>> {
1254        let fills_response = self.info_user_fills(user).await?;
1255
1256        let mut reports = Vec::new();
1257        let ts_init = UnixNanos::default();
1258
1259        for fill in fills_response {
1260            // Get instrument from cache or create synthetic for vault tokens
1261            let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1262                Some(inst) => inst,
1263                None => continue, // Skip if instrument not found
1264            };
1265
1266            // Filter by instrument_id if specified
1267            if let Some(filter_id) = instrument_id
1268                && instrument.id() != filter_id
1269            {
1270                continue;
1271            }
1272
1273            // Parse to FillReport
1274            match crate::http::parse::parse_fill_report(
1275                &fill,
1276                &instrument,
1277                self.account_id.expect("account_id not set"),
1278                ts_init,
1279            ) {
1280                Ok(report) => reports.push(report),
1281                Err(e) => log::error!("Failed to parse fill report: {e}"),
1282            }
1283        }
1284
1285        Ok(reports)
1286    }
1287
1288    /// Request position status reports for a user.
1289    ///
1290    /// Fetches clearinghouse state via `info_clearinghouse_state` and parses positions into PositionStatusReports.
1291    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1292    ///
1293    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1294    /// will be created automatically.
1295    ///
1296    /// # Errors
1297    ///
1298    /// Returns an error if the API request fails or parsing fails.
1299    ///
1300    /// # Panics
1301    ///
1302    /// Panics if `account_id` has not been set on the client.
1303    pub async fn request_position_status_reports(
1304        &self,
1305        user: &str,
1306        instrument_id: Option<InstrumentId>,
1307    ) -> Result<Vec<PositionStatusReport>> {
1308        let state_response = self.info_clearinghouse_state(user).await?;
1309
1310        // Extract asset positions from the clearinghouse state
1311        let asset_positions: Vec<serde_json::Value> = state_response
1312            .get("assetPositions")
1313            .and_then(|v| v.as_array())
1314            .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1315            .clone();
1316
1317        let mut reports = Vec::new();
1318        let ts_init = UnixNanos::default();
1319
1320        for position_value in asset_positions {
1321            // Extract coin from position data
1322            let coin = position_value
1323                .get("position")
1324                .and_then(|p| p.get("coin"))
1325                .and_then(|c| c.as_str())
1326                .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1327
1328            // Get instrument from cache - convert &str to Ustr for lookup
1329            let coin_ustr = Ustr::from(coin);
1330            let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1331                Some(inst) => inst,
1332                None => continue, // Skip if instrument not found
1333            };
1334
1335            // Filter by instrument_id if specified
1336            if let Some(filter_id) = instrument_id
1337                && instrument.id() != filter_id
1338            {
1339                continue;
1340            }
1341
1342            // Parse to PositionStatusReport
1343            match crate::http::parse::parse_position_status_report(
1344                &position_value,
1345                &instrument,
1346                self.account_id.expect("account_id not set"),
1347                ts_init,
1348            ) {
1349                Ok(report) => reports.push(report),
1350                Err(e) => log::error!("Failed to parse position status report: {e}"),
1351            }
1352        }
1353
1354        Ok(reports)
1355    }
1356
1357    /// Request historical bars for an instrument.
1358    ///
1359    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
1360    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
1361    ///
1362    /// # Errors
1363    ///
1364    /// Returns an error if:
1365    /// - The instrument is not found in cache.
1366    /// - The bar aggregation is unsupported by Hyperliquid.
1367    /// - The API request fails.
1368    /// - Parsing fails.
1369    ///
1370    /// # References
1371    ///
1372    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
1373    pub async fn request_bars(
1374        &self,
1375        bar_type: BarType,
1376        start: Option<chrono::DateTime<chrono::Utc>>,
1377        end: Option<chrono::DateTime<chrono::Utc>>,
1378        limit: Option<u32>,
1379    ) -> Result<Vec<Bar>> {
1380        let instrument_id = bar_type.instrument_id();
1381        let symbol = instrument_id.symbol;
1382
1383        let coin = Ustr::from(
1384            symbol
1385                .as_str()
1386                .split('-')
1387                .next()
1388                .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
1389        );
1390
1391        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
1392        let instrument = self
1393            .get_or_create_instrument(&coin, product_type)
1394            .ok_or_else(|| {
1395                Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
1396            })?;
1397
1398        let price_precision = instrument.price_precision();
1399        let size_precision = instrument.size_precision();
1400
1401        let interval =
1402            bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
1403
1404        // Hyperliquid uses millisecond timestamps
1405        let now = chrono::Utc::now();
1406        let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1407        let start_time = if let Some(start) = start {
1408            start.timestamp_millis() as u64
1409        } else {
1410            // Default to 1000 bars before end_time
1411            let spec = bar_type.spec();
1412            let step_ms = match spec.aggregation {
1413                BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1414                BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1415                BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1416                BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
1417                BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
1418                _ => 60_000,
1419            };
1420            end_time.saturating_sub(1000 * step_ms)
1421        };
1422
1423        let candles = self
1424            .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
1425            .await?;
1426
1427        // Filter out incomplete bars where end_timestamp >= current time
1428        let now_ms = now.timestamp_millis() as u64;
1429
1430        let mut bars: Vec<Bar> = candles
1431            .iter()
1432            .filter(|candle| candle.end_timestamp < now_ms)
1433            .enumerate()
1434            .filter_map(|(i, candle)| {
1435                crate::data::candle_to_bar(candle, bar_type, price_precision, size_precision)
1436                    .map_err(|e| {
1437                        log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
1438                        e
1439                    })
1440                    .ok()
1441            })
1442            .collect();
1443
1444        // 0 means no limit
1445        if let Some(limit) = limit
1446            && limit > 0
1447            && bars.len() > limit as usize
1448        {
1449            bars.truncate(limit as usize);
1450        }
1451
1452        log::debug!(
1453            "Received {} bars for {} (filtered {} incomplete)",
1454            bars.len(),
1455            bar_type,
1456            candles.len() - bars.len()
1457        );
1458        Ok(bars)
1459    }
1460    /// Uses the existing order conversion logic from `common::parse::order_to_hyperliquid_request`
1461    /// to avoid code duplication and ensure consistency.
1462    ///
1463    /// # Errors
1464    ///
1465    /// Returns an error if credentials are missing, order validation fails, serialization fails,
1466    /// or the API returns an error.
1467    #[allow(clippy::too_many_arguments)]
1468    pub async fn submit_order(
1469        &self,
1470        instrument_id: InstrumentId,
1471        client_order_id: ClientOrderId,
1472        order_side: OrderSide,
1473        order_type: OrderType,
1474        quantity: Quantity,
1475        time_in_force: TimeInForce,
1476        price: Option<Price>,
1477        trigger_price: Option<Price>,
1478        post_only: bool,
1479        reduce_only: bool,
1480    ) -> Result<OrderStatusReport> {
1481        let symbol = instrument_id.symbol.as_str();
1482        let asset = extract_asset_id_from_symbol(symbol)
1483            .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1484
1485        let is_buy = matches!(order_side, OrderSide::Buy);
1486
1487        // Convert price to decimal
1488        let price_decimal = match price {
1489            Some(px) => px.as_decimal(),
1490            None => {
1491                if matches!(
1492                    order_type,
1493                    OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
1494                ) {
1495                    Decimal::ZERO
1496                } else {
1497                    return Err(Error::bad_request("Limit orders require a price"));
1498                }
1499            }
1500        };
1501
1502        // Convert quantity to decimal
1503        let size_decimal = quantity.as_decimal();
1504
1505        // Determine order kind based on order type
1506        let kind = match order_type {
1507            OrderType::Market => HyperliquidExecOrderKind::Limit {
1508                limit: HyperliquidExecLimitParams {
1509                    tif: HyperliquidExecTif::Ioc,
1510                },
1511            },
1512            OrderType::Limit => {
1513                let tif = if post_only {
1514                    HyperliquidExecTif::Alo
1515                } else {
1516                    match time_in_force {
1517                        TimeInForce::Gtc => HyperliquidExecTif::Gtc,
1518                        TimeInForce::Ioc => HyperliquidExecTif::Ioc,
1519                        TimeInForce::Fok => HyperliquidExecTif::Ioc, // Hyperliquid doesn't have FOK
1520                        TimeInForce::Day
1521                        | TimeInForce::Gtd
1522                        | TimeInForce::AtTheOpen
1523                        | TimeInForce::AtTheClose => {
1524                            return Err(Error::bad_request(format!(
1525                                "Time in force {time_in_force:?} not supported"
1526                            )));
1527                        }
1528                    }
1529                };
1530                HyperliquidExecOrderKind::Limit {
1531                    limit: HyperliquidExecLimitParams { tif },
1532                }
1533            }
1534            OrderType::StopMarket
1535            | OrderType::StopLimit
1536            | OrderType::MarketIfTouched
1537            | OrderType::LimitIfTouched => {
1538                if let Some(trig_px) = trigger_price {
1539                    let trigger_price_decimal = trig_px.as_decimal();
1540
1541                    // Determine TP/SL type based on order type
1542                    // StopMarket/StopLimit are always Sl (protective stops)
1543                    // MarketIfTouched/LimitIfTouched are always Tp (profit-taking/entry)
1544                    let tpsl = match order_type {
1545                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1546                        OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
1547                            HyperliquidExecTpSl::Tp
1548                        }
1549                        _ => unreachable!(),
1550                    };
1551
1552                    let is_market = matches!(
1553                        order_type,
1554                        OrderType::StopMarket | OrderType::MarketIfTouched
1555                    );
1556
1557                    HyperliquidExecOrderKind::Trigger {
1558                        trigger: HyperliquidExecTriggerParams {
1559                            is_market,
1560                            trigger_px: trigger_price_decimal,
1561                            tpsl,
1562                        },
1563                    }
1564                } else {
1565                    return Err(Error::bad_request("Trigger orders require a trigger price"));
1566                }
1567            }
1568            _ => {
1569                return Err(Error::bad_request(format!(
1570                    "Order type {order_type:?} not supported"
1571                )));
1572            }
1573        };
1574
1575        // Build the order request
1576        let hyperliquid_order =
1577            HyperliquidExecPlaceOrderRequest {
1578                asset,
1579                is_buy,
1580                price: price_decimal,
1581                size: size_decimal,
1582                reduce_only,
1583                kind,
1584                cloid: Some(Cloid::from_hex(client_order_id).map_err(|e| {
1585                    Error::bad_request(format!("Invalid client order ID format: {e}"))
1586                })?),
1587            };
1588
1589        // Create action
1590        let action = HyperliquidExecAction::Order {
1591            orders: vec![hyperliquid_order],
1592            grouping: HyperliquidExecGrouping::Na,
1593            builder: None,
1594        };
1595
1596        // Submit to exchange
1597        let response = self.inner.post_action_exec(&action).await?;
1598
1599        // Parse response
1600        match response {
1601            HyperliquidExchangeResponse::Status {
1602                status,
1603                response: response_data,
1604            } if status == "ok" => {
1605                let data_value = if let Some(data) = response_data.get("data") {
1606                    data.clone()
1607                } else {
1608                    response_data
1609                };
1610
1611                let order_response: HyperliquidExecOrderResponseData =
1612                    serde_json::from_value(data_value).map_err(|e| {
1613                        Error::bad_request(format!("Failed to parse order response: {e}"))
1614                    })?;
1615
1616                let order_status = order_response
1617                    .statuses
1618                    .first()
1619                    .ok_or_else(|| Error::bad_request("No order status in response"))?;
1620
1621                let symbol_str = instrument_id.symbol.as_str();
1622                let asset_str = symbol_str
1623                    .trim_end_matches("-PERP")
1624                    .trim_end_matches("-USD");
1625
1626                let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
1627                let instrument = self
1628                    .get_or_create_instrument(&Ustr::from(asset_str), product_type)
1629                    .ok_or_else(|| {
1630                        Error::bad_request(format!("Instrument not found for {asset_str}"))
1631                    })?;
1632
1633                let account_id = self
1634                    .account_id
1635                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1636                let ts_init = UnixNanos::default();
1637
1638                match order_status {
1639                    HyperliquidExecOrderStatus::Resting { resting } => self
1640                        .create_order_status_report(
1641                            instrument_id,
1642                            Some(client_order_id),
1643                            VenueOrderId::new(resting.oid.to_string()),
1644                            order_side,
1645                            order_type,
1646                            quantity,
1647                            time_in_force,
1648                            price,
1649                            trigger_price,
1650                            OrderStatus::Accepted,
1651                            Quantity::new(0.0, instrument.size_precision()),
1652                            &instrument,
1653                            account_id,
1654                            ts_init,
1655                        ),
1656                    HyperliquidExecOrderStatus::Filled { filled } => {
1657                        let filled_qty = Quantity::new(
1658                            filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1659                            instrument.size_precision(),
1660                        );
1661                        self.create_order_status_report(
1662                            instrument_id,
1663                            Some(client_order_id),
1664                            VenueOrderId::new(filled.oid.to_string()),
1665                            order_side,
1666                            order_type,
1667                            quantity,
1668                            time_in_force,
1669                            price,
1670                            trigger_price,
1671                            OrderStatus::Filled,
1672                            filled_qty,
1673                            &instrument,
1674                            account_id,
1675                            ts_init,
1676                        )
1677                    }
1678                    HyperliquidExecOrderStatus::Error { error } => {
1679                        Err(Error::bad_request(format!("Order rejected: {error}")))
1680                    }
1681                }
1682            }
1683            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1684                "Order submission failed: {error}"
1685            ))),
1686            _ => Err(Error::bad_request("Unexpected response format")),
1687        }
1688    }
1689
1690    /// Submit an order using an OrderAny object.
1691    ///
1692    /// This is a convenience method that wraps submit_order.
1693    pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
1694        self.submit_order(
1695            order.instrument_id(),
1696            order.client_order_id(),
1697            order.order_side(),
1698            order.order_type(),
1699            order.quantity(),
1700            order.time_in_force(),
1701            order.price(),
1702            order.trigger_price(),
1703            order.is_post_only(),
1704            order.is_reduce_only(),
1705        )
1706        .await
1707    }
1708
1709    /// Create an OrderStatusReport from order submission details.
1710    #[allow(clippy::too_many_arguments)]
1711    fn create_order_status_report(
1712        &self,
1713        instrument_id: InstrumentId,
1714        client_order_id: Option<ClientOrderId>,
1715        venue_order_id: VenueOrderId,
1716        order_side: OrderSide,
1717        order_type: OrderType,
1718        quantity: Quantity,
1719        time_in_force: TimeInForce,
1720        price: Option<Price>,
1721        trigger_price: Option<Price>,
1722        order_status: OrderStatus,
1723        filled_qty: Quantity,
1724        _instrument: &InstrumentAny,
1725        account_id: AccountId,
1726        ts_init: UnixNanos,
1727    ) -> Result<OrderStatusReport> {
1728        let clock = get_atomic_clock_realtime();
1729        let ts_accepted = clock.get_time_ns();
1730        let ts_last = ts_accepted;
1731        let report_id = UUID4::new();
1732
1733        let mut report = OrderStatusReport::new(
1734            account_id,
1735            instrument_id,
1736            client_order_id,
1737            venue_order_id,
1738            order_side,
1739            order_type,
1740            time_in_force,
1741            order_status,
1742            quantity,
1743            filled_qty,
1744            ts_accepted,
1745            ts_last,
1746            ts_init,
1747            Some(report_id),
1748        );
1749
1750        // Add price if present
1751        if let Some(px) = price {
1752            report = report.with_price(px);
1753        }
1754
1755        // Add trigger price if present
1756        if let Some(trig_px) = trigger_price {
1757            report = report
1758                .with_trigger_price(trig_px)
1759                .with_trigger_type(TriggerType::Default);
1760        }
1761
1762        Ok(report)
1763    }
1764
1765    /// Submit multiple orders to the Hyperliquid exchange in a single request.
1766    ///
1767    /// Uses the existing order conversion logic from `common::parse::orders_to_hyperliquid_requests`
1768    /// to avoid code duplication and ensure consistency.
1769    ///
1770    /// # Errors
1771    ///
1772    /// Returns an error if credentials are missing, order validation fails, serialization fails,
1773    /// or the API returns an error.
1774    pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
1775        // Use the existing parsing function from common::parse
1776        let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
1777            .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
1778
1779        // Create typed action using HyperliquidExecAction (same as working Rust binary)
1780        let action = HyperliquidExecAction::Order {
1781            orders: hyperliquid_orders,
1782            grouping: HyperliquidExecGrouping::Na,
1783            builder: None,
1784        };
1785
1786        // Submit to exchange using the typed exec endpoint
1787        let response = self.inner.post_action_exec(&action).await?;
1788
1789        // Parse the response to extract order statuses
1790        match response {
1791            HyperliquidExchangeResponse::Status {
1792                status,
1793                response: response_data,
1794            } if status == "ok" => {
1795                // Extract the 'data' field from the response if it exists (new format)
1796                // Otherwise use response_data directly (old format)
1797                let data_value = if let Some(data) = response_data.get("data") {
1798                    data.clone()
1799                } else {
1800                    response_data
1801                };
1802
1803                // Parse the response data to extract order statuses
1804                let order_response: HyperliquidExecOrderResponseData =
1805                    serde_json::from_value(data_value).map_err(|e| {
1806                        Error::bad_request(format!("Failed to parse order response: {e}"))
1807                    })?;
1808
1809                let account_id = self
1810                    .account_id
1811                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1812                let ts_init = UnixNanos::default();
1813
1814                // Validate we have the same number of statuses as orders submitted
1815                if order_response.statuses.len() != orders.len() {
1816                    return Err(Error::bad_request(format!(
1817                        "Mismatch between submitted orders ({}) and response statuses ({})",
1818                        orders.len(),
1819                        order_response.statuses.len()
1820                    )));
1821                }
1822
1823                let mut reports = Vec::new();
1824
1825                // Create OrderStatusReport for each order
1826                for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
1827                    // Extract asset from instrument symbol
1828                    let instrument_id = order.instrument_id();
1829                    let symbol = instrument_id.symbol.as_str();
1830                    let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
1831
1832                    let product_type = HyperliquidProductType::from_symbol(symbol).ok();
1833                    let instrument = self
1834                        .get_or_create_instrument(&Ustr::from(asset), product_type)
1835                        .ok_or_else(|| {
1836                            Error::bad_request(format!("Instrument not found for {asset}"))
1837                        })?;
1838
1839                    // Create OrderStatusReport based on the order status
1840                    let report = match order_status {
1841                        HyperliquidExecOrderStatus::Resting { resting } => {
1842                            // Order is resting on the order book
1843                            self.create_order_status_report(
1844                                order.instrument_id(),
1845                                Some(order.client_order_id()),
1846                                VenueOrderId::new(resting.oid.to_string()),
1847                                order.order_side(),
1848                                order.order_type(),
1849                                order.quantity(),
1850                                order.time_in_force(),
1851                                order.price(),
1852                                order.trigger_price(),
1853                                OrderStatus::Accepted,
1854                                Quantity::new(0.0, instrument.size_precision()),
1855                                &instrument,
1856                                account_id,
1857                                ts_init,
1858                            )?
1859                        }
1860                        HyperliquidExecOrderStatus::Filled { filled } => {
1861                            // Order was filled immediately
1862                            let filled_qty = Quantity::new(
1863                                filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1864                                instrument.size_precision(),
1865                            );
1866                            self.create_order_status_report(
1867                                order.instrument_id(),
1868                                Some(order.client_order_id()),
1869                                VenueOrderId::new(filled.oid.to_string()),
1870                                order.order_side(),
1871                                order.order_type(),
1872                                order.quantity(),
1873                                order.time_in_force(),
1874                                order.price(),
1875                                order.trigger_price(),
1876                                OrderStatus::Filled,
1877                                filled_qty,
1878                                &instrument,
1879                                account_id,
1880                                ts_init,
1881                            )?
1882                        }
1883                        HyperliquidExecOrderStatus::Error { error } => {
1884                            return Err(Error::bad_request(format!(
1885                                "Order {} rejected: {error}",
1886                                order.client_order_id()
1887                            )));
1888                        }
1889                    };
1890
1891                    reports.push(report);
1892                }
1893
1894                Ok(reports)
1895            }
1896            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1897                "Order submission failed: {error}"
1898            ))),
1899            _ => Err(Error::bad_request("Unexpected response format")),
1900        }
1901    }
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906    use nautilus_core::MUTEX_POISONED;
1907    use nautilus_model::instruments::{Instrument, InstrumentAny};
1908    use rstest::rstest;
1909    use ustr::Ustr;
1910
1911    use super::HyperliquidHttpClient;
1912    use crate::{common::enums::HyperliquidProductType, http::query::InfoRequest};
1913
1914    #[rstest]
1915    fn stable_json_roundtrips() {
1916        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1917        let s = serde_json::to_string(&v).unwrap();
1918        // Parse back to ensure JSON structure is correct, regardless of field order
1919        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1920        assert_eq!(parsed["type"], "l2Book");
1921        assert_eq!(parsed["coin"], "BTC");
1922        assert_eq!(parsed, v);
1923    }
1924
1925    #[rstest]
1926    fn info_pretty_shape() {
1927        let r = InfoRequest::l2_book("BTC");
1928        let val = serde_json::to_value(&r).unwrap();
1929        let pretty = serde_json::to_string_pretty(&val).unwrap();
1930        assert!(pretty.contains("\"type\": \"l2Book\""));
1931        assert!(pretty.contains("\"coin\": \"BTC\""));
1932    }
1933
1934    #[rstest]
1935    fn test_cache_instrument_by_raw_symbol() {
1936        use nautilus_core::time::get_atomic_clock_realtime;
1937        use nautilus_model::{
1938            currencies::CURRENCY_MAP,
1939            enums::CurrencyType,
1940            identifiers::{InstrumentId, Symbol},
1941            instruments::CurrencyPair,
1942            types::{Currency, Price, Quantity},
1943        };
1944
1945        let client = HyperliquidHttpClient::new(true, None, None).unwrap();
1946
1947        // Create a test instrument with base currency "vntls:vCURSOR"
1948        let base_code = "vntls:vCURSOR";
1949        let quote_code = "USDC";
1950
1951        // Register the custom currency
1952        {
1953            let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
1954            if !currency_map.contains_key(base_code) {
1955                currency_map.insert(
1956                    base_code.to_string(),
1957                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1958                );
1959            }
1960        }
1961
1962        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1963        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1964
1965        // Nautilus symbol is "vntls:vCURSOR-USDC-SPOT"
1966        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1967        let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1968        let instrument_id = InstrumentId::new(symbol, venue);
1969
1970        // raw_symbol is set to the base currency "vntls:vCURSOR" (see parse.rs)
1971        let raw_symbol = Symbol::new(base_code);
1972
1973        let clock = get_atomic_clock_realtime();
1974        let ts = clock.get_time_ns();
1975
1976        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1977            instrument_id,
1978            raw_symbol,
1979            base_currency,
1980            quote_currency,
1981            8,
1982            8,
1983            Price::from("0.00000001"),
1984            Quantity::from("0.00000001"),
1985            None,
1986            None,
1987            None,
1988            None,
1989            None,
1990            None,
1991            None,
1992            None,
1993            None,
1994            None,
1995            None,
1996            None,
1997            ts,
1998            ts,
1999        ));
2000
2001        // Cache the instrument
2002        client.cache_instrument(instrument.clone());
2003
2004        // Verify it can be looked up by full symbol
2005        let instruments = client.instruments.read().unwrap();
2006        let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
2007        assert!(
2008            by_full_symbol.is_some(),
2009            "Instrument should be accessible by full symbol"
2010        );
2011        assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
2012
2013        // Verify it can be looked up by raw_symbol (coin) - backward compatibility
2014        let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2015        assert!(
2016            by_raw_symbol.is_some(),
2017            "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2018        );
2019        assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2020        drop(instruments);
2021
2022        // Verify it can be looked up by composite key (coin, product_type)
2023        let instruments_by_coin = client.instruments_by_coin.read().unwrap();
2024        let by_coin =
2025            instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2026        assert!(
2027            by_coin.is_some(),
2028            "Instrument should be accessible by coin and product type"
2029        );
2030        assert_eq!(by_coin.unwrap().id(), instrument.id());
2031        drop(instruments_by_coin);
2032
2033        // Verify get_or_create_instrument works with product type
2034        let retrieved_with_type = client.get_or_create_instrument(
2035            &Ustr::from("vntls:vCURSOR"),
2036            Some(HyperliquidProductType::Spot),
2037        );
2038        assert!(retrieved_with_type.is_some());
2039        assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2040
2041        // Verify get_or_create_instrument works without product type (fallback)
2042        let retrieved_without_type =
2043            client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2044        assert!(retrieved_without_type.is_some());
2045        assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2046    }
2047}