nautilus_hyperliquid/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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.adapters")
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.adapters")
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    pub async fn request_order_status_reports(
1168        &self,
1169        user: &str,
1170        instrument_id: Option<InstrumentId>,
1171    ) -> Result<Vec<OrderStatusReport>> {
1172        let response = self.info_frontend_open_orders(user).await?;
1173
1174        // Parse the JSON response into a vector of orders
1175        let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1176            .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1177
1178        let mut reports = Vec::new();
1179        let ts_init = UnixNanos::default();
1180
1181        for order_value in orders {
1182            // Parse the order data
1183            let order: crate::websocket::messages::WsBasicOrderData =
1184                match serde_json::from_value(order_value.clone()) {
1185                    Ok(o) => o,
1186                    Err(e) => {
1187                        tracing::warn!("Failed to parse order: {e}");
1188                        continue;
1189                    }
1190                };
1191
1192            // Get instrument from cache or create synthetic for vault tokens
1193            let instrument = match self.get_or_create_instrument(&order.coin, None) {
1194                Some(inst) => inst,
1195                None => continue, // Skip if instrument not found
1196            };
1197
1198            // Filter by instrument_id if specified
1199            if let Some(filter_id) = instrument_id
1200                && instrument.id() != filter_id
1201            {
1202                continue;
1203            }
1204
1205            // Determine status from order data - orders from frontend_open_orders are open
1206            let status = HyperliquidOrderStatusEnum::Open;
1207
1208            // Parse to OrderStatusReport
1209            match crate::http::parse::parse_order_status_report_from_basic(
1210                &order,
1211                &status,
1212                &instrument,
1213                self.account_id.unwrap_or_default(),
1214                ts_init,
1215            ) {
1216                Ok(report) => reports.push(report),
1217                Err(e) => tracing::error!("Failed to parse order status report: {e}"),
1218            }
1219        }
1220
1221        Ok(reports)
1222    }
1223
1224    /// Request fill reports for a user.
1225    ///
1226    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
1227    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1228    ///
1229    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1230    /// will be created automatically.
1231    ///
1232    /// # Errors
1233    ///
1234    /// Returns an error if the API request fails or parsing fails.
1235    pub async fn request_fill_reports(
1236        &self,
1237        user: &str,
1238        instrument_id: Option<InstrumentId>,
1239    ) -> Result<Vec<FillReport>> {
1240        let fills_response = self.info_user_fills(user).await?;
1241
1242        let mut reports = Vec::new();
1243        let ts_init = UnixNanos::default();
1244
1245        for fill in fills_response {
1246            // Get instrument from cache or create synthetic for vault tokens
1247            let instrument = match self.get_or_create_instrument(&fill.coin, None) {
1248                Some(inst) => inst,
1249                None => continue, // Skip if instrument not found
1250            };
1251
1252            // Filter by instrument_id if specified
1253            if let Some(filter_id) = instrument_id
1254                && instrument.id() != filter_id
1255            {
1256                continue;
1257            }
1258
1259            // Parse to FillReport
1260            match crate::http::parse::parse_fill_report(
1261                &fill,
1262                &instrument,
1263                self.account_id.unwrap_or_default(),
1264                ts_init,
1265            ) {
1266                Ok(report) => reports.push(report),
1267                Err(e) => tracing::error!("Failed to parse fill report: {e}"),
1268            }
1269        }
1270
1271        Ok(reports)
1272    }
1273
1274    /// Request position status reports for a user.
1275    ///
1276    /// Fetches clearinghouse state via `info_clearinghouse_state` and parses positions into PositionStatusReports.
1277    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1278    ///
1279    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1280    /// will be created automatically.
1281    ///
1282    /// # Errors
1283    ///
1284    /// Returns an error if the API request fails or parsing fails.
1285    pub async fn request_position_status_reports(
1286        &self,
1287        user: &str,
1288        instrument_id: Option<InstrumentId>,
1289    ) -> Result<Vec<PositionStatusReport>> {
1290        let state_response = self.info_clearinghouse_state(user).await?;
1291
1292        // Extract asset positions from the clearinghouse state
1293        let asset_positions: Vec<serde_json::Value> = state_response
1294            .get("assetPositions")
1295            .and_then(|v| v.as_array())
1296            .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1297            .clone();
1298
1299        let mut reports = Vec::new();
1300        let ts_init = UnixNanos::default();
1301
1302        for position_value in asset_positions {
1303            // Extract coin from position data
1304            let coin = position_value
1305                .get("position")
1306                .and_then(|p| p.get("coin"))
1307                .and_then(|c| c.as_str())
1308                .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1309
1310            // Get instrument from cache - convert &str to Ustr for lookup
1311            let coin_ustr = Ustr::from(coin);
1312            let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
1313                Some(inst) => inst,
1314                None => continue, // Skip if instrument not found
1315            };
1316
1317            // Filter by instrument_id if specified
1318            if let Some(filter_id) = instrument_id
1319                && instrument.id() != filter_id
1320            {
1321                continue;
1322            }
1323
1324            // Parse to PositionStatusReport
1325            match crate::http::parse::parse_position_status_report(
1326                &position_value,
1327                &instrument,
1328                self.account_id.unwrap_or_default(),
1329                ts_init,
1330            ) {
1331                Ok(report) => reports.push(report),
1332                Err(e) => tracing::error!("Failed to parse position status report: {e}"),
1333            }
1334        }
1335
1336        Ok(reports)
1337    }
1338
1339    /// Request historical bars for an instrument.
1340    ///
1341    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
1342    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
1343    ///
1344    /// # Errors
1345    ///
1346    /// Returns an error if:
1347    /// - The instrument is not found in cache.
1348    /// - The bar aggregation is unsupported by Hyperliquid.
1349    /// - The API request fails.
1350    /// - Parsing fails.
1351    ///
1352    /// # References
1353    ///
1354    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
1355    pub async fn request_bars(
1356        &self,
1357        bar_type: BarType,
1358        start: Option<chrono::DateTime<chrono::Utc>>,
1359        end: Option<chrono::DateTime<chrono::Utc>>,
1360        limit: Option<u32>,
1361    ) -> Result<Vec<Bar>> {
1362        let instrument_id = bar_type.instrument_id();
1363        let symbol = instrument_id.symbol;
1364
1365        let coin = Ustr::from(
1366            symbol
1367                .as_str()
1368                .split('-')
1369                .next()
1370                .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
1371        );
1372
1373        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
1374        let instrument = self
1375            .get_or_create_instrument(&coin, product_type)
1376            .ok_or_else(|| {
1377                Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
1378            })?;
1379
1380        let price_precision = instrument.price_precision();
1381        let size_precision = instrument.size_precision();
1382
1383        let interval =
1384            bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
1385
1386        // Hyperliquid uses millisecond timestamps
1387        let now = chrono::Utc::now();
1388        let end_time = end.unwrap_or(now).timestamp_millis() as u64;
1389        let start_time = if let Some(start) = start {
1390            start.timestamp_millis() as u64
1391        } else {
1392            // Default to 1000 bars before end_time
1393            let spec = bar_type.spec();
1394            let step_ms = match spec.aggregation {
1395                BarAggregation::Minute => spec.step.get() as u64 * 60_000,
1396                BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
1397                BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
1398                BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
1399                BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
1400                _ => 60_000,
1401            };
1402            end_time.saturating_sub(1000 * step_ms)
1403        };
1404
1405        let candles = self
1406            .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
1407            .await?;
1408
1409        // Filter out incomplete bars where end_timestamp >= current time
1410        let now_ms = now.timestamp_millis() as u64;
1411
1412        let mut bars: Vec<Bar> = candles
1413            .iter()
1414            .filter(|candle| candle.end_timestamp < now_ms)
1415            .enumerate()
1416            .filter_map(|(i, candle)| {
1417                crate::data::candle_to_bar(candle, bar_type, price_precision, size_precision)
1418                    .map_err(|e| {
1419                        tracing::error!(
1420                            "Failed to convert candle {} to bar: {:?} error: {e}",
1421                            i,
1422                            candle
1423                        );
1424                        e
1425                    })
1426                    .ok()
1427            })
1428            .collect();
1429
1430        // 0 means no limit
1431        if let Some(limit) = limit
1432            && limit > 0
1433            && bars.len() > limit as usize
1434        {
1435            bars.truncate(limit as usize);
1436        }
1437
1438        tracing::debug!(
1439            "Received {} bars for {} (filtered {} incomplete)",
1440            bars.len(),
1441            bar_type,
1442            candles.len() - bars.len()
1443        );
1444        Ok(bars)
1445    }
1446    /// Uses the existing order conversion logic from `common::parse::order_to_hyperliquid_request`
1447    /// to avoid code duplication and ensure consistency.
1448    ///
1449    /// # Errors
1450    ///
1451    /// Returns an error if credentials are missing, order validation fails, serialization fails,
1452    /// or the API returns an error.
1453    #[allow(clippy::too_many_arguments)]
1454    pub async fn submit_order(
1455        &self,
1456        instrument_id: InstrumentId,
1457        client_order_id: ClientOrderId,
1458        order_side: OrderSide,
1459        order_type: OrderType,
1460        quantity: Quantity,
1461        time_in_force: TimeInForce,
1462        price: Option<Price>,
1463        trigger_price: Option<Price>,
1464        post_only: bool,
1465        reduce_only: bool,
1466    ) -> Result<OrderStatusReport> {
1467        let symbol = instrument_id.symbol.as_str();
1468        let asset = extract_asset_id_from_symbol(symbol)
1469            .map_err(|e| Error::bad_request(format!("Failed to extract asset ID: {e}")))?;
1470
1471        let is_buy = matches!(order_side, OrderSide::Buy);
1472
1473        // Convert price to decimal
1474        let price_decimal = match price {
1475            Some(px) => px.as_decimal(),
1476            None => {
1477                if matches!(
1478                    order_type,
1479                    OrderType::Market | OrderType::StopMarket | OrderType::MarketIfTouched
1480                ) {
1481                    Decimal::ZERO
1482                } else {
1483                    return Err(Error::bad_request("Limit orders require a price"));
1484                }
1485            }
1486        };
1487
1488        // Convert quantity to decimal
1489        let size_decimal = quantity.as_decimal();
1490
1491        // Determine order kind based on order type
1492        let kind = match order_type {
1493            OrderType::Market => HyperliquidExecOrderKind::Limit {
1494                limit: HyperliquidExecLimitParams {
1495                    tif: HyperliquidExecTif::Ioc,
1496                },
1497            },
1498            OrderType::Limit => {
1499                let tif = if post_only {
1500                    HyperliquidExecTif::Alo
1501                } else {
1502                    match time_in_force {
1503                        TimeInForce::Gtc => HyperliquidExecTif::Gtc,
1504                        TimeInForce::Ioc => HyperliquidExecTif::Ioc,
1505                        TimeInForce::Fok => HyperliquidExecTif::Ioc, // Hyperliquid doesn't have FOK
1506                        TimeInForce::Day
1507                        | TimeInForce::Gtd
1508                        | TimeInForce::AtTheOpen
1509                        | TimeInForce::AtTheClose => {
1510                            return Err(Error::bad_request(format!(
1511                                "Time in force {time_in_force:?} not supported"
1512                            )));
1513                        }
1514                    }
1515                };
1516                HyperliquidExecOrderKind::Limit {
1517                    limit: HyperliquidExecLimitParams { tif },
1518                }
1519            }
1520            OrderType::StopMarket
1521            | OrderType::StopLimit
1522            | OrderType::MarketIfTouched
1523            | OrderType::LimitIfTouched => {
1524                if let Some(trig_px) = trigger_price {
1525                    let trigger_price_decimal = trig_px.as_decimal();
1526
1527                    // Determine TP/SL type based on order type
1528                    // StopMarket/StopLimit are always Sl (protective stops)
1529                    // MarketIfTouched/LimitIfTouched are always Tp (profit-taking/entry)
1530                    let tpsl = match order_type {
1531                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1532                        OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
1533                            HyperliquidExecTpSl::Tp
1534                        }
1535                        _ => unreachable!(),
1536                    };
1537
1538                    let is_market = matches!(
1539                        order_type,
1540                        OrderType::StopMarket | OrderType::MarketIfTouched
1541                    );
1542
1543                    HyperliquidExecOrderKind::Trigger {
1544                        trigger: HyperliquidExecTriggerParams {
1545                            is_market,
1546                            trigger_px: trigger_price_decimal,
1547                            tpsl,
1548                        },
1549                    }
1550                } else {
1551                    return Err(Error::bad_request("Trigger orders require a trigger price"));
1552                }
1553            }
1554            _ => {
1555                return Err(Error::bad_request(format!(
1556                    "Order type {order_type:?} not supported"
1557                )));
1558            }
1559        };
1560
1561        // Build the order request
1562        let hyperliquid_order =
1563            HyperliquidExecPlaceOrderRequest {
1564                asset,
1565                is_buy,
1566                price: price_decimal,
1567                size: size_decimal,
1568                reduce_only,
1569                kind,
1570                cloid: Some(Cloid::from_hex(client_order_id).map_err(|e| {
1571                    Error::bad_request(format!("Invalid client order ID format: {e}"))
1572                })?),
1573            };
1574
1575        // Create action
1576        let action = HyperliquidExecAction::Order {
1577            orders: vec![hyperliquid_order],
1578            grouping: HyperliquidExecGrouping::Na,
1579            builder: None,
1580        };
1581
1582        // Submit to exchange
1583        let response = self.inner.post_action_exec(&action).await?;
1584
1585        // Parse response
1586        match response {
1587            HyperliquidExchangeResponse::Status {
1588                status,
1589                response: response_data,
1590            } if status == "ok" => {
1591                let data_value = if let Some(data) = response_data.get("data") {
1592                    data.clone()
1593                } else {
1594                    response_data
1595                };
1596
1597                let order_response: HyperliquidExecOrderResponseData =
1598                    serde_json::from_value(data_value).map_err(|e| {
1599                        Error::bad_request(format!("Failed to parse order response: {e}"))
1600                    })?;
1601
1602                let order_status = order_response
1603                    .statuses
1604                    .first()
1605                    .ok_or_else(|| Error::bad_request("No order status in response"))?;
1606
1607                let symbol_str = instrument_id.symbol.as_str();
1608                let asset_str = symbol_str
1609                    .trim_end_matches("-PERP")
1610                    .trim_end_matches("-USD");
1611
1612                let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
1613                let instrument = self
1614                    .get_or_create_instrument(&Ustr::from(asset_str), product_type)
1615                    .ok_or_else(|| {
1616                        Error::bad_request(format!("Instrument not found for {asset_str}"))
1617                    })?;
1618
1619                let account_id = self
1620                    .account_id
1621                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1622                let ts_init = UnixNanos::default();
1623
1624                match order_status {
1625                    HyperliquidExecOrderStatus::Resting { resting } => self
1626                        .create_order_status_report(
1627                            instrument_id,
1628                            Some(client_order_id),
1629                            VenueOrderId::new(resting.oid.to_string()),
1630                            order_side,
1631                            order_type,
1632                            quantity,
1633                            time_in_force,
1634                            price,
1635                            trigger_price,
1636                            OrderStatus::Accepted,
1637                            Quantity::new(0.0, instrument.size_precision()),
1638                            &instrument,
1639                            account_id,
1640                            ts_init,
1641                        ),
1642                    HyperliquidExecOrderStatus::Filled { filled } => {
1643                        let filled_qty = Quantity::new(
1644                            filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1645                            instrument.size_precision(),
1646                        );
1647                        self.create_order_status_report(
1648                            instrument_id,
1649                            Some(client_order_id),
1650                            VenueOrderId::new(filled.oid.to_string()),
1651                            order_side,
1652                            order_type,
1653                            quantity,
1654                            time_in_force,
1655                            price,
1656                            trigger_price,
1657                            OrderStatus::Filled,
1658                            filled_qty,
1659                            &instrument,
1660                            account_id,
1661                            ts_init,
1662                        )
1663                    }
1664                    HyperliquidExecOrderStatus::Error { error } => {
1665                        Err(Error::bad_request(format!("Order rejected: {error}")))
1666                    }
1667                }
1668            }
1669            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1670                "Order submission failed: {error}"
1671            ))),
1672            _ => Err(Error::bad_request("Unexpected response format")),
1673        }
1674    }
1675
1676    /// Submit an order using an OrderAny object.
1677    ///
1678    /// This is a convenience method that wraps submit_order.
1679    pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
1680        self.submit_order(
1681            order.instrument_id(),
1682            order.client_order_id(),
1683            order.order_side(),
1684            order.order_type(),
1685            order.quantity(),
1686            order.time_in_force(),
1687            order.price(),
1688            order.trigger_price(),
1689            order.is_post_only(),
1690            order.is_reduce_only(),
1691        )
1692        .await
1693    }
1694
1695    /// Create an OrderStatusReport from order submission details.
1696    #[allow(clippy::too_many_arguments)]
1697    fn create_order_status_report(
1698        &self,
1699        instrument_id: InstrumentId,
1700        client_order_id: Option<ClientOrderId>,
1701        venue_order_id: VenueOrderId,
1702        order_side: OrderSide,
1703        order_type: OrderType,
1704        quantity: Quantity,
1705        time_in_force: TimeInForce,
1706        price: Option<Price>,
1707        trigger_price: Option<Price>,
1708        order_status: OrderStatus,
1709        filled_qty: Quantity,
1710        _instrument: &InstrumentAny,
1711        account_id: AccountId,
1712        ts_init: UnixNanos,
1713    ) -> Result<OrderStatusReport> {
1714        let clock = get_atomic_clock_realtime();
1715        let ts_accepted = clock.get_time_ns();
1716        let ts_last = ts_accepted;
1717        let report_id = UUID4::new();
1718
1719        let mut report = OrderStatusReport::new(
1720            account_id,
1721            instrument_id,
1722            client_order_id,
1723            venue_order_id,
1724            order_side,
1725            order_type,
1726            time_in_force,
1727            order_status,
1728            quantity,
1729            filled_qty,
1730            ts_accepted,
1731            ts_last,
1732            ts_init,
1733            Some(report_id),
1734        );
1735
1736        // Add price if present
1737        if let Some(px) = price {
1738            report = report.with_price(px);
1739        }
1740
1741        // Add trigger price if present
1742        if let Some(trig_px) = trigger_price {
1743            report = report
1744                .with_trigger_price(trig_px)
1745                .with_trigger_type(TriggerType::Default);
1746        }
1747
1748        Ok(report)
1749    }
1750
1751    /// Submit multiple orders to the Hyperliquid exchange in a single request.
1752    ///
1753    /// Uses the existing order conversion logic from `common::parse::orders_to_hyperliquid_requests`
1754    /// to avoid code duplication and ensure consistency.
1755    ///
1756    /// # Errors
1757    ///
1758    /// Returns an error if credentials are missing, order validation fails, serialization fails,
1759    /// or the API returns an error.
1760    pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
1761        // Use the existing parsing function from common::parse
1762        let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
1763            .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
1764
1765        // Create typed action using HyperliquidExecAction (same as working Rust binary)
1766        let action = HyperliquidExecAction::Order {
1767            orders: hyperliquid_orders,
1768            grouping: HyperliquidExecGrouping::Na,
1769            builder: None,
1770        };
1771
1772        // Submit to exchange using the typed exec endpoint
1773        let response = self.inner.post_action_exec(&action).await?;
1774
1775        // Parse the response to extract order statuses
1776        match response {
1777            HyperliquidExchangeResponse::Status {
1778                status,
1779                response: response_data,
1780            } if status == "ok" => {
1781                // Extract the 'data' field from the response if it exists (new format)
1782                // Otherwise use response_data directly (old format)
1783                let data_value = if let Some(data) = response_data.get("data") {
1784                    data.clone()
1785                } else {
1786                    response_data
1787                };
1788
1789                // Parse the response data to extract order statuses
1790                let order_response: HyperliquidExecOrderResponseData =
1791                    serde_json::from_value(data_value).map_err(|e| {
1792                        Error::bad_request(format!("Failed to parse order response: {e}"))
1793                    })?;
1794
1795                let account_id = self
1796                    .account_id
1797                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1798                let ts_init = UnixNanos::default();
1799
1800                // Validate we have the same number of statuses as orders submitted
1801                if order_response.statuses.len() != orders.len() {
1802                    return Err(Error::bad_request(format!(
1803                        "Mismatch between submitted orders ({}) and response statuses ({})",
1804                        orders.len(),
1805                        order_response.statuses.len()
1806                    )));
1807                }
1808
1809                let mut reports = Vec::new();
1810
1811                // Create OrderStatusReport for each order
1812                for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
1813                    // Extract asset from instrument symbol
1814                    let instrument_id = order.instrument_id();
1815                    let symbol = instrument_id.symbol.as_str();
1816                    let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
1817
1818                    let product_type = HyperliquidProductType::from_symbol(symbol).ok();
1819                    let instrument = self
1820                        .get_or_create_instrument(&Ustr::from(asset), product_type)
1821                        .ok_or_else(|| {
1822                            Error::bad_request(format!("Instrument not found for {asset}"))
1823                        })?;
1824
1825                    // Create OrderStatusReport based on the order status
1826                    let report = match order_status {
1827                        HyperliquidExecOrderStatus::Resting { resting } => {
1828                            // Order is resting on the order book
1829                            self.create_order_status_report(
1830                                order.instrument_id(),
1831                                Some(order.client_order_id()),
1832                                VenueOrderId::new(resting.oid.to_string()),
1833                                order.order_side(),
1834                                order.order_type(),
1835                                order.quantity(),
1836                                order.time_in_force(),
1837                                order.price(),
1838                                order.trigger_price(),
1839                                OrderStatus::Accepted,
1840                                Quantity::new(0.0, instrument.size_precision()),
1841                                &instrument,
1842                                account_id,
1843                                ts_init,
1844                            )?
1845                        }
1846                        HyperliquidExecOrderStatus::Filled { filled } => {
1847                            // Order was filled immediately
1848                            let filled_qty = Quantity::new(
1849                                filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
1850                                instrument.size_precision(),
1851                            );
1852                            self.create_order_status_report(
1853                                order.instrument_id(),
1854                                Some(order.client_order_id()),
1855                                VenueOrderId::new(filled.oid.to_string()),
1856                                order.order_side(),
1857                                order.order_type(),
1858                                order.quantity(),
1859                                order.time_in_force(),
1860                                order.price(),
1861                                order.trigger_price(),
1862                                OrderStatus::Filled,
1863                                filled_qty,
1864                                &instrument,
1865                                account_id,
1866                                ts_init,
1867                            )?
1868                        }
1869                        HyperliquidExecOrderStatus::Error { error } => {
1870                            return Err(Error::bad_request(format!(
1871                                "Order {} rejected: {error}",
1872                                order.client_order_id()
1873                            )));
1874                        }
1875                    };
1876
1877                    reports.push(report);
1878                }
1879
1880                Ok(reports)
1881            }
1882            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
1883                "Order submission failed: {error}"
1884            ))),
1885            _ => Err(Error::bad_request("Unexpected response format")),
1886        }
1887    }
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use nautilus_core::MUTEX_POISONED;
1893    use nautilus_model::instruments::{Instrument, InstrumentAny};
1894    use rstest::rstest;
1895    use ustr::Ustr;
1896
1897    use super::HyperliquidHttpClient;
1898    use crate::{common::enums::HyperliquidProductType, http::query::InfoRequest};
1899
1900    #[rstest]
1901    fn stable_json_roundtrips() {
1902        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1903        let s = serde_json::to_string(&v).unwrap();
1904        // Parse back to ensure JSON structure is correct, regardless of field order
1905        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1906        assert_eq!(parsed["type"], "l2Book");
1907        assert_eq!(parsed["coin"], "BTC");
1908        assert_eq!(parsed, v);
1909    }
1910
1911    #[rstest]
1912    fn info_pretty_shape() {
1913        let r = InfoRequest::l2_book("BTC");
1914        let val = serde_json::to_value(&r).unwrap();
1915        let pretty = serde_json::to_string_pretty(&val).unwrap();
1916        assert!(pretty.contains("\"type\": \"l2Book\""));
1917        assert!(pretty.contains("\"coin\": \"BTC\""));
1918    }
1919
1920    #[rstest]
1921    fn test_cache_instrument_by_raw_symbol() {
1922        use nautilus_core::time::get_atomic_clock_realtime;
1923        use nautilus_model::{
1924            currencies::CURRENCY_MAP,
1925            enums::CurrencyType,
1926            identifiers::{InstrumentId, Symbol},
1927            instruments::CurrencyPair,
1928            types::{Currency, Price, Quantity},
1929        };
1930
1931        let client = HyperliquidHttpClient::new(true, None, None).unwrap();
1932
1933        // Create a test instrument with base currency "vntls:vCURSOR"
1934        let base_code = "vntls:vCURSOR";
1935        let quote_code = "USDC";
1936
1937        // Register the custom currency
1938        {
1939            let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
1940            if !currency_map.contains_key(base_code) {
1941                currency_map.insert(
1942                    base_code.to_string(),
1943                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1944                );
1945            }
1946        }
1947
1948        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1949        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1950
1951        // Nautilus symbol is "vntls:vCURSOR-USDC-SPOT"
1952        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1953        let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1954        let instrument_id = InstrumentId::new(symbol, venue);
1955
1956        // raw_symbol is set to the base currency "vntls:vCURSOR" (see parse.rs)
1957        let raw_symbol = Symbol::new(base_code);
1958
1959        let clock = get_atomic_clock_realtime();
1960        let ts = clock.get_time_ns();
1961
1962        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1963            instrument_id,
1964            raw_symbol,
1965            base_currency,
1966            quote_currency,
1967            8,
1968            8,
1969            Price::from("0.00000001"),
1970            Quantity::from("0.00000001"),
1971            None,
1972            None,
1973            None,
1974            None,
1975            None,
1976            None,
1977            None,
1978            None,
1979            None,
1980            None,
1981            None,
1982            None,
1983            ts,
1984            ts,
1985        ));
1986
1987        // Cache the instrument
1988        client.cache_instrument(instrument.clone());
1989
1990        // Verify it can be looked up by full symbol
1991        let instruments = client.instruments.read().unwrap();
1992        let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
1993        assert!(
1994            by_full_symbol.is_some(),
1995            "Instrument should be accessible by full symbol"
1996        );
1997        assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
1998
1999        // Verify it can be looked up by raw_symbol (coin) - backward compatibility
2000        let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
2001        assert!(
2002            by_raw_symbol.is_some(),
2003            "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
2004        );
2005        assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
2006        drop(instruments);
2007
2008        // Verify it can be looked up by composite key (coin, product_type)
2009        let instruments_by_coin = client.instruments_by_coin.read().unwrap();
2010        let by_coin =
2011            instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
2012        assert!(
2013            by_coin.is_some(),
2014            "Instrument should be accessible by coin and product type"
2015        );
2016        assert_eq!(by_coin.unwrap().id(), instrument.id());
2017        drop(instruments_by_coin);
2018
2019        // Verify get_or_create_instrument works with product type
2020        let retrieved_with_type = client.get_or_create_instrument(
2021            &Ustr::from("vntls:vCURSOR"),
2022            Some(HyperliquidProductType::Spot),
2023        );
2024        assert!(retrieved_with_type.is_some());
2025        assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
2026
2027        // Verify get_or_create_instrument works without product type (fallback)
2028        let retrieved_without_type =
2029            client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
2030        assert!(retrieved_without_type.is_some());
2031        assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
2032    }
2033}