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