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