nautilus_hyperliquid/http/
client.rs

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