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},
28    time::Duration,
29};
30
31use ahash::AHashMap;
32use anyhow::Context;
33use nautilus_core::{
34    AtomicMap, UUID4, UnixNanos,
35    consts::NAUTILUS_USER_AGENT,
36    time::{AtomicTime, get_atomic_clock_realtime},
37};
38use nautilus_model::{
39    data::{Bar, BarType},
40    enums::{
41        AccountType, BarAggregation, CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce,
42        TriggerType,
43    },
44    events::AccountState,
45    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
46    instruments::{CurrencyPair, Instrument, InstrumentAny},
47    orders::{Order, OrderAny},
48    reports::{FillReport, OrderStatusReport, PositionStatusReport},
49    types::{AccountBalance, Currency, Price, Quantity},
50};
51use nautilus_network::{
52    http::{HttpClient, HttpClientError, HttpResponse, Method, USER_AGENT},
53    ratelimiter::quota::Quota,
54};
55use rust_decimal::Decimal;
56use serde_json::Value;
57use ustr::Ustr;
58
59use crate::{
60    common::{
61        consts::{HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url},
62        credential::{Secrets, VaultAddress},
63        enums::{
64            HyperliquidBarInterval, HyperliquidEnvironment,
65            HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
66        },
67        parse::{
68            bar_type_to_interval, clamp_price_to_precision, derive_limit_from_trigger,
69            determine_order_list_grouping, extract_inner_error, normalize_price,
70            order_to_hyperliquid_request_with_asset, parse_combined_account_balances_and_margins,
71            parse_spot_account_balances, round_to_sig_figs, time_in_force_to_hyperliquid_tif,
72        },
73    },
74    data::candle_to_bar,
75    http::{
76        error::{Error, Result},
77        models::{
78            ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeRequest,
79            HyperliquidExchangeResponse, HyperliquidExecAction, HyperliquidExecBuilderFee,
80            HyperliquidExecCancelByCloidRequest, HyperliquidExecCancelOrderRequest,
81            HyperliquidExecGrouping, HyperliquidExecLimitParams, HyperliquidExecMergeOutcomeParams,
82            HyperliquidExecMergeQuestionParams, HyperliquidExecModifyOrderRequest,
83            HyperliquidExecNegateOutcomeParams, HyperliquidExecOrderKind,
84            HyperliquidExecOrderResponseData, HyperliquidExecOrderStatus,
85            HyperliquidExecPlaceOrderRequest, HyperliquidExecSplitOutcomeParams,
86            HyperliquidExecTif, HyperliquidExecTpSl, HyperliquidExecTriggerParams,
87            HyperliquidExecUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
88            HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus, OutcomeMeta, PerpMeta,
89            PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
90        },
91        parse::{
92            HyperliquidInstrumentDef, instruments_from_defs_owned, parse_fill_report,
93            parse_order_status_report_from_basic, parse_outcome_instruments,
94            parse_perp_instruments, parse_position_status_report, parse_spot_instruments,
95            parse_spot_position_status_report,
96        },
97        query::{ExchangeAction, InfoRequest},
98        rate_limits::{
99            RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
100            info_base_weight, info_extra_weight,
101        },
102    },
103    signing::{
104        HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
105    },
106    websocket::messages::WsBasicOrderData,
107};
108
109// https://hyperliquid.xyz/docs/api#rate-limits
110pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> =
111    LazyLock::new(|| Quota::per_minute(NonZeroU32::new(1200).unwrap()));
112
113/// Provides a raw HTTP client for low-level Hyperliquid REST API operations.
114///
115/// This client handles HTTP infrastructure, request signing, and raw API calls
116/// that closely match Hyperliquid endpoint specifications.
117#[derive(Debug, Clone)]
118#[cfg_attr(
119    feature = "python",
120    pyo3::pyclass(
121        module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
122        from_py_object
123    )
124)]
125#[cfg_attr(
126    feature = "python",
127    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
128)]
129pub struct HyperliquidRawHttpClient {
130    client: HttpClient,
131    environment: HyperliquidEnvironment,
132    base_info: String,
133    base_exchange: String,
134    signer: Option<HyperliquidEip712Signer>,
135    nonce_manager: Option<Arc<NonceManager>>,
136    vault_address: Option<VaultAddress>,
137    rest_limiter: Arc<WeightedLimiter>,
138    rate_limit_backoff_base: Duration,
139    rate_limit_backoff_cap: Duration,
140    rate_limit_max_attempts_info: u32,
141}
142
143impl HyperliquidRawHttpClient {
144    /// Creates a new [`HyperliquidRawHttpClient`] for public endpoints only.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the HTTP client cannot be created.
149    pub fn new(
150        environment: HyperliquidEnvironment,
151        timeout_secs: u64,
152        proxy_url: Option<String>,
153    ) -> std::result::Result<Self, HttpClientError> {
154        Ok(Self {
155            client: HttpClient::new(
156                Self::default_headers(),
157                vec![],
158                vec![],
159                Some(*HYPERLIQUID_REST_QUOTA),
160                Some(timeout_secs),
161                proxy_url,
162            )?,
163            environment,
164            base_info: info_url(environment).to_string(),
165            base_exchange: exchange_url(environment).to_string(),
166            signer: None,
167            nonce_manager: None,
168            vault_address: None,
169            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
170            rate_limit_backoff_base: Duration::from_millis(125),
171            rate_limit_backoff_cap: Duration::from_secs(5),
172            rate_limit_max_attempts_info: 3,
173        })
174    }
175
176    /// Creates a new [`HyperliquidRawHttpClient`] configured with credentials
177    /// for authenticated requests.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the HTTP client cannot be created.
182    pub fn with_credentials(
183        secrets: &Secrets,
184        timeout_secs: u64,
185        proxy_url: Option<String>,
186    ) -> std::result::Result<Self, HttpClientError> {
187        let signer = HyperliquidEip712Signer::new(&secrets.private_key)
188            .map_err(|e| HttpClientError::from(e.to_string()))?;
189        let nonce_manager = Arc::new(NonceManager::new());
190
191        Ok(Self {
192            client: HttpClient::new(
193                Self::default_headers(),
194                vec![],
195                vec![],
196                Some(*HYPERLIQUID_REST_QUOTA),
197                Some(timeout_secs),
198                proxy_url,
199            )?,
200            environment: secrets.environment,
201            base_info: info_url(secrets.environment).to_string(),
202            base_exchange: exchange_url(secrets.environment).to_string(),
203            signer: Some(signer),
204            nonce_manager: Some(nonce_manager),
205            vault_address: secrets.vault_address,
206            rest_limiter: Arc::new(WeightedLimiter::per_minute(1200)),
207            rate_limit_backoff_base: Duration::from_millis(125),
208            rate_limit_backoff_cap: Duration::from_secs(5),
209            rate_limit_max_attempts_info: 3,
210        })
211    }
212
213    /// Overrides the base info URL (for testing with mock servers).
214    pub fn set_base_info_url(&mut self, url: String) {
215        self.base_info = url;
216    }
217
218    /// Overrides the base exchange URL (for testing with mock servers).
219    pub fn set_base_exchange_url(&mut self, url: String) {
220        self.base_exchange = url;
221    }
222
223    /// Creates an authenticated client from environment variables for the specified network.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`Error::Auth`] if required environment variables are not set.
228    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
229        let secrets = Secrets::from_env(environment)
230            .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
231        Self::with_credentials(&secrets, 60, None)
232            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
233    }
234
235    /// Creates a new [`HyperliquidRawHttpClient`] configured with explicit credentials.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
240    pub fn from_credentials(
241        private_key: &str,
242        vault_address: Option<&str>,
243        environment: HyperliquidEnvironment,
244        timeout_secs: u64,
245        proxy_url: Option<String>,
246    ) -> Result<Self> {
247        let secrets = Secrets::from_private_key(private_key, vault_address, environment)
248            .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
249        Self::with_credentials(&secrets, timeout_secs, proxy_url)
250            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
251    }
252
253    /// Configure rate limiting parameters (chainable).
254    #[must_use]
255    pub fn with_rate_limits(mut self) -> Self {
256        self.rest_limiter = Arc::new(WeightedLimiter::per_minute(1200));
257        self.rate_limit_backoff_base = Duration::from_millis(125);
258        self.rate_limit_backoff_cap = Duration::from_secs(5);
259        self.rate_limit_max_attempts_info = 3;
260        self
261    }
262
263    /// Returns the configured environment.
264    #[must_use]
265    pub fn environment(&self) -> HyperliquidEnvironment {
266        self.environment
267    }
268
269    /// Returns whether this client is configured for testnet.
270    #[must_use]
271    pub fn is_testnet(&self) -> bool {
272        self.environment == HyperliquidEnvironment::Testnet
273    }
274
275    /// Gets the user address derived from the private key (if client has credentials).
276    ///
277    /// # Errors
278    ///
279    /// Returns [`Error::Auth`] if the client has no signer configured.
280    pub fn get_user_address(&self) -> Result<String> {
281        self.signer
282            .as_ref()
283            .ok_or_else(|| Error::auth("No signer configured"))?
284            .address()
285    }
286
287    /// Returns `true` if a vault address is configured.
288    #[must_use]
289    pub fn has_vault_address(&self) -> bool {
290        self.vault_address.is_some()
291    }
292
293    /// Gets the account address for queries: vault address if configured,
294    /// otherwise the user (EOA) address.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`Error::Auth`] if the client has no signer configured.
299    pub fn get_account_address(&self) -> Result<String> {
300        if let Some(vault) = &self.vault_address {
301            Ok(vault.to_hex())
302        } else {
303            self.get_user_address()
304        }
305    }
306
307    fn default_headers() -> HashMap<String, String> {
308        HashMap::from([
309            (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
310            ("Content-Type".to_string(), "application/json".to_string()),
311        ])
312    }
313
314    fn signer_id(&self) -> SignerId {
315        SignerId("hyperliquid:default".into())
316    }
317
318    fn parse_retry_after_simple(&self, headers: &HashMap<String, String>) -> Option<u64> {
319        let retry_after = headers.get("retry-after")?;
320        retry_after.parse::<u64>().ok().map(|s| s * 1000) // convert seconds to ms
321    }
322
323    /// Get metadata about available markets.
324    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
325        let request = InfoRequest::meta();
326        let response = self.send_info_request(&request).await?;
327        serde_json::from_value(response).map_err(Error::Serde)
328    }
329
330    /// Get complete spot metadata (tokens and pairs).
331    pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
332        let request = InfoRequest::spot_meta();
333        let response = self.send_info_request(&request).await?;
334        serde_json::from_value(response).map_err(Error::Serde)
335    }
336
337    /// Get perpetuals metadata with asset contexts (for price precision refinement).
338    pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
339        let request = InfoRequest::meta_and_asset_ctxs();
340        let response = self.send_info_request(&request).await?;
341        serde_json::from_value(response).map_err(Error::Serde)
342    }
343
344    /// Get spot metadata with asset contexts (for price precision refinement).
345    pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
346        let request = InfoRequest::spot_meta_and_asset_ctxs();
347        let response = self.send_info_request(&request).await?;
348        serde_json::from_value(response).map_err(Error::Serde)
349    }
350
351    /// Get outcome metadata.
352    pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
353        let request = InfoRequest::outcome_meta();
354        let response = self.send_info_request(&request).await?;
355        serde_json::from_value(response).map_err(Error::Serde)
356    }
357
358    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
359        let request = InfoRequest::meta();
360        let response = self.send_info_request(&request).await?;
361        serde_json::from_value(response).map_err(Error::Serde)
362    }
363
364    /// Get metadata for all perp dexes (standard + HIP-3).
365    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
366        let request = InfoRequest::all_perp_metas();
367        let response = self.send_info_request(&request).await?;
368        serde_json::from_value(response).map_err(Error::Serde)
369    }
370
371    /// Get L2 order book for a coin.
372    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
373        let request = InfoRequest::l2_book(coin);
374        let response = self.send_info_request(&request).await?;
375        serde_json::from_value(response).map_err(Error::Serde)
376    }
377
378    /// Get user fills (trading history).
379    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
380        let request = InfoRequest::user_fills(user);
381        let response = self.send_info_request(&request).await?;
382        serde_json::from_value(response).map_err(Error::Serde)
383    }
384
385    /// Get order status for a user.
386    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
387        let request = InfoRequest::order_status(user, oid);
388        let response = self.send_info_request(&request).await?;
389        serde_json::from_value(response).map_err(Error::Serde)
390    }
391
392    /// Get all open orders for a user.
393    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
394        let request = InfoRequest::open_orders(user);
395        self.send_info_request(&request).await
396    }
397
398    /// Get frontend open orders (includes more detail) for a user.
399    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
400        let request = InfoRequest::frontend_open_orders(user);
401        self.send_info_request(&request).await
402    }
403
404    /// Get clearinghouse state (balances, positions, margin) for a user.
405    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
406        let request = InfoRequest::clearinghouse_state(user);
407        self.send_info_request(&request).await
408    }
409
410    /// Get spot clearinghouse state (per-token spot balances) for a user.
411    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
412        let request = InfoRequest::spot_clearinghouse_state(user);
413        self.send_info_request(&request).await
414    }
415
416    /// Get user fee schedule and effective rates.
417    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
418        let request = InfoRequest::user_fees(user);
419        self.send_info_request(&request).await
420    }
421
422    /// Get candle/bar data for a coin.
423    pub async fn info_candle_snapshot(
424        &self,
425        coin: &str,
426        interval: HyperliquidBarInterval,
427        start_time: u64,
428        end_time: u64,
429    ) -> Result<HyperliquidCandleSnapshot> {
430        let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
431        let response = self.send_info_request(&request).await?;
432
433        log::trace!(
434            "Candle snapshot raw response (len={}): {:?}",
435            response.as_array().map_or(0, |a| a.len()),
436            response
437        );
438
439        serde_json::from_value(response).map_err(Error::Serde)
440    }
441
442    /// Get historical funding rates for a coin.
443    ///
444    /// `start_time` and `end_time` are Unix milliseconds. `end_time` is optional;
445    /// if omitted, the venue returns entries up to the most recent funding.
446    pub async fn info_funding_history(
447        &self,
448        coin: &str,
449        start_time: u64,
450        end_time: Option<u64>,
451    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
452        let request = InfoRequest::funding_history(coin, start_time, end_time);
453        let response = self.send_info_request(&request).await?;
454        serde_json::from_value(response).map_err(Error::Serde)
455    }
456
457    /// Generic info request method that returns raw JSON (useful for new endpoints and testing).
458    pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
459        self.send_info_request(request).await
460    }
461
462    async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
463        let base_w = info_base_weight(request);
464        self.rest_limiter.acquire(base_w).await;
465
466        let mut attempt = 0u32;
467
468        loop {
469            let response = self.http_roundtrip_info(request).await?;
470
471            if response.status.is_success() {
472                // decode once to count items, then materialize T
473                let val: Value = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
474                let extra = info_extra_weight(request, &val);
475                if extra > 0 {
476                    self.rest_limiter.debit_extra(extra).await;
477                    log::debug!(
478                        "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
479                    );
480                }
481                return Ok(val);
482            }
483
484            // 429 → respect Retry-After; else jittered backoff. Retry Info only.
485            if response.status.as_u16() == 429 {
486                if attempt >= self.rate_limit_max_attempts_info {
487                    let ra = self.parse_retry_after_simple(&response.headers);
488                    return Err(Error::rate_limit("info", base_w, ra));
489                }
490                let delay = self
491                    .parse_retry_after_simple(&response.headers)
492                    .map_or_else(
493                        || {
494                            backoff_full_jitter(
495                                attempt,
496                                self.rate_limit_backoff_base,
497                                self.rate_limit_backoff_cap,
498                            )
499                        },
500                        Duration::from_millis,
501                    );
502                log::warn!(
503                    "429 Too Many Requests; backing off: endpoint={request:?}, attempt={attempt}, wait_ms={:?}",
504                    delay.as_millis()
505                );
506                attempt += 1;
507                tokio::time::sleep(delay).await;
508                // tiny re-acquire to avoid stampede exactly on minute boundary
509                self.rest_limiter.acquire(1).await;
510                continue;
511            }
512
513            // transient 5xx: treat like retryable Info (bounded)
514            if (response.status.is_server_error() || response.status.as_u16() == 408)
515                && attempt < self.rate_limit_max_attempts_info
516            {
517                let delay = backoff_full_jitter(
518                    attempt,
519                    self.rate_limit_backoff_base,
520                    self.rate_limit_backoff_cap,
521                );
522                log::warn!(
523                    "Transient error; retrying: endpoint={request:?}, attempt={attempt}, status={:?}, wait_ms={:?}",
524                    response.status.as_u16(),
525                    delay.as_millis()
526                );
527                attempt += 1;
528                tokio::time::sleep(delay).await;
529                continue;
530            }
531
532            // non-retryable or exhausted
533            let error_body = String::from_utf8_lossy(&response.body);
534            return Err(Error::http(
535                response.status.as_u16(),
536                error_body.to_string(),
537            ));
538        }
539    }
540
541    async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
542        let url = &self.base_info;
543        let body = serde_json::to_value(request).map_err(Error::Serde)?;
544        let body_bytes = serde_json::to_string(&body)
545            .map_err(Error::Serde)?
546            .into_bytes();
547
548        self.client
549            .request(
550                Method::POST,
551                url.clone(),
552                None,
553                None,
554                Some(body_bytes),
555                None,
556                None,
557            )
558            .await
559            .map_err(Error::from_http_client)
560    }
561
562    /// Send a signed action to the exchange.
563    pub async fn post_action(
564        &self,
565        action: &ExchangeAction,
566    ) -> Result<HyperliquidExchangeResponse> {
567        let w = exchange_weight(action);
568        self.rest_limiter.acquire(w).await;
569
570        let signer = self
571            .signer
572            .as_ref()
573            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
574
575        let nonce_manager = self
576            .nonce_manager
577            .as_ref()
578            .ok_or_else(|| Error::auth("nonce manager missing"))?;
579
580        let signer_id = self.signer_id();
581        let time_nonce = nonce_manager.next(signer_id)?;
582
583        let action_value = serde_json::to_value(action)
584            .context("serialize exchange action")
585            .map_err(|e| Error::bad_request(e.to_string()))?;
586
587        // Serialize the original action struct with MessagePack for L1 signing
588        let action_bytes = rmp_serde::to_vec_named(action)
589            .context("serialize action with MessagePack")
590            .map_err(|e| Error::bad_request(e.to_string()))?;
591
592        let sign_request = SignRequest {
593            action: action_value,
594            action_bytes: Some(action_bytes),
595            time_nonce,
596            action_type: HyperliquidActionType::L1,
597            is_testnet: self.is_testnet(),
598            vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
599        };
600
601        let sig = signer.sign(&sign_request)?.signature;
602
603        let nonce_u64 = time_nonce.as_millis() as u64;
604
605        let request = if let Some(vault) = self.vault_address {
606            HyperliquidExchangeRequest::with_vault(
607                action.clone(),
608                nonce_u64,
609                sig,
610                vault.to_string(),
611            )
612        } else {
613            HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
614        };
615
616        let response = self.http_roundtrip_exchange(&request).await?;
617
618        if response.status.is_success() {
619            let parsed_response: HyperliquidExchangeResponse =
620                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
621
622            // Check if the response contains an error status
623            match &parsed_response {
624                HyperliquidExchangeResponse::Status {
625                    status,
626                    response: response_data,
627                } if status == "err" => {
628                    let error_msg = response_data
629                        .as_str()
630                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
631                    log::error!("Hyperliquid API returned error: {error_msg}");
632                    Err(Error::bad_request(format!("API error: {error_msg}")))
633                }
634                HyperliquidExchangeResponse::Error { error } => {
635                    log::error!("Hyperliquid API returned error: {error}");
636                    Err(Error::bad_request(format!("API error: {error}")))
637                }
638                _ => Ok(parsed_response),
639            }
640        } else if response.status.as_u16() == 429 {
641            let ra = self.parse_retry_after_simple(&response.headers);
642            Err(Error::rate_limit("exchange", w, ra))
643        } else {
644            let error_body = String::from_utf8_lossy(&response.body);
645            log::error!(
646                "Exchange API error (status {}): {}",
647                response.status.as_u16(),
648                error_body
649            );
650            Err(Error::http(
651                response.status.as_u16(),
652                error_body.to_string(),
653            ))
654        }
655    }
656
657    /// Send a signed action to the exchange using the typed HyperliquidExecAction enum.
658    ///
659    /// This is the preferred method for placing orders as it uses properly typed
660    /// structures that match Hyperliquid's API expectations exactly.
661    pub async fn post_action_exec(
662        &self,
663        action: &HyperliquidExecAction,
664    ) -> Result<HyperliquidExchangeResponse> {
665        let w = match action {
666            HyperliquidExecAction::Order { orders, .. } => 1 + (orders.len() as u32 / 40),
667            HyperliquidExecAction::Cancel { cancels } => 1 + (cancels.len() as u32 / 40),
668            HyperliquidExecAction::CancelByCloid { cancels } => 1 + (cancels.len() as u32 / 40),
669            HyperliquidExecAction::BatchModify { modifies } => 1 + (modifies.len() as u32 / 40),
670            _ => 1,
671        };
672        self.rest_limiter.acquire(w).await;
673
674        let signer = self
675            .signer
676            .as_ref()
677            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
678
679        let nonce_manager = self
680            .nonce_manager
681            .as_ref()
682            .ok_or_else(|| Error::auth("nonce manager missing"))?;
683
684        let signer_id = self.signer_id();
685        let time_nonce = nonce_manager.next(signer_id)?;
686        // No need to validate - next() guarantees a valid, unused nonce
687
688        let action_value = serde_json::to_value(action)
689            .context("serialize exchange action")
690            .map_err(|e| Error::bad_request(e.to_string()))?;
691
692        // Serialize the original action struct with MessagePack for L1 signing
693        let action_bytes = rmp_serde::to_vec_named(action)
694            .context("serialize action with MessagePack")
695            .map_err(|e| Error::bad_request(e.to_string()))?;
696
697        let sig = signer
698            .sign(&SignRequest {
699                action: action_value,
700                action_bytes: Some(action_bytes),
701                time_nonce,
702                action_type: HyperliquidActionType::L1,
703                is_testnet: self.is_testnet(),
704                vault_address: self.vault_address.as_ref().map(|v| v.to_hex()),
705            })?
706            .signature;
707
708        let request = if let Some(vault) = self.vault_address {
709            HyperliquidExchangeRequest::with_vault(
710                action.clone(),
711                time_nonce.as_millis() as u64,
712                sig,
713                vault.to_string(),
714            )
715        } else {
716            HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
717        };
718
719        let response = self.http_roundtrip_exchange(&request).await?;
720
721        if response.status.is_success() {
722            let parsed_response: HyperliquidExchangeResponse =
723                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
724
725            // Check if the response contains an error status
726            match &parsed_response {
727                HyperliquidExchangeResponse::Status {
728                    status,
729                    response: response_data,
730                } if status == "err" => {
731                    let error_msg = response_data
732                        .as_str()
733                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
734                    log::error!("Hyperliquid API returned error: {error_msg}");
735                    Err(Error::bad_request(format!("API error: {error_msg}")))
736                }
737                HyperliquidExchangeResponse::Error { error } => {
738                    log::error!("Hyperliquid API returned error: {error}");
739                    Err(Error::bad_request(format!("API error: {error}")))
740                }
741                _ => Ok(parsed_response),
742            }
743        } else if response.status.as_u16() == 429 {
744            let ra = self.parse_retry_after_simple(&response.headers);
745            Err(Error::rate_limit("exchange", w, ra))
746        } else {
747            let error_body = String::from_utf8_lossy(&response.body);
748            Err(Error::http(
749                response.status.as_u16(),
750                error_body.to_string(),
751            ))
752        }
753    }
754
755    /// Submit a single order to the Hyperliquid exchange.
756    ///
757    pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
758        self.rest_limiter.snapshot().await
759    }
760    async fn http_roundtrip_exchange<T>(
761        &self,
762        request: &HyperliquidExchangeRequest<T>,
763    ) -> Result<HttpResponse>
764    where
765        T: serde::Serialize,
766    {
767        let url = &self.base_exchange;
768        let body = serde_json::to_string(&request).map_err(Error::Serde)?;
769        let body_bytes = body.into_bytes();
770
771        let response = self
772            .client
773            .request(
774                Method::POST,
775                url.clone(),
776                None,
777                None,
778                Some(body_bytes),
779                None,
780                None,
781            )
782            .await
783            .map_err(Error::from_http_client)?;
784
785        Ok(response)
786    }
787}
788
789/// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
790///
791/// This domain client wraps [`HyperliquidRawHttpClient`] and provides methods that work
792/// with Nautilus domain types. It maintains an instrument cache and handles conversions
793/// between Hyperliquid API responses and Nautilus domain models.
794#[derive(Debug, Clone)]
795#[cfg_attr(
796    feature = "python",
797    pyo3::pyclass(
798        module = "nautilus_trader.core.nautilus_pyo3.hyperliquid",
799        from_py_object
800    )
801)]
802#[cfg_attr(
803    feature = "python",
804    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.hyperliquid")
805)]
806pub struct HyperliquidHttpClient {
807    pub(crate) inner: Arc<HyperliquidRawHttpClient>,
808    clock: &'static AtomicTime,
809    instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
810    instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
811    /// Mapping from symbol to asset index for order submission.
812    asset_indices: Arc<AtomicMap<Ustr, u32>>,
813    /// Mapping from spot fill coin (`@{pair_index}`) to instrument symbol.
814    spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
815    account_id: Option<AccountId>,
816    /// Optional override address for queries (agent wallet / API sub-key support).
817    /// When set, used for balance queries, position reports, and WS subscriptions
818    /// instead of the address derived from the private key.
819    account_address: Option<String>,
820    normalize_prices: bool,
821    market_order_slippage_bps: u32,
822}
823
824impl Default for HyperliquidHttpClient {
825    fn default() -> Self {
826        Self::new(HyperliquidEnvironment::Mainnet, 60, None)
827            .expect("Failed to create default Hyperliquid HTTP client")
828    }
829}
830
831impl HyperliquidHttpClient {
832    /// Creates a new [`HyperliquidHttpClient`] for public endpoints only.
833    ///
834    /// # Errors
835    ///
836    /// Returns an error if the HTTP client cannot be created.
837    pub fn new(
838        environment: HyperliquidEnvironment,
839        timeout_secs: u64,
840        proxy_url: Option<String>,
841    ) -> std::result::Result<Self, HttpClientError> {
842        let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
843        Ok(Self::from_raw(raw_client))
844    }
845
846    /// Creates a new [`HyperliquidHttpClient`] configured with a [`Secrets`] struct.
847    ///
848    /// # Errors
849    ///
850    /// Returns an error if the HTTP client cannot be created.
851    pub fn with_secrets(
852        secrets: &Secrets,
853        timeout_secs: u64,
854        proxy_url: Option<String>,
855    ) -> std::result::Result<Self, HttpClientError> {
856        let raw_client =
857            HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
858        Ok(Self::from_raw(raw_client))
859    }
860
861    fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
862        Self {
863            inner: Arc::new(raw_client),
864            clock: get_atomic_clock_realtime(),
865            instruments: Arc::new(AtomicMap::new()),
866            instruments_by_coin: Arc::new(AtomicMap::new()),
867            asset_indices: Arc::new(AtomicMap::new()),
868            spot_fill_coins: Arc::new(AtomicMap::new()),
869            account_id: None,
870            account_address: None,
871            normalize_prices: true,
872            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
873        }
874    }
875
876    /// Overrides the base info URL (for testing with mock servers).
877    ///
878    /// # Panics
879    ///
880    /// Panics if the inner `Arc` has multiple references.
881    pub fn set_base_info_url(&mut self, url: String) {
882        Arc::get_mut(&mut self.inner)
883            .expect("cannot override URL: Arc has multiple references")
884            .set_base_info_url(url);
885    }
886
887    /// Overrides the base exchange URL (for testing with mock servers).
888    ///
889    /// # Panics
890    ///
891    /// Panics if the inner `Arc` has multiple references.
892    pub fn set_base_exchange_url(&mut self, url: String) {
893        Arc::get_mut(&mut self.inner)
894            .expect("cannot override URL: Arc has multiple references")
895            .set_base_exchange_url(url);
896    }
897
898    /// Creates an authenticated client from environment variables for the specified network.
899    ///
900    /// # Errors
901    ///
902    /// Returns [`Error::Auth`] if required environment variables are not set.
903    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
904        let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
905        Ok(Self {
906            inner: Arc::new(raw_client),
907            clock: get_atomic_clock_realtime(),
908            instruments: Arc::new(AtomicMap::new()),
909            instruments_by_coin: Arc::new(AtomicMap::new()),
910            asset_indices: Arc::new(AtomicMap::new()),
911            spot_fill_coins: Arc::new(AtomicMap::new()),
912            account_id: None,
913            account_address: None,
914            normalize_prices: true,
915            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
916        })
917    }
918
919    /// Creates a new [`HyperliquidHttpClient`] configured with credentials.
920    ///
921    /// If credentials are not provided, falls back to environment variables:
922    /// - Testnet: `HYPERLIQUID_TESTNET_PK`, `HYPERLIQUID_TESTNET_VAULT`
923    /// - Mainnet: `HYPERLIQUID_PK`, `HYPERLIQUID_VAULT`
924    ///
925    /// If no credentials are provided and no environment variables are set,
926    /// creates an unauthenticated client for public endpoints only.
927    ///
928    /// # Errors
929    ///
930    /// Returns [`Error::Auth`] if credentials are invalid.
931    pub fn with_credentials(
932        private_key: Option<String>,
933        vault_address: Option<String>,
934        account_address: Option<String>,
935        environment: HyperliquidEnvironment,
936        timeout_secs: u64,
937        proxy_url: Option<String>,
938    ) -> Result<Self> {
939        let (pk_env_var, vault_env_var) =
940            crate::common::credential::credential_env_vars(environment);
941
942        // Resolve private key: explicit value -> env var -> None (unauthenticated)
943        let resolved_pk = match private_key {
944            Some(pk) => Some(pk),
945            None => env::var(pk_env_var).ok(),
946        };
947
948        // Resolve vault address: explicit value -> env var -> None
949        let resolved_vault = match vault_address {
950            Some(vault) => Some(vault),
951            None => env::var(vault_env_var).ok(),
952        };
953
954        // Resolve account address: explicit value -> env var -> None
955        let resolved_account_address = match account_address {
956            Some(addr) => Some(addr),
957            None => env::var("HYPERLIQUID_ACCOUNT_ADDRESS").ok(),
958        };
959
960        match resolved_pk {
961            Some(pk) => {
962                let raw_client = HyperliquidRawHttpClient::from_credentials(
963                    &pk,
964                    resolved_vault.as_deref(),
965                    environment,
966                    timeout_secs,
967                    proxy_url,
968                )?;
969                Ok(Self {
970                    inner: Arc::new(raw_client),
971                    clock: get_atomic_clock_realtime(),
972                    instruments: Arc::new(AtomicMap::new()),
973                    instruments_by_coin: Arc::new(AtomicMap::new()),
974                    asset_indices: Arc::new(AtomicMap::new()),
975                    spot_fill_coins: Arc::new(AtomicMap::new()),
976                    account_id: None,
977                    account_address: resolved_account_address,
978                    normalize_prices: true,
979                    market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
980                })
981            }
982            None => {
983                // No credentials available, create unauthenticated client
984                Self::new(environment, timeout_secs, proxy_url)
985                    .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
986            }
987        }
988    }
989
990    /// Creates a new [`HyperliquidHttpClient`] configured with explicit credentials.
991    ///
992    /// # Errors
993    ///
994    /// Returns [`Error::Auth`] if the private key is invalid or cannot be parsed.
995    pub fn from_credentials(
996        private_key: &str,
997        vault_address: Option<&str>,
998        environment: HyperliquidEnvironment,
999        timeout_secs: u64,
1000        proxy_url: Option<String>,
1001    ) -> Result<Self> {
1002        let raw_client = HyperliquidRawHttpClient::from_credentials(
1003            private_key,
1004            vault_address,
1005            environment,
1006            timeout_secs,
1007            proxy_url,
1008        )?;
1009        Ok(Self {
1010            inner: Arc::new(raw_client),
1011            clock: get_atomic_clock_realtime(),
1012            instruments: Arc::new(AtomicMap::new()),
1013            instruments_by_coin: Arc::new(AtomicMap::new()),
1014            asset_indices: Arc::new(AtomicMap::new()),
1015            spot_fill_coins: Arc::new(AtomicMap::new()),
1016            account_id: None,
1017            account_address: None,
1018            normalize_prices: true,
1019            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1020        })
1021    }
1022
1023    /// Returns whether this client is configured for testnet.
1024    #[must_use]
1025    pub fn is_testnet(&self) -> bool {
1026        self.inner.is_testnet()
1027    }
1028
1029    /// Returns whether order price normalization is enabled.
1030    #[must_use]
1031    pub fn normalize_prices(&self) -> bool {
1032        self.normalize_prices
1033    }
1034
1035    /// Sets whether to normalize order prices to 5 significant figures.
1036    pub fn set_normalize_prices(&mut self, value: bool) {
1037        self.normalize_prices = value;
1038    }
1039
1040    /// Returns the MARKET-order slippage buffer in basis points.
1041    #[must_use]
1042    pub fn market_order_slippage_bps(&self) -> u32 {
1043        self.market_order_slippage_bps
1044    }
1045
1046    /// Sets the MARKET-order slippage buffer in basis points.
1047    pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1048        self.market_order_slippage_bps = value;
1049    }
1050
1051    /// Gets the user address derived from the private key (if client has credentials).
1052    ///
1053    /// # Errors
1054    ///
1055    /// Returns [`Error::Auth`] if the client has no signer configured.
1056    pub fn get_user_address(&self) -> Result<String> {
1057        self.inner.get_user_address()
1058    }
1059
1060    /// Returns `true` if a vault address is configured.
1061    #[must_use]
1062    pub fn has_vault_address(&self) -> bool {
1063        self.inner.has_vault_address()
1064    }
1065
1066    /// Returns the builder-attribution fee to attach to outgoing orders, or
1067    /// `None` when attribution must be omitted (vault orders and testnet).
1068    #[must_use]
1069    pub fn builder_attribution(&self) -> Option<HyperliquidExecBuilderFee> {
1070        if self.has_vault_address() || self.is_testnet() {
1071            None
1072        } else {
1073            Some(HyperliquidExecBuilderFee {
1074                address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1075                fee_tenths_bp: 0,
1076            })
1077        }
1078    }
1079
1080    /// Gets the account address for queries: account_address if configured
1081    /// (agent wallet), then vault address, otherwise the user (EOA) address.
1082    ///
1083    /// # Errors
1084    ///
1085    /// Returns [`Error::Auth`] if the client has no signer configured and
1086    /// no account_address override is set.
1087    pub fn get_account_address(&self) -> Result<String> {
1088        if let Some(addr) = &self.account_address {
1089            return Ok(addr.clone());
1090        }
1091        self.inner.get_account_address()
1092    }
1093
1094    /// Sets the account address override for queries (agent wallet support).
1095    pub fn set_account_address(&mut self, address: Option<String>) {
1096        self.account_address = address;
1097    }
1098
1099    /// Caches a single instrument.
1100    ///
1101    /// This is required for parsing orders, fills, and positions into reports.
1102    /// Any existing instrument with the same symbol will be replaced.
1103    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1104        let full_symbol = instrument.symbol().inner();
1105        let coin = instrument.raw_symbol().inner();
1106
1107        self.instruments.rcu(|m| {
1108            m.insert(full_symbol, instrument.clone());
1109            // HTTP responses only include coins, external code may lookup by coin
1110            m.insert(coin, instrument.clone());
1111        });
1112
1113        // Composite key allows disambiguating same coin across PERP and SPOT
1114        if let Ok(product_type) = HyperliquidProductType::from_symbol(full_symbol.as_str()) {
1115            self.instruments_by_coin.rcu(|m| {
1116                m.insert((coin, product_type), instrument.clone());
1117
1118                // Index the leading symbol component (the part before the first
1119                // `-`) as a secondary key for two distinct callers:
1120                //
1121                // * Spot raw_symbols are either `@{pair_index}` or slash format
1122                //   (e.g., "PURR/USDC"); spot balance/position reconciliation
1123                //   maps the venue token name (e.g., "PURR") to instruments via
1124                //   this alias.
1125                // * Order submission paths split `instrument_id.symbol` on `-`
1126                //   to derive a coin key. For HIP-3 perps with wildcard-bearing
1127                //   venue names, the sanitized base in `instrument_id.symbol`
1128                //   (e.g., "dex:STREAMABCDxxxx") differs from `raw_symbol` /
1129                //   `coin` (e.g., "dex:STREAMABCD****"), so an alias on the
1130                //   sanitized base lets that lookup resolve.
1131                //
1132                // First-write-wins guards against non-canonical spot pairs that
1133                // share a base token overwriting the canonical instrument; the
1134                // spot loader sorts canonical pairs first so the alias resolves
1135                // to the canonical one. For standard perps `base == coin`, so
1136                // the alias is a no-op.
1137                if let Some(base) = full_symbol.as_str().split('-').next() {
1138                    let base_ustr = Ustr::from(base);
1139                    let key = (base_ustr, product_type);
1140                    if base_ustr != coin && !m.contains_key(&key) {
1141                        m.insert(key, instrument.clone());
1142                    }
1143                }
1144            });
1145        } else {
1146            log::warn!("Unable to determine product type for symbol: {full_symbol}");
1147        }
1148    }
1149
1150    fn get_or_create_instrument(
1151        &self,
1152        coin: &Ustr,
1153        product_type: Option<HyperliquidProductType>,
1154    ) -> Option<InstrumentAny> {
1155        if let Some(pt) = product_type
1156            && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1157        {
1158            return Some(instrument.clone());
1159        }
1160
1161        // HTTP responses lack product type context. HIP-4 outcome coins
1162        // (`#E`/`+E`) are checked first because they never collide with
1163        // perp or spot symbols, then perp, then spot.
1164        if product_type.is_none() {
1165            let guard = self.instruments_by_coin.load();
1166
1167            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1168                return Some(instrument.clone());
1169            }
1170
1171            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1172                return Some(instrument.clone());
1173            }
1174
1175            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1176                return Some(instrument.clone());
1177            }
1178        }
1179
1180        // Spot fills use @{pair_index} format, translate to full symbol and look up
1181        if coin.as_str().starts_with('@')
1182            && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1183        {
1184            // Look up by full symbol in instruments map (not instruments_by_coin
1185            // which uses raw_symbol)
1186            if let Some(instrument) = self.instruments.load().get(symbol) {
1187                return Some(instrument.clone());
1188            }
1189        }
1190
1191        // Vault tokens aren't in standard API, create synthetic instruments
1192        if coin.as_str().starts_with("vntls:") {
1193            log::info!("Creating synthetic instrument for vault token: {coin}");
1194
1195            let ts_event = self.clock.get_time_ns();
1196
1197            // Create synthetic vault token instrument
1198            let symbol_str = format!("{coin}-USDC-SPOT");
1199            let symbol = Symbol::new(&symbol_str);
1200            let venue = *HYPERLIQUID_VENUE;
1201            let instrument_id = InstrumentId::new(symbol, venue);
1202
1203            // Create currencies
1204            let base_currency = Currency::new(
1205                coin.as_str(),
1206                8, // precision
1207                0, // ISO code (not applicable)
1208                coin.as_str(),
1209                CurrencyType::Crypto,
1210            );
1211
1212            let quote_currency = Currency::new(
1213                "USDC",
1214                6, // USDC standard precision
1215                0,
1216                "USDC",
1217                CurrencyType::Crypto,
1218            );
1219
1220            let price_increment = Price::from("0.00000001");
1221            let size_increment = Quantity::from("0.00000001");
1222
1223            let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
1224                instrument_id,
1225                symbol,
1226                base_currency,
1227                quote_currency,
1228                8, // price_precision
1229                8, // size_precision
1230                price_increment,
1231                size_increment,
1232                None, // multiplier
1233                None, // lot_size
1234                None, // max_quantity
1235                None, // min_quantity
1236                None, // max_notional
1237                None, // min_notional
1238                None, // max_price
1239                None, // min_price
1240                None, // margin_init
1241                None, // margin_maint
1242                None, // maker_fee
1243                None, // taker_fee
1244                None, // info
1245                ts_event,
1246                ts_event,
1247            ));
1248
1249            self.cache_instrument(&instrument);
1250
1251            Some(instrument)
1252        } else {
1253            // For non-vault tokens, log warning and return None
1254            log::warn!("Instrument not found in cache: {coin}");
1255            None
1256        }
1257    }
1258
1259    /// Set the account ID for this client.
1260    ///
1261    /// This is required for generating reports with the correct account ID.
1262    pub fn set_account_id(&mut self, account_id: AccountId) {
1263        self.account_id = Some(account_id);
1264    }
1265
1266    /// Fetch and parse all instrument definitions, populating the asset indices cache.
1267    pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1268        let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1269
1270        // Load all perp dexes: index 0 = standard, index 1+ = HIP-3
1271        match self.inner.load_all_perp_metas().await {
1272            Ok(all_metas) => {
1273                for (dex_index, meta) in all_metas.iter().enumerate() {
1274                    let base = perp_dex_asset_index_base(dex_index);
1275
1276                    match parse_perp_instruments(meta, base) {
1277                        Ok(perp_defs) => {
1278                            log::debug!(
1279                                "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1280                                perp_defs.len(),
1281                            );
1282                            defs.extend(perp_defs);
1283                        }
1284                        Err(e) => {
1285                            log::warn!("Failed to parse perp instruments for dex {dex_index}: {e}");
1286                        }
1287                    }
1288                }
1289            }
1290            Err(e) => {
1291                log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1292
1293                match self.inner.load_perp_meta().await {
1294                    Ok(perp_meta) => match parse_perp_instruments(&perp_meta, 0) {
1295                        Ok(perp_defs) => {
1296                            log::debug!(
1297                                "Loaded Hyperliquid perp defs via fallback: count={}",
1298                                perp_defs.len(),
1299                            );
1300                            defs.extend(perp_defs);
1301                        }
1302                        Err(e) => {
1303                            log::warn!("Failed to parse perp instruments: {e}");
1304                        }
1305                    },
1306                    Err(e) => {
1307                        log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1308                    }
1309                }
1310            }
1311        }
1312
1313        match self.inner.get_spot_meta().await {
1314            Ok(spot_meta) => match parse_spot_instruments(&spot_meta) {
1315                Ok(spot_defs) => {
1316                    log::debug!(
1317                        "Loaded Hyperliquid spot definitions: count={}",
1318                        spot_defs.len(),
1319                    );
1320                    defs.extend(spot_defs);
1321                }
1322                Err(e) => {
1323                    log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1324                }
1325            },
1326            Err(e) => {
1327                log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1328            }
1329        }
1330
1331        // HIP-4 outcome metadata is best-effort: the venue may not expose it
1332        // and the response shape is still firming up. Treat any error as a
1333        // soft skip so missing outcomes do not break perp/spot loading.
1334        match self.inner.get_outcome_meta().await {
1335            Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1336                Ok(outcome_defs) => {
1337                    log::debug!(
1338                        "Loaded Hyperliquid outcome definitions: count={}",
1339                        outcome_defs.len(),
1340                    );
1341                    defs.extend(outcome_defs);
1342                }
1343                Err(e) => {
1344                    log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1345                }
1346            },
1347            Err(e) => {
1348                log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1349            }
1350        }
1351
1352        // Drop defs whose Nautilus-internal symbol collides with one already
1353        // accepted. This guards the HIP-3 case where two distinct venue names
1354        // (e.g. `dex:FOO*` and `dex:FOO?`) sanitize onto the same internal
1355        // symbol; without this filter the second def would silently overwrite
1356        // the first in `asset_indices`, which would route orders to the wrong
1357        // asset. First-write-wins matches the spot canonical-pair ordering.
1358        let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1359        let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1360        for def in defs {
1361            if seen_symbols.insert(def.symbol) {
1362                deduped.push(def);
1363            } else {
1364                log::warn!(
1365                    "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1366                    def.symbol,
1367                    def.raw_symbol,
1368                );
1369            }
1370        }
1371        let defs = deduped;
1372
1373        // Populate asset indices for all instruments (including filtered HIP-3)
1374        self.asset_indices.rcu(|m| {
1375            for def in &defs {
1376                m.insert(def.symbol, def.asset_index);
1377            }
1378        });
1379        log::debug!(
1380            "Populated asset indices map (count={})",
1381            self.asset_indices.len()
1382        );
1383
1384        Ok(defs)
1385    }
1386
1387    /// Converts instrument definitions into Nautilus instruments.
1388    pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1389        let ts_init = self.clock.get_time_ns();
1390        instruments_from_defs_owned(defs, ts_init)
1391    }
1392
1393    /// Fetch and parse all available instrument definitions from Hyperliquid.
1394    pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1395        let defs = self.request_instrument_defs().await?;
1396        Ok(self.convert_defs(defs))
1397    }
1398
1399    /// Get asset index for a symbol from the cached map.
1400    ///
1401    /// For perps: index in meta.universe (0, 1, 2, ...).
1402    /// For spot: 10_000 + index in spotMeta.universe.
1403    /// For HIP-3: 100_000 + dex_index * 10_000 + index in dex meta.universe.
1404    ///
1405    /// Returns `None` if the symbol is not found in the map.
1406    pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1407        self.asset_indices.load().get(&Ustr::from(symbol)).copied()
1408    }
1409
1410    /// Get the price precision for a cached instrument by symbol.
1411    pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1412        self.instruments
1413            .load()
1414            .get(&Ustr::from(symbol))
1415            .map(|inst| inst.price_precision())
1416    }
1417
1418    /// Get mapping from spot fill coin identifiers to instrument symbols.
1419    ///
1420    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1421    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1422    /// This mapping allows looking up the instrument from a spot fill.
1423    ///
1424    /// This method also caches the mapping internally for use by fill parsing methods.
1425    #[must_use]
1426    pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1427        const SPOT_INDEX_OFFSET: u32 = 10_000;
1428        const BUILDER_PERP_OFFSET: u32 = 100_000;
1429
1430        let guard = self.asset_indices.load();
1431
1432        let mut mapping = AHashMap::new();
1433
1434        for (symbol, &asset_index) in guard.iter() {
1435            // Spot instruments: asset_index in [10_000, 100_000)
1436            if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1437                let pair_index = asset_index - SPOT_INDEX_OFFSET;
1438                let fill_coin = Ustr::from(&format!("@{pair_index}"));
1439                mapping.insert(fill_coin, *symbol);
1440            }
1441        }
1442
1443        // Cache the mapping internally for fill parsing
1444        self.spot_fill_coins.store(mapping.clone());
1445
1446        mapping
1447    }
1448
1449    /// Get perpetuals metadata (internal helper).
1450    #[allow(dead_code)]
1451    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1452        self.inner.load_perp_meta().await
1453    }
1454
1455    /// Get metadata for all perp dexes (standard + HIP-3).
1456    #[allow(dead_code)]
1457    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1458        self.inner.load_all_perp_metas().await
1459    }
1460
1461    /// Get spot metadata (internal helper).
1462    #[allow(dead_code)]
1463    pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1464        self.inner.get_spot_meta().await
1465    }
1466
1467    /// Get outcome metadata (internal helper).
1468    pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1469        self.inner.get_outcome_meta().await
1470    }
1471
1472    /// Get L2 order book for a coin.
1473    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1474        self.inner.info_l2_book(coin).await
1475    }
1476
1477    /// Get user fills (trading history).
1478    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1479        self.inner.info_user_fills(user).await
1480    }
1481
1482    /// Get order status for a user.
1483    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1484        self.inner.info_order_status(user, oid).await
1485    }
1486
1487    /// Get all open orders for a user.
1488    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1489        self.inner.info_open_orders(user).await
1490    }
1491
1492    /// Get frontend open orders (includes more detail) for a user.
1493    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1494        self.inner.info_frontend_open_orders(user).await
1495    }
1496
1497    /// Get clearinghouse state (balances, positions, margin) for a user.
1498    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
1499        self.inner.info_clearinghouse_state(user).await
1500    }
1501
1502    /// Get spot clearinghouse state (per-token spot balances) for a user.
1503    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
1504        self.inner.info_spot_clearinghouse_state(user).await
1505    }
1506
1507    /// Get user fee schedule and effective rates.
1508    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
1509        self.inner.info_user_fees(user).await
1510    }
1511
1512    /// Get candle/bar data for a coin.
1513    pub async fn info_candle_snapshot(
1514        &self,
1515        coin: &str,
1516        interval: HyperliquidBarInterval,
1517        start_time: u64,
1518        end_time: u64,
1519    ) -> Result<HyperliquidCandleSnapshot> {
1520        self.inner
1521            .info_candle_snapshot(coin, interval, start_time, end_time)
1522            .await
1523    }
1524
1525    /// Get historical funding rates for a coin.
1526    pub async fn info_funding_history(
1527        &self,
1528        coin: &str,
1529        start_time: u64,
1530        end_time: Option<u64>,
1531    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
1532        self.inner
1533            .info_funding_history(coin, start_time, end_time)
1534            .await
1535    }
1536
1537    /// Post an action to the exchange endpoint (low-level delegation).
1538    pub async fn post_action(
1539        &self,
1540        action: &ExchangeAction,
1541    ) -> Result<HyperliquidExchangeResponse> {
1542        self.inner.post_action(action).await
1543    }
1544
1545    /// Post an execution action (low-level delegation).
1546    pub async fn post_action_exec(
1547        &self,
1548        action: &HyperliquidExecAction,
1549    ) -> Result<HyperliquidExchangeResponse> {
1550        self.inner.post_action_exec(action).await
1551    }
1552
1553    /// Get metadata about available markets (low-level delegation).
1554    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
1555        self.inner.info_meta().await
1556    }
1557
1558    /// Cancel an order on the Hyperliquid exchange.
1559    ///
1560    /// Can cancel either by venue order ID or client order ID.
1561    /// At least one ID must be provided.
1562    ///
1563    /// # Errors
1564    ///
1565    /// Returns an error if credentials are missing, no order ID is provided,
1566    /// or the API returns an error.
1567    pub async fn cancel_order(
1568        &self,
1569        instrument_id: InstrumentId,
1570        client_order_id: Option<ClientOrderId>,
1571        venue_order_id: Option<VenueOrderId>,
1572    ) -> Result<()> {
1573        // Get asset ID from cached indices map
1574        let symbol = instrument_id.symbol.as_str();
1575        let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1576            Error::bad_request(format!(
1577                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1578            ))
1579        })?;
1580
1581        // Create cancel action based on which ID we have
1582        let action = if let Some(cloid) = client_order_id {
1583            // Hash the client order ID to CLOID (same as order submission)
1584            let cloid_hash = Cloid::from_client_order_id(cloid);
1585            let cancel_req = HyperliquidExecCancelByCloidRequest {
1586                asset: asset_id,
1587                cloid: cloid_hash,
1588            };
1589            HyperliquidExecAction::CancelByCloid {
1590                cancels: vec![cancel_req],
1591            }
1592        } else if let Some(oid) = venue_order_id {
1593            let oid_u64 = oid
1594                .as_str()
1595                .parse::<u64>()
1596                .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1597            let cancel_req = HyperliquidExecCancelOrderRequest {
1598                asset: asset_id,
1599                oid: oid_u64,
1600            };
1601            HyperliquidExecAction::Cancel {
1602                cancels: vec![cancel_req],
1603            }
1604        } else {
1605            return Err(Error::bad_request(
1606                "Either client_order_id or venue_order_id must be provided",
1607            ));
1608        };
1609
1610        // Submit cancellation
1611        let response = self.inner.post_action_exec(&action).await?;
1612
1613        // Check response - only check for error status
1614        match response {
1615            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
1616            HyperliquidExchangeResponse::Status {
1617                status,
1618                response: error_data,
1619            } => Err(Error::bad_request(format!(
1620                "Cancel order failed: status={status}, error={error_data}"
1621            ))),
1622            HyperliquidExchangeResponse::Error { error } => {
1623                Err(Error::bad_request(format!("Cancel order error: {error}")))
1624            }
1625        }
1626    }
1627
1628    /// Modify an order on the Hyperliquid exchange.
1629    ///
1630    /// The HL modify API requires a full replacement order spec plus the
1631    /// venue order ID. The caller must provide all order fields.
1632    ///
1633    /// # Errors
1634    ///
1635    /// Returns an error if the asset index is not found, the venue order ID
1636    /// is invalid, or the API returns an error.
1637    #[expect(clippy::too_many_arguments)]
1638    pub async fn modify_order(
1639        &self,
1640        instrument_id: InstrumentId,
1641        venue_order_id: VenueOrderId,
1642        order_side: OrderSide,
1643        order_type: OrderType,
1644        price: Price,
1645        quantity: Quantity,
1646        trigger_price: Option<Price>,
1647        reduce_only: bool,
1648        post_only: bool,
1649        time_in_force: TimeInForce,
1650        client_order_id: Option<ClientOrderId>,
1651    ) -> Result<()> {
1652        let symbol = instrument_id.symbol.as_str();
1653        let asset_id = self.get_asset_index(symbol).ok_or_else(|| {
1654            Error::bad_request(format!(
1655                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
1656            ))
1657        })?;
1658
1659        let oid: u64 = venue_order_id
1660            .as_str()
1661            .parse()
1662            .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
1663
1664        let is_buy = matches!(order_side, OrderSide::Buy);
1665        let decimals = self.get_price_precision(symbol).unwrap_or(2);
1666
1667        let normalized_price = if self.normalize_prices {
1668            normalize_price(price.as_decimal(), decimals).normalize()
1669        } else {
1670            price.as_decimal().normalize()
1671        };
1672
1673        let size = quantity.as_decimal().normalize();
1674        let cloid = client_order_id.map(Cloid::from_client_order_id);
1675
1676        let kind = match order_type {
1677            OrderType::Market => HyperliquidExecOrderKind::Limit {
1678                limit: HyperliquidExecLimitParams {
1679                    tif: HyperliquidExecTif::Ioc,
1680                },
1681            },
1682            OrderType::Limit => {
1683                let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
1684                    .map_err(|e| Error::bad_request(format!("{e}")))?;
1685                HyperliquidExecOrderKind::Limit {
1686                    limit: HyperliquidExecLimitParams { tif },
1687                }
1688            }
1689            OrderType::StopMarket
1690            | OrderType::StopLimit
1691            | OrderType::MarketIfTouched
1692            | OrderType::LimitIfTouched => {
1693                if let Some(trig_px) = trigger_price {
1694                    let trigger_price_decimal = if self.normalize_prices {
1695                        normalize_price(trig_px.as_decimal(), decimals).normalize()
1696                    } else {
1697                        trig_px.as_decimal().normalize()
1698                    };
1699                    let tpsl = match order_type {
1700                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
1701                        _ => HyperliquidExecTpSl::Tp,
1702                    };
1703                    let is_market = matches!(
1704                        order_type,
1705                        OrderType::StopMarket | OrderType::MarketIfTouched
1706                    );
1707                    HyperliquidExecOrderKind::Trigger {
1708                        trigger: HyperliquidExecTriggerParams {
1709                            is_market,
1710                            trigger_px: trigger_price_decimal,
1711                            tpsl,
1712                        },
1713                    }
1714                } else {
1715                    return Err(Error::bad_request("Trigger orders require a trigger price"));
1716                }
1717            }
1718            _ => {
1719                return Err(Error::bad_request(format!(
1720                    "Order type {order_type:?} not supported for modify"
1721                )));
1722            }
1723        };
1724
1725        let order = HyperliquidExecPlaceOrderRequest {
1726            asset: asset_id,
1727            is_buy,
1728            price: normalized_price,
1729            size,
1730            reduce_only,
1731            kind,
1732            cloid,
1733        };
1734
1735        let action = HyperliquidExecAction::Modify {
1736            modify: HyperliquidExecModifyOrderRequest { oid, order },
1737        };
1738
1739        let response = self.inner.post_action_exec(&action).await?;
1740
1741        match response {
1742            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
1743                if let Some(inner_error) = extract_inner_error(&response) {
1744                    Err(Error::bad_request(format!(
1745                        "Modify order rejected: {inner_error}",
1746                    )))
1747                } else {
1748                    Ok(())
1749                }
1750            }
1751            HyperliquidExchangeResponse::Status {
1752                status,
1753                response: error_data,
1754            } => Err(Error::bad_request(format!(
1755                "Modify order failed: status={status}, error={error_data}"
1756            ))),
1757            HyperliquidExchangeResponse::Error { error } => {
1758                Err(Error::bad_request(format!("Modify order error: {error}")))
1759            }
1760        }
1761    }
1762
1763    /// Split an HIP-4 outcome's quote tokens into matched Yes and No side tokens.
1764    ///
1765    /// Submits a `userOutcome` exchange action with the `splitOutcome` operation:
1766    /// debits `amount` quote tokens (USDH) and credits `amount` Yes plus `amount`
1767    /// No side tokens for the given `outcome` index. Ordinary directional
1768    /// buys and sells on outcome instruments go through the standard order path
1769    /// without calling this; the action is for dual-side market making and
1770    /// inventory creation.
1771    ///
1772    /// # Errors
1773    ///
1774    /// Returns an error if credentials are missing, the venue rejects the
1775    /// action, or the response cannot be parsed.
1776    pub async fn submit_split_outcome(
1777        &self,
1778        outcome: u32,
1779        amount: Decimal,
1780    ) -> Result<HyperliquidExchangeResponse> {
1781        let action = HyperliquidExecAction::UserOutcome {
1782            op: HyperliquidExecUserOutcomeOp::SplitOutcome(HyperliquidExecSplitOutcomeParams {
1783                outcome,
1784                amount,
1785            }),
1786        };
1787        self.inner.post_action_exec(&action).await
1788    }
1789
1790    /// Merge matched Yes + No side-token pairs of an HIP-4 outcome back into quote tokens.
1791    ///
1792    /// Submits a `userOutcome` action with the `mergeOutcome` operation. Pass
1793    /// `amount = None` to merge the maximum mergeable balance (venue-side
1794    /// `null`).
1795    ///
1796    /// # Errors
1797    ///
1798    /// Returns an error if credentials are missing, the venue rejects the
1799    /// action, or the response cannot be parsed.
1800    pub async fn submit_merge_outcome(
1801        &self,
1802        outcome: u32,
1803        amount: Option<Decimal>,
1804    ) -> Result<HyperliquidExchangeResponse> {
1805        let action = HyperliquidExecAction::UserOutcome {
1806            op: HyperliquidExecUserOutcomeOp::MergeOutcome(HyperliquidExecMergeOutcomeParams {
1807                outcome,
1808                amount,
1809            }),
1810        };
1811        self.inner.post_action_exec(&action).await
1812    }
1813
1814    /// Merge `Yes` shares of every outcome in a multi-outcome question into quote tokens.
1815    ///
1816    /// Submits a `userOutcome` action with the `mergeQuestion` operation. Pass
1817    /// `amount = None` to merge the maximum balance.
1818    ///
1819    /// # Errors
1820    ///
1821    /// Returns an error if credentials are missing, the venue rejects the
1822    /// action, or the response cannot be parsed.
1823    pub async fn submit_merge_question(
1824        &self,
1825        question: u32,
1826        amount: Option<Decimal>,
1827    ) -> Result<HyperliquidExchangeResponse> {
1828        let action = HyperliquidExecAction::UserOutcome {
1829            op: HyperliquidExecUserOutcomeOp::MergeQuestion(HyperliquidExecMergeQuestionParams {
1830                question,
1831                amount,
1832            }),
1833        };
1834        self.inner.post_action_exec(&action).await
1835    }
1836
1837    /// Swap `No` shares of one outcome into `Yes` shares of every other outcome.
1838    ///
1839    /// Submits a `userOutcome` action with the `negateOutcome` operation. Both
1840    /// outcomes must belong to the same multi-outcome `question`.
1841    ///
1842    /// # Errors
1843    ///
1844    /// Returns an error if credentials are missing, the venue rejects the
1845    /// action, or the response cannot be parsed.
1846    pub async fn submit_negate_outcome(
1847        &self,
1848        question: u32,
1849        outcome: u32,
1850        amount: Decimal,
1851    ) -> Result<HyperliquidExchangeResponse> {
1852        let action = HyperliquidExecAction::UserOutcome {
1853            op: HyperliquidExecUserOutcomeOp::NegateOutcome(HyperliquidExecNegateOutcomeParams {
1854                question,
1855                outcome,
1856                amount,
1857            }),
1858        };
1859        self.inner.post_action_exec(&action).await
1860    }
1861
1862    /// Request order status reports for a user.
1863    ///
1864    /// Fetches open orders via `info_frontend_open_orders` and parses them into OrderStatusReports.
1865    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
1866    ///
1867    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
1868    /// will be created automatically.
1869    ///
1870    /// # Errors
1871    ///
1872    /// Returns an error if the API request fails or parsing fails.
1873    pub async fn request_order_status_reports(
1874        &self,
1875        user: &str,
1876        instrument_id: Option<InstrumentId>,
1877    ) -> Result<Vec<OrderStatusReport>> {
1878        let account_id = self
1879            .account_id
1880            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1881        let response = self.info_frontend_open_orders(user).await?;
1882
1883        // Parse the JSON response into a vector of orders
1884        let orders: Vec<serde_json::Value> = serde_json::from_value(response)
1885            .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
1886
1887        let mut reports = Vec::new();
1888        let ts_init = self.clock.get_time_ns();
1889
1890        for order_value in orders {
1891            // Parse the order data
1892            let order: WsBasicOrderData = match serde_json::from_value(order_value.clone()) {
1893                Ok(o) => o,
1894                Err(e) => {
1895                    log::warn!("Failed to parse order: {e}");
1896                    continue;
1897                }
1898            };
1899
1900            // Get instrument from cache or create synthetic for vault tokens
1901            let instrument = match self.get_or_create_instrument(&order.coin, None) {
1902                Some(inst) => inst,
1903                None => continue, // Skip if instrument not found
1904            };
1905
1906            // Filter by instrument_id if specified
1907            if let Some(filter_id) = instrument_id
1908                && instrument.id() != filter_id
1909            {
1910                continue;
1911            }
1912
1913            // Determine status from order data - orders from frontend_open_orders are open
1914            let status = HyperliquidOrderStatusEnum::Open;
1915
1916            // Parse to OrderStatusReport
1917            match parse_order_status_report_from_basic(
1918                &order,
1919                &status,
1920                &instrument,
1921                account_id,
1922                ts_init,
1923            ) {
1924                Ok(report) => reports.push(report),
1925                Err(e) => log::error!("Failed to parse order status report: {e}"),
1926            }
1927        }
1928
1929        Ok(reports)
1930    }
1931
1932    /// Request a single order status report by venue order ID.
1933    ///
1934    /// Queries `info_frontend_open_orders` and filters for the given oid so the
1935    /// result includes trigger metadata (trigger_px, tpsl, trailing_stop, etc.).
1936    /// Falls back to `info_order_status` when the order is no longer open.
1937    ///
1938    /// # Errors
1939    ///
1940    /// Returns an error if the API request fails or parsing fails.
1941    pub async fn request_order_status_report(
1942        &self,
1943        user: &str,
1944        oid: u64,
1945    ) -> Result<Option<OrderStatusReport>> {
1946        let account_id = self
1947            .account_id
1948            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
1949
1950        let ts_init = self.clock.get_time_ns();
1951
1952        // Try open orders first (returns full WsBasicOrderData with trigger fields).
1953        // A transport error here must not abort the call: the oid fallback to
1954        // info_order_status below still covers closed orders, so a transient
1955        // frontendOpenOrders outage is downgraded to a warning.
1956        let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
1957            Ok(response) => match serde_json::from_value(response) {
1958                Ok(v) => v,
1959                Err(e) => {
1960                    log::warn!("Failed to parse frontend open orders response: {e}");
1961                    Vec::new()
1962                }
1963            },
1964            Err(e) => {
1965                log::warn!(
1966                    "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
1967                );
1968                Vec::new()
1969            }
1970        };
1971
1972        if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
1973            let instrument = match self.get_or_create_instrument(&order.coin, None) {
1974                Some(inst) => inst,
1975                None => return Ok(None),
1976            };
1977
1978            let status = if order.trigger_activated == Some(true) {
1979                HyperliquidOrderStatusEnum::Triggered
1980            } else {
1981                HyperliquidOrderStatusEnum::Open
1982            };
1983
1984            return match parse_order_status_report_from_basic(
1985                &order,
1986                &status,
1987                &instrument,
1988                account_id,
1989                ts_init,
1990            ) {
1991                Ok(report) => Ok(Some(report)),
1992                Err(e) => {
1993                    log::error!("Failed to parse order status report for oid {oid}: {e}");
1994                    Ok(None)
1995                }
1996            };
1997        }
1998
1999        // Order not in open set: query by oid (returns limited HyperliquidOrderInfo)
2000        let response = self.info_order_status(user, oid).await?;
2001        let entry = match response.into_order() {
2002            Some(e) => e,
2003            None => return Ok(None),
2004        };
2005
2006        let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2007            Some(inst) => inst,
2008            None => return Ok(None),
2009        };
2010
2011        // The info_order_status endpoint returns limited HyperliquidOrderInfo
2012        // without trigger fields (trigger_px, tpsl, is_market, trailing_stop).
2013        // Closed trigger orders will report as Limit type. This is an exchange
2014        // API limitation: trigger metadata is only available on open orders.
2015        let basic = WsBasicOrderData {
2016            coin: entry.order.coin,
2017            side: entry.order.side,
2018            limit_px: entry.order.limit_px,
2019            sz: entry.order.sz,
2020            oid: entry.order.oid,
2021            timestamp: entry.order.timestamp,
2022            orig_sz: entry.order.orig_sz,
2023            cloid: entry.order.cloid,
2024            trigger_px: None,
2025            is_market: None,
2026            tpsl: None,
2027            trigger_activated: None,
2028            trailing_stop: None,
2029        };
2030
2031        match parse_order_status_report_from_basic(
2032            &basic,
2033            &entry.status,
2034            &instrument,
2035            account_id,
2036            ts_init,
2037        ) {
2038            Ok(mut report) => {
2039                // Use status_timestamp for ts_last when available (more accurate
2040                // than the order creation timestamp for filled/canceled orders)
2041                if entry.status_timestamp > 0 {
2042                    report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2043                }
2044                Ok(Some(report))
2045            }
2046            Err(e) => {
2047                log::error!("Failed to parse order status report for oid {oid}: {e}");
2048                Ok(None)
2049            }
2050        }
2051    }
2052
2053    /// Request a single order status report by client order ID.
2054    ///
2055    /// Searches `info_frontend_open_orders` for an order whose cloid matches the
2056    /// keccak256 hash of the given client order ID. Only finds open orders.
2057    ///
2058    /// # Errors
2059    ///
2060    /// Returns an error if the API request fails or parsing fails.
2061    pub async fn request_order_status_report_by_client_order_id(
2062        &self,
2063        user: &str,
2064        client_order_id: &ClientOrderId,
2065    ) -> Result<Option<OrderStatusReport>> {
2066        let account_id = self
2067            .account_id
2068            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2069
2070        let ts_init = self.clock.get_time_ns();
2071
2072        let cloid_hex = Cloid::from_client_order_id(*client_order_id).to_hex();
2073
2074        let response = self.info_frontend_open_orders(user).await?;
2075        let orders: Vec<WsBasicOrderData> = match serde_json::from_value(response) {
2076            Ok(v) => v,
2077            Err(e) => {
2078                log::warn!("Failed to parse frontend open orders response: {e}");
2079                return Ok(None);
2080            }
2081        };
2082
2083        let order = match orders
2084            .into_iter()
2085            .find(|o| o.cloid.as_ref().is_some_and(|c| c == &cloid_hex))
2086        {
2087            Some(o) => o,
2088            None => return Ok(None),
2089        };
2090
2091        let instrument = match self.get_or_create_instrument(&order.coin, None) {
2092            Some(inst) => inst,
2093            None => return Ok(None),
2094        };
2095
2096        let status = if order.trigger_activated == Some(true) {
2097            HyperliquidOrderStatusEnum::Triggered
2098        } else {
2099            HyperliquidOrderStatusEnum::Open
2100        };
2101
2102        match parse_order_status_report_from_basic(
2103            &order,
2104            &status,
2105            &instrument,
2106            account_id,
2107            ts_init,
2108        ) {
2109            Ok(mut report) => {
2110                report.client_order_id = Some(*client_order_id);
2111                Ok(Some(report))
2112            }
2113            Err(e) => {
2114                log::error!("Failed to parse order status report for cloid {cloid_hex}: {e}");
2115                Ok(None)
2116            }
2117        }
2118    }
2119
2120    /// Request fill reports for a user.
2121    ///
2122    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
2123    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
2124    ///
2125    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
2126    /// will be created automatically.
2127    ///
2128    /// # Errors
2129    ///
2130    /// Returns an error if the API request fails or parsing fails.
2131    ///
2132    /// Returns an error if `account_id` is not set on the client.
2133    pub async fn request_fill_reports(
2134        &self,
2135        user: &str,
2136        instrument_id: Option<InstrumentId>,
2137    ) -> Result<Vec<FillReport>> {
2138        let account_id = self
2139            .account_id
2140            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2141        let fills_response = self.info_user_fills(user).await?;
2142
2143        let mut reports = Vec::new();
2144        let ts_init = self.clock.get_time_ns();
2145
2146        for fill in fills_response {
2147            // Get instrument from cache or create synthetic for vault tokens
2148            let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2149                Some(inst) => inst,
2150                None => continue, // Skip if instrument not found
2151            };
2152
2153            // Filter by instrument_id if specified
2154            if let Some(filter_id) = instrument_id
2155                && instrument.id() != filter_id
2156            {
2157                continue;
2158            }
2159
2160            // Parse to FillReport
2161            match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2162                Ok(report) => reports.push(report),
2163                Err(e) => log::error!("Failed to parse fill report: {e}"),
2164            }
2165        }
2166
2167        Ok(reports)
2168    }
2169
2170    /// Request position status reports for a user.
2171    ///
2172    /// Fetches perp clearinghouse state and spot clearinghouse state, then returns
2173    /// the union of perp asset positions (short/long with PnL) and spot holdings
2174    /// (long only). This method requires instruments to be added to the client
2175    /// cache via `cache_instrument()`.
2176    ///
2177    /// When `instrument_id` resolves to a specific product type, the opposite
2178    /// product's endpoint is skipped to avoid wasted round trips and make
2179    /// filtered queries independent of the unused endpoint's availability.
2180    /// HIP-4 outcomes live in `spotClearinghouseState`, so an outcome filter
2181    /// is routed like a spot filter (perp leg skipped).
2182    ///
2183    /// For vault tokens (starting with "vntls:") that are not in the cache,
2184    /// synthetic instruments will be created automatically. Spot balances whose
2185    /// base token has no cached instrument are skipped with a debug log.
2186    ///
2187    /// # Errors
2188    ///
2189    /// Returns an error if either clearinghouse request fails (when that
2190    /// product is in scope) or parsing fails.
2191    ///
2192    /// Returns an error if `account_id` has not been set on the client.
2193    pub async fn request_position_status_reports(
2194        &self,
2195        user: &str,
2196        instrument_id: Option<InstrumentId>,
2197    ) -> Result<Vec<PositionStatusReport>> {
2198        let account_id = self
2199            .account_id
2200            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2201
2202        let filter_product = instrument_id
2203            .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
2204
2205        let fetch_perp = !matches!(
2206            filter_product,
2207            Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
2208        );
2209        let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
2210
2211        let mut reports = Vec::new();
2212        let ts_init = self.clock.get_time_ns();
2213
2214        if !fetch_perp {
2215            let spot_reports = self
2216                .request_spot_position_status_reports(user, instrument_id)
2217                .await?;
2218            reports.extend(spot_reports);
2219            return Ok(reports);
2220        }
2221
2222        let state_response = self.info_clearinghouse_state(user).await?;
2223
2224        // Extract asset positions from the clearinghouse state
2225        let asset_positions: Vec<serde_json::Value> = state_response
2226            .get("assetPositions")
2227            .and_then(|v| v.as_array())
2228            .ok_or_else(|| Error::bad_request("assetPositions not found in clearinghouse state"))?
2229            .clone();
2230
2231        for position_value in asset_positions {
2232            // Extract coin from position data
2233            let coin = position_value
2234                .get("position")
2235                .and_then(|p| p.get("coin"))
2236                .and_then(|c| c.as_str())
2237                .ok_or_else(|| Error::bad_request("coin not found in position"))?;
2238
2239            // Get instrument from cache - convert &str to Ustr for lookup
2240            let coin_ustr = Ustr::from(coin);
2241            let instrument = match self.get_or_create_instrument(&coin_ustr, None) {
2242                Some(inst) => inst,
2243                None => continue, // Skip if instrument not found
2244            };
2245
2246            // Filter by instrument_id if specified
2247            if let Some(filter_id) = instrument_id
2248                && instrument.id() != filter_id
2249            {
2250                continue;
2251            }
2252
2253            // Parse to PositionStatusReport
2254            match parse_position_status_report(&position_value, &instrument, account_id, ts_init) {
2255                Ok(report) => reports.push(report),
2256                Err(e) => log::error!("Failed to parse position status report: {e}"),
2257            }
2258        }
2259
2260        // Spot positions are part of the report truth; propagate fetch errors
2261        // rather than silently omitting spot holdings from reconciliation.
2262        if fetch_spot {
2263            let spot_reports = self
2264                .request_spot_position_status_reports(user, instrument_id)
2265                .await?;
2266            reports.extend(spot_reports);
2267        }
2268
2269        Ok(reports)
2270    }
2271
2272    /// Request account state (balances and margins) for a user.
2273    ///
2274    /// Fetches perp and spot clearinghouse state from Hyperliquid and merges them
2275    /// into a single [`AccountState`]. USDC is taken from the perp margin summary
2276    /// when present (to avoid double-counting combined `withdrawable`); non-USDC
2277    /// tokens are appended from the spot balances.
2278    ///
2279    /// # Errors
2280    ///
2281    /// Returns an error if `account_id` is not set, or if either the perp or
2282    /// spot clearinghouse request fails. Spot failures are propagated so the
2283    /// caller sees real API errors instead of a silently truncated snapshot.
2284    pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
2285        let account_id = self
2286            .account_id
2287            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2288        let state_response = self.info_clearinghouse_state(user).await?;
2289        let ts_init = self.clock.get_time_ns();
2290
2291        log::trace!("Clearinghouse state response: {state_response}");
2292
2293        let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
2294            .map_err(|e| {
2295                log::error!("Failed to parse clearinghouse state: {e}");
2296                log::debug!("Raw response: {state_response}");
2297                Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
2298            })?;
2299
2300        // Spot must not be silently dropped: a 429 or parse error would
2301        // otherwise make non-USDC holdings look like they vanished.
2302        let spot_response = self.info_spot_clearinghouse_state(user).await?;
2303        let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
2304            .map_err(|e| {
2305                log::error!("Failed to parse spot clearinghouse state: {e}");
2306                log::debug!("Raw spot response: {spot_response}");
2307                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2308            })?;
2309
2310        let (balances, margins) =
2311            parse_combined_account_balances_and_margins(&perp_state, &spot_state)
2312                .map_err(|e| Error::decode(e.to_string()))?;
2313
2314        Ok(AccountState::new(
2315            account_id,
2316            AccountType::Margin,
2317            balances,
2318            margins,
2319            true, // reported
2320            UUID4::new(),
2321            ts_init,
2322            ts_init,
2323            None,
2324        ))
2325    }
2326
2327    /// Request spot token balances for a user.
2328    ///
2329    /// Fetches `spotClearinghouseState` and returns one [`AccountBalance`] per
2330    /// non-zero token. USDC is included as a separate balance entry when present;
2331    /// callers that also report perp margin state must dedupe currencies before
2332    /// emitting an [`AccountState`].
2333    ///
2334    /// # Errors
2335    ///
2336    /// Returns an error if the API request fails or the response cannot be parsed.
2337    pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
2338        let response = self.info_spot_clearinghouse_state(user).await?;
2339
2340        log::trace!("Spot clearinghouse state response: {response}");
2341
2342        let state: SpotClearinghouseState =
2343            serde_json::from_value(response.clone()).map_err(|e| {
2344                log::error!("Failed to parse spot clearinghouse state: {e}");
2345                log::debug!("Raw response: {response}");
2346                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2347            })?;
2348
2349        parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
2350    }
2351
2352    /// Request spot position status reports for a user.
2353    ///
2354    /// Each non-zero spot balance is reported as a Long position against its
2355    /// `{BASE}-{QUOTE}-SPOT` instrument. HIP-4 outcome side tokens arrive on
2356    /// this same endpoint with `coin` set to the `+<encoding>` token form;
2357    /// those balances are resolved against the matching Outcome instrument so
2358    /// outcome holdings surface as positions through the standard reconcile
2359    /// path. Balances whose base token has no matching instrument in the
2360    /// cache are skipped with a debug log (callers should ensure
2361    /// [`request_instruments`](Self::request_instruments) has run first).
2362    ///
2363    /// # Errors
2364    ///
2365    /// Returns an error if `account_id` has not been set or the API request fails.
2366    pub async fn request_spot_position_status_reports(
2367        &self,
2368        user: &str,
2369        instrument_id: Option<InstrumentId>,
2370    ) -> Result<Vec<PositionStatusReport>> {
2371        let account_id = self
2372            .account_id
2373            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2374        let response = self.info_spot_clearinghouse_state(user).await?;
2375
2376        let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
2377            log::error!("Failed to parse spot clearinghouse state: {e}");
2378            Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
2379        })?;
2380
2381        let ts_init = self.clock.get_time_ns();
2382        let mut reports = Vec::with_capacity(state.balances.len());
2383
2384        for balance in &state.balances {
2385            if balance.total.is_zero() {
2386                continue;
2387            }
2388
2389            // USDC is the universal quote for Hyperliquid spot: it funds every
2390            // pair and has no `USDC-*-SPOT` instrument. Skip it so the loop
2391            // does not trigger a misleading cache-miss WARN. Revisit if
2392            // Hyperliquid ever introduces a USDC-base spot pair.
2393            if balance.coin.as_str() == "USDC" {
2394                continue;
2395            }
2396
2397            let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
2398                Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
2399                _ => HyperliquidProductType::Spot,
2400            };
2401
2402            let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
2403            {
2404                Some(inst) => inst,
2405                None => continue,
2406            };
2407
2408            if let Some(filter_id) = instrument_id
2409                && instrument.id() != filter_id
2410            {
2411                continue;
2412            }
2413
2414            match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
2415                Ok(report) => reports.push(report),
2416                Err(e) => log::error!(
2417                    "Failed to parse spot position status report for {}: {e}",
2418                    balance.coin,
2419                ),
2420            }
2421        }
2422
2423        Ok(reports)
2424    }
2425
2426    /// Request historical bars for an instrument.
2427    ///
2428    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
2429    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
2430    ///
2431    /// # Errors
2432    ///
2433    /// Returns an error if:
2434    /// - The instrument is not found in cache.
2435    /// - The bar aggregation is unsupported by Hyperliquid.
2436    /// - The API request fails.
2437    /// - Parsing fails.
2438    ///
2439    /// # References
2440    ///
2441    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
2442    pub async fn request_bars(
2443        &self,
2444        bar_type: BarType,
2445        start: Option<chrono::DateTime<chrono::Utc>>,
2446        end: Option<chrono::DateTime<chrono::Utc>>,
2447        limit: Option<u32>,
2448    ) -> Result<Vec<Bar>> {
2449        let instrument_id = bar_type.instrument_id();
2450        let symbol = instrument_id.symbol;
2451
2452        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
2453
2454        // Extract base currency for lookup, then use raw_symbol for the API call
2455        let base = Ustr::from(
2456            symbol
2457                .as_str()
2458                .split('-')
2459                .next()
2460                .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?,
2461        );
2462
2463        let instrument = self
2464            .get_or_create_instrument(&base, product_type)
2465            .ok_or_else(|| {
2466                Error::bad_request(format!("Instrument not found in cache: {instrument_id}"))
2467            })?;
2468
2469        // Use raw_symbol which has the correct Hyperliquid API format:
2470        // - Perps: base currency (e.g., "BTC")
2471        // - Spot PURR: slash format (e.g., "PURR/USDC")
2472        // - Spot others: @{index} format (e.g., "@107")
2473        let coin = instrument.raw_symbol().inner();
2474
2475        let price_precision = instrument.price_precision();
2476        let size_precision = instrument.size_precision();
2477
2478        let interval =
2479            bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
2480
2481        // Hyperliquid uses millisecond timestamps
2482        let now = chrono::Utc::now();
2483        let end_time = end.unwrap_or(now).timestamp_millis() as u64;
2484        let start_time = if let Some(start) = start {
2485            start.timestamp_millis() as u64
2486        } else {
2487            // Default to 1000 bars before end_time
2488            let spec = bar_type.spec();
2489            let step_ms = match spec.aggregation {
2490                BarAggregation::Minute => spec.step.get() as u64 * 60_000,
2491                BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
2492                BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
2493                BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
2494                BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
2495                _ => 60_000,
2496            };
2497            end_time.saturating_sub(1000 * step_ms)
2498        };
2499
2500        let candles = self
2501            .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
2502            .await?;
2503
2504        // Filter out incomplete bars where end_timestamp >= current time
2505        let now_ms = now.timestamp_millis() as u64;
2506
2507        let mut bars: Vec<Bar> = candles
2508            .iter()
2509            .filter(|candle| candle.end_timestamp < now_ms)
2510            .enumerate()
2511            .filter_map(|(i, candle)| {
2512                candle_to_bar(candle, bar_type, price_precision, size_precision)
2513                    .map_err(|e| {
2514                        log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
2515                        e
2516                    })
2517                    .ok()
2518            })
2519            .collect();
2520
2521        // 0 means no limit
2522        if let Some(limit) = limit
2523            && limit > 0
2524            && bars.len() > limit as usize
2525        {
2526            bars.truncate(limit as usize);
2527        }
2528
2529        log::debug!(
2530            "Received {} bars for {} (filtered {} incomplete)",
2531            bars.len(),
2532            bar_type,
2533            candles.len() - bars.len()
2534        );
2535        Ok(bars)
2536    }
2537
2538    /// Submits an order to the exchange.
2539    ///
2540    /// # Errors
2541    ///
2542    /// Returns an error if credentials are missing, order validation fails, serialization fails,
2543    /// or the API returns an error.
2544    #[expect(clippy::too_many_arguments)]
2545    pub async fn submit_order(
2546        &self,
2547        instrument_id: InstrumentId,
2548        client_order_id: ClientOrderId,
2549        order_side: OrderSide,
2550        order_type: OrderType,
2551        quantity: Quantity,
2552        time_in_force: TimeInForce,
2553        price: Option<Price>,
2554        trigger_price: Option<Price>,
2555        post_only: bool,
2556        reduce_only: bool,
2557    ) -> Result<OrderStatusReport> {
2558        let symbol = instrument_id.symbol.as_str();
2559        let asset = self.get_asset_index(symbol).ok_or_else(|| {
2560            Error::bad_request(format!(
2561                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2562            ))
2563        })?;
2564
2565        let is_buy = matches!(order_side, OrderSide::Buy);
2566        let price_precision = self.get_price_precision(symbol).unwrap_or(2);
2567
2568        let price_decimal = match price {
2569            Some(px) if self.normalize_prices => {
2570                normalize_price(px.as_decimal(), price_precision).normalize()
2571            }
2572            Some(px) => px.as_decimal().normalize(),
2573            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
2574            None if matches!(
2575                order_type,
2576                OrderType::StopMarket | OrderType::MarketIfTouched
2577            ) =>
2578            {
2579                match trigger_price {
2580                    Some(tp) => {
2581                        let derived = derive_limit_from_trigger(
2582                            tp.as_decimal().normalize(),
2583                            is_buy,
2584                            self.market_order_slippage_bps,
2585                        );
2586                        let sig_rounded = round_to_sig_figs(derived, 5);
2587                        clamp_price_to_precision(sig_rounded, price_precision, is_buy).normalize()
2588                    }
2589                    None => Decimal::ZERO,
2590                }
2591            }
2592            None => return Err(Error::bad_request("Limit orders require a price")),
2593        };
2594
2595        let size_decimal = quantity.as_decimal().normalize();
2596
2597        let kind = match order_type {
2598            OrderType::Market => HyperliquidExecOrderKind::Limit {
2599                limit: HyperliquidExecLimitParams {
2600                    tif: HyperliquidExecTif::Ioc,
2601                },
2602            },
2603            OrderType::Limit => {
2604                let tif = if post_only {
2605                    HyperliquidExecTif::Alo
2606                } else {
2607                    match time_in_force {
2608                        TimeInForce::Gtc => HyperliquidExecTif::Gtc,
2609                        TimeInForce::Ioc => HyperliquidExecTif::Ioc,
2610                        TimeInForce::Fok
2611                        | TimeInForce::Day
2612                        | TimeInForce::Gtd
2613                        | TimeInForce::AtTheOpen
2614                        | TimeInForce::AtTheClose => {
2615                            return Err(Error::bad_request(format!(
2616                                "Time in force {time_in_force:?} not supported"
2617                            )));
2618                        }
2619                    }
2620                };
2621                HyperliquidExecOrderKind::Limit {
2622                    limit: HyperliquidExecLimitParams { tif },
2623                }
2624            }
2625            OrderType::StopMarket
2626            | OrderType::StopLimit
2627            | OrderType::MarketIfTouched
2628            | OrderType::LimitIfTouched => {
2629                if let Some(trig_px) = trigger_price {
2630                    let trigger_price_decimal = if self.normalize_prices {
2631                        normalize_price(trig_px.as_decimal(), price_precision).normalize()
2632                    } else {
2633                        trig_px.as_decimal().normalize()
2634                    };
2635
2636                    // Determine TP/SL type based on order type
2637                    // StopMarket/StopLimit are always Sl (protective stops)
2638                    // MarketIfTouched/LimitIfTouched are always Tp (profit-taking/entry)
2639                    let tpsl = match order_type {
2640                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExecTpSl::Sl,
2641                        OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
2642                            HyperliquidExecTpSl::Tp
2643                        }
2644                        _ => unreachable!(),
2645                    };
2646
2647                    let is_market = matches!(
2648                        order_type,
2649                        OrderType::StopMarket | OrderType::MarketIfTouched
2650                    );
2651
2652                    HyperliquidExecOrderKind::Trigger {
2653                        trigger: HyperliquidExecTriggerParams {
2654                            is_market,
2655                            trigger_px: trigger_price_decimal,
2656                            tpsl,
2657                        },
2658                    }
2659                } else {
2660                    return Err(Error::bad_request("Trigger orders require a trigger price"));
2661                }
2662            }
2663            _ => {
2664                return Err(Error::bad_request(format!(
2665                    "Order type {order_type:?} not supported"
2666                )));
2667            }
2668        };
2669
2670        let hyperliquid_order = HyperliquidExecPlaceOrderRequest {
2671            asset,
2672            is_buy,
2673            price: price_decimal,
2674            size: size_decimal,
2675            reduce_only,
2676            kind,
2677            cloid: Some(Cloid::from_client_order_id(client_order_id)),
2678        };
2679
2680        let builder = self.builder_attribution();
2681
2682        let action = HyperliquidExecAction::Order {
2683            orders: vec![hyperliquid_order],
2684            grouping: HyperliquidExecGrouping::Na,
2685            builder,
2686        };
2687
2688        let response = self.inner.post_action_exec(&action).await?;
2689
2690        match response {
2691            HyperliquidExchangeResponse::Status {
2692                status,
2693                response: response_data,
2694            } if status == RESPONSE_STATUS_OK => {
2695                let data_value = if let Some(data) = response_data.get("data") {
2696                    data.clone()
2697                } else {
2698                    response_data
2699                };
2700
2701                let order_response: HyperliquidExecOrderResponseData =
2702                    serde_json::from_value(data_value).map_err(|e| {
2703                        Error::bad_request(format!("Failed to parse order response: {e}"))
2704                    })?;
2705
2706                let order_status = order_response
2707                    .statuses
2708                    .first()
2709                    .ok_or_else(|| Error::bad_request("No order status in response"))?;
2710
2711                let symbol_str = instrument_id.symbol.as_str();
2712                let product_type = HyperliquidProductType::from_symbol(symbol_str).ok();
2713
2714                // Extract base coin from symbol (first segment before '-')
2715                let asset_str = symbol_str.split('-').next().unwrap_or(symbol_str);
2716                let instrument = self
2717                    .get_or_create_instrument(&Ustr::from(asset_str), product_type)
2718                    .ok_or_else(|| {
2719                        Error::bad_request(format!("Instrument not found for {asset_str}"))
2720                    })?;
2721
2722                let account_id = self
2723                    .account_id
2724                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2725                let ts_init = self.clock.get_time_ns();
2726
2727                match order_status {
2728                    HyperliquidExecOrderStatus::Resting { resting } => Ok(self
2729                        .create_order_status_report(
2730                            instrument_id,
2731                            Some(client_order_id),
2732                            VenueOrderId::new(resting.oid.to_string()),
2733                            order_side,
2734                            order_type,
2735                            quantity,
2736                            time_in_force,
2737                            price,
2738                            trigger_price,
2739                            OrderStatus::Accepted,
2740                            Quantity::new(0.0, instrument.size_precision()),
2741                            &instrument,
2742                            account_id,
2743                            ts_init,
2744                        )),
2745                    HyperliquidExecOrderStatus::Filled { filled } => {
2746                        let filled_qty = Quantity::new(
2747                            filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2748                            instrument.size_precision(),
2749                        );
2750                        Ok(self.create_order_status_report(
2751                            instrument_id,
2752                            Some(client_order_id),
2753                            VenueOrderId::new(filled.oid.to_string()),
2754                            order_side,
2755                            order_type,
2756                            quantity,
2757                            time_in_force,
2758                            price,
2759                            trigger_price,
2760                            OrderStatus::Filled,
2761                            filled_qty,
2762                            &instrument,
2763                            account_id,
2764                            ts_init,
2765                        ))
2766                    }
2767                    HyperliquidExecOrderStatus::Error { error } => {
2768                        Err(Error::bad_request(format!("Order rejected: {error}")))
2769                    }
2770                }
2771            }
2772            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
2773                "Order submission failed: {error}"
2774            ))),
2775            _ => Err(Error::bad_request("Unexpected response format")),
2776        }
2777    }
2778
2779    /// Submit an order using an OrderAny object.
2780    ///
2781    /// This is a convenience method that wraps submit_order.
2782    pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
2783        self.submit_order(
2784            order.instrument_id(),
2785            order.client_order_id(),
2786            order.order_side(),
2787            order.order_type(),
2788            order.quantity(),
2789            order.time_in_force(),
2790            order.price(),
2791            order.trigger_price(),
2792            order.is_post_only(),
2793            order.is_reduce_only(),
2794        )
2795        .await
2796    }
2797
2798    #[expect(clippy::too_many_arguments)]
2799    fn create_order_status_report(
2800        &self,
2801        instrument_id: InstrumentId,
2802        client_order_id: Option<ClientOrderId>,
2803        venue_order_id: VenueOrderId,
2804        order_side: OrderSide,
2805        order_type: OrderType,
2806        quantity: Quantity,
2807        time_in_force: TimeInForce,
2808        price: Option<Price>,
2809        trigger_price: Option<Price>,
2810        order_status: OrderStatus,
2811        filled_qty: Quantity,
2812        _instrument: &InstrumentAny,
2813        account_id: AccountId,
2814        ts_init: UnixNanos,
2815    ) -> OrderStatusReport {
2816        let ts_accepted = self.clock.get_time_ns();
2817        let ts_last = ts_accepted;
2818        let report_id = UUID4::new();
2819
2820        let mut report = OrderStatusReport::new(
2821            account_id,
2822            instrument_id,
2823            client_order_id,
2824            venue_order_id,
2825            order_side,
2826            order_type,
2827            time_in_force,
2828            order_status,
2829            quantity,
2830            filled_qty,
2831            ts_accepted,
2832            ts_last,
2833            ts_init,
2834            Some(report_id),
2835        );
2836
2837        if let Some(px) = price {
2838            report = report.with_price(px);
2839        }
2840
2841        if let Some(trig_px) = trigger_price {
2842            report = report
2843                .with_trigger_price(trig_px)
2844                .with_trigger_type(TriggerType::Default);
2845        }
2846
2847        report
2848    }
2849
2850    /// Submit multiple orders to the Hyperliquid exchange in a single request.
2851    ///
2852    /// # Errors
2853    ///
2854    /// Returns an error if credentials are missing, order validation fails, serialization fails,
2855    /// or the API returns an error.
2856    pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
2857        // Convert orders using asset indices from the cached map
2858        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
2859
2860        for order in orders {
2861            let instrument_id = order.instrument_id();
2862            let symbol = instrument_id.symbol.as_str();
2863            let asset = self.get_asset_index(symbol).ok_or_else(|| {
2864                Error::bad_request(format!(
2865                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2866                ))
2867            })?;
2868            let price_decimals = self.get_price_precision(symbol).unwrap_or(2);
2869            let request = order_to_hyperliquid_request_with_asset(
2870                order,
2871                asset,
2872                price_decimals,
2873                self.normalize_prices,
2874                self.market_order_slippage_bps,
2875            )
2876            .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
2877            hyperliquid_orders.push(request);
2878        }
2879
2880        let builder = self.builder_attribution();
2881
2882        let grouping =
2883            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
2884
2885        let action = HyperliquidExecAction::Order {
2886            orders: hyperliquid_orders,
2887            grouping,
2888            builder,
2889        };
2890
2891        // Submit to exchange using the typed exec endpoint
2892        let response = self.inner.post_action_exec(&action).await?;
2893
2894        // Parse the response to extract order statuses
2895        match response {
2896            HyperliquidExchangeResponse::Status {
2897                status,
2898                response: response_data,
2899            } if status == RESPONSE_STATUS_OK => {
2900                // Extract the 'data' field from the response if it exists (new format)
2901                // Otherwise use response_data directly (old format)
2902                let data_value = if let Some(data) = response_data.get("data") {
2903                    data.clone()
2904                } else {
2905                    response_data
2906                };
2907
2908                // Parse the response data to extract order statuses
2909                let order_response: HyperliquidExecOrderResponseData =
2910                    serde_json::from_value(data_value).map_err(|e| {
2911                        Error::bad_request(format!("Failed to parse order response: {e}"))
2912                    })?;
2913
2914                let account_id = self
2915                    .account_id
2916                    .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2917                let ts_init = self.clock.get_time_ns();
2918
2919                // For grouped orders (NormalTpsl/PositionTpsl), the exchange
2920                // returns a single status for the whole group. Only enforce
2921                // 1:1 matching for ungrouped (Na) submissions.
2922                if grouping == HyperliquidExecGrouping::Na
2923                    && order_response.statuses.len() != orders.len()
2924                {
2925                    return Err(Error::bad_request(format!(
2926                        "Mismatch between submitted orders ({}) and response statuses ({})",
2927                        orders.len(),
2928                        order_response.statuses.len()
2929                    )));
2930                }
2931
2932                let mut reports = Vec::new();
2933
2934                // Create OrderStatusReport for each order with a matching
2935                // status. For grouped submissions the exchange may return
2936                // fewer statuses; remaining orders are confirmed via WS.
2937                for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
2938                    // Extract asset from instrument symbol
2939                    let instrument_id = order.instrument_id();
2940                    let symbol = instrument_id.symbol.as_str();
2941                    let product_type = HyperliquidProductType::from_symbol(symbol).ok();
2942
2943                    // Extract base coin from symbol (first segment before '-')
2944                    let asset = symbol.split('-').next().unwrap_or(symbol);
2945                    let instrument = self
2946                        .get_or_create_instrument(&Ustr::from(asset), product_type)
2947                        .ok_or_else(|| {
2948                            Error::bad_request(format!("Instrument not found for {asset}"))
2949                        })?;
2950
2951                    // Create OrderStatusReport based on the order status
2952                    let report = match order_status {
2953                        HyperliquidExecOrderStatus::Resting { resting } => {
2954                            // Order is resting on the order book
2955                            self.create_order_status_report(
2956                                order.instrument_id(),
2957                                Some(order.client_order_id()),
2958                                VenueOrderId::new(resting.oid.to_string()),
2959                                order.order_side(),
2960                                order.order_type(),
2961                                order.quantity(),
2962                                order.time_in_force(),
2963                                order.price(),
2964                                order.trigger_price(),
2965                                OrderStatus::Accepted,
2966                                Quantity::new(0.0, instrument.size_precision()),
2967                                &instrument,
2968                                account_id,
2969                                ts_init,
2970                            )
2971                        }
2972                        HyperliquidExecOrderStatus::Filled { filled } => {
2973                            // Order was filled immediately
2974                            let filled_qty = Quantity::new(
2975                                filled.total_sz.to_string().parse::<f64>().unwrap_or(0.0),
2976                                instrument.size_precision(),
2977                            );
2978                            self.create_order_status_report(
2979                                order.instrument_id(),
2980                                Some(order.client_order_id()),
2981                                VenueOrderId::new(filled.oid.to_string()),
2982                                order.order_side(),
2983                                order.order_type(),
2984                                order.quantity(),
2985                                order.time_in_force(),
2986                                order.price(),
2987                                order.trigger_price(),
2988                                OrderStatus::Filled,
2989                                filled_qty,
2990                                &instrument,
2991                                account_id,
2992                                ts_init,
2993                            )
2994                        }
2995                        HyperliquidExecOrderStatus::Error { error } => {
2996                            return Err(Error::bad_request(format!(
2997                                "Order {} rejected: {error}",
2998                                order.client_order_id()
2999                            )));
3000                        }
3001                    };
3002
3003                    reports.push(report);
3004                }
3005
3006                Ok(reports)
3007            }
3008            HyperliquidExchangeResponse::Error { error } => Err(Error::bad_request(format!(
3009                "Order submission failed: {error}"
3010            ))),
3011            _ => Err(Error::bad_request("Unexpected response format")),
3012        }
3013    }
3014}
3015
3016/// Returns the asset index base for a perp dex.
3017///
3018/// Standard perps (dex 0) start at 0. HIP-3 dexes start at
3019/// 100_000 + dex_index * 10_000.
3020fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
3021    if dex_index == 0 {
3022        0
3023    } else {
3024        100_000 + dex_index as u32 * 10_000
3025    }
3026}
3027
3028#[cfg(test)]
3029mod tests {
3030    use std::{net::SocketAddr, sync::Arc};
3031
3032    use axum::{
3033        Router,
3034        extract::State,
3035        http::StatusCode,
3036        response::{IntoResponse, Json, Response},
3037        routing::post,
3038    };
3039    use nautilus_core::{MUTEX_POISONED, time::get_atomic_clock_realtime};
3040    use nautilus_model::{
3041        currencies::CURRENCY_MAP,
3042        enums::CurrencyType,
3043        identifiers::{InstrumentId, Symbol},
3044        instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
3045        types::{Currency, Price, Quantity},
3046    };
3047    use rstest::rstest;
3048    use serde_json::{Value, json};
3049    use ustr::Ustr;
3050
3051    use super::HyperliquidHttpClient;
3052    use crate::{
3053        common::{
3054            consts::HYPERLIQUID_VENUE,
3055            enums::{HyperliquidEnvironment, HyperliquidProductType},
3056        },
3057        http::query::InfoRequest,
3058    };
3059
3060    #[derive(Clone, Default)]
3061    struct OutcomeMetaServerState {
3062        last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
3063    }
3064
3065    async fn handle_outcome_meta_info(
3066        State(state): State<OutcomeMetaServerState>,
3067        body: axum::body::Bytes,
3068    ) -> Response {
3069        let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
3070            return (
3071                StatusCode::BAD_REQUEST,
3072                Json(json!({"error": "Invalid JSON body"})),
3073            )
3074                .into_response();
3075        };
3076
3077        *state.last_request_body.lock().await = Some(request_body.clone());
3078
3079        if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
3080            return (
3081                StatusCode::BAD_REQUEST,
3082                Json(json!({"error": "Expected outcomeMeta request"})),
3083            )
3084                .into_response();
3085        }
3086
3087        Json(json!({
3088            "outcomes": [
3089                {
3090                    "outcome": 123,
3091                    "name": "Recurring",
3092                    "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
3093                    "sideSpecs": [
3094                        {"name": "Yes"},
3095                        {"name": "No"}
3096                    ]
3097                }
3098            ]
3099        }))
3100        .into_response()
3101    }
3102
3103    async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
3104        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3105        let addr = listener.local_addr().unwrap();
3106        let router = Router::new()
3107            .route("/info", post(handle_outcome_meta_info))
3108            .with_state(state);
3109
3110        tokio::spawn(async move {
3111            axum::serve(listener, router).await.unwrap();
3112        });
3113
3114        addr
3115    }
3116
3117    #[rstest]
3118    fn stable_json_roundtrips() {
3119        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
3120        let s = serde_json::to_string(&v).unwrap();
3121        // Parse back to ensure JSON structure is correct, regardless of field order
3122        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
3123        assert_eq!(parsed["type"], "l2Book");
3124        assert_eq!(parsed["coin"], "BTC");
3125        assert_eq!(parsed, v);
3126    }
3127
3128    #[rstest]
3129    fn info_pretty_shape() {
3130        let r = InfoRequest::l2_book("BTC");
3131        let val = serde_json::to_value(&r).unwrap();
3132        let pretty = serde_json::to_string_pretty(&val).unwrap();
3133        assert!(pretty.contains("\"type\": \"l2Book\""));
3134        assert!(pretty.contains("\"coin\": \"BTC\""));
3135    }
3136
3137    #[rstest]
3138    #[tokio::test]
3139    async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
3140        let state = OutcomeMetaServerState::default();
3141        let addr = start_outcome_meta_server(state.clone()).await;
3142        let mut client =
3143            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3144        client.set_base_info_url(format!("http://{addr}/info"));
3145
3146        let meta = client.get_outcome_meta().await.unwrap();
3147        let request_body = state.last_request_body.lock().await.clone().unwrap();
3148
3149        assert_eq!(request_body, json!({"type": "outcomeMeta"}));
3150        assert_eq!(meta.outcomes.len(), 1);
3151        assert_eq!(meta.outcomes[0].outcome, 123);
3152        assert_eq!(meta.outcomes[0].name, "Recurring");
3153        assert_eq!(meta.outcomes[0].side_specs.len(), 2);
3154        assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
3155        assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
3156    }
3157
3158    #[rstest]
3159    fn test_cache_instrument_by_raw_symbol() {
3160        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3161
3162        // Create a test instrument with base currency "vntls:vCURSOR"
3163        let base_code = "vntls:vCURSOR";
3164        let quote_code = "USDC";
3165
3166        // Register the custom currency
3167        {
3168            let mut currency_map = CURRENCY_MAP.lock().expect(MUTEX_POISONED);
3169            if !currency_map.contains_key(base_code) {
3170                currency_map.insert(
3171                    base_code.to_string(),
3172                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
3173                );
3174            }
3175        }
3176
3177        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
3178        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
3179
3180        // Nautilus symbol is "vntls:vCURSOR-USDC-SPOT"
3181        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
3182        let venue = *HYPERLIQUID_VENUE;
3183        let instrument_id = InstrumentId::new(symbol, venue);
3184
3185        // raw_symbol is set to the base currency "vntls:vCURSOR" (see parse.rs)
3186        let raw_symbol = Symbol::new(base_code);
3187
3188        let clock = get_atomic_clock_realtime();
3189        let ts = clock.get_time_ns();
3190
3191        let instrument = InstrumentAny::CurrencyPair(CurrencyPair::new(
3192            instrument_id,
3193            raw_symbol,
3194            base_currency,
3195            quote_currency,
3196            8,
3197            8,
3198            Price::from("0.00000001"),
3199            Quantity::from("0.00000001"),
3200            None,
3201            None,
3202            None,
3203            None,
3204            None,
3205            None,
3206            None,
3207            None,
3208            None,
3209            None,
3210            None,
3211            None, // taker_fee
3212            None, // info
3213            ts,
3214            ts,
3215        ));
3216
3217        // Cache the instrument
3218        client.cache_instrument(&instrument);
3219
3220        // Verify it can be looked up by full symbol
3221        let instruments = client.instruments.load();
3222        let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
3223        assert!(
3224            by_full_symbol.is_some(),
3225            "Instrument should be accessible by full symbol"
3226        );
3227        assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
3228
3229        // Verify it can be looked up by raw_symbol (coin) - backward compatibility
3230        let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
3231        assert!(
3232            by_raw_symbol.is_some(),
3233            "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
3234        );
3235        assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
3236        drop(instruments);
3237
3238        // Verify it can be looked up by composite key (coin, product_type)
3239        let instruments_by_coin = client.instruments_by_coin.load();
3240        let by_coin =
3241            instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
3242        assert!(
3243            by_coin.is_some(),
3244            "Instrument should be accessible by coin and product type"
3245        );
3246        assert_eq!(by_coin.unwrap().id(), instrument.id());
3247        drop(instruments_by_coin);
3248
3249        // Verify get_or_create_instrument works with product type
3250        let retrieved_with_type = client.get_or_create_instrument(
3251            &Ustr::from("vntls:vCURSOR"),
3252            Some(HyperliquidProductType::Spot),
3253        );
3254        assert!(retrieved_with_type.is_some());
3255        assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
3256
3257        // Verify get_or_create_instrument works without product type (fallback)
3258        let retrieved_without_type =
3259            client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
3260        assert!(retrieved_without_type.is_some());
3261        assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
3262    }
3263
3264    #[rstest]
3265    fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
3266        // HTTP fill payloads for HIP-4 outcomes arrive with `coin = "#E"` and
3267        // no product-type context, so the no-product fallback in
3268        // `get_or_create_instrument` must check the Outcome bucket. Without
3269        // this, venue Settlement and userOutcome fills are silently dropped
3270        // from request_fill_reports / request_order_status_reports.
3271        use nautilus_core::time::get_atomic_clock_realtime;
3272        use nautilus_model::{
3273            enums::AssetClass,
3274            identifiers::{InstrumentId, Symbol},
3275            instruments::{BinaryOption, InstrumentAny},
3276            types::{Currency, Price, Quantity},
3277        };
3278
3279        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3280        let coin = "#500";
3281        let token = "+500";
3282
3283        let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
3284        let symbol = Symbol::new(token);
3285        let raw_symbol = Symbol::new(coin);
3286        let venue = *HYPERLIQUID_VENUE;
3287        let instrument_id = InstrumentId::new(symbol, venue);
3288
3289        let clock = get_atomic_clock_realtime();
3290        let ts = clock.get_time_ns();
3291
3292        let binary = InstrumentAny::BinaryOption(BinaryOption::new(
3293            instrument_id,
3294            raw_symbol,
3295            AssetClass::Alternative,
3296            usdh,
3297            Default::default(),
3298            Default::default(),
3299            4,
3300            2,
3301            Price::from("0.0001"),
3302            Quantity::from("0.01"),
3303            None,
3304            None,
3305            None,
3306            None,
3307            None,
3308            None,
3309            None,
3310            None,
3311            None,
3312            None,
3313            None,
3314            None,
3315            None,
3316            ts,
3317            ts,
3318        ));
3319
3320        client.cache_instrument(&binary);
3321
3322        let with_type = client
3323            .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
3324        assert!(with_type.is_some());
3325        assert_eq!(with_type.unwrap().id(), instrument_id);
3326
3327        let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
3328        assert!(
3329            no_type.is_some(),
3330            "Outcome coin must resolve through the no-product fallback",
3331        );
3332        assert_eq!(no_type.unwrap().id(), instrument_id);
3333
3334        let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
3335        assert!(missing.is_none());
3336    }
3337
3338    #[rstest]
3339    fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
3340        // Two spot pairs share the base token "HYPE": the canonical pair is
3341        // cached first; a subsequent non-canonical pair must not overwrite the
3342        // base-token alias so lookups by "HYPE" keep resolving to the canonical
3343        // instrument.
3344        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3345
3346        let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
3347        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3348        let clock = get_atomic_clock_realtime();
3349        let ts = clock.get_time_ns();
3350
3351        let canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3352            InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3353            Symbol::new("@107"),
3354            hype,
3355            usdc,
3356            5,
3357            2,
3358            Price::from("0.00001"),
3359            Quantity::from("0.01"),
3360            None,
3361            None,
3362            None,
3363            None,
3364            None,
3365            None,
3366            None,
3367            None,
3368            None,
3369            None,
3370            None,
3371            None,
3372            None,
3373            ts,
3374            ts,
3375        ));
3376
3377        let non_canonical = InstrumentAny::CurrencyPair(CurrencyPair::new(
3378            InstrumentId::new(Symbol::new("HYPE-USDC-SPOT"), *HYPERLIQUID_VENUE),
3379            Symbol::new("@999"),
3380            hype,
3381            usdc,
3382            5,
3383            2,
3384            Price::from("0.00001"),
3385            Quantity::from("0.01"),
3386            None,
3387            None,
3388            None,
3389            None,
3390            None,
3391            None,
3392            None,
3393            None,
3394            None,
3395            None,
3396            None,
3397            None,
3398            None,
3399            ts,
3400            ts,
3401        ));
3402
3403        client.cache_instrument(&canonical);
3404        client.cache_instrument(&non_canonical);
3405
3406        let instruments_by_coin = client.instruments_by_coin.load();
3407        let by_base = instruments_by_coin
3408            .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
3409            .expect("base alias must resolve");
3410        assert_eq!(
3411            by_base.raw_symbol().inner().as_str(),
3412            "@107",
3413            "base alias must point to the canonical pair, not the one cached later",
3414        );
3415    }
3416
3417    #[rstest]
3418    fn test_cache_instrument_perp_aliases_sanitized_base() {
3419        // HIP-3 perp with wildcard-bearing venue name: `instrument_id.symbol`
3420        // is sanitized but order paths derive a coin key by splitting that
3421        // sanitized symbol on `-`. The cache must alias on the sanitized base
3422        // so those lookups resolve to the same instrument cached under
3423        // `raw_symbol` (the venue-official name).
3424        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
3425
3426        let base_currency = Currency::new(
3427            "dex:STREAMABCD****",
3428            8,
3429            0,
3430            "dex:STREAMABCD****",
3431            CurrencyType::Crypto,
3432        );
3433        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
3434        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
3435        let clock = get_atomic_clock_realtime();
3436        let ts = clock.get_time_ns();
3437
3438        let hip3 = InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
3439            InstrumentId::new(
3440                Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
3441                *HYPERLIQUID_VENUE,
3442            ),
3443            Symbol::new("dex:STREAMABCD****"),
3444            base_currency,
3445            usd,
3446            usdc,
3447            false,
3448            6,
3449            3,
3450            Price::from("0.000001"),
3451            Quantity::from("0.001"),
3452            None,
3453            None,
3454            None,
3455            None,
3456            None,
3457            None,
3458            None,
3459            None,
3460            None,
3461            None,
3462            None,
3463            None,
3464            None,
3465            ts,
3466            ts,
3467        ));
3468
3469        client.cache_instrument(&hip3);
3470
3471        let instruments_by_coin = client.instruments_by_coin.load();
3472        let by_raw = instruments_by_coin
3473            .get(&(
3474                Ustr::from("dex:STREAMABCD****"),
3475                HyperliquidProductType::Perp,
3476            ))
3477            .expect("venue coin lookup must resolve");
3478        assert_eq!(by_raw.id(), hip3.id());
3479
3480        let by_sanitized = instruments_by_coin
3481            .get(&(
3482                Ustr::from("dex:STREAMABCDxxxx"),
3483                HyperliquidProductType::Perp,
3484            ))
3485            .expect("sanitized base lookup must resolve");
3486        assert_eq!(by_sanitized.id(), hip3.id());
3487        drop(instruments_by_coin);
3488
3489        // Confirm the order-submission lookup path resolves through the alias.
3490        let resolved = client
3491            .get_or_create_instrument(
3492                &Ustr::from("dex:STREAMABCDxxxx"),
3493                Some(HyperliquidProductType::Perp),
3494            )
3495            .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
3496        assert_eq!(resolved.id(), hip3.id());
3497    }
3498}