Skip to main content

nautilus_hyperliquid/http/
client.rs

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