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::consts::NAUTILUS_USER_AGENT;
33use nautilus_model::{
34    identifiers::AccountId,
35    instruments::{Instrument, InstrumentAny},
36    orders::Order,
37};
38use nautilus_network::{http::HttpClient, ratelimiter::quota::Quota};
39use reqwest::{Method, header::USER_AGENT};
40use serde_json::Value;
41use tokio::time::sleep;
42use ustr::Ustr;
43
44use crate::{
45    common::{
46        consts::{HYPERLIQUID_VENUE, exchange_url, info_url},
47        credential::{Secrets, VaultAddress},
48        parse::order_to_hyperliquid_request,
49    },
50    http::{
51        error::{Error, Result},
52        models::{
53            HyperliquidExchangeRequest, HyperliquidExchangeResponse, HyperliquidFills,
54            HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, PerpMeta, PerpMetaAndCtxs,
55            SpotMeta, SpotMetaAndCtxs,
56        },
57        parse::{
58            HyperliquidInstrumentDef, instruments_from_defs_owned, parse_perp_instruments,
59            parse_spot_instruments,
60        },
61        query::{ExchangeAction, InfoRequest},
62        rate_limits::{
63            RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
64            info_base_weight, info_extra_weight,
65        },
66    },
67    signing::{
68        HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
69    },
70};
71
72// https://hyperliquid.xyz/docs/api#rate-limits
73pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
74    LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
75
76/// Provides a lower-level HTTP client for connecting to the [Hyperliquid](https://hyperliquid.xyz/) REST API.
77///
78/// This client wraps the underlying `HttpClient` to handle functionality
79/// specific to Hyperliquid, such as request signing (for authenticated endpoints),
80/// forming request URLs, and deserializing responses into specific data models.
81#[derive(Debug, Clone)]
82#[cfg_attr(
83    feature = "python",
84    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.adapters")
85)]
86pub struct HyperliquidHttpClient {
87    client: HttpClient,
88    is_testnet: bool,
89    base_info: String,
90    base_exchange: String,
91    signer: Option<HyperliquidEip712Signer>,
92    nonce_manager: Option<Arc<NonceManager>>,
93    vault_address: Option<VaultAddress>,
94    rest_limiter: Arc<WeightedLimiter>,
95    rate_limit_backoff_base: Duration,
96    rate_limit_backoff_cap: Duration,
97    rate_limit_max_attempts_info: u32,
98    instruments: Arc<RwLock<AHashMap<Ustr, InstrumentAny>>>,
99    account_id: Option<AccountId>,
100}
101
102impl Default for HyperliquidHttpClient {
103    fn default() -> Self {
104        Self::new(true, None) // Default to testnet
105    }
106}
107
108impl HyperliquidHttpClient {
109    /// Creates a new [`HyperliquidHttpClient`] using the default Hyperliquid HTTP URL,
110    /// optionally overridden with a custom timeout.
111    ///
112    /// This version of the client has **no credentials**, so it can only
113    /// call publicly accessible endpoints.
114    #[must_use]
115    pub fn new(is_testnet: bool, timeout_secs: Option<u64>) -> Self {
116        Self {
117            client: HttpClient::new(
118                Self::default_headers(),
119                vec![],
120                vec![],
121                Some(*HYPERLIQUID_REST_QUOTA),
122                timeout_secs,
123            ),
124            is_testnet,
125            base_info: info_url(is_testnet).to_string(),
126            base_exchange: exchange_url(is_testnet).to_string(),
127            signer: None,
128            nonce_manager: None,
129            vault_address: None,
130            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
131            rate_limit_backoff_base: Duration::from_millis(125),
132            rate_limit_backoff_cap: Duration::from_secs(5),
133            rate_limit_max_attempts_info: 3,
134            instruments: Arc::new(RwLock::new(AHashMap::new())),
135            account_id: None,
136        }
137    }
138
139    /// Creates a new [`HyperliquidHttpClient`] configured with credentials
140    /// for authenticated requests.
141    #[must_use]
142    pub fn with_credentials(secrets: &Secrets, timeout_secs: Option<u64>) -> Self {
143        let signer = HyperliquidEip712Signer::new(secrets.private_key.clone());
144        let nonce_manager = Arc::new(NonceManager::new());
145
146        Self {
147            client: HttpClient::new(
148                Self::default_headers(),
149                vec![],
150                vec![],
151                Some(*HYPERLIQUID_REST_QUOTA),
152                timeout_secs,
153            ),
154            is_testnet: secrets.is_testnet,
155            base_info: info_url(secrets.is_testnet).to_string(),
156            base_exchange: exchange_url(secrets.is_testnet).to_string(),
157            signer: Some(signer),
158            nonce_manager: Some(nonce_manager),
159            vault_address: secrets.vault_address,
160            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
161            rate_limit_backoff_base: Duration::from_millis(125),
162            rate_limit_backoff_cap: Duration::from_secs(5),
163            rate_limit_max_attempts_info: 3,
164            instruments: Arc::new(RwLock::new(AHashMap::new())),
165            account_id: None,
166        }
167    }
168
169    /// Creates an authenticated client from environment variables.
170    ///
171    /// # Errors
172    ///
173    /// Returns [`Error::Auth`] if required environment variables
174    /// are not set.
175    pub fn from_env() -> Result<Self> {
176        let secrets =
177            Secrets::from_env().map_err(|_| Error::auth("missing credentials in environment"))?;
178        Ok(Self::with_credentials(&secrets, None))
179    }
180
181    /// Configure rate limiting parameters (chainable).
182    pub fn with_rate_limits(mut self) -> Self {
183        self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
184        self.rate_limit_backoff_base = Duration::from_millis(125);
185        self.rate_limit_backoff_cap = Duration::from_secs(5);
186        self.rate_limit_max_attempts_info = 3;
187        self
188    }
189
190    /// Returns whether this client is configured for testnet.
191    #[must_use]
192    pub fn is_testnet(&self) -> bool {
193        self.is_testnet
194    }
195
196    /// Gets the user address derived from the private key (if client has credentials).
197    ///
198    /// # Errors
199    ///
200    /// Returns [`Error::Auth`] if the client has no signer configured.
201    pub fn get_user_address(&self) -> Result<String> {
202        self.signer
203            .as_ref()
204            .ok_or_else(|| Error::auth("No signer configured"))?
205            .address()
206    }
207
208    /// Add an instrument to the internal cache for report generation.
209    ///
210    /// This is required for parsing orders, fills, and positions into reports.
211    /// Instruments are stored under two keys:
212    /// 1. The Nautilus symbol (e.g., "BTC-USD-PERP")
213    /// 2. The Hyperliquid coin identifier (base currency, e.g., "BTC" or "vntls:vCURSOR")
214    ///
215    /// # Panics
216    ///
217    /// Panics if the instrument lock cannot be acquired.
218    pub fn add_instrument(&self, instrument: InstrumentAny) {
219        let mut instruments = self
220            .instruments
221            .write()
222            .expect("Failed to acquire write lock");
223
224        // Store by Nautilus symbol
225        let nautilus_symbol = instrument.id().symbol.inner();
226        instruments.insert(nautilus_symbol, instrument.clone());
227
228        // Store by Hyperliquid coin identifier (base currency)
229        // This allows lookup by the "coin" field returned in API responses
230        if let Some(base_currency) = instrument.base_currency() {
231            let coin_key = Ustr::from(base_currency.code.as_str());
232            instruments.insert(coin_key, instrument);
233        }
234    }
235
236    /// Get an instrument from cache, or create a synthetic one for vault tokens.
237    ///
238    /// Vault tokens (starting with "vntls:") are not available in the standard spotMeta API.
239    /// This method creates synthetic CurrencyPair instruments for vault tokens on-the-fly
240    /// to allow order/fill/position parsing to continue.
241    ///
242    /// For non-vault tokens that are not in cache, returns None and logs a warning.
243    /// This can happen if instruments weren't loaded properly or if there are new instruments
244    /// that weren't present during initialization.
245    ///
246    /// The synthetic instruments use reasonable defaults:
247    /// - Quote currency: USDC (most common quote for vault tokens)
248    /// - Price/size decimals: 8 (standard precision)
249    /// - Price increment: 0.00000001
250    /// - Size increment: 0.00000001
251    fn get_or_create_instrument(&self, coin: &Ustr) -> Option<InstrumentAny> {
252        // Try to get from cache first
253        {
254            let instruments = self
255                .instruments
256                .read()
257                .expect("Failed to acquire read lock");
258            if let Some(instrument) = instruments.get(coin) {
259                return Some(instrument.clone());
260            }
261        }
262
263        // If not found and it's a vault token, create a synthetic instrument
264        if coin.as_str().starts_with("vntls:") {
265            tracing::info!("Creating synthetic instrument for vault token: {}", coin);
266
267            let clock = nautilus_core::time::get_atomic_clock_realtime();
268            let ts_event = clock.get_time_ns();
269
270            // Create synthetic vault token instrument
271            let symbol_str = format!("{}-USDC-SPOT", coin);
272            let symbol = nautilus_model::identifiers::Symbol::new(&symbol_str);
273            let venue = *HYPERLIQUID_VENUE;
274            let instrument_id = nautilus_model::identifiers::InstrumentId::new(symbol, venue);
275
276            // Create currencies
277            let base_currency = nautilus_model::types::Currency::new(
278                coin.as_str(),
279                8, // precision
280                0, // ISO code (not applicable)
281                coin.as_str(),
282                nautilus_model::enums::CurrencyType::Crypto,
283            );
284
285            let quote_currency = nautilus_model::types::Currency::new(
286                "USDC",
287                6, // USDC standard precision
288                0,
289                "USDC",
290                nautilus_model::enums::CurrencyType::Crypto,
291            );
292
293            let price_increment = nautilus_model::types::Price::from("0.00000001");
294            let size_increment = nautilus_model::types::Quantity::from("0.00000001");
295
296            let instrument =
297                InstrumentAny::CurrencyPair(nautilus_model::instruments::CurrencyPair::new(
298                    instrument_id,
299                    symbol,
300                    base_currency,
301                    quote_currency,
302                    8, // price_precision
303                    8, // size_precision
304                    price_increment,
305                    size_increment,
306                    None, // price_increment
307                    None, // size_increment
308                    None, // maker_fee
309                    None, // taker_fee
310                    None, // margin_init
311                    None, // margin_maint
312                    None, // lot_size
313                    None, // max_quantity
314                    None, // min_quantity
315                    None, // max_notional
316                    None, // min_notional
317                    None, // max_price
318                    ts_event,
319                    ts_event,
320                ));
321
322            // Add to cache for future lookups
323            self.add_instrument(instrument.clone());
324
325            Some(instrument)
326        } else {
327            // For non-vault tokens, log warning and return None
328            tracing::warn!("Instrument not found in cache: {}", coin);
329            None
330        }
331    }
332
333    /// Set the account ID for this client.
334    ///
335    /// This is required for generating reports with the correct account ID.
336    pub fn set_account_id(&mut self, account_id: AccountId) {
337        self.account_id = Some(account_id);
338    }
339
340    /// Builds the default headers to include with each request (e.g., `User-Agent`).
341    fn default_headers() -> HashMap<String, String> {
342        HashMap::from([
343            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
344            ("Content-Type".to_string(), "application/json".to_string()),
345        ])
346    }
347    // ---------------- INFO ENDPOINTS --------------------------------------------
348
349    /// Get metadata about available markets.
350    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
351        let request = InfoRequest::meta();
352        let response = self.send_info_request(&request).await?;
353        serde_json::from_value(response).map_err(Error::Serde)
354    }
355
356    /// Get complete spot metadata (tokens and pairs).
357    pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
358        let request = InfoRequest::spot_meta();
359        let response = self.send_info_request(&request).await?;
360        serde_json::from_value(response).map_err(Error::Serde)
361    }
362
363    /// Get perpetuals metadata with asset contexts (for price precision refinement).
364    pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
365        let request = InfoRequest::meta_and_asset_ctxs();
366        let response = self.send_info_request(&request).await?;
367        serde_json::from_value(response).map_err(Error::Serde)
368    }
369
370    /// Get spot metadata with asset contexts (for price precision refinement).
371    pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
372        let request = InfoRequest::spot_meta_and_asset_ctxs();
373        let response = self.send_info_request(&request).await?;
374        serde_json::from_value(response).map_err(Error::Serde)
375    }
376
377    /// Fetch and parse all available instrument definitions from Hyperliquid.
378    pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
379        let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
380
381        match self.load_perp_meta().await {
382            Ok(perp_meta) => match parse_perp_instruments(&perp_meta) {
383                Ok(perp_defs) => {
384                    tracing::debug!(
385                        count = perp_defs.len(),
386                        "Loaded Hyperliquid perp definitions"
387                    );
388                    defs.extend(perp_defs);
389                }
390                Err(err) => {
391                    tracing::warn!(%err, "Failed to parse Hyperliquid perp instruments");
392                }
393            },
394            Err(err) => {
395                tracing::warn!(%err, "Failed to load Hyperliquid perp metadata");
396            }
397        }
398
399        match self.get_spot_meta().await {
400            Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
401                Ok(spot_defs) => {
402                    tracing::debug!(
403                        count = spot_defs.len(),
404                        "Loaded Hyperliquid spot definitions"
405                    );
406                    defs.extend(spot_defs);
407                }
408                Err(err) => {
409                    tracing::warn!(%err, "Failed to parse Hyperliquid spot instruments");
410                }
411            },
412            Err(err) => {
413                tracing::warn!(%err, "Failed to load Hyperliquid spot metadata");
414            }
415        }
416
417        Ok(instruments_from_defs_owned(defs))
418    }
419
420    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
421        let request = InfoRequest::meta();
422        let response = self.send_info_request(&request).await?;
423        serde_json::from_value(response).map_err(Error::Serde)
424    }
425
426    /// Get L2 order book for a coin.
427    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
428        let request = InfoRequest::l2_book(coin);
429        let response = self.send_info_request(&request).await?;
430        serde_json::from_value(response).map_err(Error::Serde)
431    }
432
433    /// Get user fills (trading history).
434    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
435        let request = InfoRequest::user_fills(user);
436        let response = self.send_info_request(&request).await?;
437        serde_json::from_value(response).map_err(Error::Serde)
438    }
439
440    /// Get order status for a user.
441    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
442        let request = InfoRequest::order_status(user, oid);
443        let response = self.send_info_request(&request).await?;
444        serde_json::from_value(response).map_err(Error::Serde)
445    }
446
447    /// Get all open orders for a user.
448    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
449        let request = InfoRequest::open_orders(user);
450        self.send_info_request(&request).await
451    }
452
453    /// Get frontend open orders (includes more detail) for a user.
454    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
455        let request = InfoRequest::frontend_open_orders(user);
456        self.send_info_request(&request).await
457    }
458
459    /// Get clearinghouse state (balances, positions, margin) for a user.
460    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
461        let request = InfoRequest::clearinghouse_state(user);
462        self.send_info_request(&request).await
463    }
464
465    /// Get candle/bar data for a coin.
466    ///
467    /// # Arguments
468    /// * `coin` - The coin symbol (e.g., "BTC")
469    /// * `interval` - The timeframe (e.g., "1m", "5m", "15m", "1h", "4h", "1d")
470    /// * `start_time` - Start timestamp in milliseconds
471    /// * `end_time` - End timestamp in milliseconds
472    pub async fn info_candle_snapshot(
473        &self,
474        coin: &str,
475        interval: &str,
476        start_time: u64,
477        end_time: u64,
478    ) -> Result<crate::http::models::HyperliquidCandleSnapshot> {
479        let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
480        let response = self.send_info_request(&request).await?;
481        serde_json::from_value(response).map_err(Error::Serde)
482    }
483
484    /// Generic info request method that returns raw JSON (useful for new endpoints and testing).
485    pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
486        self.send_info_request(request).await
487    }
488
489    /// Send a raw info request and return the JSON response.
490    async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
491        let base_w = info_base_weight(request);
492        self.rest_limiter.acquire(base_w).await;
493
494        let mut attempt = 0u32;
495        loop {
496            let response = self.http_roundtrip_info(request).await?;
497
498            if response.status.is_success() {
499                // decode once to count items, then materialize T
500                let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
501                let extra = info_extra_weight(request, &val);
502                if extra > 0 {
503                    self.rest_limiter.debit_extra(extra).await;
504                    tracing::debug!(endpoint=?request, base_w, extra, "info: debited extra weight");
505                }
506                return Ok(val);
507            }
508
509            // 429 → respect Retry-After; else jittered backoff. Retry Info only.
510            if response.status.as_u16() == 429 {
511                if attempt >= self.rate_limit_max_attempts_info {
512                    let ra = self.parse_retry_after_simple(&response.headers);
513                    return Err(Error::rate_limit("info", base_w, ra));
514                }
515                let delay = self
516                    .parse_retry_after_simple(&response.headers)
517                    .map(Duration::from_millis)
518                    .unwrap_or_else(|| {
519                        backoff_full_jitter(
520                            attempt,
521                            self.rate_limit_backoff_base,
522                            self.rate_limit_backoff_cap,
523                        )
524                    });
525                tracing::warn!(endpoint=?request, attempt, wait_ms=?delay.as_millis(), "429 Too Many Requests; backing off");
526                attempt += 1;
527                sleep(delay).await;
528                // tiny re-acquire to avoid stampede exactly on minute boundary
529                self.rest_limiter.acquire(1).await;
530                continue;
531            }
532
533            // transient 5xx: treat like retryable Info (bounded)
534            if (response.status.is_server_error() || response.status.as_u16() == 408)
535                && attempt < self.rate_limit_max_attempts_info
536            {
537                let delay = backoff_full_jitter(
538                    attempt,
539                    self.rate_limit_backoff_base,
540                    self.rate_limit_backoff_cap,
541                );
542                tracing::warn!(endpoint=?request, attempt, status=?response.status.as_u16(), wait_ms=?delay.as_millis(), "transient error; retrying");
543                attempt += 1;
544                sleep(delay).await;
545                continue;
546            }
547
548            // non-retryable or exhausted
549            let error_body = String::from_utf8_lossy(&response.body);
550            return Err(Error::http(
551                response.status.as_u16(),
552                error_body.to_string(),
553            ));
554        }
555    }
556
557    /// Raw HTTP roundtrip for info requests - returns the original HttpResponse
558    async fn http_roundtrip_info(
559        &self,
560        request: &InfoRequest,
561    ) -> Result<nautilus_network::http::HttpResponse> {
562        let url = &self.base_info;
563        let body = serde_json::to_value(request).map_err(Error::Serde)?;
564        let body_bytes = serde_json::to_string(&body)
565            .map_err(Error::Serde)?
566            .into_bytes();
567
568        self.client
569            .request(
570                Method::POST,
571                url.clone(),
572                None,
573                Some(body_bytes),
574                None,
575                None,
576            )
577            .await
578            .map_err(Error::from_http_client)
579    }
580
581    /// Parse Retry-After from response headers (simplified)
582    fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
583        let retry_after = headers.get("retry-after")?;
584        retry_after.parse::<u64>().ok().map(|s| s * 1000) // convert seconds to ms
585    }
586
587    // ---------------- EXCHANGE ENDPOINTS ---------------------------------------
588
589    /// Send a signed action to the exchange.
590    pub async fn post_action(
591        &self,
592        action: &ExchangeAction,
593    ) -> Result<HyperliquidExchangeResponse> {
594        let w = exchange_weight(action);
595        self.rest_limiter.acquire(w).await;
596
597        let signer = self
598            .signer
599            .as_ref()
600            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
601
602        let nonce_manager = self
603            .nonce_manager
604            .as_ref()
605            .ok_or_else(|| Error::auth("nonce manager missing"))?;
606
607        let signer_id = self.signer_id()?;
608        let time_nonce = nonce_manager.next(signer_id.clone())?;
609        nonce_manager.validate_local(signer_id, time_nonce)?;
610
611        let action_value = serde_json::to_value(action)
612            .context("serialize exchange action")
613            .map_err(|e| Error::bad_request(e.to_string()))?;
614
615        let sig = signer
616            .sign(&SignRequest {
617                action: action_value.clone(),
618                time_nonce,
619                action_type: HyperliquidActionType::UserSigned,
620            })?
621            .signature;
622
623        let request = if let Some(vault) = self.vault_address {
624            HyperliquidExchangeRequest::with_vault(
625                action.clone(),
626                time_nonce.as_millis() as u64,
627                sig,
628                vault.to_string(),
629            )
630        } else {
631            HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
632        };
633
634        let response = self.http_roundtrip_exchange(&request).await?;
635
636        if response.status.is_success() {
637            serde_json::from_slice(&response.body).map_err(Error::Serde)
638        } else if response.status.as_u16() == 429 {
639            let ra = self.parse_retry_after_simple(&response.headers);
640            Err(Error::rate_limit("exchange", w, ra))
641        } else {
642            let error_body = String::from_utf8_lossy(&response.body);
643            Err(Error::http(
644                response.status.as_u16(),
645                error_body.to_string(),
646            ))
647        }
648    }
649
650    /// Submit a single order to the Hyperliquid exchange.
651    ///
652    /// Uses the existing order conversion logic from `common::parse::order_to_hyperliquid_request`
653    /// to avoid code duplication and ensure consistency.
654    ///
655    /// # Errors
656    ///
657    /// Returns an error if credentials are missing, order validation fails, serialization fails,
658    /// or the API returns an error.
659    pub async fn submit_order(
660        &self,
661        order: &nautilus_model::orders::any::OrderAny,
662    ) -> Result<nautilus_model::reports::OrderStatusReport> {
663        // Use the existing parsing function from common::parse
664        let hyperliquid_order = order_to_hyperliquid_request(order)
665            .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
666
667        // Convert single order to JSON array format for the exchange action
668        let orders_value = serde_json::json!([hyperliquid_order]);
669
670        // Create exchange action
671        let action = ExchangeAction::order(orders_value);
672
673        // Submit to exchange
674        let response = self.post_action(&action).await?;
675
676        // Parse the response to extract order status
677        match response {
678            HyperliquidExchangeResponse::Status {
679                status,
680                response: response_data,
681            } if status == "ok" => {
682                // Parse the response data to extract order status
683                let order_response: crate::http::models::HyperliquidExecOrderResponseData =
684                    serde_json::from_value(response_data.clone()).map_err(|e| {
685                        Error::bad_request(format!("Failed to parse order response: {e}"))
686                    })?;
687
688                // Get the first (and only) order status
689                let order_status = order_response
690                    .statuses
691                    .first()
692                    .ok_or_else(|| Error::bad_request("No order status in response"))?;
693
694                // Extract asset from instrument symbol
695                let instrument_id = order.instrument_id();
696                let symbol = instrument_id.symbol.as_str();
697                let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD");
698
699                // Get instrument from cache for parsing
700                let instrument = self
701                    .get_or_create_instrument(&Ustr::from(asset))
702                    .ok_or_else(|| {
703                        Error::bad_request(format!("Instrument not found for {asset}"))
704                    })?;
705
706                let account_id = self
707                    .account_id
708                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
709                let ts_init = nautilus_core::UnixNanos::default();
710
711                // Create OrderStatusReport based on the order status
712                match order_status {
713                    crate::http::models::HyperliquidExecOrderStatus::Resting { resting } => {
714                        // Order is resting on the order book
715                        self.create_order_status_report(
716                            order.instrument_id(),
717                            Some(order.client_order_id()),
718                            nautilus_model::identifiers::VenueOrderId::new(resting.oid.to_string()),
719                            order.order_side(),
720                            order.order_type(),
721                            order.quantity(),
722                            order.time_in_force(),
723                            order.price(),
724                            order.trigger_price(),
725                            nautilus_model::enums::OrderStatus::Accepted,
726                            nautilus_model::types::Quantity::new(0.0, instrument.size_precision()),
727                            &instrument,
728                            account_id,
729                            ts_init,
730                        )
731                    }
732                    crate::http::models::HyperliquidExecOrderStatus::Filled { filled } => {
733                        // Order was filled immediately
734                        let filled_qty = nautilus_model::types::Quantity::new(
735                            filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
736                            instrument.size_precision(),
737                        );
738                        self.create_order_status_report(
739                            order.instrument_id(),
740                            Some(order.client_order_id()),
741                            nautilus_model::identifiers::VenueOrderId::new(filled.oid.to_string()),
742                            order.order_side(),
743                            order.order_type(),
744                            order.quantity(),
745                            order.time_in_force(),
746                            order.price(),
747                            order.trigger_price(),
748                            nautilus_model::enums::OrderStatus::Filled,
749                            filled_qty,
750                            &instrument,
751                            account_id,
752                            ts_init,
753                        )
754                    }
755                    crate::http::models::HyperliquidExecOrderStatus::Error { error } => {
756                        Err(Error::bad_request(format!("Order rejected: {error}")))
757                    }
758                }
759            }
760            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
761                "Order submission failed: {error}"
762            ))),
763            _ => Err(Error::bad_request("Unexpected response format")),
764        }
765    }
766
767    /// Create an OrderStatusReport from order submission details.
768    #[allow(clippy::too_many_arguments)]
769    fn create_order_status_report(
770        &self,
771        instrument_id: nautilus_model::identifiers::InstrumentId,
772        client_order_id: Option<nautilus_model::identifiers::ClientOrderId>,
773        venue_order_id: nautilus_model::identifiers::VenueOrderId,
774        order_side: nautilus_model::enums::OrderSide,
775        order_type: nautilus_model::enums::OrderType,
776        quantity: nautilus_model::types::Quantity,
777        time_in_force: nautilus_model::enums::TimeInForce,
778        price: Option<nautilus_model::types::Price>,
779        trigger_price: Option<nautilus_model::types::Price>,
780        order_status: nautilus_model::enums::OrderStatus,
781        filled_qty: nautilus_model::types::Quantity,
782        _instrument: &nautilus_model::instruments::InstrumentAny,
783        account_id: nautilus_model::identifiers::AccountId,
784        ts_init: nautilus_core::UnixNanos,
785    ) -> Result<nautilus_model::reports::OrderStatusReport> {
786        use nautilus_core::time::get_atomic_clock_realtime;
787
788        let clock = get_atomic_clock_realtime();
789        let ts_accepted = clock.get_time_ns();
790        let ts_last = ts_accepted;
791        let report_id = nautilus_core::UUID4::new();
792
793        let mut report = nautilus_model::reports::OrderStatusReport::new(
794            account_id,
795            instrument_id,
796            client_order_id,
797            venue_order_id,
798            order_side,
799            order_type,
800            time_in_force,
801            order_status,
802            quantity,
803            filled_qty,
804            ts_accepted,
805            ts_last,
806            ts_init,
807            Some(report_id),
808        );
809
810        // Add price if present
811        if let Some(px) = price {
812            report = report.with_price(px);
813        }
814
815        // Add trigger price if present
816        if let Some(trig_px) = trigger_price {
817            report = report
818                .with_trigger_price(trig_px)
819                .with_trigger_type(nautilus_model::enums::TriggerType::Default);
820        }
821
822        Ok(report)
823    }
824
825    /// Submit multiple orders to the Hyperliquid exchange in a single request.
826    ///
827    /// Uses the existing order conversion logic from `common::parse::orders_to_hyperliquid_requests`
828    /// to avoid code duplication and ensure consistency.
829    ///
830    /// # Errors
831    ///
832    /// Returns an error if credentials are missing, order validation fails, serialization fails,
833    /// or the API returns an error.
834    pub async fn submit_orders(
835        &self,
836        orders: &[&nautilus_model::orders::any::OrderAny],
837    ) -> Result<Vec<nautilus_model::reports::OrderStatusReport>> {
838        use crate::common::parse::orders_to_hyperliquid_requests;
839
840        // Use the existing parsing function from common::parse
841        let hyperliquid_orders = orders_to_hyperliquid_requests(orders)
842            .map_err(|e| Error::bad_request(format!("Failed to convert orders: {e}")))?;
843
844        // Convert orders to JSON value for the exchange action
845        let orders_value = serde_json::to_value(hyperliquid_orders)
846            .map_err(|e| Error::bad_request(format!("Failed to serialize orders: {e}")))?;
847
848        // Create exchange action
849        let action = ExchangeAction::order(orders_value);
850
851        // Submit to exchange
852        let response = self.post_action(&action).await?;
853
854        // Parse the response to extract order statuses
855        match response {
856            HyperliquidExchangeResponse::Status {
857                status,
858                response: response_data,
859            } if status == "ok" => {
860                // Parse the response data to extract order statuses
861                let order_response: crate::http::models::HyperliquidExecOrderResponseData =
862                    serde_json::from_value(response_data.clone()).map_err(|e| {
863                        Error::bad_request(format!("Failed to parse order response: {e}"))
864                    })?;
865
866                let account_id = self
867                    .account_id
868                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
869                let ts_init = nautilus_core::UnixNanos::default();
870
871                // Validate we have the same number of statuses as orders submitted
872                if order_response.statuses.len() != orders.len() {
873                    return Err(Error::bad_request(format!(
874                        "Mismatch between submitted orders ({}) and response statuses ({})",
875                        orders.len(),
876                        order_response.statuses.len()
877                    )));
878                }
879
880                let mut reports = Vec::new();
881
882                // Create OrderStatusReport for each order
883                for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
884                    // Extract asset from instrument symbol
885                    let instrument_id = order.instrument_id();
886                    let symbol = instrument_id.symbol.as_str();
887                    let asset = symbol.trim_end_matches("-PERP").trim_end_matches("-USD"); // Get instrument from cache
888                    let instrument = self
889                        .get_or_create_instrument(&Ustr::from(asset))
890                        .ok_or_else(|| {
891                            Error::bad_request(format!("Instrument not found for {asset}"))
892                        })?;
893
894                    // Create OrderStatusReport based on the order status
895                    let report = match order_status {
896                        crate::http::models::HyperliquidExecOrderStatus::Resting { resting } => {
897                            // Order is resting on the order book
898                            self.create_order_status_report(
899                                order.instrument_id(),
900                                Some(order.client_order_id()),
901                                nautilus_model::identifiers::VenueOrderId::new(
902                                    resting.oid.to_string(),
903                                ),
904                                order.order_side(),
905                                order.order_type(),
906                                order.quantity(),
907                                order.time_in_force(),
908                                order.price(),
909                                order.trigger_price(),
910                                nautilus_model::enums::OrderStatus::Accepted,
911                                nautilus_model::types::Quantity::new(
912                                    0.0,
913                                    instrument.size_precision(),
914                                ),
915                                &instrument,
916                                account_id,
917                                ts_init,
918                            )?
919                        }
920                        crate::http::models::HyperliquidExecOrderStatus::Filled { filled } => {
921                            // Order was filled immediately
922                            let filled_qty = nautilus_model::types::Quantity::new(
923                                filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
924                                instrument.size_precision(),
925                            );
926                            self.create_order_status_report(
927                                order.instrument_id(),
928                                Some(order.client_order_id()),
929                                nautilus_model::identifiers::VenueOrderId::new(
930                                    filled.oid.to_string(),
931                                ),
932                                order.order_side(),
933                                order.order_type(),
934                                order.quantity(),
935                                order.time_in_force(),
936                                order.price(),
937                                order.trigger_price(),
938                                nautilus_model::enums::OrderStatus::Filled,
939                                filled_qty,
940                                &instrument,
941                                account_id,
942                                ts_init,
943                            )?
944                        }
945                        crate::http::models::HyperliquidExecOrderStatus::Error { error } => {
946                            return Err(Error::bad_request(format!(
947                                "Order {} rejected: {error}",
948                                order.client_order_id()
949                            )));
950                        }
951                    };
952
953                    reports.push(report);
954                }
955
956                Ok(reports)
957            }
958            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
959                "Order submission failed: {error}"
960            ))),
961            _ => Err(Error::bad_request("Unexpected response format")),
962        }
963    }
964
965    /// Raw HTTP roundtrip for exchange requests
966    async fn http_roundtrip_exchange(
967        &self,
968        request: &HyperliquidExchangeRequest<ExchangeAction>,
969    ) -> Result<nautilus_network::http::HttpResponse> {
970        let url = &self.base_exchange;
971        let body = serde_json::to_string(&request).map_err(Error::Serde)?;
972        let body_bytes = body.into_bytes();
973
974        self.client
975            .request(
976                Method::POST,
977                url.clone(),
978                None,
979                Some(body_bytes),
980                None,
981                None,
982            )
983            .await
984            .map_err(Error::from_http_client)
985    }
986
987    /// Request order status reports for a user.
988    ///
989    /// Fetches open orders via `info_frontend_open_orders` and parses them into OrderStatusReports.
990    /// This method requires instruments to be added to the client cache via `add_instrument()`.
991    ///
992    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
993    /// will be created automatically.
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if the API request fails or parsing fails.
998    pub async fn request_order_status_reports(
999        &self,
1000        user: &str,
1001        instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1002    ) -> Result<Vec<nautilus_model::reports::OrderStatusReport>> {
1003        let response = self.info_frontend_open_orders(user).await?;
1004
1005        // Parse the JSON response into a vector of orders
1006        let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1007            .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1008
1009        let mut reports = Vec::new();
1010        let ts_init = nautilus_core::UnixNanos::default();
1011
1012        for order_value in orders {
1013            // Parse the order data
1014            let order: crate::websocket::messages::WsBasicOrderData =
1015                match serde_json::from_value(order_value.clone()) {
1016                    Ok(o) => o,
1017                    Err(e) => {
1018                        tracing::warn!("Failed to parse order: {}", e);
1019                        continue;
1020                    }
1021                };
1022
1023            // Get instrument from cache or create synthetic for vault tokens
1024            let instrument = match self.get_or_create_instrument(&order.coin) {
1025                Some(inst) => inst,
1026                None => continue, // Skip if instrument not found
1027            };
1028
1029            // Filter by instrument_id if specified
1030            if let Some(filter_id) = instrument_id
1031                && instrument.id() != filter_id
1032            {
1033                continue;
1034            }
1035
1036            // Determine status from order data - orders from frontend_open_orders are open
1037            let status = "open";
1038
1039            // Parse to OrderStatusReport
1040            match crate::http::parse::parse_order_status_report_from_basic(
1041                &order,
1042                status,
1043                &instrument,
1044                self.account_id.unwrap_or_default(),
1045                ts_init,
1046            ) {
1047                Ok(report) => reports.push(report),
1048                Err(e) => tracing::error!("Failed to parse order status report: {e}"),
1049            }
1050        }
1051
1052        Ok(reports)
1053    }
1054
1055    /// Request fill reports for a user.
1056    ///
1057    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
1058    /// This method requires instruments to be added to the client cache via `add_instrument()`.
1059    ///
1060    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1061    /// will be created automatically.
1062    ///
1063    /// # Errors
1064    ///
1065    /// Returns an error if the API request fails or parsing fails.
1066    pub async fn request_fill_reports(
1067        &self,
1068        user: &str,
1069        instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1070    ) -> Result<Vec<nautilus_model::reports::FillReport>> {
1071        let fills_response = self.info_user_fills(user).await?;
1072
1073        let mut reports = Vec::new();
1074        let ts_init = nautilus_core::UnixNanos::default();
1075
1076        for fill in fills_response {
1077            // Get instrument from cache or create synthetic for vault tokens
1078            let instrument = match self.get_or_create_instrument(&fill.coin) {
1079                Some(inst) => inst,
1080                None => continue, // Skip if instrument not found
1081            };
1082
1083            // Filter by instrument_id if specified
1084            if let Some(filter_id) = instrument_id
1085                && instrument.id() != filter_id
1086            {
1087                continue;
1088            }
1089
1090            // Parse to FillReport
1091            match crate::http::parse::parse_fill_report(
1092                &fill,
1093                &instrument,
1094                self.account_id.unwrap_or_default(),
1095                ts_init,
1096            ) {
1097                Ok(report) => reports.push(report),
1098                Err(e) => tracing::error!("Failed to parse fill report: {e}"),
1099            }
1100        }
1101
1102        Ok(reports)
1103    }
1104
1105    /// Request position status reports for a user.
1106    ///
1107    /// Fetches clearinghouse state via `info_clearinghouse_state` and parses positions into PositionStatusReports.
1108    /// This method requires instruments to be added to the client cache via `add_instrument()`.
1109    ///
1110    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1111    /// will be created automatically.
1112    ///
1113    /// # Errors
1114    ///
1115    /// Returns an error if the API request fails or parsing fails.
1116    pub async fn request_position_status_reports(
1117        &self,
1118        user: &str,
1119        instrument_id: Option<nautilus_model::identifiers::InstrumentId>,
1120    ) -> Result<Vec<nautilus_model::reports::PositionStatusReport>> {
1121        let state_response = self.info_clearinghouse_state(user).await?;
1122
1123        // Extract asset positions from the clearinghouse state
1124        let asset_positions: Vec<serde_json::Value> = state_response
1125            .get("assetPositions")
1126            .and_then(|v| v.as_array())
1127            .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
1128            .clone();
1129
1130        let mut reports = Vec::new();
1131        let ts_init = nautilus_core::UnixNanos::default();
1132
1133        for position_value in asset_positions {
1134            // Extract coin from position data
1135            let coin = position_value
1136                .get("position")
1137                .and_then(|p| p.get("coin"))
1138                .and_then(|c| c.as_str())
1139                .ok_or_else(|| Error::bad_request("coin not found in position"))?;
1140
1141            // Get instrument from cache - convert &str to Ustr for lookup
1142            let coin_ustr = Ustr::from(coin);
1143            let instrument = match self.get_or_create_instrument(&coin_ustr) {
1144                Some(inst) => inst,
1145                None => continue, // Skip if instrument not found
1146            };
1147
1148            // Filter by instrument_id if specified
1149            if let Some(filter_id) = instrument_id
1150                && instrument.id() != filter_id
1151            {
1152                continue;
1153            }
1154
1155            // Parse to PositionStatusReport
1156            match crate::http::parse::parse_position_status_report(
1157                &position_value,
1158                &instrument,
1159                self.account_id.unwrap_or_default(),
1160                ts_init,
1161            ) {
1162                Ok(report) => reports.push(report),
1163                Err(e) => tracing::error!("Failed to parse position status report: {e}"),
1164            }
1165        }
1166
1167        Ok(reports)
1168    }
1169
1170    /// Best-effort gauge for diagnostics/metrics
1171    pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
1172        self.rest_limiter.snapshot().await
1173    }
1174
1175    // ---------------- INTERNALS -----------------------------------------------
1176
1177    fn signer_id(&self) -> Result<SignerId> {
1178        Ok(SignerId("hyperliquid:default".into()))
1179    }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    use nautilus_model::instruments::{Instrument, InstrumentAny};
1185    use rstest::rstest;
1186    use ustr::Ustr;
1187
1188    use super::HyperliquidHttpClient;
1189    use crate::http::query::InfoRequest;
1190
1191    #[rstest]
1192    fn stable_json_roundtrips() {
1193        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
1194        let s = serde_json::to_string(&v).unwrap();
1195        // Parse back to ensure JSON structure is correct, regardless of field order
1196        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
1197        assert_eq!(parsed["type"], "l2Book");
1198        assert_eq!(parsed["coin"], "BTC");
1199        assert_eq!(parsed, v);
1200    }
1201
1202    #[rstest]
1203    fn info_pretty_shape() {
1204        let r = InfoRequest::l2_book("BTC");
1205        let val = serde_json::to_value(&r).unwrap();
1206        let pretty = serde_json::to_string_pretty(&val).unwrap();
1207        assert!(pretty.contains("\"type\": \"l2Book\""));
1208        assert!(pretty.contains("\"coin\": \"BTC\""));
1209    }
1210
1211    #[rstest]
1212    fn test_add_instrument_dual_key_storage() {
1213        use nautilus_core::time::get_atomic_clock_realtime;
1214        use nautilus_model::{
1215            currencies::CURRENCY_MAP,
1216            enums::CurrencyType,
1217            identifiers::{InstrumentId, Symbol},
1218            instruments::CurrencyPair,
1219            types::{Currency, Price, Quantity},
1220        };
1221
1222        let client = HyperliquidHttpClient::new(true, None);
1223
1224        // Create a test instrument with base currency "vntls:vCURSOR"
1225        let base_code = "vntls:vCURSOR";
1226        let quote_code = "USDC";
1227
1228        // Register the custom currency
1229        {
1230            let mut currency_map = CURRENCY_MAP.lock().unwrap();
1231            if !currency_map.contains_key(base_code) {
1232                currency_map.insert(
1233                    base_code.to_string(),
1234                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
1235                );
1236            }
1237        }
1238
1239        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
1240        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
1241
1242        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
1243        let venue = *crate::common::consts::HYPERLIQUID_VENUE;
1244        let instrument_id = InstrumentId::new(symbol, venue);
1245
1246        let clock = get_atomic_clock_realtime();
1247        let ts = clock.get_time_ns();
1248
1249        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1250            instrument_id,
1251            symbol,
1252            base_currency,
1253            quote_currency,
1254            8,
1255            8,
1256            Price::from("0.00000001"),
1257            Quantity::from("0.00000001"),
1258            None,
1259            None,
1260            None,
1261            None,
1262            None,
1263            None,
1264            None,
1265            None,
1266            None,
1267            None,
1268            None,
1269            None,
1270            ts,
1271            ts,
1272        ));
1273
1274        // Add the instrument
1275        client.add_instrument(instrument.clone());
1276
1277        // Verify it can be looked up by Nautilus symbol
1278        let instruments = client.instruments.read().unwrap();
1279        let by_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
1280        assert!(
1281            by_symbol.is_some(),
1282            "Instrument should be accessible by Nautilus symbol"
1283        );
1284
1285        // Verify it can be looked up by Hyperliquid coin identifier (base currency)
1286        let by_coin = instruments.get(&Ustr::from("vntls:vCURSOR"));
1287        assert!(
1288            by_coin.is_some(),
1289            "Instrument should be accessible by Hyperliquid coin identifier"
1290        );
1291
1292        // Verify both lookups return the same instrument
1293        assert_eq!(by_symbol.unwrap().id(), by_coin.unwrap().id());
1294    }
1295}