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