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