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},
27    time::Duration,
28};
29
30use ahash::{AHashMap, AHashSet};
31use anyhow::Context;
32use nautilus_common::cache::InstrumentLookupError;
33use nautilus_core::{
34    AtomicMap, UUID4, UnixNanos,
35    datetime::datetime_to_unix_nanos,
36    string::secret::SecretString,
37    time::{AtomicTime, get_atomic_clock_realtime},
38};
39use nautilus_model::{
40    data::{Bar, BarType, FundingRateUpdate},
41    enums::{
42        AccountType, BarAggregation, CurrencyType, MarketStatusAction, OrderSide, OrderStatus,
43        OrderType, TimeInForce, TriggerType,
44    },
45    events::AccountState,
46    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, VenueOrderId},
47    instruments::{CurrencyPair, Instrument, InstrumentAny},
48    orders::{Order, OrderAny},
49    reports::{FillReport, OrderStatusReport, PositionStatusReport},
50    types::{AccountBalance, Currency, Price, Quantity},
51};
52use nautilus_network::{
53    http::{
54        HttpClient, HttpClientError, HttpRedirectPolicy, HttpResponse, Method,
55        create_standard_nautilus_headers, parse_retry_after,
56    },
57    ratelimiter::quota::Quota,
58};
59use parking_lot::Mutex;
60use rust_decimal::Decimal;
61use serde_json::Value;
62use ustr::Ustr;
63
64use crate::{
65    account::{normalize_account_address, resolve_execution_account_address},
66    common::{
67        consts::{
68            ASSET_INDEX_INFO_KEY, HYPERLIQUID_REST_WEIGHT_PER_MINUTE, HYPERLIQUID_VENUE,
69            NAUTILUS_BUILDER_ADDRESS, exchange_url, info_url,
70        },
71        credential::{Secrets, VaultAddress, credential_env_vars},
72        enums::{
73            HyperliquidAccountAbstraction, HyperliquidBarInterval, HyperliquidEnvironment,
74            HyperliquidOrderStatus as HyperliquidOrderStatusEnum, HyperliquidProductType,
75        },
76        parse::{
77            bar_type_to_interval, cache_alias_for_symbol, clamp_price_to_precision,
78            derive_limit_from_trigger, determine_order_list_grouping, extract_inner_error,
79            normalize_or_validate_wire_price, order_to_hyperliquid_request_with_optional_decimals,
80            parse_combined_account_balances_and_margins, parse_outcome_symbol,
81            parse_spot_account_balances, parse_trigger_order_type, parse_trigger_order_type_label,
82            round_to_sig_figs, time_in_force_to_hyperliquid_tif,
83        },
84    },
85    data::{candle_to_bar, funding_entry_to_update},
86    data_types::HyperliquidPublicTrade,
87    http::{
88        error::{Error, Result},
89        models::{
90            ClearinghouseState, Cloid, HyperliquidCandleSnapshot, HyperliquidExchangeAction,
91            HyperliquidExchangeBuilderFee, HyperliquidExchangeCancelByCloidRequest,
92            HyperliquidExchangeCancelOrderRequest, HyperliquidExchangeGrouping,
93            HyperliquidExchangeLimitParams, HyperliquidExchangeMergeOutcomeParams,
94            HyperliquidExchangeMergeQuestionParams, HyperliquidExchangeModifyOrderRequest,
95            HyperliquidExchangeModifyTarget, HyperliquidExchangeNegateOutcomeParams,
96            HyperliquidExchangeOrderKind, HyperliquidExchangeOrderResponseData,
97            HyperliquidExchangeOrderStatus, HyperliquidExchangePlaceOrderRequest,
98            HyperliquidExchangeRequest, HyperliquidExchangeResponse,
99            HyperliquidExchangeResponseData, HyperliquidExchangeSplitOutcomeParams,
100            HyperliquidExchangeTif, HyperliquidExchangeTpSl, HyperliquidExchangeTriggerParams,
101            HyperliquidExchangeUserOutcomeOp, HyperliquidFills, HyperliquidFundingHistoryEntry,
102            HyperliquidL2Book, HyperliquidMeta, HyperliquidOrderStatus,
103            HyperliquidOrderStatusEntry, HyperliquidRecentTrade, OutcomeMeta, PerpDex, PerpMeta,
104            PerpMetaAndCtxs, RESPONSE_STATUS_OK, SpotClearinghouseState, SpotMeta, SpotMetaAndCtxs,
105        },
106        parse::{
107            DEFAULT_OUTCOME_QUOTE_CURRENCY, HyperliquidInstrumentDef, create_instrument_from_def,
108            filter_recent_public_trades, get_outcome_currency, instruments_from_defs_owned,
109            parse_fill_report, parse_order_status_report_from_basic, parse_outcome_instruments,
110            parse_perp_instruments_with_settlement, parse_position_status_report,
111            parse_recent_public_trade, parse_spot_instruments, parse_spot_position_status_report,
112            parse_unlisted_outcome_instrument, resolve_perp_settlement_currency,
113        },
114        query::{ExchangeAction, InfoRequest},
115        rate_limits::{
116            RateLimitSnapshot, WeightedLimiter, backoff_full_jitter, exchange_weight,
117            exec_action_weight, info_base_weight, info_extra_weight, shared_rest_limiter,
118        },
119    },
120    signing::{
121        HyperliquidActionType, HyperliquidEip712Signer, NonceManager, SignRequest, types::SignerId,
122    },
123    websocket::messages::WsBasicOrderData,
124};
125
126fn deduplicate_historical_order_reports(reports: Vec<OrderStatusReport>) -> Vec<OrderStatusReport> {
127    let mut best_by_venue_order_id = AHashMap::new();
128
129    for candidate in reports {
130        let Some(current) = best_by_venue_order_id.remove(&candidate.venue_order_id) else {
131            best_by_venue_order_id.insert(candidate.venue_order_id, candidate);
132            continue;
133        };
134        let (mut best, other) = if historical_report_is_more_advanced(&candidate, &current) {
135            (candidate, current)
136        } else {
137            (current, candidate)
138        };
139
140        if matches!(
141            best.order_type,
142            OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
143        ) {
144            best.price = best.price.or(other.price);
145        }
146        best.trigger_price = best.trigger_price.or(other.trigger_price);
147        best.trigger_type = best.trigger_type.or(other.trigger_type);
148        best_by_venue_order_id.insert(best.venue_order_id, best);
149    }
150
151    best_by_venue_order_id
152        .into_values()
153        .map(untrigger_closed_report_without_trigger_price)
154        .collect()
155}
156
157// The venue zeroes `triggerPx` on the final row of a triggered order that has filled, so when no
158// earlier row survives the order's trigger cannot be rebuilt and a conditional order type would
159// fail initialization. A closed order no longer needs its trigger: report it as the limit order it
160// executed as when a limit price survived, otherwise as a market order, so its fills reconcile.
161fn untrigger_closed_report_without_trigger_price(
162    mut report: OrderStatusReport,
163) -> OrderStatusReport {
164    let is_trigger_type = matches!(
165        report.order_type,
166        OrderType::StopMarket
167            | OrderType::StopLimit
168            | OrderType::MarketIfTouched
169            | OrderType::LimitIfTouched
170    );
171
172    if !is_trigger_type || report.trigger_price.is_some() || !report.order_status.is_closed() {
173        return report;
174    }
175
176    let order_type = match report.order_type {
177        OrderType::StopLimit | OrderType::LimitIfTouched if report.price.is_some() => {
178            OrderType::Limit
179        }
180        _ => {
181            // A market order has no price; a leftover limit price would be used to infer fills
182            report.price = None;
183            OrderType::Market
184        }
185    };
186    log::debug!(
187        "Historical {} order {} has no trigger price, reconciling as {order_type}",
188        report.order_type,
189        report.venue_order_id,
190    );
191    report.order_type = order_type;
192    report
193}
194
195fn historical_report_is_more_advanced(
196    candidate: &OrderStatusReport,
197    current: &OrderStatusReport,
198) -> bool {
199    candidate.filled_qty > current.filled_qty
200        || (candidate.filled_qty == current.filled_qty
201            && (historical_status_priority(candidate.order_status)
202                > historical_status_priority(current.order_status)
203                || (candidate.order_status == current.order_status
204                    && candidate.ts_last > current.ts_last)))
205}
206
207const fn historical_status_priority(status: OrderStatus) -> u8 {
208    match status {
209        OrderStatus::Initialized | OrderStatus::Submitted | OrderStatus::Emulated => 0,
210        OrderStatus::Released | OrderStatus::Denied => 1,
211        OrderStatus::Accepted | OrderStatus::PendingUpdate | OrderStatus::PendingCancel => 2,
212        OrderStatus::Triggered => 3,
213        OrderStatus::PartiallyFilled => 4,
214        OrderStatus::Canceled | OrderStatus::Expired | OrderStatus::Rejected => 5,
215        OrderStatus::Filled | OrderStatus::Voided => 6,
216    }
217}
218
219/// Unweighted REST quota retained for compatibility with existing callers.
220///
221/// Adapter clients use a shared weighted limiter because Hyperliquid aggregates request weights
222/// across `/info` and `/exchange`.
223pub static HYPERLIQUID_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
224    Quota::per_minute(NonZeroU32::new(HYPERLIQUID_REST_WEIGHT_PER_MINUTE).unwrap())
225});
226
227pub(crate) const HYPERLIQUID_RECENT_HISTORY_LIMIT: usize = 2_000;
228const RATE_LIMIT_BACKOFF_BASE: Duration = Duration::from_millis(125);
229const RATE_LIMIT_BACKOFF_CAP: Duration = Duration::from_secs(5);
230const RATE_LIMIT_INFO_RETRIES_MAX: u32 = 3;
231const RETRY_AFTER_HEADER: &str = "retry-after";
232const VAULT_TOKEN_PREFIX: &str = "vntls:";
233
234/// Maximum rows returned per `fundingHistory` call (the venue per-response cap).
235pub const HYPERLIQUID_FUNDING_HISTORY_MAX_LIMIT: usize = 500;
236/// Seven days of hourly funding, used when a request gives no start or limit.
237const DEFAULT_FUNDING_RATES_LIMIT: usize = 7 * 24;
238
239/// Provides a raw HTTP client for low-level Hyperliquid REST API operations.
240///
241/// This client handles HTTP infrastructure, request signing, and raw API calls
242/// that closely match Hyperliquid endpoint specifications.
243#[derive(Debug, Clone)]
244#[cfg_attr(
245    feature = "python",
246    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
247)]
248pub struct HyperliquidRawHttpClient {
249    client: HttpClient,
250    environment: HyperliquidEnvironment,
251    base_info: String,
252    base_exchange: String,
253    signer: Option<HyperliquidEip712Signer>,
254    nonce_manager: Option<Arc<NonceManager>>,
255    vault_address: Option<VaultAddress>,
256    proxy_url: Option<SecretString>,
257    info_limiter: Arc<WeightedLimiter>,
258    exchange_limiter: Arc<WeightedLimiter>,
259    info_max_retries: u32,
260    info_retry_delay_initial: Duration,
261    info_retry_delay_max: Duration,
262}
263
264impl HyperliquidRawHttpClient {
265    /// Creates a new [`HyperliquidRawHttpClient`] for public endpoints only.
266    ///
267    /// # Errors
268    ///
269    /// Returns an error if the HTTP client cannot be created.
270    pub fn new(
271        environment: HyperliquidEnvironment,
272        timeout_secs: u64,
273        proxy_url: Option<String>,
274    ) -> std::result::Result<Self, HttpClientError> {
275        let base_info = info_url(environment).to_string();
276        let base_exchange = exchange_url(environment).to_string();
277        let info_limiter = shared_rest_limiter(environment, &base_info, proxy_url.as_deref());
278        let exchange_limiter =
279            shared_rest_limiter(environment, &base_exchange, proxy_url.as_deref());
280
281        Ok(Self {
282            client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
283            environment,
284            base_info,
285            base_exchange,
286            signer: None,
287            nonce_manager: None,
288            vault_address: None,
289            proxy_url: proxy_url.map(SecretString::from),
290            info_limiter,
291            exchange_limiter,
292            info_max_retries: RATE_LIMIT_INFO_RETRIES_MAX,
293            info_retry_delay_initial: RATE_LIMIT_BACKOFF_BASE,
294            info_retry_delay_max: RATE_LIMIT_BACKOFF_CAP,
295        })
296    }
297
298    /// Creates a new [`HyperliquidRawHttpClient`] configured with credentials
299    /// for authenticated requests.
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if the HTTP client cannot be created.
304    pub fn with_credentials(
305        secrets: &Secrets,
306        timeout_secs: u64,
307        proxy_url: Option<String>,
308    ) -> std::result::Result<Self, HttpClientError> {
309        let signer = HyperliquidEip712Signer::new(&secrets.private_key)
310            .map_err(|e| HttpClientError::from(e.to_string()))?;
311        let nonce_manager = Arc::new(NonceManager::new());
312        let base_info = info_url(secrets.environment).to_string();
313        let base_exchange = exchange_url(secrets.environment).to_string();
314        let info_limiter =
315            shared_rest_limiter(secrets.environment, &base_info, proxy_url.as_deref());
316        let exchange_limiter =
317            shared_rest_limiter(secrets.environment, &base_exchange, proxy_url.as_deref());
318
319        Ok(Self {
320            client: Self::build_http_client(timeout_secs, proxy_url.clone())?,
321            environment: secrets.environment,
322            base_info,
323            base_exchange,
324            signer: Some(signer),
325            nonce_manager: Some(nonce_manager),
326            vault_address: secrets.vault_address,
327            proxy_url: proxy_url.map(SecretString::from),
328            info_limiter,
329            exchange_limiter,
330            info_max_retries: RATE_LIMIT_INFO_RETRIES_MAX,
331            info_retry_delay_initial: RATE_LIMIT_BACKOFF_BASE,
332            info_retry_delay_max: RATE_LIMIT_BACKOFF_CAP,
333        })
334    }
335
336    /// Overrides the base info URL (for testing with mock servers).
337    pub fn set_base_info_url(&mut self, url: String) {
338        self.info_limiter = shared_rest_limiter(
339            self.environment,
340            &url,
341            self.proxy_url.as_ref().map(|value| value.expose_secret()),
342        );
343        self.base_info = url;
344    }
345
346    /// Overrides the base exchange URL (for testing with mock servers).
347    pub fn set_base_exchange_url(&mut self, url: String) {
348        self.exchange_limiter = shared_rest_limiter(
349            self.environment,
350            &url,
351            self.proxy_url.as_ref().map(|value| value.expose_secret()),
352        );
353        self.base_exchange = url;
354    }
355
356    /// Creates an authenticated client from environment variables for the specified network.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if required environment variables are not set.
361    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
362        let secrets = Secrets::from_env(environment)
363            .map_err(|e| Error::auth(format!("missing credentials in environment: {e}")))?;
364        Self::with_credentials(&secrets, 60, None)
365            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
366    }
367
368    /// Creates a new [`HyperliquidRawHttpClient`] configured with explicit credentials.
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if the private key is invalid or cannot be parsed.
373    pub fn from_credentials(
374        private_key: &str,
375        vault_address: Option<&str>,
376        environment: HyperliquidEnvironment,
377        timeout_secs: u64,
378        proxy_url: Option<String>,
379    ) -> Result<Self> {
380        let secrets = Secrets::from_private_key(private_key, vault_address, environment)
381            .map_err(|e| Error::auth(format!("invalid credentials: {e}")))?;
382        Self::with_credentials(&secrets, timeout_secs, proxy_url)
383            .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))
384    }
385
386    /// Rebinds the client to the shared rate limits for its configured routes.
387    #[must_use]
388    pub fn with_rate_limits(mut self) -> Self {
389        let proxy_url = self.proxy_url.as_ref().map(|value| value.expose_secret());
390        self.info_limiter = shared_rest_limiter(self.environment, &self.base_info, proxy_url);
391        self.exchange_limiter =
392            shared_rest_limiter(self.environment, &self.base_exchange, proxy_url);
393        self
394    }
395
396    /// Returns the configured environment.
397    #[must_use]
398    pub fn environment(&self) -> HyperliquidEnvironment {
399        self.environment
400    }
401
402    /// Returns whether this client is configured for testnet.
403    #[must_use]
404    pub fn is_testnet(&self) -> bool {
405        self.environment == HyperliquidEnvironment::Testnet
406    }
407
408    /// Gets the user address derived from the private key (if client has credentials).
409    ///
410    /// # Errors
411    ///
412    /// Returns an error if the client has no signer configured.
413    pub fn get_user_address(&self) -> Result<String> {
414        self.signer
415            .as_ref()
416            .ok_or_else(|| Error::auth("No signer configured"))?
417            .address()
418    }
419
420    /// Returns `true` if a vault address is configured.
421    #[must_use]
422    pub fn has_vault_address(&self) -> bool {
423        self.vault_address.is_some()
424    }
425
426    /// Gets the account address for queries: vault address if configured,
427    /// otherwise the user (EOA) address.
428    ///
429    /// # Errors
430    ///
431    /// Returns an error if the client has no signer configured.
432    pub fn get_account_address(&self) -> Result<String> {
433        if let Some(vault) = &self.vault_address {
434            Ok(vault.to_hex())
435        } else {
436            self.get_user_address()
437        }
438    }
439
440    fn build_http_client(
441        timeout_secs: u64,
442        proxy_url: Option<String>,
443    ) -> std::result::Result<HttpClient, HttpClientError> {
444        HttpClient::builder()
445            .redirect_policy(HttpRedirectPolicy::Reject)
446            .headers(Self::default_headers())
447            .header_keys(vec![RETRY_AFTER_HEADER.to_string()])
448            .rate_limiters(Vec::new())
449            .timeout_secs(timeout_secs)
450            .maybe_proxy_url(proxy_url)
451            .build()
452    }
453
454    fn default_headers() -> HashMap<String, String> {
455        let mut headers: HashMap<String, String> =
456            create_standard_nautilus_headers().into_iter().collect();
457        headers.insert("Content-Type".to_string(), "application/json".to_string());
458        headers
459    }
460
461    fn signer_id(&self) -> SignerId {
462        SignerId("hyperliquid:default".into())
463    }
464
465    fn retry_after_ms(headers: &HashMap<String, String>) -> Option<u64> {
466        let retry_after = headers.get(RETRY_AFTER_HEADER)?;
467        parse_retry_after(retry_after, jiff::Timestamp::now())
468            .map(|delay| delay.as_millis().min(u128::from(u64::MAX)) as u64)
469    }
470
471    /// Get metadata about available markets.
472    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
473        let request = InfoRequest::meta();
474        let response = self.send_info_request(&request).await?;
475        serde_json::from_value(response).map_err(Error::Serde)
476    }
477
478    /// Get complete spot metadata (tokens and pairs).
479    pub async fn get_spot_meta(&self) -> Result<SpotMeta> {
480        let request = InfoRequest::spot_meta();
481        let response = self.send_info_request(&request).await?;
482        serde_json::from_value(response).map_err(Error::Serde)
483    }
484
485    /// Get perpetuals metadata with asset contexts (for price precision refinement).
486    pub async fn get_perp_meta_and_ctxs(&self) -> Result<PerpMetaAndCtxs> {
487        let request = InfoRequest::meta_and_asset_ctxs();
488        let response = self.send_info_request(&request).await?;
489        serde_json::from_value(response).map_err(Error::Serde)
490    }
491
492    /// Get spot metadata with asset contexts (for price precision refinement).
493    pub async fn get_spot_meta_and_ctxs(&self) -> Result<SpotMetaAndCtxs> {
494        let request = InfoRequest::spot_meta_and_asset_ctxs();
495        let response = self.send_info_request(&request).await?;
496        serde_json::from_value(response).map_err(Error::Serde)
497    }
498
499    /// Get outcome metadata.
500    pub async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
501        let request = InfoRequest::outcome_meta();
502        let response = self.send_info_request(&request).await?;
503        serde_json::from_value(response).map_err(Error::Serde)
504    }
505
506    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
507        let request = InfoRequest::meta();
508        let response = self.send_info_request(&request).await?;
509        serde_json::from_value(response).map_err(Error::Serde)
510    }
511
512    /// Get metadata for all perp dexes (standard + HIP-3).
513    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
514        let request = InfoRequest::all_perp_metas();
515        let response = self.send_info_request(&request).await?;
516        serde_json::from_value(response).map_err(Error::Serde)
517    }
518
519    /// Get the list of perp dex names aligned by dex index.
520    pub(crate) async fn load_perp_dexs(&self) -> Result<Vec<Option<PerpDex>>> {
521        let request = InfoRequest::perp_dexs();
522        let response = self.send_info_request(&request).await?;
523        serde_json::from_value(response).map_err(Error::Serde)
524    }
525
526    /// Get L2 order book for a coin.
527    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
528        let request = InfoRequest::l2_book(coin);
529        let response = self.send_info_request(&request).await?;
530        serde_json::from_value(response).map_err(Error::Serde)
531    }
532
533    /// Get recent public trades for a coin.
534    ///
535    /// Returns a recent snapshot (newest first) with no time range. Depends on the
536    /// Hyperliquid indexer: self-hosted `/info` nodes return HTTP 422.
537    pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
538        let request = InfoRequest::recent_trades(coin);
539        let response = self.send_info_request(&request).await?;
540        serde_json::from_value(response).map_err(Error::Serde)
541    }
542
543    /// Get user fills (trading history).
544    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
545        let request = InfoRequest::user_fills(user);
546        let response = self.send_info_request(&request).await?;
547        serde_json::from_value(response).map_err(Error::Serde)
548    }
549
550    /// Get order status for a user.
551    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
552        let request = InfoRequest::order_status(user, oid);
553        let response = self.send_info_request(&request).await?;
554        serde_json::from_value(response).map_err(Error::Serde)
555    }
556
557    /// Get all open orders for a user.
558    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
559        let request = InfoRequest::open_orders(user);
560        self.send_info_request(&request).await
561    }
562
563    /// Get frontend open orders (includes more detail) for a user.
564    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
565        self.info_frontend_open_orders_for_dex(user, None).await
566    }
567
568    async fn info_frontend_open_orders_for_dex(
569        &self,
570        user: &str,
571        dex: Option<&str>,
572    ) -> Result<Value> {
573        let request = InfoRequest::frontend_open_orders_for_dex(user, dex);
574        self.send_info_request(&request).await
575    }
576
577    /// Get the most recent historical orders for a user.
578    pub async fn info_historical_orders(
579        &self,
580        user: &str,
581    ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
582        let request = InfoRequest::historical_orders(user);
583        let response = self.send_info_request(&request).await?;
584        serde_json::from_value(response).map_err(Error::Serde)
585    }
586
587    /// Get clearinghouse state (balances, positions, margin) for a user.
588    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
589        self.info_clearinghouse_state_for_dex(user, None).await
590    }
591
592    async fn info_clearinghouse_state_for_dex(
593        &self,
594        user: &str,
595        dex: Option<&str>,
596    ) -> Result<Value> {
597        let request = InfoRequest::clearinghouse_state_for_dex(user, dex);
598        self.send_info_request(&request).await
599    }
600
601    /// Get spot clearinghouse state (per-token spot balances) for a user.
602    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
603        let request = InfoRequest::spot_clearinghouse_state(user);
604        self.send_info_request(&request).await
605    }
606
607    /// Get the account abstraction mode for a user.
608    pub async fn info_user_abstraction(&self, user: &str) -> Result<Value> {
609        let request = InfoRequest::user_abstraction(user);
610        self.send_info_request(&request).await
611    }
612
613    /// Get user fee schedule and effective rates.
614    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
615        let request = InfoRequest::user_fees(user);
616        self.send_info_request(&request).await
617    }
618
619    /// Get candle/bar data for a coin.
620    pub async fn info_candle_snapshot(
621        &self,
622        coin: &str,
623        interval: HyperliquidBarInterval,
624        start_time: u64,
625        end_time: u64,
626    ) -> Result<HyperliquidCandleSnapshot> {
627        let request = InfoRequest::candle_snapshot(coin, interval, start_time, end_time);
628        let response = self.send_info_request(&request).await?;
629
630        log::trace!(
631            "Candle snapshot raw response (len={}): {:?}",
632            response.as_array().map_or(0, |a| a.len()),
633            response
634        );
635
636        serde_json::from_value(response).map_err(Error::Serde)
637    }
638
639    /// Get historical funding rates for a coin.
640    ///
641    /// `start_time` and `end_time` are Unix milliseconds. `end_time` is optional;
642    /// if omitted, the venue returns entries up to the most recent funding.
643    pub async fn info_funding_history(
644        &self,
645        coin: &str,
646        start_time: u64,
647        end_time: Option<u64>,
648    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
649        let request = InfoRequest::funding_history(coin, start_time, end_time);
650        let response = self.send_info_request(&request).await?;
651        serde_json::from_value(response).map_err(Error::Serde)
652    }
653
654    /// Generic info request method that returns raw JSON (useful for new endpoints and testing).
655    pub async fn send_info_request_raw(&self, request: &InfoRequest) -> Result<Value> {
656        self.send_info_request(request).await
657    }
658
659    async fn send_info_request(&self, request: &InfoRequest) -> Result<Value> {
660        let base_w = info_base_weight(request);
661        let mut attempt = 0u32;
662
663        loop {
664            self.info_limiter.acquire(base_w).await;
665
666            let (e, retry_after_ms) = match self.http_roundtrip_info(request).await {
667                Ok(response) if response.status.is_success() => {
668                    let val: Value =
669                        serde_json::from_slice(&response.body).map_err(Error::Serde)?;
670                    let extra = info_extra_weight(request, &val);
671                    if extra > 0 {
672                        self.info_limiter.debit_extra(extra).await;
673                        log::debug!(
674                            "Info debited extra weight: endpoint={request:?}, base_w={base_w}, extra={extra}"
675                        );
676                    }
677
678                    return Ok(val);
679                }
680                Ok(response) => {
681                    let retry_after_ms = Self::retry_after_ms(&response.headers);
682                    let error = if response.status.as_u16() == 429 {
683                        Error::rate_limit("info", base_w, retry_after_ms)
684                    } else {
685                        let body = String::from_utf8_lossy(&response.body);
686                        Error::http(response.status.as_u16(), body.to_string())
687                    };
688
689                    (error, retry_after_ms)
690                }
691                Err(e) => (e, None),
692            };
693
694            if !e.is_retryable() || attempt >= self.info_max_retries {
695                return Err(e);
696            }
697
698            let delay = retry_after_ms.map_or_else(
699                || {
700                    backoff_full_jitter(
701                        attempt,
702                        self.info_retry_delay_initial,
703                        self.info_retry_delay_max,
704                    )
705                },
706                Duration::from_millis,
707            );
708
709            log::debug!(
710                "Info request failed; retrying: endpoint={request:?}, attempt={attempt}, wait_ms={}",
711                delay.as_millis()
712            );
713            attempt += 1;
714            tokio::time::sleep(delay).await;
715        }
716    }
717
718    async fn http_roundtrip_info(&self, request: &InfoRequest) -> Result<HttpResponse> {
719        let url = &self.base_info;
720        let body = serde_json::to_value(request).map_err(Error::Serde)?;
721        let body_bytes = serde_json::to_string(&body)
722            .map_err(Error::Serde)?
723            .into_bytes();
724
725        self.client
726            .request(
727                Method::POST,
728                url.clone(),
729                None,
730                None,
731                Some(body_bytes),
732                None,
733                None,
734            )
735            .await
736            .map_err(Error::from_http_client)
737    }
738
739    /// Send a signed action to the exchange.
740    pub async fn post_action(
741        &self,
742        action: &ExchangeAction,
743    ) -> Result<HyperliquidExchangeResponse> {
744        let w = exchange_weight(action);
745        self.exchange_limiter.acquire(w).await;
746
747        let signer = self
748            .signer
749            .as_ref()
750            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
751
752        let nonce_manager = self
753            .nonce_manager
754            .as_ref()
755            .ok_or_else(|| Error::auth("nonce manager missing"))?;
756
757        let signer_id = self.signer_id();
758        let time_nonce = nonce_manager.next(signer_id)?;
759
760        // L1 signing uses `action_bytes` only; skip the JSON value to save work
761        let action_bytes = rmp_serde::to_vec_named(action)
762            .context("serialize action with MessagePack")
763            .map_err(|e| Error::bad_request(e.to_string()))?;
764
765        let sign_request = SignRequest {
766            action: None,
767            action_bytes: Some(action_bytes),
768            time_nonce,
769            action_type: HyperliquidActionType::L1,
770            is_testnet: self.is_testnet(),
771            vault_address: self.vault_address,
772            expires_after: None,
773        };
774
775        let sig = signer.sign(&sign_request)?.signature;
776
777        let nonce_u64 = time_nonce.as_millis() as u64;
778
779        let request = if let Some(vault) = self.vault_address {
780            HyperliquidExchangeRequest::with_vault(
781                action.clone(),
782                nonce_u64,
783                sig,
784                vault.to_string(),
785            )
786        } else {
787            HyperliquidExchangeRequest::new(action.clone(), nonce_u64, sig)
788        };
789
790        let response = self.http_roundtrip_exchange(&request).await?;
791
792        if response.status.is_success() {
793            let parsed_response: HyperliquidExchangeResponse =
794                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
795
796            // Check if the response contains an error status
797            match &parsed_response {
798                HyperliquidExchangeResponse::Status {
799                    status,
800                    response: response_data,
801                } if status == "err" => {
802                    let error_msg = response_data
803                        .as_str()
804                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
805                    log::error!("Hyperliquid API returned error: {error_msg}");
806                    Err(Error::bad_request(format!("API error: {error_msg}")))
807                }
808                HyperliquidExchangeResponse::Error { error } => {
809                    log::error!("Hyperliquid API returned error: {error}");
810                    Err(Error::bad_request(format!("API error: {error}")))
811                }
812                _ => Ok(parsed_response),
813            }
814        } else if response.status.as_u16() == 429 {
815            let ra = Self::retry_after_ms(&response.headers);
816            Err(Error::rate_limit("exchange", w, ra))
817        } else {
818            let error_body = String::from_utf8_lossy(&response.body);
819            log::error!(
820                "Exchange API error (status {}): {}",
821                response.status.as_u16(),
822                error_body
823            );
824            Err(Error::http(
825                response.status.as_u16(),
826                error_body.to_string(),
827            ))
828        }
829    }
830
831    /// Build a signed exchange request using the typed HyperliquidExchangeAction enum.
832    ///
833    /// # Errors
834    ///
835    /// Returns an error if the action is unsupported, credentials are missing, or signing fails.
836    pub fn sign_action_exec_request(
837        &self,
838        action: &HyperliquidExchangeAction,
839        expires_after: Option<u64>,
840    ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
841        if matches!(action, HyperliquidExchangeAction::UsdClassTransfer { .. }) {
842            return Err(Error::bad_request("Unsupported action: usdClassTransfer"));
843        }
844
845        let signer = self
846            .signer
847            .as_ref()
848            .ok_or_else(|| Error::auth("credentials required for exchange operations"))?;
849
850        let nonce_manager = self
851            .nonce_manager
852            .as_ref()
853            .ok_or_else(|| Error::auth("nonce manager missing"))?;
854
855        let signer_id = self.signer_id();
856        let time_nonce = nonce_manager.next(signer_id)?;
857        // No need to validate - next() guarantees a valid, unused nonce
858
859        // L1 signing uses `action_bytes` only; skip the JSON value to save work
860        let action_bytes = rmp_serde::to_vec_named(action)
861            .context("serialize action with MessagePack")
862            .map_err(|e| Error::bad_request(e.to_string()))?;
863
864        let sig = signer
865            .sign(&SignRequest {
866                action: None,
867                action_bytes: Some(action_bytes),
868                time_nonce,
869                action_type: HyperliquidActionType::L1,
870                is_testnet: self.is_testnet(),
871                vault_address: self.vault_address,
872                expires_after,
873            })?
874            .signature;
875
876        let mut request = if let Some(vault) = self.vault_address {
877            HyperliquidExchangeRequest::with_vault(
878                action.clone(),
879                time_nonce.as_millis() as u64,
880                sig,
881                vault.to_string(),
882            )
883        } else {
884            HyperliquidExchangeRequest::new(action.clone(), time_nonce.as_millis() as u64, sig)
885        };
886        request.expires_after = expires_after;
887        Ok(request)
888    }
889
890    /// Send a signed action to the exchange using the typed HyperliquidExchangeAction enum.
891    ///
892    /// This is the preferred method for placing orders as it uses properly typed
893    /// structures that match Hyperliquid's API expectations exactly.
894    pub async fn post_action_exec(
895        &self,
896        action: &HyperliquidExchangeAction,
897    ) -> Result<HyperliquidExchangeResponse> {
898        let w = exec_action_weight(action);
899        self.exchange_limiter.acquire(w).await;
900
901        let request = self.sign_action_exec_request(action, None)?;
902
903        let response = self.http_roundtrip_exchange(&request).await?;
904
905        if response.status.is_success() {
906            let parsed_response: HyperliquidExchangeResponse =
907                serde_json::from_slice(&response.body).map_err(Error::Serde)?;
908
909            // Check if the response contains an error status
910            match &parsed_response {
911                HyperliquidExchangeResponse::Status {
912                    status,
913                    response: response_data,
914                } if status == "err" => {
915                    let error_msg = response_data
916                        .as_str()
917                        .map_or_else(|| response_data.to_string(), |s| s.to_string());
918                    log::error!("Hyperliquid API returned error: {error_msg}");
919                    Err(Error::bad_request(format!("API error: {error_msg}")))
920                }
921                HyperliquidExchangeResponse::Error { error } => {
922                    log::error!("Hyperliquid API returned error: {error}");
923                    Err(Error::bad_request(format!("API error: {error}")))
924                }
925                _ => Ok(parsed_response),
926            }
927        } else if response.status.as_u16() == 429 {
928            let ra = Self::retry_after_ms(&response.headers);
929            Err(Error::rate_limit("exchange", w, ra))
930        } else {
931            let error_body = String::from_utf8_lossy(&response.body);
932            Err(Error::http(
933                response.status.as_u16(),
934                error_body.to_string(),
935            ))
936        }
937    }
938
939    /// Returns the current rate-limit state for the info endpoint route.
940    pub async fn rest_limiter_snapshot(&self) -> RateLimitSnapshot {
941        self.info_limiter.snapshot().await
942    }
943
944    async fn http_roundtrip_exchange<T>(
945        &self,
946        request: &HyperliquidExchangeRequest<T>,
947    ) -> Result<HttpResponse>
948    where
949        T: serde::Serialize,
950    {
951        let url = &self.base_exchange;
952        let body = serde_json::to_string(&request).map_err(Error::Serde)?;
953        let body_bytes = body.into_bytes();
954
955        let response = self
956            .client
957            .request(
958                Method::POST,
959                url.clone(),
960                None,
961                None,
962                Some(body_bytes),
963                None,
964                None,
965            )
966            .await
967            .map_err(Error::from_http_client)?;
968
969        Ok(response)
970    }
971}
972
973/// Provides a high-level HTTP client for the [Hyperliquid](https://hyperliquid.xyz/) REST API.
974///
975/// This domain client wraps [`HyperliquidRawHttpClient`] and provides methods that work
976/// with Nautilus domain types. It maintains an instrument cache and handles conversions
977/// between Hyperliquid API responses and Nautilus domain models.
978#[derive(Debug, Clone)]
979#[cfg_attr(
980    feature = "python",
981    pyo3::pyclass(module = "nautilus_trader.adapters.hyperliquid", from_py_object)
982)]
983#[cfg_attr(
984    feature = "python",
985    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.hyperliquid")
986)]
987pub struct HyperliquidHttpClient {
988    pub(crate) inner: Arc<HyperliquidRawHttpClient>,
989    clock: &'static AtomicTime,
990    instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
991    instruments_by_coin: Arc<AtomicMap<(Ustr, HyperliquidProductType), InstrumentAny>>,
992    /// Mapping from symbol to asset index for order submission.
993    asset_indices: Arc<AtomicMap<Ustr, u32>>,
994    /// Mapping from spot fill coin (`@{pair_index}`) to instrument symbol.
995    spot_fill_coins: Arc<AtomicMap<Ustr, Ustr>>,
996    client_order_id_cloids: Arc<Mutex<AHashMap<ClientOrderId, Cloid>>>,
997    account_id: Option<AccountId>,
998    /// Optional override address for queries (agent wallet / API sub-key support).
999    /// When set, used for balance queries, position reports, and WS subscriptions
1000    /// instead of the address derived from the private key.
1001    account_address: Option<String>,
1002    normalize_prices: bool,
1003    market_order_slippage_bps: u32,
1004    include_builder_attribution: bool,
1005}
1006
1007impl Default for HyperliquidHttpClient {
1008    fn default() -> Self {
1009        Self::new(HyperliquidEnvironment::Mainnet, 60, None)
1010            .expect("Failed to create default Hyperliquid HTTP client")
1011    }
1012}
1013
1014impl HyperliquidHttpClient {
1015    /// Creates a new [`HyperliquidHttpClient`] for public endpoints only.
1016    ///
1017    /// # Errors
1018    ///
1019    /// Returns an error if the HTTP client cannot be created.
1020    pub fn new(
1021        environment: HyperliquidEnvironment,
1022        timeout_secs: u64,
1023        proxy_url: Option<String>,
1024    ) -> std::result::Result<Self, HttpClientError> {
1025        let raw_client = HyperliquidRawHttpClient::new(environment, timeout_secs, proxy_url)?;
1026        Ok(Self::from_raw(raw_client))
1027    }
1028
1029    /// Creates a new [`HyperliquidHttpClient`] configured with a [`Secrets`] struct.
1030    ///
1031    /// # Errors
1032    ///
1033    /// Returns an error if the HTTP client cannot be created.
1034    pub fn with_secrets(
1035        secrets: &Secrets,
1036        timeout_secs: u64,
1037        proxy_url: Option<String>,
1038    ) -> std::result::Result<Self, HttpClientError> {
1039        let raw_client =
1040            HyperliquidRawHttpClient::with_credentials(secrets, timeout_secs, proxy_url)?;
1041        Ok(Self::from_raw(raw_client))
1042    }
1043
1044    fn from_raw(raw_client: HyperliquidRawHttpClient) -> Self {
1045        Self {
1046            inner: Arc::new(raw_client),
1047            clock: get_atomic_clock_realtime(),
1048            instruments: Arc::new(AtomicMap::new()),
1049            instruments_by_coin: Arc::new(AtomicMap::new()),
1050            asset_indices: Arc::new(AtomicMap::new()),
1051            spot_fill_coins: Arc::new(AtomicMap::new()),
1052            client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1053            account_id: None,
1054            account_address: None,
1055            normalize_prices: true,
1056            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1057            include_builder_attribution: true,
1058        }
1059    }
1060
1061    /// Returns the cached CLOID for a client order ID, or derives and caches it.
1062    #[must_use]
1063    pub fn get_or_generate_client_order_id_cloid(&self, client_order_id: ClientOrderId) -> Cloid {
1064        let mut cloids = self.client_order_id_cloids.lock();
1065        *cloids
1066            .entry(client_order_id)
1067            .or_insert_with(|| Cloid::from_client_order_id(client_order_id))
1068    }
1069
1070    /// Caches a CLOID for a client order ID if one is not already cached.
1071    pub fn cache_client_order_id_cloid(&self, client_order_id: ClientOrderId, cloid: Cloid) {
1072        self.client_order_id_cloids
1073            .lock()
1074            .entry(client_order_id)
1075            .or_insert(cloid);
1076    }
1077
1078    /// Returns the cached CLOID for a client order ID.
1079    #[must_use]
1080    pub fn cached_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1081        self.client_order_id_cloids
1082            .lock()
1083            .get(client_order_id)
1084            .copied()
1085    }
1086
1087    /// Returns the cached CLOID for a client order ID when no other client
1088    /// order ID maps to the same CLOID.
1089    #[must_use]
1090    pub(crate) fn unique_cached_client_order_id_cloid(
1091        &self,
1092        client_order_id: &ClientOrderId,
1093    ) -> Option<Cloid> {
1094        let cloids = self.client_order_id_cloids.lock();
1095        let cloid = cloids.get(client_order_id).copied()?;
1096        let mapping_count = cloids
1097            .values()
1098            .filter(|cached_cloid| **cached_cloid == cloid)
1099            .count();
1100
1101        (mapping_count == 1).then_some(cloid)
1102    }
1103
1104    /// Removes the cached CLOID for a client order ID.
1105    pub fn remove_client_order_id_cloid(&self, client_order_id: &ClientOrderId) -> Option<Cloid> {
1106        self.client_order_id_cloids.lock().remove(client_order_id)
1107    }
1108
1109    /// Overrides the base info URL (for testing with mock servers).
1110    ///
1111    /// # Panics
1112    ///
1113    /// Panics if the inner `Arc` has multiple references.
1114    pub fn set_base_info_url(&mut self, url: String) {
1115        Arc::get_mut(&mut self.inner)
1116            .expect("cannot override URL: Arc has multiple references")
1117            .set_base_info_url(url);
1118    }
1119
1120    /// Overrides the base exchange URL (for testing with mock servers).
1121    ///
1122    /// # Panics
1123    ///
1124    /// Panics if the inner `Arc` has multiple references.
1125    pub fn set_base_exchange_url(&mut self, url: String) {
1126        Arc::get_mut(&mut self.inner)
1127            .expect("cannot override URL: Arc has multiple references")
1128            .set_base_exchange_url(url);
1129    }
1130
1131    pub(crate) fn set_info_retry_config(
1132        &mut self,
1133        max_retries: u32,
1134        delay_initial: Duration,
1135        delay_max: Duration,
1136    ) {
1137        let inner = Arc::make_mut(&mut self.inner);
1138        inner.info_max_retries = max_retries;
1139        inner.info_retry_delay_initial = delay_initial;
1140        inner.info_retry_delay_max = delay_max;
1141    }
1142
1143    /// Creates an authenticated client from environment variables for the specified network.
1144    ///
1145    /// # Errors
1146    ///
1147    /// Returns an error if required environment variables are not set.
1148    pub fn from_env(environment: HyperliquidEnvironment) -> Result<Self> {
1149        let raw_client = HyperliquidRawHttpClient::from_env(environment)?;
1150        Ok(Self {
1151            inner: Arc::new(raw_client),
1152            clock: get_atomic_clock_realtime(),
1153            instruments: Arc::new(AtomicMap::new()),
1154            instruments_by_coin: Arc::new(AtomicMap::new()),
1155            asset_indices: Arc::new(AtomicMap::new()),
1156            spot_fill_coins: Arc::new(AtomicMap::new()),
1157            client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1158            account_id: None,
1159            account_address: None,
1160            normalize_prices: true,
1161            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1162            include_builder_attribution: true,
1163        })
1164    }
1165
1166    /// Creates a new [`HyperliquidHttpClient`] configured with credentials.
1167    ///
1168    /// If credentials are not provided, falls back to environment variables:
1169    /// - Testnet: `HYPERLIQUID_TESTNET_PK`, `HYPERLIQUID_TESTNET_VAULT`
1170    /// - Mainnet: `HYPERLIQUID_PK`, `HYPERLIQUID_VAULT`
1171    ///
1172    /// If no credentials are provided and no environment variables are set,
1173    /// creates an unauthenticated client for public endpoints only.
1174    ///
1175    /// # Errors
1176    ///
1177    /// Returns an error if credentials are invalid.
1178    pub fn with_credentials(
1179        private_key: Option<String>,
1180        vault_address: Option<String>,
1181        account_address: Option<&str>,
1182        environment: HyperliquidEnvironment,
1183        timeout_secs: u64,
1184        proxy_url: Option<String>,
1185    ) -> Result<Self> {
1186        let (pk_env_var, vault_env_var) = credential_env_vars(environment);
1187
1188        let resolved_account_address = resolve_execution_account_address(
1189            private_key.as_deref(),
1190            vault_address.as_deref(),
1191            account_address,
1192            environment,
1193        )?;
1194
1195        // Resolve private key: explicit value -> env var -> None (unauthenticated)
1196        let resolved_pk = private_key.or_else(|| std::env::var(pk_env_var).ok());
1197
1198        // Resolve vault address: explicit value -> env var -> None
1199        let resolved_vault = vault_address.or_else(|| std::env::var(vault_env_var).ok());
1200
1201        Self::from_resolved_credentials(
1202            resolved_pk,
1203            resolved_vault.as_deref(),
1204            resolved_account_address,
1205            environment,
1206            timeout_secs,
1207            proxy_url,
1208        )
1209    }
1210
1211    fn from_resolved_credentials(
1212        private_key: Option<String>,
1213        vault_address: Option<&str>,
1214        account_address: Option<String>,
1215        environment: HyperliquidEnvironment,
1216        timeout_secs: u64,
1217        proxy_url: Option<String>,
1218    ) -> Result<Self> {
1219        match private_key {
1220            Some(pk) => {
1221                let raw_client = HyperliquidRawHttpClient::from_credentials(
1222                    &pk,
1223                    vault_address,
1224                    environment,
1225                    timeout_secs,
1226                    proxy_url,
1227                )?;
1228                Ok(Self {
1229                    inner: Arc::new(raw_client),
1230                    clock: get_atomic_clock_realtime(),
1231                    instruments: Arc::new(AtomicMap::new()),
1232                    instruments_by_coin: Arc::new(AtomicMap::new()),
1233                    asset_indices: Arc::new(AtomicMap::new()),
1234                    spot_fill_coins: Arc::new(AtomicMap::new()),
1235                    client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1236                    account_id: None,
1237                    account_address,
1238                    normalize_prices: true,
1239                    market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1240                    include_builder_attribution: true,
1241                })
1242            }
1243            None => {
1244                // No credentials available, create unauthenticated client
1245                let mut client = Self::new(environment, timeout_secs, proxy_url)
1246                    .map_err(|e| Error::auth(format!("Failed to create HTTP client: {e}")))?;
1247                client.set_account_address(account_address)?;
1248                Ok(client)
1249            }
1250        }
1251    }
1252
1253    /// Creates a new [`HyperliquidHttpClient`] configured with explicit credentials.
1254    ///
1255    /// # Errors
1256    ///
1257    /// Returns an error if the private key is invalid or cannot be parsed.
1258    pub fn from_credentials(
1259        private_key: &str,
1260        vault_address: Option<&str>,
1261        environment: HyperliquidEnvironment,
1262        timeout_secs: u64,
1263        proxy_url: Option<String>,
1264    ) -> Result<Self> {
1265        let raw_client = HyperliquidRawHttpClient::from_credentials(
1266            private_key,
1267            vault_address,
1268            environment,
1269            timeout_secs,
1270            proxy_url,
1271        )?;
1272        Ok(Self {
1273            inner: Arc::new(raw_client),
1274            clock: get_atomic_clock_realtime(),
1275            instruments: Arc::new(AtomicMap::new()),
1276            instruments_by_coin: Arc::new(AtomicMap::new()),
1277            asset_indices: Arc::new(AtomicMap::new()),
1278            spot_fill_coins: Arc::new(AtomicMap::new()),
1279            client_order_id_cloids: Arc::new(Mutex::new(AHashMap::new())),
1280            account_id: None,
1281            account_address: None,
1282            normalize_prices: true,
1283            market_order_slippage_bps: crate::common::parse::DEFAULT_MARKET_SLIPPAGE_BPS,
1284            include_builder_attribution: true,
1285        })
1286    }
1287
1288    /// Returns whether this client is configured for testnet.
1289    #[must_use]
1290    pub fn is_testnet(&self) -> bool {
1291        self.inner.is_testnet()
1292    }
1293
1294    /// Returns whether order price normalization is enabled.
1295    #[must_use]
1296    pub fn normalize_prices(&self) -> bool {
1297        self.normalize_prices
1298    }
1299
1300    /// Sets whether to normalize order prices to 5 significant figures.
1301    pub fn set_normalize_prices(&mut self, value: bool) {
1302        self.normalize_prices = value;
1303    }
1304
1305    /// Returns the MARKET-order slippage buffer in basis points.
1306    #[must_use]
1307    pub fn market_order_slippage_bps(&self) -> u32 {
1308        self.market_order_slippage_bps
1309    }
1310
1311    /// Sets the MARKET-order slippage buffer in basis points.
1312    pub fn set_market_order_slippage_bps(&mut self, value: u32) {
1313        self.market_order_slippage_bps = value;
1314    }
1315
1316    /// Returns whether eligible mainnet orders include builder attribution.
1317    #[must_use]
1318    pub fn include_builder_attribution(&self) -> bool {
1319        self.include_builder_attribution
1320    }
1321
1322    /// Sets whether eligible mainnet orders include builder attribution.
1323    pub fn set_include_builder_attribution(&mut self, value: bool) {
1324        self.include_builder_attribution = value;
1325    }
1326
1327    /// Gets the user address derived from the private key (if client has credentials).
1328    ///
1329    /// # Errors
1330    ///
1331    /// Returns an error if the client has no signer configured.
1332    pub fn get_user_address(&self) -> Result<String> {
1333        self.inner.get_user_address()
1334    }
1335
1336    /// Returns `true` if a vault address is configured.
1337    #[must_use]
1338    pub fn has_vault_address(&self) -> bool {
1339        self.inner.has_vault_address()
1340    }
1341
1342    /// Returns the builder-attribution fee to attach to outgoing orders.
1343    ///
1344    /// Returns `None` when attribution is disabled, or when Hyperliquid does
1345    /// not support it for the current request context (vault orders and testnet).
1346    #[must_use]
1347    pub fn builder_attribution(&self) -> Option<HyperliquidExchangeBuilderFee> {
1348        if !self.include_builder_attribution || self.has_vault_address() || self.is_testnet() {
1349            None
1350        } else {
1351            Some(HyperliquidExchangeBuilderFee {
1352                address: NAUTILUS_BUILDER_ADDRESS.to_string(),
1353                fee_tenths_bp: 0,
1354            })
1355        }
1356    }
1357
1358    /// Gets the account address for queries: account_address if configured
1359    /// (agent wallet), then vault address, otherwise the user (EOA) address.
1360    ///
1361    /// # Errors
1362    ///
1363    /// Returns an error if the client has no signer configured and no account_address override is
1364    /// set.
1365    pub fn get_account_address(&self) -> Result<String> {
1366        if let Some(addr) = &self.account_address {
1367            return Ok(addr.clone());
1368        }
1369        self.inner.get_account_address()
1370    }
1371
1372    /// Sets the account address override for queries (agent wallet support).
1373    ///
1374    /// Trims surrounding whitespace and stores lowercase hex. Requires a `0x`-prefixed lowercase
1375    /// or EIP-55 checksummed address.
1376    /// `None` clears the override. Invalid input leaves the current override unchanged.
1377    ///
1378    /// # Errors
1379    ///
1380    /// Returns an error if the account address is invalid.
1381    pub fn set_account_address(&mut self, address: Option<String>) -> Result<()> {
1382        self.account_address = address
1383            .map(|address| normalize_account_address(&address))
1384            .transpose()?;
1385        Ok(())
1386    }
1387
1388    /// Caches a single instrument.
1389    ///
1390    /// This is required for parsing orders, fills, and positions into reports.
1391    /// Any existing instrument with the same symbol will be replaced.
1392    ///
1393    /// The venue asset index is taken from the instrument's `info` map so an
1394    /// instrument arriving on the message bus becomes submittable without
1395    /// refetching venue metadata. An instrument without the key keeps its
1396    /// existing asset index, if any, because guessing one would route orders to
1397    /// the wrong asset.
1398    pub fn cache_instrument(&self, instrument: &InstrumentAny) {
1399        self.merge_instruments(std::slice::from_ref(instrument));
1400    }
1401
1402    pub(crate) fn merge_instruments(&self, instruments: &[InstrumentAny]) {
1403        if instruments.is_empty() {
1404            return;
1405        }
1406
1407        self.cache_asset_indices(instruments);
1408        self.instruments.rcu(|m| {
1409            for instrument in instruments {
1410                m.insert(instrument.symbol().inner(), instrument.clone());
1411                m.insert(instrument.raw_symbol().inner(), instrument.clone());
1412            }
1413        });
1414
1415        let by_coin: Vec<_> = instruments
1416            .iter()
1417            .filter_map(|instrument| {
1418                let symbol = instrument.symbol();
1419                match HyperliquidProductType::from_symbol(symbol.as_str()) {
1420                    Ok(product_type) => Some((instrument, product_type)),
1421                    Err(_) => {
1422                        log::warn!("Unable to determine product type for symbol: {symbol}");
1423                        None
1424                    }
1425                }
1426            })
1427            .collect();
1428
1429        if !by_coin.is_empty() {
1430            self.instruments_by_coin.rcu(|m| {
1431                for (instrument, product_type) in &by_coin {
1432                    cache_coin_aliases(m, instrument, *product_type);
1433                }
1434            });
1435        }
1436    }
1437
1438    fn cache_asset_indices(&self, instruments: &[InstrumentAny]) {
1439        let cached = self.asset_indices.load();
1440        let mut pending = AHashMap::new();
1441
1442        for instrument in instruments {
1443            let full_symbol = instrument.symbol().inner();
1444            let coin = instrument.raw_symbol().inner();
1445            match instrument
1446                .info()
1447                .and_then(|info| info.get_u64(ASSET_INDEX_INFO_KEY))
1448                .and_then(|value| u32::try_from(value).ok())
1449            {
1450                Some(asset_index) => {
1451                    pending.insert(full_symbol, asset_index);
1452                }
1453                // vault tokens are synthesized locally to value balances and are never
1454                // submitted, so the venue assigns them no asset index to carry
1455                None if coin.starts_with(VAULT_TOKEN_PREFIX) => {}
1456                // without an index we cannot address the asset on the wire, so a market we
1457                // have never indexed is untradable rather than merely stale
1458                None if !pending.contains_key(&full_symbol)
1459                    && !cached.contains_key(&full_symbol) =>
1460                {
1461                    log::warn!(
1462                        "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value \
1463                         and has no cached asset index; orders for it will be rejected"
1464                    );
1465                }
1466                None => log::warn!(
1467                    "Instrument '{full_symbol}' carries no '{ASSET_INDEX_INFO_KEY}' info value; \
1468                     leaving the cached asset index unchanged"
1469                ),
1470            }
1471        }
1472
1473        drop(cached);
1474
1475        if !pending.is_empty() {
1476            self.asset_indices.rcu(|m| {
1477                m.extend(pending.iter().map(|(symbol, index)| (*symbol, *index)));
1478            });
1479        }
1480    }
1481
1482    fn get_or_create_instrument(
1483        &self,
1484        coin: &Ustr,
1485        product_type: Option<HyperliquidProductType>,
1486    ) -> Option<InstrumentAny> {
1487        if let Some(pt) = product_type
1488            && let Some(instrument) = self.instruments_by_coin.load().get(&(*coin, pt))
1489        {
1490            return Some(instrument.clone());
1491        }
1492
1493        // HTTP responses lack product type context. HIP-4 outcome coins
1494        // (`#E`/`+E`) are checked first because they never collide with
1495        // perp or spot symbols, then perp, then spot.
1496        if product_type.is_none() {
1497            let guard = self.instruments_by_coin.load();
1498
1499            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Outcome)) {
1500                return Some(instrument.clone());
1501            }
1502
1503            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Perp)) {
1504                return Some(instrument.clone());
1505            }
1506
1507            if let Some(instrument) = guard.get(&(*coin, HyperliquidProductType::Spot)) {
1508                return Some(instrument.clone());
1509            }
1510        }
1511
1512        // Spot fills use @{pair_index} format, translate to full symbol and look up
1513        if coin.starts_with('@')
1514            && let Some(symbol) = self.spot_fill_coins.load().get(coin)
1515        {
1516            // Look up by full symbol in instruments map (not instruments_by_coin
1517            // which uses raw_symbol)
1518            if let Some(instrument) = self.instruments.load().get(symbol) {
1519                return Some(instrument.clone());
1520            }
1521        }
1522
1523        // Settled HIP-4 outcomes drop out of `outcomeMeta`, so uncached
1524        // instruments default to USDC because the encoding lacks a quote token.
1525        if let Ok(asset_id) = parse_outcome_symbol(coin)
1526            && let Ok(def) = parse_unlisted_outcome_instrument(asset_id)
1527            && let Some(instrument) = create_instrument_from_def(&def, self.clock.get_time_ns())
1528        {
1529            log::debug!("Creating instrument for unlisted outcome coin: {coin}");
1530            self.cache_instrument(&instrument);
1531            return Some(instrument);
1532        }
1533
1534        // Vault tokens aren't in standard API, create synthetic instruments
1535        if coin.starts_with(VAULT_TOKEN_PREFIX) {
1536            log::debug!("Creating synthetic instrument for vault token: {coin}");
1537
1538            let ts_event = self.clock.get_time_ns();
1539
1540            // Create synthetic vault token instrument
1541            let symbol_str = format!("{coin}-USDC-SPOT");
1542            let symbol = Symbol::new(&symbol_str);
1543            let venue = *HYPERLIQUID_VENUE;
1544            let instrument_id = InstrumentId::new(symbol, venue);
1545
1546            // Create currencies
1547            let base_currency = Currency::new(
1548                coin.as_str(),
1549                8, // precision
1550                0, // ISO code (not applicable)
1551                coin.as_str(),
1552                CurrencyType::Crypto,
1553            );
1554
1555            let quote_currency = Currency::new(
1556                "USDC",
1557                6, // USDC standard precision
1558                0,
1559                "USDC",
1560                CurrencyType::Crypto,
1561            );
1562
1563            let price_increment = Price::from("0.00000001");
1564            let size_increment = Quantity::from("0.00000001");
1565
1566            let instrument = InstrumentAny::CurrencyPair(
1567                CurrencyPair::builder()
1568                    .instrument_id(instrument_id)
1569                    .raw_symbol(symbol)
1570                    .base_currency(base_currency)
1571                    .quote_currency(quote_currency)
1572                    .price_precision(8)
1573                    .size_precision(8)
1574                    .price_increment(price_increment)
1575                    .size_increment(size_increment)
1576                    .ts_event(ts_event)
1577                    .ts_init(ts_event)
1578                    .build()
1579                    .unwrap(),
1580            );
1581
1582            self.cache_instrument(&instrument);
1583
1584            Some(instrument)
1585        } else {
1586            // For non-vault tokens, log warning and return None
1587            log::warn!("Instrument not found in cache: {coin}");
1588            None
1589        }
1590    }
1591
1592    /// Set the account ID for this client.
1593    ///
1594    /// This is required for generating reports with the correct account ID.
1595    pub fn set_account_id(&mut self, account_id: AccountId) {
1596        self.account_id = Some(account_id);
1597    }
1598
1599    /// Fetch and parse all instrument definitions, populating the asset indices cache.
1600    pub async fn request_instrument_defs(&self) -> Result<Vec<HyperliquidInstrumentDef>> {
1601        Ok(self.load_instrument_defs().await?.0)
1602    }
1603
1604    /// Loads all instrument definitions, together with the product types whose
1605    /// metadata loaded in full.
1606    async fn load_instrument_defs(
1607        &self,
1608    ) -> Result<(
1609        Vec<HyperliquidInstrumentDef>,
1610        AHashSet<HyperliquidProductType>,
1611    )> {
1612        let mut defs: Vec<HyperliquidInstrumentDef> = Vec::new();
1613        let mut loaded = AHashSet::new();
1614        let spot_meta = match self.inner.get_spot_meta().await {
1615            Ok(spot_meta) => Some(spot_meta),
1616            Err(e) => {
1617                log::warn!("Failed to load Hyperliquid spot metadata: {e}");
1618                None
1619            }
1620        };
1621
1622        // Load all perp dexes: index 0 = standard, index 1+ = HIP-3
1623        match self.inner.load_all_perp_metas().await {
1624            Ok(all_metas) => {
1625                for (dex_index, meta) in all_metas.iter().enumerate() {
1626                    let base = perp_dex_asset_index_base(dex_index);
1627                    let settlement_currency = match resolve_perp_settlement_currency(
1628                        meta,
1629                        spot_meta.as_ref(),
1630                    ) {
1631                        Ok(settlement_currency) => settlement_currency,
1632                        Err(e) => {
1633                            return Err(Error::decode(format!(
1634                                "failed to resolve perp settlement currency for dex {dex_index}: {e}",
1635                            )));
1636                        }
1637                    };
1638
1639                    let perp_defs = parse_perp_instruments_with_settlement(
1640                        meta,
1641                        base,
1642                        settlement_currency.as_str(),
1643                    );
1644                    log::debug!(
1645                        "Loaded Hyperliquid perp defs: dex_index={dex_index}, count={}",
1646                        perp_defs.len(),
1647                    );
1648                    defs.extend(perp_defs);
1649                }
1650                loaded.insert(HyperliquidProductType::Perp);
1651            }
1652            Err(e) => {
1653                log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1654
1655                match self.inner.load_perp_meta().await {
1656                    Ok(perp_meta) => {
1657                        match resolve_perp_settlement_currency(&perp_meta, spot_meta.as_ref()) {
1658                            Ok(settlement_currency) => {
1659                                let perp_defs = parse_perp_instruments_with_settlement(
1660                                    &perp_meta,
1661                                    0,
1662                                    settlement_currency.as_str(),
1663                                );
1664                                log::debug!(
1665                                    "Loaded Hyperliquid perp defs via fallback: count={}",
1666                                    perp_defs.len(),
1667                                );
1668                                defs.extend(perp_defs);
1669                            }
1670                            Err(e) => {
1671                                return Err(Error::decode(format!(
1672                                    "failed to resolve fallback perp settlement currency: {e}",
1673                                )));
1674                            }
1675                        }
1676                    }
1677                    Err(e) => {
1678                        log::warn!("Failed to load Hyperliquid perp metadata: {e}");
1679                    }
1680                }
1681            }
1682        }
1683
1684        if let Some(spot_meta) = spot_meta.as_ref() {
1685            match parse_spot_instruments(spot_meta) {
1686                Ok(spot_defs) => {
1687                    log::debug!(
1688                        "Loaded Hyperliquid spot definitions: count={}",
1689                        spot_defs.len(),
1690                    );
1691                    defs.extend(spot_defs);
1692                    loaded.insert(HyperliquidProductType::Spot);
1693                }
1694                Err(e) => {
1695                    log::warn!("Failed to parse Hyperliquid spot instruments: {e}");
1696                }
1697            }
1698        }
1699
1700        // HIP-4 outcome metadata is best-effort: the venue may not expose it
1701        // and the response shape is still firming up. Treat any error as a
1702        // soft skip so missing outcomes do not break perp/spot loading.
1703        match self.inner.get_outcome_meta().await {
1704            Ok(outcome_meta) => match parse_outcome_instruments(&outcome_meta) {
1705                Ok(outcome_defs) => {
1706                    log::debug!(
1707                        "Loaded Hyperliquid outcome definitions: count={}",
1708                        outcome_defs.len(),
1709                    );
1710                    defs.extend(outcome_defs);
1711                    loaded.insert(HyperliquidProductType::Outcome);
1712                }
1713                Err(e) => {
1714                    log::warn!("Failed to parse Hyperliquid outcome instruments: {e}");
1715                }
1716            },
1717            Err(e) => {
1718                log::debug!("Skipping Hyperliquid outcome metadata: {e}");
1719            }
1720        }
1721
1722        // Drop defs whose Nautilus-internal symbol collides with one already
1723        // accepted. This guards the HIP-3 case where two distinct venue names
1724        // (e.g. `dex:FOO*` and `dex:FOO?`) sanitize onto the same internal
1725        // symbol; without this filter the second def would silently overwrite
1726        // the first in `asset_indices`, which would route orders to the wrong
1727        // asset. First-write-wins matches the spot canonical-pair ordering.
1728        let mut seen_symbols = ahash::AHashSet::with_capacity(defs.len());
1729        let mut deduped: Vec<HyperliquidInstrumentDef> = Vec::with_capacity(defs.len());
1730        for def in defs {
1731            if seen_symbols.insert(def.symbol) {
1732                deduped.push(def);
1733            } else {
1734                log::warn!(
1735                    "Dropping Hyperliquid instrument: sanitized symbol '{}' collides with an earlier def (raw_symbol='{}')",
1736                    def.symbol,
1737                    def.raw_symbol,
1738                );
1739            }
1740        }
1741        let defs = deduped;
1742
1743        // Populate asset indices for all instruments (including filtered HIP-3)
1744        self.asset_indices.rcu(|m| {
1745            for def in &defs {
1746                m.insert(def.symbol, def.asset_index);
1747            }
1748        });
1749        log::debug!(
1750            "Populated asset indices map (count={})",
1751            self.asset_indices.len()
1752        );
1753
1754        Ok((defs, loaded))
1755    }
1756
1757    /// Converts instrument definitions into Nautilus instruments.
1758    pub fn convert_defs(&self, defs: Vec<HyperliquidInstrumentDef>) -> Vec<InstrumentAny> {
1759        let ts_init = self.clock.get_time_ns();
1760        instruments_from_defs_owned(defs, ts_init)
1761    }
1762
1763    /// Fetch and parse all available instrument definitions from Hyperliquid.
1764    pub async fn request_instruments(&self) -> Result<Vec<InstrumentAny>> {
1765        let defs = self.request_instrument_defs().await?;
1766        Ok(self.convert_defs(defs))
1767    }
1768
1769    /// Fetch all available instruments together with the market status of each.
1770    pub(crate) async fn request_instruments_with_statuses(&self) -> Result<InstrumentSnapshot> {
1771        let (defs, loaded) = self.load_instrument_defs().await?;
1772        let def_statuses: AHashMap<Ustr, MarketStatusAction> = defs
1773            .iter()
1774            .map(|def| {
1775                let action = if def.active {
1776                    MarketStatusAction::Trading
1777                } else {
1778                    MarketStatusAction::Close
1779                };
1780                (def.symbol, action)
1781            })
1782            .collect();
1783        let instruments = self.convert_defs(defs);
1784
1785        // Defs that fail conversion yield no instrument, so they get no status
1786        let statuses = instruments
1787            .iter()
1788            .filter_map(|instrument| {
1789                let instrument_id = instrument.id();
1790                def_statuses
1791                    .get(&instrument_id.symbol.inner())
1792                    .map(|&action| (instrument_id, action))
1793            })
1794            .collect();
1795
1796        Ok(InstrumentSnapshot {
1797            instruments,
1798            statuses,
1799            loaded,
1800        })
1801    }
1802
1803    /// Builds the `allDexsAssetCtxs` normalization map from dex name to ordered instrument IDs.
1804    ///
1805    /// The order of instrument IDs must match the venue universe ordering for each perp dex so
1806    /// incoming `ctxs` arrays can be normalized without leaking raw positional payloads.
1807    pub async fn build_all_dex_asset_ctxs_instrument_ids(
1808        &self,
1809    ) -> Result<AHashMap<String, Vec<Option<InstrumentId>>>> {
1810        let all_metas = match self.inner.load_all_perp_metas().await {
1811            Ok(all_metas) => all_metas,
1812            Err(e) => {
1813                log::warn!("Failed to load allPerpMetas, falling back to meta: {e}");
1814                vec![self.inner.load_perp_meta().await?]
1815            }
1816        };
1817
1818        let perp_dexs = match self.inner.load_perp_dexs().await {
1819            Ok(dexs) => Some(dexs),
1820            Err(e) => {
1821                log::warn!("Failed to load perpDexs, inferring dex names from metadata: {e}");
1822                None
1823            }
1824        };
1825
1826        let raw_symbol_to_id =
1827            self.instruments
1828                .load()
1829                .values()
1830                .fold(AHashMap::new(), |mut acc, instrument| {
1831                    acc.insert(instrument.raw_symbol().to_string(), instrument.id());
1832                    acc
1833                });
1834
1835        let mut mapping = AHashMap::new();
1836
1837        for (dex_index, meta) in all_metas.iter().enumerate() {
1838            let dex_name = resolve_perp_dex_name(dex_index, meta, perp_dexs.as_deref());
1839            let mut instrument_ids = Vec::with_capacity(meta.universe.len());
1840
1841            for asset in &meta.universe {
1842                if let Some(instrument_id) = raw_symbol_to_id.get(&asset.name) {
1843                    instrument_ids.push(Some(*instrument_id));
1844                } else {
1845                    log::warn!(
1846                        "Missing cached Hyperliquid instrument for dex='{}' raw_symbol='{}'",
1847                        dex_name,
1848                        asset.name
1849                    );
1850                    instrument_ids.push(None);
1851                }
1852            }
1853
1854            mapping.insert(dex_name, instrument_ids);
1855        }
1856
1857        Ok(mapping)
1858    }
1859
1860    /// Get asset index for a symbol from the cached map.
1861    ///
1862    /// For perps: index in meta.universe (0, 1, 2, ...).
1863    /// For spot: 10_000 + index in spotMeta.universe.
1864    /// For HIP-3: 100_000 + dex_index * 10_000 + index in dex meta.universe.
1865    ///
1866    /// Returns `None` if the symbol is not found in the map.
1867    pub fn get_asset_index(&self, symbol: &str) -> Option<u32> {
1868        self.get_asset_index_for_symbol(Ustr::from(symbol))
1869    }
1870
1871    /// Get asset index for an already-interned symbol from the cached map.
1872    ///
1873    /// Returns `None` if the symbol is not found in the map.
1874    pub(crate) fn get_asset_index_for_symbol(&self, symbol: Ustr) -> Option<u32> {
1875        self.asset_indices.load().get(&symbol).copied()
1876    }
1877
1878    /// Get the price precision for a cached instrument by symbol.
1879    pub fn get_price_precision(&self, symbol: &str) -> Option<u8> {
1880        self.get_price_precision_for_symbol(Ustr::from(symbol))
1881    }
1882
1883    /// Get the price precision for a cached instrument by interned symbol.
1884    pub(crate) fn get_price_precision_for_symbol(&self, symbol: Ustr) -> Option<u8> {
1885        self.instruments
1886            .load()
1887            .get(&symbol)
1888            .map(|inst| inst.price_precision())
1889    }
1890
1891    /// Get mapping from spot fill coin identifiers to instrument symbols.
1892    ///
1893    /// Hyperliquid WebSocket fills for spot use `@{pair_index}` format (e.g., `@107`),
1894    /// while instruments are identified by full symbols (e.g., `HYPE-USDC-SPOT`).
1895    /// This mapping allows looking up the instrument from a spot fill.
1896    ///
1897    /// This method also caches the mapping internally for use by fill parsing methods.
1898    #[must_use]
1899    pub fn get_spot_fill_coin_mapping(&self) -> AHashMap<Ustr, Ustr> {
1900        const SPOT_INDEX_OFFSET: u32 = 10_000;
1901        const BUILDER_PERP_OFFSET: u32 = 100_000;
1902
1903        let guard = self.asset_indices.load();
1904
1905        let mut mapping = AHashMap::new();
1906
1907        for (symbol, &asset_index) in guard.iter() {
1908            // Spot instruments: asset_index in [10_000, 100_000)
1909            if (SPOT_INDEX_OFFSET..BUILDER_PERP_OFFSET).contains(&asset_index) {
1910                let pair_index = asset_index - SPOT_INDEX_OFFSET;
1911                let fill_coin = Ustr::from(&format!("@{pair_index}"));
1912                mapping.insert(fill_coin, *symbol);
1913            }
1914        }
1915
1916        // Cache the mapping internally for fill parsing
1917        self.spot_fill_coins.store(mapping.clone());
1918
1919        mapping
1920    }
1921
1922    /// Gets perpetuals metadata for internal use.
1923    #[allow(dead_code)]
1924    pub(crate) async fn load_perp_meta(&self) -> Result<PerpMeta> {
1925        self.inner.load_perp_meta().await
1926    }
1927
1928    /// Get metadata for all perp dexes (standard + HIP-3).
1929    #[allow(dead_code)]
1930    pub(crate) async fn load_all_perp_metas(&self) -> Result<Vec<PerpMeta>> {
1931        self.inner.load_all_perp_metas().await
1932    }
1933
1934    /// Gets spot metadata for internal use.
1935    #[allow(dead_code)]
1936    pub(crate) async fn get_spot_meta(&self) -> Result<SpotMeta> {
1937        self.inner.get_spot_meta().await
1938    }
1939
1940    /// Gets outcome metadata for internal use.
1941    pub(crate) async fn get_outcome_meta(&self) -> Result<OutcomeMeta> {
1942        self.inner.get_outcome_meta().await
1943    }
1944
1945    pub(crate) fn outcome_quote_currency(
1946        &self,
1947        instrument_id: InstrumentId,
1948        outcome_index: u32,
1949        meta: &OutcomeMeta,
1950    ) -> Currency {
1951        if let Some(instrument) = self.instruments.load().get(&instrument_id.symbol.inner()) {
1952            return instrument.quote_currency();
1953        }
1954
1955        let code = meta
1956            .outcomes
1957            .iter()
1958            .find(|market| market.outcome == outcome_index)
1959            .and_then(|market| market.quote_token.as_deref())
1960            .unwrap_or(DEFAULT_OUTCOME_QUOTE_CURRENCY);
1961        get_outcome_currency(code)
1962    }
1963
1964    /// Get L2 order book for a coin.
1965    pub async fn info_l2_book(&self, coin: &str) -> Result<HyperliquidL2Book> {
1966        self.inner.info_l2_book(coin).await
1967    }
1968
1969    /// Get recent public trades for a coin.
1970    pub async fn info_recent_trades(&self, coin: &str) -> Result<Vec<HyperliquidRecentTrade>> {
1971        self.inner.info_recent_trades(coin).await
1972    }
1973
1974    /// Get user fills (trading history).
1975    pub async fn info_user_fills(&self, user: &str) -> Result<HyperliquidFills> {
1976        self.inner.info_user_fills(user).await
1977    }
1978
1979    /// Get order status for a user.
1980    pub async fn info_order_status(&self, user: &str, oid: u64) -> Result<HyperliquidOrderStatus> {
1981        self.inner.info_order_status(user, oid).await
1982    }
1983
1984    /// Get all open orders for a user.
1985    pub async fn info_open_orders(&self, user: &str) -> Result<Value> {
1986        self.inner.info_open_orders(user).await
1987    }
1988
1989    /// Get frontend open orders (includes more detail) for a user.
1990    pub async fn info_frontend_open_orders(&self, user: &str) -> Result<Value> {
1991        self.inner.info_frontend_open_orders(user).await
1992    }
1993
1994    async fn info_frontend_open_orders_for_dex(
1995        &self,
1996        user: &str,
1997        dex: Option<&str>,
1998    ) -> Result<Value> {
1999        self.inner
2000            .info_frontend_open_orders_for_dex(user, dex)
2001            .await
2002    }
2003
2004    /// Get the most recent historical orders for a user.
2005    pub async fn info_historical_orders(
2006        &self,
2007        user: &str,
2008    ) -> Result<Vec<HyperliquidOrderStatusEntry>> {
2009        self.inner.info_historical_orders(user).await
2010    }
2011
2012    /// Get clearinghouse state (balances, positions, margin) for a user.
2013    pub async fn info_clearinghouse_state(&self, user: &str) -> Result<Value> {
2014        self.inner.info_clearinghouse_state(user).await
2015    }
2016
2017    async fn info_clearinghouse_state_for_dex(
2018        &self,
2019        user: &str,
2020        dex: Option<&str>,
2021    ) -> Result<Value> {
2022        self.inner.info_clearinghouse_state_for_dex(user, dex).await
2023    }
2024
2025    /// Get spot clearinghouse state (per-token spot balances) for a user.
2026    pub async fn info_spot_clearinghouse_state(&self, user: &str) -> Result<Value> {
2027        self.inner.info_spot_clearinghouse_state(user).await
2028    }
2029
2030    /// Get the account abstraction mode for a user.
2031    ///
2032    /// A mode string this adapter does not recognize maps to
2033    /// [`HyperliquidAccountAbstraction::Unknown`] and is logged as a warning; such accounts keep
2034    /// the perp-summary balance logic.
2035    ///
2036    /// # Errors
2037    ///
2038    /// Returns an error if the request fails or the response is not a JSON string.
2039    pub async fn info_user_abstraction(&self, user: &str) -> Result<HyperliquidAccountAbstraction> {
2040        let response = self.inner.info_user_abstraction(user).await?;
2041
2042        // Serde reads a single-key object like `{"mode": null}` as a unit variant, which
2043        // `#[serde(other)]` would map to `Unknown`, so only a bare string is accepted. Any
2044        // string decodes, to a known mode or to `Unknown`
2045        let abstraction = response
2046            .as_str()
2047            .and_then(|_| serde_json::from_value(response.clone()).ok())
2048            .ok_or_else(|| {
2049                Error::decode(format!(
2050                    "Failed to parse user abstraction: expected a mode string, was {response}"
2051                ))
2052            })?;
2053
2054        if abstraction == HyperliquidAccountAbstraction::Unknown {
2055            log::warn!(
2056                "Unrecognized Hyperliquid account abstraction {response}, using perp clearinghouse balances"
2057            );
2058        } else {
2059            log::debug!("Hyperliquid account abstraction: {abstraction}");
2060        }
2061
2062        Ok(abstraction)
2063    }
2064
2065    /// Get user fee schedule and effective rates.
2066    pub async fn info_user_fees(&self, user: &str) -> Result<Value> {
2067        self.inner.info_user_fees(user).await
2068    }
2069
2070    /// Get candle/bar data for a coin.
2071    pub async fn info_candle_snapshot(
2072        &self,
2073        coin: &str,
2074        interval: HyperliquidBarInterval,
2075        start_time: u64,
2076        end_time: u64,
2077    ) -> Result<HyperliquidCandleSnapshot> {
2078        self.inner
2079            .info_candle_snapshot(coin, interval, start_time, end_time)
2080            .await
2081    }
2082
2083    /// Get historical funding rates for a coin.
2084    pub async fn info_funding_history(
2085        &self,
2086        coin: &str,
2087        start_time: u64,
2088        end_time: Option<u64>,
2089    ) -> Result<Vec<HyperliquidFundingHistoryEntry>> {
2090        self.inner
2091            .info_funding_history(coin, start_time, end_time)
2092            .await
2093    }
2094
2095    /// Post an action to the exchange endpoint (low-level delegation).
2096    pub async fn post_action(
2097        &self,
2098        action: &ExchangeAction,
2099    ) -> Result<HyperliquidExchangeResponse> {
2100        self.inner.post_action(action).await
2101    }
2102
2103    /// Post an execution action (low-level delegation).
2104    pub async fn post_action_exec(
2105        &self,
2106        action: &HyperliquidExchangeAction,
2107    ) -> Result<HyperliquidExchangeResponse> {
2108        self.inner.post_action_exec(action).await
2109    }
2110
2111    /// Build the signed exchange request used by both HTTP and WebSocket post transports.
2112    ///
2113    /// # Errors
2114    ///
2115    /// Returns an error if the action is unsupported, credentials are missing, or signing fails.
2116    pub fn sign_action_exec_request(
2117        &self,
2118        action: &HyperliquidExchangeAction,
2119        expires_after: Option<u64>,
2120    ) -> Result<HyperliquidExchangeRequest<HyperliquidExchangeAction>> {
2121        self.inner.sign_action_exec_request(action, expires_after)
2122    }
2123
2124    /// Get metadata about available markets (low-level delegation).
2125    pub async fn info_meta(&self) -> Result<HyperliquidMeta> {
2126        self.inner.info_meta().await
2127    }
2128
2129    /// Cancel an order on the Hyperliquid exchange.
2130    ///
2131    /// Can cancel either by venue order ID or client order ID.
2132    /// At least one ID must be provided.
2133    ///
2134    /// # Errors
2135    ///
2136    /// Returns an error if credentials are missing, no order ID is provided,
2137    /// or the API returns an error.
2138    pub async fn cancel_order(
2139        &self,
2140        instrument_id: InstrumentId,
2141        client_order_id: Option<ClientOrderId>,
2142        venue_order_id: Option<VenueOrderId>,
2143    ) -> Result<()> {
2144        // Get asset ID from cached indices map
2145        let symbol = instrument_id.symbol.inner();
2146        let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2147            Error::bad_request(format!(
2148                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2149            ))
2150        })?;
2151
2152        let action = if let Some(client_order_id) = client_order_id {
2153            if let Some(cloid) = self.cached_client_order_id_cloid(&client_order_id) {
2154                HyperliquidExchangeAction::CancelByCloid {
2155                    cancels: vec![HyperliquidExchangeCancelByCloidRequest {
2156                        asset: asset_id,
2157                        cloid,
2158                    }],
2159                    fast: None,
2160                }
2161            } else if let Some(oid) = venue_order_id {
2162                let oid_u64 = oid
2163                    .as_str()
2164                    .parse::<u64>()
2165                    .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
2166                HyperliquidExchangeAction::Cancel {
2167                    cancels: vec![HyperliquidExchangeCancelOrderRequest {
2168                        asset: asset_id,
2169                        oid: oid_u64,
2170                    }],
2171                    fast: None,
2172                }
2173            } else {
2174                let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
2175                HyperliquidExchangeAction::CancelByCloid {
2176                    cancels: vec![HyperliquidExchangeCancelByCloidRequest {
2177                        asset: asset_id,
2178                        cloid,
2179                    }],
2180                    fast: None,
2181                }
2182            }
2183        } else if let Some(oid) = venue_order_id {
2184            let oid_u64 = oid
2185                .as_str()
2186                .parse::<u64>()
2187                .map_err(|_| Error::bad_request("Invalid venue order ID format"))?;
2188            HyperliquidExchangeAction::Cancel {
2189                cancels: vec![HyperliquidExchangeCancelOrderRequest {
2190                    asset: asset_id,
2191                    oid: oid_u64,
2192                }],
2193                fast: None,
2194            }
2195        } else {
2196            return Err(Error::bad_request(
2197                "Either client_order_id or venue_order_id must be provided",
2198            ));
2199        };
2200
2201        // Submit cancellation
2202        let response = self.inner.post_action_exec(&action).await?;
2203
2204        // Check response - only check for error status
2205        match response {
2206            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => Ok(()),
2207            HyperliquidExchangeResponse::Status {
2208                status,
2209                response: error_data,
2210            } => Err(Error::bad_request(format!(
2211                "Cancel order failed: status={status}, error={error_data}"
2212            ))),
2213            HyperliquidExchangeResponse::Error { error } => {
2214                Err(Error::bad_request(format!("Cancel order error: {error}")))
2215            }
2216        }
2217    }
2218
2219    /// Modify an order on the Hyperliquid exchange.
2220    ///
2221    /// The HL modify API requires a full replacement order spec plus a venue
2222    /// order ID or cached CLOID target. The caller must provide all order fields.
2223    ///
2224    /// # Errors
2225    ///
2226    /// Returns an error if the asset index is not found, no safe modify target
2227    /// exists, the venue order ID is invalid, or the API returns an error.
2228    #[expect(clippy::too_many_arguments)]
2229    pub async fn modify_order(
2230        &self,
2231        instrument_id: InstrumentId,
2232        venue_order_id: Option<VenueOrderId>,
2233        order_side: OrderSide,
2234        order_type: OrderType,
2235        price: Price,
2236        quantity: Quantity,
2237        trigger_price: Option<Price>,
2238        reduce_only: bool,
2239        post_only: bool,
2240        time_in_force: TimeInForce,
2241        client_order_id: Option<ClientOrderId>,
2242    ) -> Result<()> {
2243        let symbol = instrument_id.symbol.inner();
2244        let asset_id = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
2245            Error::bad_request(format!(
2246                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
2247            ))
2248        })?;
2249
2250        let oid = match client_order_id
2251            .as_ref()
2252            .and_then(|id| self.unique_cached_client_order_id_cloid(id))
2253        {
2254            Some(cloid) => HyperliquidExchangeModifyTarget::Cloid(cloid),
2255            None => {
2256                let Some(venue_order_id) = venue_order_id.as_ref() else {
2257                    return Err(Error::bad_request(
2258                        "venue_order_id or unique cached CLOID is required for modify",
2259                    ));
2260                };
2261                HyperliquidExchangeModifyTarget::from_venue_order_id(venue_order_id)
2262                    .map_err(|_| Error::bad_request("Invalid venue order ID format"))?
2263            }
2264        };
2265
2266        let is_buy = matches!(order_side, OrderSide::Buy);
2267        let decimals = self.get_price_precision_for_symbol(symbol);
2268
2269        let normalized_price = normalize_or_validate_wire_price(
2270            price.as_decimal(),
2271            "Price",
2272            decimals,
2273            self.normalize_prices,
2274        )
2275        .map_err(|e| Error::bad_request(format!("{e}")))?;
2276
2277        let size = quantity.as_decimal().normalize();
2278
2279        let kind = match order_type {
2280            OrderType::Market => HyperliquidExchangeOrderKind::Limit {
2281                limit: HyperliquidExchangeLimitParams {
2282                    tif: HyperliquidExchangeTif::Ioc,
2283                },
2284            },
2285            OrderType::Limit => {
2286                let tif = time_in_force_to_hyperliquid_tif(time_in_force, post_only)
2287                    .map_err(|e| Error::bad_request(format!("{e}")))?;
2288                HyperliquidExchangeOrderKind::Limit {
2289                    limit: HyperliquidExchangeLimitParams { tif },
2290                }
2291            }
2292            OrderType::StopMarket
2293            | OrderType::StopLimit
2294            | OrderType::MarketIfTouched
2295            | OrderType::LimitIfTouched => {
2296                if let Some(trig_px) = trigger_price {
2297                    let trigger_price_decimal = normalize_or_validate_wire_price(
2298                        trig_px.as_decimal(),
2299                        "Trigger price",
2300                        decimals,
2301                        self.normalize_prices,
2302                    )
2303                    .map_err(|e| Error::bad_request(format!("{e}")))?;
2304                    let tpsl = match order_type {
2305                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
2306                        _ => HyperliquidExchangeTpSl::Tp,
2307                    };
2308                    let is_market = matches!(
2309                        order_type,
2310                        OrderType::StopMarket | OrderType::MarketIfTouched
2311                    );
2312                    HyperliquidExchangeOrderKind::Trigger {
2313                        trigger: HyperliquidExchangeTriggerParams {
2314                            is_market,
2315                            trigger_px: trigger_price_decimal,
2316                            tpsl,
2317                        },
2318                    }
2319                } else {
2320                    return Err(Error::bad_request("Trigger orders require a trigger price"));
2321                }
2322            }
2323            _ => {
2324                return Err(Error::bad_request(format!(
2325                    "Order type {order_type:?} not supported for modify"
2326                )));
2327            }
2328        };
2329        let cloid = client_order_id.map(|id| self.get_or_generate_client_order_id_cloid(id));
2330
2331        let order = HyperliquidExchangePlaceOrderRequest {
2332            asset: asset_id,
2333            is_buy,
2334            price: normalized_price,
2335            size,
2336            reduce_only,
2337            kind,
2338            cloid,
2339        };
2340
2341        let action = HyperliquidExchangeAction::Modify {
2342            modify: HyperliquidExchangeModifyOrderRequest { oid, order },
2343        };
2344
2345        let response = self.inner.post_action_exec(&action).await?;
2346
2347        match response {
2348            ref r @ HyperliquidExchangeResponse::Status { .. } if r.is_ok() => {
2349                if let Some(inner_error) = extract_inner_error(&response) {
2350                    Err(Error::bad_request(format!(
2351                        "Modify order rejected: {inner_error}",
2352                    )))
2353                } else {
2354                    Ok(())
2355                }
2356            }
2357            HyperliquidExchangeResponse::Status {
2358                status,
2359                response: error_data,
2360            } => Err(Error::bad_request(format!(
2361                "Modify order failed: status={status}, error={error_data}"
2362            ))),
2363            HyperliquidExchangeResponse::Error { error } => {
2364                Err(Error::bad_request(format!("Modify order error: {error}")))
2365            }
2366        }
2367    }
2368
2369    /// Arms, replaces, or clears the account-wide scheduled cancellation of all open orders.
2370    ///
2371    /// `time` is a Unix timestamp in milliseconds, at least five seconds after the local
2372    /// clock when called. `None` clears the schedule. Allow extra time for rate limiting,
2373    /// network latency, and clock skew. The venue can still reject a delayed request.
2374    /// The schedule remains at the venue after disconnect or process exit; this method
2375    /// does not start a refresh loop or close positions.
2376    /// Convert Nautilus nanosecond timestamps to milliseconds before passing them here.
2377    /// The venue requires $1,000,000 trading volume and permits at most ten schedule triggers
2378    /// per day, resetting the trigger count at 00:00 UTC.
2379    ///
2380    /// # Errors
2381    ///
2382    /// Returns an error if:
2383    /// - The timestamp is less than five seconds ahead of the local clock.
2384    /// - Credentials are missing or the request fails.
2385    /// - The venue rejects the action or returns an unexpected acknowledgement.
2386    pub async fn schedule_cancel(&self, time: Option<u64>) -> Result<()> {
2387        if let Some(time) = time {
2388            validate_schedule_cancel_time(time, self.clock.get_time_ms())?;
2389        }
2390
2391        let action = HyperliquidExchangeAction::ScheduleCancel { time };
2392        let response = self.inner.post_action_exec(&action).await?;
2393        match response {
2394            HyperliquidExchangeResponse::Status { status, response }
2395                if status == RESPONSE_STATUS_OK =>
2396            {
2397                let data = serde_json::from_value::<HyperliquidExchangeResponseData>(response)
2398                    .map_err(Error::Serde)?;
2399
2400                if matches!(data, HyperliquidExchangeResponseData::Default) {
2401                    Ok(())
2402                } else {
2403                    Err(Error::bad_request(
2404                        "Unexpected schedule cancel acknowledgement",
2405                    ))
2406                }
2407            }
2408            response => Err(Error::bad_request(format!(
2409                "Schedule cancel failed: {response:?}"
2410            ))),
2411        }
2412    }
2413
2414    /// Split an HIP-4 outcome's quote tokens into matched Yes and No side tokens.
2415    ///
2416    /// Submits a `userOutcome` exchange action with the `splitOutcome` operation:
2417    /// debits `amount` quote tokens and credits `amount` Yes plus `amount`
2418    /// No side tokens for the given `outcome` index. Ordinary directional
2419    /// buys and sells on outcome instruments go through the standard order path
2420    /// without calling this; the action is for dual-side market making and
2421    /// inventory creation.
2422    ///
2423    /// # Errors
2424    ///
2425    /// Returns an error if credentials are missing, the venue rejects the
2426    /// action, or the response cannot be parsed.
2427    pub async fn submit_split_outcome(
2428        &self,
2429        outcome: u32,
2430        amount: Decimal,
2431    ) -> Result<HyperliquidExchangeResponse> {
2432        let action = HyperliquidExchangeAction::UserOutcome {
2433            op: HyperliquidExchangeUserOutcomeOp::SplitOutcome(
2434                HyperliquidExchangeSplitOutcomeParams { outcome, amount },
2435            ),
2436        };
2437        self.inner.post_action_exec(&action).await
2438    }
2439
2440    /// Merge matched Yes + No side-token pairs of an HIP-4 outcome back into quote tokens.
2441    ///
2442    /// Submits a `userOutcome` action with the `mergeOutcome` operation. Pass
2443    /// `amount = None` to merge the maximum mergeable balance (venue-side
2444    /// `null`).
2445    ///
2446    /// # Errors
2447    ///
2448    /// Returns an error if credentials are missing, the venue rejects the
2449    /// action, or the response cannot be parsed.
2450    pub async fn submit_merge_outcome(
2451        &self,
2452        outcome: u32,
2453        amount: Option<Decimal>,
2454    ) -> Result<HyperliquidExchangeResponse> {
2455        let action = HyperliquidExchangeAction::UserOutcome {
2456            op: HyperliquidExchangeUserOutcomeOp::MergeOutcome(
2457                HyperliquidExchangeMergeOutcomeParams { outcome, amount },
2458            ),
2459        };
2460        self.inner.post_action_exec(&action).await
2461    }
2462
2463    /// Merge `Yes` shares of every outcome in a multi-outcome question into quote tokens.
2464    ///
2465    /// Submits a `userOutcome` action with the `mergeQuestion` operation. Pass
2466    /// `amount = None` to merge the maximum balance.
2467    ///
2468    /// # Errors
2469    ///
2470    /// Returns an error if credentials are missing, the venue rejects the
2471    /// action, or the response cannot be parsed.
2472    pub async fn submit_merge_question(
2473        &self,
2474        question: u32,
2475        amount: Option<Decimal>,
2476    ) -> Result<HyperliquidExchangeResponse> {
2477        let action = HyperliquidExchangeAction::UserOutcome {
2478            op: HyperliquidExchangeUserOutcomeOp::MergeQuestion(
2479                HyperliquidExchangeMergeQuestionParams { question, amount },
2480            ),
2481        };
2482        self.inner.post_action_exec(&action).await
2483    }
2484
2485    /// Swap `No` shares of one outcome into `Yes` shares of every other outcome.
2486    ///
2487    /// Submits a `userOutcome` action with the `negateOutcome` operation. Both
2488    /// outcomes must belong to the same multi-outcome `question`.
2489    ///
2490    /// # Errors
2491    ///
2492    /// Returns an error if credentials are missing, the venue rejects the
2493    /// action, or the response cannot be parsed.
2494    pub async fn submit_negate_outcome(
2495        &self,
2496        question: u32,
2497        outcome: u32,
2498        amount: Decimal,
2499    ) -> Result<HyperliquidExchangeResponse> {
2500        let action = HyperliquidExchangeAction::UserOutcome {
2501            op: HyperliquidExchangeUserOutcomeOp::NegateOutcome(
2502                HyperliquidExchangeNegateOutcomeParams {
2503                    question,
2504                    outcome,
2505                    amount,
2506                },
2507            ),
2508        };
2509        self.inner.post_action_exec(&action).await
2510    }
2511
2512    /// Request order status reports for a user.
2513    ///
2514    /// Fetches frontend open orders from the default and all cached builder dexes when unfiltered,
2515    /// or from the dex selected by an instrument filter, then parses them into OrderStatusReports.
2516    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
2517    ///
2518    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
2519    /// will be created automatically.
2520    ///
2521    /// # Errors
2522    ///
2523    /// Returns an error if the API request fails, parsing fails, or a venue row cannot be resolved
2524    /// to an instrument or converted into a report (the snapshot is then incomplete and must not be
2525    /// treated as authoritative).
2526    pub async fn request_order_status_reports(
2527        &self,
2528        user: &str,
2529        instrument_id: Option<InstrumentId>,
2530    ) -> Result<Vec<OrderStatusReport>> {
2531        let dexes = self.reconciliation_dexes(instrument_id);
2532        let sweep = self
2533            .request_order_status_reports_for_dexes(user, instrument_id, &dexes)
2534            .await?;
2535
2536        if !sweep.complete {
2537            return Err(Error::bad_request(
2538                "Open-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2539            ));
2540        }
2541
2542        Ok(sweep.reports)
2543    }
2544
2545    pub(crate) async fn request_order_status_reports_for_dexes(
2546        &self,
2547        user: &str,
2548        instrument_id: Option<InstrumentId>,
2549        dexes: &[Option<Ustr>],
2550    ) -> Result<ReportSweep<OrderStatusReport>> {
2551        let account_id = self
2552            .account_id
2553            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2554        let mut reports = Vec::new();
2555        let mut complete = true;
2556        let ts_init = self.clock.get_time_ns();
2557
2558        for dex in dexes {
2559            let response = self
2560                .info_frontend_open_orders_for_dex(user, dex.as_deref())
2561                .await?;
2562            let orders: Vec<serde_json::Value> = serde_json::from_value(response)
2563                .map_err(|e| Error::bad_request(format!("Failed to parse orders: {e}")))?;
2564
2565            for order_value in orders {
2566                let order: WsBasicOrderData = match serde_json::from_value(order_value) {
2567                    Ok(order) => order,
2568                    Err(e) => {
2569                        log::warn!("Failed to parse order: {e}");
2570                        complete = false;
2571                        continue;
2572                    }
2573                };
2574
2575                let instrument = match self.get_or_create_instrument(&order.coin, None) {
2576                    Some(instrument) => instrument,
2577                    // get_or_create_instrument warns with the coin
2578                    None => {
2579                        complete = false;
2580                        continue;
2581                    }
2582                };
2583
2584                if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2585                    continue;
2586                }
2587
2588                match parse_order_status_report_from_basic(
2589                    &order,
2590                    &HyperliquidOrderStatusEnum::Open,
2591                    &instrument,
2592                    account_id,
2593                    ts_init,
2594                ) {
2595                    Ok(report) => reports.push(report),
2596                    Err(e) => {
2597                        log::error!("Failed to parse order status report: {e}");
2598                        complete = false;
2599                    }
2600                }
2601            }
2602        }
2603
2604        Ok(ReportSweep { reports, complete })
2605    }
2606
2607    /// Request historical order status reports for a user.
2608    ///
2609    /// The venue bounds this endpoint to its 2,000 most recent historical
2610    /// orders. Mass-status reconciliation narrows these reports to venue order
2611    /// IDs represented by the retained fill window.
2612    ///
2613    /// # Errors
2614    ///
2615    /// Returns an error if the API request fails or a venue row cannot be resolved to an
2616    /// instrument or converted into a report (the snapshot is then incomplete and must not be
2617    /// treated as authoritative).
2618    pub async fn request_historical_order_status_reports(
2619        &self,
2620        user: &str,
2621        instrument_id: Option<InstrumentId>,
2622    ) -> Result<Vec<OrderStatusReport>> {
2623        let entries = self.info_historical_orders(user).await?;
2624        let sweep = self.historical_order_status_reports_from_response(entries, instrument_id)?;
2625
2626        if !sweep.complete {
2627            return Err(Error::bad_request(
2628                "Historical-order snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2629            ));
2630        }
2631
2632        Ok(sweep.reports)
2633    }
2634
2635    pub(crate) fn historical_order_status_reports_from_response(
2636        &self,
2637        entries: Vec<HyperliquidOrderStatusEntry>,
2638        instrument_id: Option<InstrumentId>,
2639    ) -> Result<ReportSweep<OrderStatusReport>> {
2640        let account_id = self
2641            .account_id
2642            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2643        let mut reports = Vec::new();
2644        let mut complete = true;
2645        let ts_init = self.clock.get_time_ns();
2646
2647        for entry in entries {
2648            let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2649                // get_or_create_instrument warns with the coin
2650                Some(instrument) => instrument,
2651                None => {
2652                    complete = false;
2653                    continue;
2654                }
2655            };
2656
2657            if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
2658                continue;
2659            }
2660
2661            let label = entry.order.order_type.as_deref().unwrap_or_default();
2662            let tpsl = parse_trigger_order_type_label(label).map(|(tpsl, _)| tpsl);
2663            let is_market = label.ends_with("Market");
2664            let historical_order_type = match tpsl.as_ref() {
2665                Some(tpsl) => parse_trigger_order_type(is_market, tpsl),
2666                None if is_market => OrderType::Market,
2667                None => OrderType::Limit,
2668            };
2669            let order = WsBasicOrderData {
2670                coin: entry.order.coin,
2671                side: entry.order.side,
2672                limit_px: entry.order.limit_px,
2673                sz: entry.order.sz,
2674                oid: entry.order.oid,
2675                timestamp: entry.order.timestamp,
2676                orig_sz: entry.order.orig_sz,
2677                cloid: entry.order.cloid,
2678                tif: entry.order.tif,
2679                reduce_only: entry.order.reduce_only,
2680                trigger_px: entry
2681                    .order
2682                    .trigger_px
2683                    .filter(|price| *price != Decimal::ZERO),
2684                is_market: tpsl.is_some().then_some(is_market),
2685                tpsl,
2686                trigger_activated: None,
2687                trailing_stop: None,
2688                order_type: None,
2689            };
2690
2691            match parse_order_status_report_from_basic(
2692                &order,
2693                &entry.status,
2694                &instrument,
2695                account_id,
2696                ts_init,
2697            ) {
2698                Ok(mut report) => {
2699                    report.order_type = historical_order_type;
2700                    report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2701                    reports.push(report);
2702                }
2703                Err(e) => {
2704                    log::error!("Failed to parse historical order status report: {e}");
2705                    complete = false;
2706                }
2707            }
2708        }
2709
2710        Ok(ReportSweep {
2711            reports: deduplicate_historical_order_reports(reports),
2712            complete,
2713        })
2714    }
2715
2716    /// Request a single order status report by venue order ID.
2717    ///
2718    /// Queries `info_frontend_open_orders` and filters for the given oid so the
2719    /// result includes trigger metadata (trigger_px, tpsl, trailing_stop, etc.).
2720    /// Falls back to `info_order_status` when the order is no longer open.
2721    ///
2722    /// # Errors
2723    ///
2724    /// Returns an error if the API request fails, parsing fails, or the matched venue row cannot be
2725    /// resolved to an instrument or converted into a report. A genuinely absent order returns
2726    /// `Ok(None)`.
2727    pub async fn request_order_status_report(
2728        &self,
2729        user: &str,
2730        oid: u64,
2731    ) -> Result<Option<OrderStatusReport>> {
2732        let account_id = self
2733            .account_id
2734            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2735
2736        let ts_init = self.clock.get_time_ns();
2737
2738        // Try open orders first (returns full WsBasicOrderData with trigger fields).
2739        // A transport error here must not abort the call: the oid fallback to
2740        // info_order_status below still covers closed orders, so a transient
2741        // frontendOpenOrders outage is downgraded to a warning.
2742        let orders: Vec<WsBasicOrderData> = match self.info_frontend_open_orders(user).await {
2743            Ok(response) => match serde_json::from_value(response) {
2744                Ok(v) => v,
2745                Err(e) => {
2746                    log::warn!("Failed to parse frontend open orders response: {e}");
2747                    Vec::new()
2748                }
2749            },
2750            Err(e) => {
2751                log::warn!(
2752                    "Failed to fetch frontendOpenOrders for oid {oid}: {e}; falling back to orderStatus"
2753                );
2754                Vec::new()
2755            }
2756        };
2757
2758        if let Some(order) = orders.into_iter().find(|o| o.oid == oid) {
2759            let instrument = match self.get_or_create_instrument(&order.coin, None) {
2760                Some(inst) => inst,
2761                None => {
2762                    return Err(Error::bad_request(format!(
2763                        "Failed to resolve instrument for open order oid {oid} with coin {}",
2764                        order.coin,
2765                    )));
2766                }
2767            };
2768
2769            let status = if order.trigger_activated == Some(true) {
2770                HyperliquidOrderStatusEnum::Triggered
2771            } else {
2772                HyperliquidOrderStatusEnum::Open
2773            };
2774
2775            return parse_order_status_report_from_basic(
2776                &order,
2777                &status,
2778                &instrument,
2779                account_id,
2780                ts_init,
2781            )
2782            .map(Some)
2783            .map_err(|e| {
2784                Error::bad_request(format!(
2785                    "Failed to parse order status report for oid {oid}: {e}"
2786                ))
2787            });
2788        }
2789
2790        // Order not in open set: query by oid (returns limited HyperliquidOrderInfo)
2791        let response = self.info_order_status(user, oid).await?;
2792        let entry = match response.into_order() {
2793            Some(e) => e,
2794            None => return Ok(None),
2795        };
2796
2797        let instrument = match self.get_or_create_instrument(&entry.order.coin, None) {
2798            Some(inst) => inst,
2799            None => {
2800                return Err(Error::bad_request(format!(
2801                    "Failed to resolve instrument for order oid {oid} with coin {}",
2802                    entry.order.coin,
2803                )));
2804            }
2805        };
2806
2807        // The info_order_status endpoint returns limited HyperliquidOrderInfo
2808        // without trigger fields (trigger_px, tpsl, is_market, trailing_stop).
2809        // Closed trigger orders will report as Limit type. This is an exchange
2810        // API limitation: trigger metadata is only available on open orders.
2811        // The reduce-only flag is carried through so reconciliation can clamp a
2812        // reduce-only fill to its fills.
2813        let basic = WsBasicOrderData {
2814            coin: entry.order.coin,
2815            side: entry.order.side,
2816            limit_px: entry.order.limit_px,
2817            sz: entry.order.sz,
2818            oid: entry.order.oid,
2819            timestamp: entry.order.timestamp,
2820            orig_sz: entry.order.orig_sz,
2821            cloid: entry.order.cloid,
2822            tif: None,
2823            reduce_only: entry.order.reduce_only,
2824            trigger_px: None,
2825            is_market: None,
2826            tpsl: None,
2827            trigger_activated: None,
2828            trailing_stop: None,
2829            order_type: None,
2830        };
2831
2832        let mut report = parse_order_status_report_from_basic(
2833            &basic,
2834            &entry.status,
2835            &instrument,
2836            account_id,
2837            ts_init,
2838        )
2839        .map_err(|e| {
2840            Error::bad_request(format!(
2841                "Failed to parse order status report for oid {oid}: {e}"
2842            ))
2843        })?;
2844
2845        // Use status_timestamp for ts_last when available (more accurate
2846        // than the order creation timestamp for filled/canceled orders)
2847        if entry.status_timestamp > 0 {
2848            report.ts_last = UnixNanos::from(entry.status_timestamp * 1_000_000);
2849        }
2850
2851        Ok(Some(report))
2852    }
2853
2854    /// Request a single order status report by client order ID.
2855    ///
2856    /// Searches `info_frontend_open_orders` for an order whose cloid matches the
2857    /// cached CLOID or the generated CLOID. Only finds open orders.
2858    ///
2859    /// # Errors
2860    ///
2861    /// Returns an error if the API request fails, the response cannot be decoded, or the matched
2862    /// venue row cannot be resolved to an instrument or converted into a report. A genuinely
2863    /// absent order returns `Ok(None)`.
2864    pub async fn request_order_status_report_by_client_order_id(
2865        &self,
2866        user: &str,
2867        client_order_id: &ClientOrderId,
2868    ) -> Result<Option<OrderStatusReport>> {
2869        let account_id = self
2870            .account_id
2871            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2872
2873        let ts_init = self.clock.get_time_ns();
2874
2875        let cached_cloid_hex = self
2876            .cached_client_order_id_cloid(client_order_id)
2877            .map(|cloid| cloid.to_hex());
2878        let cloid = Cloid::from_client_order_id(*client_order_id);
2879        let cloid_hex = cloid.to_hex();
2880
2881        let response = self.info_frontend_open_orders(user).await?;
2882
2883        let orders: Vec<WsBasicOrderData> = serde_json::from_value(response).map_err(|e| {
2884            Error::bad_request(format!("Failed to parse open orders response: {e}"))
2885        })?;
2886
2887        let order = match orders.into_iter().find(|o| {
2888            o.cloid
2889                .as_ref()
2890                .is_some_and(|c| cached_cloid_hex.as_ref() == Some(c) || c == &cloid_hex)
2891        }) {
2892            Some(o) => o,
2893            None => return Ok(None),
2894        };
2895
2896        let instrument = match self.get_or_create_instrument(&order.coin, None) {
2897            Some(inst) => inst,
2898            None => {
2899                return Err(Error::bad_request(format!(
2900                    "Failed to resolve instrument for open order with cloid {cloid_hex} and coin {}",
2901                    order.coin,
2902                )));
2903            }
2904        };
2905
2906        let status = if order.trigger_activated == Some(true) {
2907            HyperliquidOrderStatusEnum::Triggered
2908        } else {
2909            HyperliquidOrderStatusEnum::Open
2910        };
2911
2912        let mut report =
2913            parse_order_status_report_from_basic(&order, &status, &instrument, account_id, ts_init)
2914                .map_err(|e| {
2915                    Error::bad_request(format!(
2916                        "Failed to parse order status report for cloid {cloid_hex}: {e}"
2917                    ))
2918                })?;
2919
2920        report.client_order_id = Some(*client_order_id);
2921        Ok(Some(report))
2922    }
2923
2924    /// Request fill reports for a user.
2925    ///
2926    /// Fetches user fills via `info_user_fills` and parses them into FillReports.
2927    /// This method requires instruments to be added to the client cache via `cache_instrument()`.
2928    ///
2929    /// For vault tokens (starting with "vntls:") that are not in the cache, synthetic instruments
2930    /// will be created automatically.
2931    ///
2932    /// # Errors
2933    ///
2934    /// Returns an error if the API request fails, parsing fails, or a venue row cannot be resolved
2935    /// to an instrument or converted into a report (the snapshot is then incomplete and must not be
2936    /// treated as authoritative).
2937    ///
2938    /// Returns an error if `account_id` is not set on the client.
2939    pub async fn request_fill_reports(
2940        &self,
2941        user: &str,
2942        instrument_id: Option<InstrumentId>,
2943    ) -> Result<Vec<FillReport>> {
2944        let fills_response = self.info_user_fills(user).await?;
2945        let sweep = self.fill_reports_from_response(fills_response, instrument_id)?;
2946
2947        if !sweep.complete {
2948            return Err(Error::bad_request(
2949                "Fill snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
2950            ));
2951        }
2952
2953        Ok(sweep.reports)
2954    }
2955
2956    pub(crate) fn fill_reports_from_response(
2957        &self,
2958        fills_response: HyperliquidFills,
2959        instrument_id: Option<InstrumentId>,
2960    ) -> Result<ReportSweep<FillReport>> {
2961        let account_id = self
2962            .account_id
2963            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
2964
2965        let mut reports = Vec::new();
2966        let mut complete = true;
2967        let ts_init = self.clock.get_time_ns();
2968
2969        for fill in fills_response {
2970            // Get instrument from cache or create synthetic for vault tokens
2971            let instrument = match self.get_or_create_instrument(&fill.coin, None) {
2972                Some(inst) => inst,
2973                // get_or_create_instrument warns with the coin
2974                None => {
2975                    complete = false;
2976                    continue;
2977                }
2978            };
2979
2980            // Filter by instrument_id if specified
2981            if let Some(filter_id) = instrument_id
2982                && instrument.id() != filter_id
2983            {
2984                continue;
2985            }
2986
2987            // Parse to FillReport
2988            match parse_fill_report(&fill, &instrument, account_id, ts_init) {
2989                Ok(report) => reports.push(report),
2990                Err(e) => {
2991                    log::error!("Failed to parse fill report: {e}");
2992                    complete = false;
2993                }
2994            }
2995        }
2996
2997        Ok(ReportSweep { reports, complete })
2998    }
2999
3000    /// Request position status reports for a user.
3001    ///
3002    /// Fetches clearinghouse state from the default and all cached builder dexes when unfiltered,
3003    /// plus spot clearinghouse state, then returns the union of perp asset positions (short/long
3004    /// with PnL) and spot holdings (long only). This method requires instruments to be added to the
3005    /// client cache via `cache_instrument()`.
3006    ///
3007    /// When `instrument_id` resolves to a specific product type, the opposite
3008    /// product's endpoint is skipped to avoid wasted round trips and make
3009    /// filtered queries independent of the unused endpoint's availability.
3010    /// HIP-4 outcomes live in `spotClearinghouseState`, so an outcome filter
3011    /// is routed like a spot filter (perp leg skipped).
3012    ///
3013    /// For vault tokens (starting with "vntls:") that are not in the cache,
3014    /// synthetic instruments will be created automatically.
3015    ///
3016    /// # Errors
3017    ///
3018    /// Returns an error if any clearinghouse request fails (when that product or dex is in scope),
3019    /// parsing fails, or a venue row cannot be resolved to an instrument or converted into a
3020    /// report (the snapshot is then incomplete and must not be treated as authoritative).
3021    ///
3022    /// Returns an error if `account_id` has not been set on the client.
3023    pub async fn request_position_status_reports(
3024        &self,
3025        user: &str,
3026        instrument_id: Option<InstrumentId>,
3027    ) -> Result<Vec<PositionStatusReport>> {
3028        let dexes = self.reconciliation_dexes(instrument_id);
3029        let sweep = self
3030            .request_position_status_reports_for_dexes(user, instrument_id, &dexes)
3031            .await?;
3032
3033        if !sweep.complete {
3034            return Err(Error::bad_request(
3035                "Position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
3036            ));
3037        }
3038
3039        Ok(sweep.reports)
3040    }
3041
3042    pub(crate) async fn request_position_status_reports_for_dexes(
3043        &self,
3044        user: &str,
3045        instrument_id: Option<InstrumentId>,
3046        dexes: &[Option<Ustr>],
3047    ) -> Result<ReportSweep<PositionStatusReport>> {
3048        let account_id = self
3049            .account_id
3050            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3051
3052        let filter_product = instrument_id
3053            .and_then(|id| HyperliquidProductType::from_symbol(id.symbol.as_str()).ok());
3054
3055        let fetch_perp = !matches!(
3056            filter_product,
3057            Some(HyperliquidProductType::Spot | HyperliquidProductType::Outcome)
3058        );
3059        let fetch_spot = filter_product != Some(HyperliquidProductType::Perp);
3060
3061        let mut reports = Vec::new();
3062        let mut complete = true;
3063        let ts_init = self.clock.get_time_ns();
3064
3065        if !fetch_perp {
3066            return self
3067                .request_spot_position_status_reports_sweep(user, instrument_id)
3068                .await;
3069        }
3070
3071        for dex in dexes {
3072            let state_response = self
3073                .info_clearinghouse_state_for_dex(user, dex.as_deref())
3074                .await?;
3075            let asset_positions: Vec<serde_json::Value> = state_response
3076                .get("assetPositions")
3077                .and_then(|value| value.as_array())
3078                .ok_or_else(|| {
3079                    Error::bad_request("assetPositions not found in clearinghouse state")
3080                })?
3081                .clone();
3082
3083            for position_value in asset_positions {
3084                let coin = position_value
3085                    .get("position")
3086                    .and_then(|position| position.get("coin"))
3087                    .and_then(|coin| coin.as_str())
3088                    .ok_or_else(|| Error::bad_request("coin not found in position"))?;
3089
3090                let instrument = match self.get_or_create_instrument(&Ustr::from(coin), None) {
3091                    Some(instrument) => instrument,
3092                    // get_or_create_instrument warns with the coin
3093                    None => {
3094                        complete = false;
3095                        continue;
3096                    }
3097                };
3098
3099                if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
3100                    continue;
3101                }
3102
3103                match parse_position_status_report(
3104                    &position_value,
3105                    &instrument,
3106                    account_id,
3107                    ts_init,
3108                ) {
3109                    Ok(report) => reports.push(report),
3110                    Err(e) => {
3111                        log::error!("Failed to parse position status report: {e}");
3112                        complete = false;
3113                    }
3114                }
3115            }
3116        }
3117
3118        // Spot positions are part of the report truth; propagate fetch errors
3119        // rather than silently omitting spot holdings from reconciliation.
3120        if fetch_spot {
3121            let spot_sweep = self
3122                .request_spot_position_status_reports_sweep(user, instrument_id)
3123                .await?;
3124            reports.extend(spot_sweep.reports);
3125            complete &= spot_sweep.complete;
3126        }
3127
3128        Ok(ReportSweep { reports, complete })
3129    }
3130
3131    /// Request account state (balances and margins) for a user.
3132    ///
3133    /// Fetches perp and spot clearinghouse state and the account abstraction mode from
3134    /// Hyperliquid and merges them into a single [`AccountState`]. For unified and portfolio
3135    /// margin accounts, balances come from the spot state alone and spot USDC `hold` is the
3136    /// account-wide margin. Otherwise USDC comes from the perp margin summary only when that
3137    /// summary reflects non-zero collateral, margin used, or withdrawable balance; if the
3138    /// summary is absent or zeroed, spot USDC is used instead. Non-USDC tokens are always
3139    /// appended from the spot balances. Spot tokens the venue lists at zero are reported at
3140    /// zero, and USDC is reported at zero when there is no USDC balance, so a previous balance
3141    /// is cleared. On accounts without spot collateral this needs a perp summary in the response.
3142    ///
3143    /// # Errors
3144    ///
3145    /// Returns an error if `account_id` is not set, or if the perp clearinghouse, spot
3146    /// clearinghouse, or user abstraction request fails. Spot and abstraction failures are
3147    /// propagated so the caller sees real API errors instead of a silently truncated or
3148    /// misread snapshot.
3149    pub async fn request_account_state(&self, user: &str) -> Result<AccountState> {
3150        let account_id = self
3151            .account_id
3152            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3153        let state_response = self.info_clearinghouse_state(user).await?;
3154        let ts_init = self.clock.get_time_ns();
3155
3156        log::trace!("Clearinghouse state response: {state_response}");
3157
3158        let perp_state: ClearinghouseState = serde_json::from_value(state_response.clone())
3159            .map_err(|e| {
3160                log::error!("Failed to parse clearinghouse state: {e}");
3161                log::debug!("Raw response: {state_response}");
3162                Error::bad_request(format!("Failed to parse clearinghouse state: {e}"))
3163            })?;
3164
3165        // Spot must not be silently dropped: a 429 or parse error would
3166        // otherwise make non-USDC holdings look like they vanished.
3167        let spot_response = self.info_spot_clearinghouse_state(user).await?;
3168        let spot_state: SpotClearinghouseState = serde_json::from_value(spot_response.clone())
3169            .map_err(|e| {
3170                log::error!("Failed to parse spot clearinghouse state: {e}");
3171                log::debug!("Raw spot response: {spot_response}");
3172                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3173            })?;
3174
3175        // Without the mode a unified account would be read from the default-dex perp summary.
3176        let abstraction = self.info_user_abstraction(user).await?;
3177
3178        let (balances, margins) =
3179            parse_combined_account_balances_and_margins(&perp_state, &spot_state, abstraction)
3180                .map_err(|e| Error::decode(e.to_string()))?;
3181
3182        Ok(AccountState::new(
3183            account_id,
3184            AccountType::Margin,
3185            balances,
3186            margins,
3187            true, // reported
3188            UUID4::new(),
3189            ts_init,
3190            ts_init,
3191            None,
3192        ))
3193    }
3194
3195    /// Request spot token balances for a user.
3196    ///
3197    /// Fetches `spotClearinghouseState` and returns one [`AccountBalance`] per
3198    /// non-zero token. USDC is included as a separate balance entry when present;
3199    /// callers that also report perp margin state must dedupe currencies before
3200    /// emitting an [`AccountState`].
3201    ///
3202    /// # Errors
3203    ///
3204    /// Returns an error if the API request fails or the response cannot be parsed.
3205    pub async fn request_spot_balances(&self, user: &str) -> Result<Vec<AccountBalance>> {
3206        let response = self.info_spot_clearinghouse_state(user).await?;
3207
3208        log::trace!("Spot clearinghouse state response: {response}");
3209
3210        let state: SpotClearinghouseState =
3211            serde_json::from_value(response.clone()).map_err(|e| {
3212                log::error!("Failed to parse spot clearinghouse state: {e}");
3213                log::debug!("Raw response: {response}");
3214                Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3215            })?;
3216
3217        parse_spot_account_balances(&state).map_err(|e| Error::decode(e.to_string()))
3218    }
3219
3220    /// Request spot position status reports for a user.
3221    ///
3222    /// Each non-zero spot balance is reported as a Long position against its
3223    /// `{BASE}-{QUOTE}-SPOT` instrument. HIP-4 outcome side tokens arrive on
3224    /// this same endpoint with `coin` set to the `+<encoding>` token form;
3225    /// those balances are resolved against the matching Outcome instrument so
3226    /// outcome holdings surface as positions through the standard reconcile
3227    /// path.
3228    ///
3229    /// # Errors
3230    ///
3231    /// Returns an error if `account_id` has not been set, the API request fails,
3232    /// or a non-zero balance cannot be resolved to an instrument or converted
3233    /// into a report (the snapshot is then incomplete and must not be treated
3234    /// as authoritative).
3235    pub async fn request_spot_position_status_reports(
3236        &self,
3237        user: &str,
3238        instrument_id: Option<InstrumentId>,
3239    ) -> Result<Vec<PositionStatusReport>> {
3240        let sweep = self
3241            .request_spot_position_status_reports_sweep(user, instrument_id)
3242            .await?;
3243
3244        if !sweep.complete {
3245            return Err(Error::bad_request(
3246                "Spot position snapshot incomplete: at least one venue row could not be decoded, resolved to an instrument, or converted into a report",
3247            ));
3248        }
3249
3250        Ok(sweep.reports)
3251    }
3252
3253    pub(crate) async fn request_spot_position_status_reports_sweep(
3254        &self,
3255        user: &str,
3256        instrument_id: Option<InstrumentId>,
3257    ) -> Result<ReportSweep<PositionStatusReport>> {
3258        let account_id = self
3259            .account_id
3260            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3261        let response = self.info_spot_clearinghouse_state(user).await?;
3262
3263        let state: SpotClearinghouseState = serde_json::from_value(response).map_err(|e| {
3264            log::error!("Failed to parse spot clearinghouse state: {e}");
3265            Error::bad_request(format!("Failed to parse spot clearinghouse state: {e}"))
3266        })?;
3267
3268        let ts_init = self.clock.get_time_ns();
3269        let mut reports = Vec::with_capacity(state.balances.len());
3270        let mut complete = true;
3271
3272        for balance in &state.balances {
3273            if balance.total.is_zero() {
3274                continue;
3275            }
3276
3277            // USDC is the universal quote for Hyperliquid spot: it funds every
3278            // pair and has no `USDC-*-SPOT` instrument. Skip it so the loop
3279            // does not trigger a misleading cache-miss WARN. Revisit if
3280            // Hyperliquid ever introduces a USDC-base spot pair.
3281            if balance.coin == "USDC" {
3282                continue;
3283            }
3284
3285            let product_type = match HyperliquidProductType::from_symbol(balance.coin.as_str()) {
3286                Ok(HyperliquidProductType::Outcome) => HyperliquidProductType::Outcome,
3287                _ => HyperliquidProductType::Spot,
3288            };
3289
3290            let instrument = match self.get_or_create_instrument(&balance.coin, Some(product_type))
3291            {
3292                Some(inst) => inst,
3293                // get_or_create_instrument warns with the coin
3294                None => {
3295                    complete = false;
3296                    continue;
3297                }
3298            };
3299
3300            if let Some(filter_id) = instrument_id
3301                && instrument.id() != filter_id
3302            {
3303                continue;
3304            }
3305
3306            match parse_spot_position_status_report(balance, &instrument, account_id, ts_init) {
3307                Ok(report) => reports.push(report),
3308                Err(e) => {
3309                    log::error!(
3310                        "Failed to parse spot position status report for {}: {e}",
3311                        balance.coin,
3312                    );
3313                    complete = false;
3314                }
3315            }
3316        }
3317
3318        Ok(ReportSweep { reports, complete })
3319    }
3320
3321    /// Request historical bars for an instrument.
3322    ///
3323    /// Fetches candle data from the Hyperliquid API and converts it to Nautilus bars.
3324    /// Incomplete bars (where end_timestamp >= current time) are filtered out.
3325    ///
3326    /// # Errors
3327    ///
3328    /// Returns an error if:
3329    /// - The instrument is not found in cache.
3330    /// - The bar aggregation is unsupported by Hyperliquid.
3331    /// - The API request fails.
3332    /// - Parsing fails.
3333    ///
3334    /// # References
3335    ///
3336    /// <https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/info-endpoint#candles-snapshot>
3337    pub async fn request_bars(
3338        &self,
3339        bar_type: BarType,
3340        start: Option<jiff::Timestamp>,
3341        end: Option<jiff::Timestamp>,
3342        limit: Option<u32>,
3343    ) -> Result<Vec<Bar>> {
3344        let instrument_id = bar_type.instrument_id();
3345        let symbol = instrument_id.symbol;
3346
3347        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3348
3349        // `cache_alias_for_symbol` mirrors how `cache_instrument` stores the
3350        // secondary key (token form `+<encoding>` for outcomes, leading
3351        // segment for perps / spots), so this lookup stays in sync.
3352        let alias = cache_alias_for_symbol(symbol.as_str())
3353            .map(|alias| Ustr::from(alias.as_str()))
3354            .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3355
3356        let instrument = self
3357            .get_or_create_instrument(&alias, product_type)
3358            .ok_or_else(|| {
3359                Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3360            })?;
3361
3362        // Use raw_symbol which has the correct Hyperliquid API format:
3363        // - Perps: base currency (e.g., "BTC")
3364        // - Spot PURR: slash format (e.g., "PURR/USDC")
3365        // - Spot others: @{index} format (e.g., "@107")
3366        let coin = instrument.raw_symbol().inner();
3367
3368        let price_precision = instrument.price_precision();
3369        let size_precision = instrument.size_precision();
3370
3371        let interval =
3372            bar_type_to_interval(&bar_type).map_err(|e| Error::bad_request(e.to_string()))?;
3373
3374        // Hyperliquid uses millisecond timestamps
3375        let now = jiff::Timestamp::now();
3376        let end_time = end.unwrap_or(now).as_millisecond() as u64;
3377        let start_time = if let Some(start) = start {
3378            start.as_millisecond() as u64
3379        } else {
3380            // Default to 1000 bars before end_time
3381            let spec = bar_type.spec();
3382            let step_ms = match spec.aggregation {
3383                BarAggregation::Minute => spec.step.get() as u64 * 60_000,
3384                BarAggregation::Hour => spec.step.get() as u64 * 3_600_000,
3385                BarAggregation::Day => spec.step.get() as u64 * 86_400_000,
3386                BarAggregation::Week => spec.step.get() as u64 * 604_800_000,
3387                BarAggregation::Month => spec.step.get() as u64 * 2_592_000_000,
3388                _ => 60_000,
3389            };
3390            end_time.saturating_sub(1000 * step_ms)
3391        };
3392
3393        let candles = self
3394            .info_candle_snapshot(coin.as_str(), interval, start_time, end_time)
3395            .await?;
3396
3397        // Filter out incomplete bars where end_timestamp >= current time
3398        let now_ms = now.as_millisecond() as u64;
3399
3400        let mut bars: Vec<Bar> = candles
3401            .iter()
3402            .filter(|candle| candle.end_timestamp < now_ms)
3403            .enumerate()
3404            .filter_map(|(i, candle)| {
3405                candle_to_bar(candle, bar_type, price_precision, size_precision)
3406                    .map_err(|e| {
3407                        log::error!("Failed to convert candle {i} to bar: {candle:?} error: {e}");
3408                        e
3409                    })
3410                    .ok()
3411            })
3412            .collect();
3413
3414        // 0 means no limit
3415        if let Some(limit) = limit
3416            && limit > 0
3417            && bars.len() > limit as usize
3418        {
3419            bars.truncate(limit as usize);
3420        }
3421
3422        log::debug!(
3423            "Received {} bars for {} (filtered {} incomplete)",
3424            bars.len(),
3425            bar_type,
3426            candles.len() - bars.len()
3427        );
3428        Ok(bars)
3429    }
3430
3431    /// Request the recent public trade snapshot for an instrument.
3432    ///
3433    /// Hyperliquid's `recentTrades` endpoint is a bounded newest-first snapshot,
3434    /// rather than a range-query endpoint. The returned trades are normalized to
3435    /// ascending event time and then constrained to the requested window.
3436    ///
3437    /// A self-hosted node without the indexer responds with HTTP 422. This is
3438    /// treated as no available coverage so requests can still complete.
3439    pub async fn request_public_trades(
3440        &self,
3441        instrument_id: InstrumentId,
3442        start: Option<jiff::Timestamp>,
3443        end: Option<jiff::Timestamp>,
3444        limit: Option<usize>,
3445    ) -> Result<Vec<HyperliquidPublicTrade>> {
3446        let symbol = instrument_id.symbol;
3447        let product_type = HyperliquidProductType::from_symbol(symbol.as_str()).ok();
3448        let alias = cache_alias_for_symbol(symbol.as_str())
3449            .map(|alias| Ustr::from(alias.as_str()))
3450            .ok_or_else(|| Error::bad_request("Invalid instrument symbol"))?;
3451        let instrument = self
3452            .get_or_create_instrument(&alias, product_type)
3453            .ok_or_else(|| {
3454                Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
3455            })?;
3456
3457        let raw_trades = match self
3458            .info_recent_trades(instrument.raw_symbol().as_ref())
3459            .await
3460        {
3461            Ok(trades) => trades,
3462            Err(e) if e.is_unprocessable_entity() => {
3463                log::warn!(
3464                    "Recent public trades endpoint unavailable for {instrument_id} \
3465                     (requires the Hyperliquid indexer); returning empty response"
3466                );
3467                Vec::new()
3468            }
3469            Err(e) => return Err(e),
3470        };
3471
3472        let mut trades: Vec<HyperliquidPublicTrade> = raw_trades
3473            .iter()
3474            .filter_map(|raw| match parse_recent_public_trade(raw, &instrument) {
3475                Ok(trade) => Some(trade),
3476                Err(e) => {
3477                    log::warn!("Skipping recent public trade for {instrument_id}: {e}");
3478                    None
3479                }
3480            })
3481            .collect();
3482        trades.sort_by_key(|trade| trade.ts_event);
3483
3484        Ok(filter_recent_public_trades(
3485            trades,
3486            datetime_to_unix_nanos(start),
3487            datetime_to_unix_nanos(end),
3488            limit.filter(|limit| *limit > 0),
3489            instrument_id,
3490        ))
3491    }
3492
3493    /// Requests funding rate history for a perpetual, paging past the venue's
3494    /// per-response cap.
3495    ///
3496    /// With `start`, returns up to `limit` rows from `start` onward. Without it,
3497    /// returns the most recent `limit` rows (default seven days).
3498    ///
3499    /// # Errors
3500    ///
3501    /// Returns an error if the instrument is not a perpetual, `start` is not
3502    /// before `end`, or a request fails.
3503    pub async fn request_funding_rates(
3504        &self,
3505        instrument: &InstrumentAny,
3506        start: Option<jiff::Timestamp>,
3507        end: Option<jiff::Timestamp>,
3508        limit: Option<usize>,
3509    ) -> Result<Vec<FundingRateUpdate>> {
3510        let instrument_id = instrument.id();
3511
3512        if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
3513            return Err(Error::bad_request(format!(
3514                "funding rates are only available for perpetual instruments: {instrument_id}",
3515            )));
3516        }
3517
3518        let coin = instrument.raw_symbol().inner();
3519
3520        if let (Some(start), Some(end)) = (start, end)
3521            && start >= end
3522        {
3523            return Err(Error::bad_request(format!(
3524                "invalid funding request range: start={start}, end={end}",
3525            )));
3526        }
3527
3528        let now = jiff::Timestamp::now();
3529        let end = end.unwrap_or(now).min(now);
3530
3531        if let Some(start) = start
3532            && start >= end
3533        {
3534            return Ok(Vec::new());
3535        }
3536
3537        let requested_limit = limit.filter(|n| *n > 0);
3538        let target_limit = requested_limit.unwrap_or(DEFAULT_FUNDING_RATES_LIMIT);
3539        let start_was_unspecified = start.is_none();
3540        let end_ms = end.as_millisecond().max(0) as u64;
3541
3542        let start_ms = start.map_or_else(
3543            || {
3544                let lookback_rows = target_limit.saturating_add(1) as u64;
3545                // Hyperliquid funding interval is 1h
3546                end_ms.saturating_sub((60 * 60 * 1_000_u64).saturating_mul(lookback_rows))
3547            },
3548            |dt| dt.as_millisecond().max(0) as u64,
3549        );
3550
3551        let mut funding_rates = Vec::new();
3552        let mut cursor_ms = start_ms;
3553
3554        'pages: while cursor_ms <= end_ms {
3555            let entries = self
3556                .info_funding_history(coin.as_str(), cursor_ms, Some(end_ms))
3557                .await?;
3558            let Some(last_time_ms) = entries.iter().map(|entry| entry.time).max() else {
3559                break;
3560            };
3561
3562            let mut page: Vec<FundingRateUpdate> = entries
3563                .iter()
3564                .filter(|entry| (cursor_ms..=end_ms).contains(&entry.time))
3565                .map(|entry| funding_entry_to_update(entry, instrument_id))
3566                .collect();
3567            page.sort_by_key(|rate| rate.ts_event);
3568
3569            for update in page {
3570                if funding_rates
3571                    .last()
3572                    .is_some_and(|last: &FundingRateUpdate| last.ts_event == update.ts_event)
3573                {
3574                    continue;
3575                }
3576                funding_rates.push(update);
3577
3578                if !start_was_unspecified
3579                    && requested_limit.is_some_and(|limit| funding_rates.len() >= limit)
3580                {
3581                    break 'pages;
3582                }
3583            }
3584
3585            if entries.len() < HYPERLIQUID_FUNDING_HISTORY_MAX_LIMIT {
3586                break;
3587            }
3588
3589            // `startTime` is inclusive, so step past the last row
3590            let next_cursor_ms = last_time_ms.saturating_add(1);
3591            if next_cursor_ms <= cursor_ms {
3592                break;
3593            }
3594            cursor_ms = next_cursor_ms;
3595        }
3596
3597        if start_was_unspecified && funding_rates.len() > target_limit {
3598            funding_rates = funding_rates.split_off(funding_rates.len() - target_limit);
3599        }
3600
3601        Ok(funding_rates)
3602    }
3603
3604    /// Submits an order to the exchange.
3605    ///
3606    /// # Errors
3607    ///
3608    /// Returns an error if credentials are missing, order validation fails, serialization fails,
3609    /// or the API returns an error.
3610    #[expect(clippy::too_many_arguments)]
3611    pub async fn submit_order(
3612        &self,
3613        instrument_id: InstrumentId,
3614        client_order_id: ClientOrderId,
3615        order_side: OrderSide,
3616        order_type: OrderType,
3617        quantity: Quantity,
3618        time_in_force: TimeInForce,
3619        price: Option<Price>,
3620        trigger_price: Option<Price>,
3621        post_only: bool,
3622        reduce_only: bool,
3623    ) -> Result<OrderStatusReport> {
3624        let symbol = instrument_id.symbol.inner();
3625        let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3626            Error::bad_request(format!(
3627                "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3628            ))
3629        })?;
3630
3631        let is_buy = matches!(order_side, OrderSide::Buy);
3632        let price_precision = self.get_price_precision_for_symbol(symbol);
3633
3634        let price_decimal = match price {
3635            Some(px) => normalize_or_validate_wire_price(
3636                px.as_decimal(),
3637                "Price",
3638                price_precision,
3639                self.normalize_prices,
3640            )
3641            .map_err(|e| Error::bad_request(format!("{e}")))?,
3642            None if matches!(order_type, OrderType::Market) => Decimal::ZERO,
3643            None if matches!(
3644                order_type,
3645                OrderType::StopMarket | OrderType::MarketIfTouched
3646            ) =>
3647            {
3648                match trigger_price {
3649                    Some(tp) => {
3650                        let derived = derive_limit_from_trigger(
3651                            tp.as_decimal().normalize(),
3652                            is_buy,
3653                            self.market_order_slippage_bps,
3654                        );
3655                        let sig_rounded = round_to_sig_figs(derived, 5);
3656                        clamp_price_to_precision(sig_rounded, price_precision.unwrap_or(2), is_buy)
3657                            .normalize()
3658                    }
3659                    None => Decimal::ZERO,
3660                }
3661            }
3662            None => return Err(Error::bad_request("Limit orders require a price")),
3663        };
3664
3665        let size_decimal = quantity.as_decimal().normalize();
3666
3667        let kind = match order_type {
3668            OrderType::Market => HyperliquidExchangeOrderKind::Limit {
3669                limit: HyperliquidExchangeLimitParams {
3670                    tif: HyperliquidExchangeTif::Ioc,
3671                },
3672            },
3673            OrderType::Limit => {
3674                let tif = if post_only {
3675                    HyperliquidExchangeTif::Alo
3676                } else {
3677                    match time_in_force {
3678                        TimeInForce::Gtc => HyperliquidExchangeTif::Gtc,
3679                        TimeInForce::Ioc => HyperliquidExchangeTif::Ioc,
3680                        TimeInForce::Fok
3681                        | TimeInForce::Day
3682                        | TimeInForce::Gtd
3683                        | TimeInForce::AtTheOpen
3684                        | TimeInForce::AtTheClose => {
3685                            return Err(Error::bad_request(format!(
3686                                "Time in force {time_in_force:?} not supported"
3687                            )));
3688                        }
3689                    }
3690                };
3691                HyperliquidExchangeOrderKind::Limit {
3692                    limit: HyperliquidExchangeLimitParams { tif },
3693                }
3694            }
3695            OrderType::StopMarket
3696            | OrderType::StopLimit
3697            | OrderType::MarketIfTouched
3698            | OrderType::LimitIfTouched => {
3699                if let Some(trig_px) = trigger_price {
3700                    let trigger_price_decimal = normalize_or_validate_wire_price(
3701                        trig_px.as_decimal(),
3702                        "Trigger price",
3703                        price_precision,
3704                        self.normalize_prices,
3705                    )
3706                    .map_err(|e| Error::bad_request(format!("{e}")))?;
3707
3708                    // Determine TP/SL type based on order type
3709                    // StopMarket/StopLimit are always Sl (protective stops)
3710                    // MarketIfTouched/LimitIfTouched are always Tp (profit-taking/entry)
3711                    let tpsl = match order_type {
3712                        OrderType::StopMarket | OrderType::StopLimit => HyperliquidExchangeTpSl::Sl,
3713                        OrderType::MarketIfTouched | OrderType::LimitIfTouched => {
3714                            HyperliquidExchangeTpSl::Tp
3715                        }
3716                        _ => unreachable!(),
3717                    };
3718
3719                    let is_market = matches!(
3720                        order_type,
3721                        OrderType::StopMarket | OrderType::MarketIfTouched
3722                    );
3723
3724                    HyperliquidExchangeOrderKind::Trigger {
3725                        trigger: HyperliquidExchangeTriggerParams {
3726                            is_market,
3727                            trigger_px: trigger_price_decimal,
3728                            tpsl,
3729                        },
3730                    }
3731                } else {
3732                    return Err(Error::bad_request("Trigger orders require a trigger price"));
3733                }
3734            }
3735            _ => {
3736                return Err(Error::bad_request(format!(
3737                    "Order type {order_type:?} not supported"
3738                )));
3739            }
3740        };
3741
3742        let cloid = self.get_or_generate_client_order_id_cloid(client_order_id);
3743        let hyperliquid_order = HyperliquidExchangePlaceOrderRequest {
3744            asset,
3745            is_buy,
3746            price: price_decimal,
3747            size: size_decimal,
3748            reduce_only,
3749            kind,
3750            cloid: Some(cloid),
3751        };
3752
3753        let builder = self.builder_attribution();
3754
3755        let action = HyperliquidExchangeAction::Order {
3756            orders: vec![hyperliquid_order],
3757            grouping: HyperliquidExchangeGrouping::Na,
3758            builder,
3759        };
3760
3761        let response = self.inner.post_action_exec(&action).await?;
3762
3763        // A single (non-bracket) order should return an actionable status;
3764        // `None` (a deferred `Tag` child) is unexpected on this HTTP path.
3765        self.build_submit_order_report(
3766            instrument_id,
3767            client_order_id,
3768            order_side,
3769            order_type,
3770            quantity,
3771            time_in_force,
3772            price,
3773            trigger_price,
3774            response,
3775        )?
3776        .ok_or_else(|| {
3777            Error::bad_request(
3778                "Single-order submission returned no actionable status (deferred trigger child)",
3779            )
3780        })
3781    }
3782
3783    /// Submit an order using an OrderAny object.
3784    ///
3785    /// This is a convenience method that wraps submit_order.
3786    ///
3787    /// # Errors
3788    ///
3789    /// Returns an error if the quantity is quote-denominated: this raw path has no cached market
3790    /// data for a quote-to-base conversion, so the order must be submitted through the execution
3791    /// client instead.
3792    pub async fn submit_order_from_order_any(&self, order: &OrderAny) -> Result<OrderStatusReport> {
3793        if order.is_quote_quantity() {
3794            return Err(Error::bad_request(
3795                "Quote-denominated quantity orders must submit through the execution client \
3796                 for quote-to-base conversion",
3797            ));
3798        }
3799
3800        self.submit_order(
3801            order.instrument_id(),
3802            order.client_order_id(),
3803            order.order_side(),
3804            order.order_type(),
3805            order.quantity(),
3806            order.time_in_force(),
3807            order.price(),
3808            order.trigger_price(),
3809            order.is_post_only(),
3810            order.is_reduce_only(),
3811        )
3812        .await
3813    }
3814
3815    #[expect(clippy::too_many_arguments)]
3816    fn create_order_status_report(
3817        &self,
3818        instrument_id: InstrumentId,
3819        client_order_id: Option<ClientOrderId>,
3820        venue_order_id: VenueOrderId,
3821        order_side: OrderSide,
3822        order_type: OrderType,
3823        quantity: Quantity,
3824        time_in_force: TimeInForce,
3825        price: Option<Price>,
3826        trigger_price: Option<Price>,
3827        order_status: OrderStatus,
3828        filled_qty: Quantity,
3829        _instrument: &InstrumentAny,
3830        account_id: AccountId,
3831        ts_init: UnixNanos,
3832    ) -> OrderStatusReport {
3833        let ts_accepted = self.clock.get_time_ns();
3834        let ts_last = ts_accepted;
3835        let report_id = UUID4::new();
3836
3837        let mut report = OrderStatusReport::new(
3838            account_id,
3839            instrument_id,
3840            client_order_id,
3841            venue_order_id,
3842            order_side.into(),
3843            order_type,
3844            time_in_force,
3845            order_status,
3846            quantity,
3847            filled_qty,
3848            ts_accepted,
3849            ts_last,
3850            ts_init,
3851            Some(report_id),
3852        );
3853
3854        if let Some(px) = price {
3855            report = report.with_price(px);
3856        }
3857
3858        if let Some(trig_px) = trigger_price {
3859            report = report
3860                .with_trigger_price(trig_px)
3861                .with_trigger_type(TriggerType::Default);
3862        }
3863
3864        report
3865    }
3866
3867    /// Submit multiple orders to the Hyperliquid exchange in a single request.
3868    ///
3869    /// # Errors
3870    ///
3871    /// Returns an error if credentials are missing, order validation fails, serialization fails,
3872    /// or the API returns an error. Also returns an error for any quote-denominated quantity:
3873    /// this raw path has no cached market data for a quote-to-base conversion, so such orders
3874    /// must be submitted through the execution client instead.
3875    pub async fn submit_orders(&self, orders: &[&OrderAny]) -> Result<Vec<OrderStatusReport>> {
3876        // Convert orders using asset indices from the cached map
3877        let mut hyperliquid_orders = Vec::with_capacity(orders.len());
3878        let mut client_order_ids = Vec::with_capacity(orders.len());
3879
3880        for order in orders {
3881            if order.is_quote_quantity() {
3882                return Err(Error::bad_request(format!(
3883                    "Quote-denominated quantity order {} must submit through the execution \
3884                     client for quote-to-base conversion",
3885                    order.client_order_id()
3886                )));
3887            }
3888
3889            let instrument_id = order.instrument_id();
3890            let symbol = instrument_id.symbol.inner();
3891            let asset = self.get_asset_index_for_symbol(symbol).ok_or_else(|| {
3892                Error::bad_request(format!(
3893                    "Asset index not found for symbol: {symbol}. Ensure instruments are loaded."
3894                ))
3895            })?;
3896            let price_decimals = self.get_price_precision_for_symbol(symbol);
3897            let request = order_to_hyperliquid_request_with_optional_decimals(
3898                order,
3899                asset,
3900                price_decimals,
3901                self.normalize_prices,
3902                self.market_order_slippage_bps,
3903                None,
3904            )
3905            .map_err(|e| Error::bad_request(format!("Failed to convert order: {e}")))?;
3906            client_order_ids.push(order.client_order_id());
3907            hyperliquid_orders.push(request);
3908        }
3909
3910        for (request, client_order_id) in hyperliquid_orders.iter_mut().zip(client_order_ids) {
3911            request.cloid = Some(self.get_or_generate_client_order_id_cloid(client_order_id));
3912        }
3913
3914        let builder = self.builder_attribution();
3915
3916        let grouping =
3917            determine_order_list_grouping(&orders.iter().copied().cloned().collect::<Vec<_>>());
3918
3919        let action = HyperliquidExchangeAction::Order {
3920            orders: hyperliquid_orders,
3921            grouping,
3922            builder,
3923        };
3924
3925        // Submit to exchange using the typed exec endpoint
3926        let response = self.inner.post_action_exec(&action).await?;
3927
3928        self.build_submit_orders_reports(orders, grouping, response)
3929    }
3930
3931    /// Parses a Hyperliquid exchange order response for a single-order submit
3932    /// into an [`OrderStatusReport`].
3933    ///
3934    /// Returns `Ok(None)` when the venue returned an empty `statuses` array or
3935    /// when the only status is a deferred `Tag` child (for example
3936    /// `waitingForFill`): the venue accepted the order but has not assigned an
3937    /// oid yet, so the order stays `SUBMITTED` until the user-events stream
3938    /// drives the first `OrderAccepted` with the real oid.
3939    ///
3940    /// Shared by the HTTP and WebSocket single-submit paths.
3941    ///
3942    /// # Errors
3943    ///
3944    /// Returns an error if account credentials are missing, the response is
3945    /// malformed, or the order returned an `error` status.
3946    #[expect(clippy::too_many_arguments)]
3947    pub fn build_submit_order_report(
3948        &self,
3949        instrument_id: InstrumentId,
3950        client_order_id: ClientOrderId,
3951        order_side: OrderSide,
3952        order_type: OrderType,
3953        quantity: Quantity,
3954        time_in_force: TimeInForce,
3955        price: Option<Price>,
3956        trigger_price: Option<Price>,
3957        response: HyperliquidExchangeResponse,
3958    ) -> Result<Option<OrderStatusReport>> {
3959        let order_response = parse_order_response(response)?;
3960
3961        let Some(order_status) = order_response.statuses.first() else {
3962            return Ok(None);
3963        };
3964
3965        let account_id = self
3966            .account_id
3967            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
3968        let ts_init = self.clock.get_time_ns();
3969
3970        self.build_status_report(
3971            instrument_id,
3972            client_order_id,
3973            order_side,
3974            order_type,
3975            quantity,
3976            time_in_force,
3977            price,
3978            trigger_price,
3979            order_status,
3980            account_id,
3981            ts_init,
3982        )
3983    }
3984
3985    /// Parses a Hyperliquid exchange order response into per-order
3986    /// [`OrderStatusReport`]s, paired positionally with `orders`.
3987    ///
3988    /// Shared by the HTTP and WebSocket batch-submit paths since the response
3989    /// envelope is identical regardless of transport. Deferred `Tag` children
3990    /// (for example `waitingForFill`) are elided from the result; those orders
3991    /// stay `SUBMITTED` until the user-events stream delivers an `OrderAccepted`
3992    /// with the real oid.
3993    ///
3994    /// # Errors
3995    ///
3996    /// Returns an error if account credentials are missing, the response is
3997    /// malformed, an order returned an `error` status, or, for ungrouped
3998    /// submissions, the response status count diverges from the order count.
3999    pub fn build_submit_orders_reports(
4000        &self,
4001        orders: &[&OrderAny],
4002        grouping: HyperliquidExchangeGrouping,
4003        response: HyperliquidExchangeResponse,
4004    ) -> Result<Vec<OrderStatusReport>> {
4005        let order_response = parse_order_response(response)?;
4006
4007        let account_id = self
4008            .account_id
4009            .ok_or_else(|| Error::bad_request("Account ID not set"))?;
4010        let ts_init = self.clock.get_time_ns();
4011
4012        // For grouped orders (NormalTpsl/PositionTpsl) the exchange returns a
4013        // single status for the whole group, so only enforce 1:1 matching for
4014        // ungrouped (Na) submissions.
4015        if grouping == HyperliquidExchangeGrouping::Na
4016            && order_response.statuses.len() != orders.len()
4017        {
4018            return Err(Error::bad_request(format!(
4019                "Mismatch between submitted orders ({}) and response statuses ({})",
4020                orders.len(),
4021                order_response.statuses.len()
4022            )));
4023        }
4024
4025        // The exchange returns statuses in submission order, so pair each order
4026        // with its status positionally.
4027        let mut reports = Vec::with_capacity(order_response.statuses.len());
4028        for (order, order_status) in orders.iter().zip(order_response.statuses.iter()) {
4029            if let Some(report) = self.build_status_report(
4030                order.instrument_id(),
4031                order.client_order_id(),
4032                order.order_side(),
4033                order.order_type(),
4034                order.quantity(),
4035                order.time_in_force(),
4036                order.price(),
4037                order.trigger_price(),
4038                order_status,
4039                account_id,
4040                ts_init,
4041            )? {
4042                reports.push(report);
4043            }
4044        }
4045
4046        Ok(reports)
4047    }
4048
4049    /// Builds an [`OrderStatusReport`] from a single venue status, or `Ok(None)`
4050    /// for a deferred `Tag` child that has no oid yet.
4051    ///
4052    /// `Tag` rows are elided rather than given a synthetic placeholder venue id:
4053    /// an earlier placeholder accept was deduped against the later real accept,
4054    /// so the cache never picked up the real oid and cancel/modify by venue id
4055    /// broke on bracket children.
4056    #[expect(clippy::too_many_arguments)]
4057    fn build_status_report(
4058        &self,
4059        instrument_id: InstrumentId,
4060        client_order_id: ClientOrderId,
4061        order_side: OrderSide,
4062        order_type: OrderType,
4063        quantity: Quantity,
4064        time_in_force: TimeInForce,
4065        price: Option<Price>,
4066        trigger_price: Option<Price>,
4067        order_status: &HyperliquidExchangeOrderStatus,
4068        account_id: AccountId,
4069        ts_init: UnixNanos,
4070    ) -> Result<Option<OrderStatusReport>> {
4071        if matches!(order_status, HyperliquidExchangeOrderStatus::Tag(_)) {
4072            return Ok(None);
4073        }
4074
4075        let symbol = instrument_id.symbol.as_str();
4076        let product_type = HyperliquidProductType::from_symbol(symbol).ok();
4077
4078        // Mirror the alias `cache_instrument` stored (token form for outcomes,
4079        // leading segment for perps / spots).
4080        let asset = cache_alias_for_symbol(symbol).unwrap_or_else(|| symbol.to_string());
4081        let instrument = self
4082            .get_or_create_instrument(&Ustr::from(asset.as_str()), product_type)
4083            .ok_or_else(|| {
4084                Error::bad_request(InstrumentLookupError::not_found(instrument_id).to_string())
4085            })?;
4086
4087        let report = match order_status {
4088            HyperliquidExchangeOrderStatus::Resting { resting } => self.create_order_status_report(
4089                instrument_id,
4090                Some(client_order_id),
4091                VenueOrderId::new(resting.oid.to_string()),
4092                order_side,
4093                order_type,
4094                quantity,
4095                time_in_force,
4096                price,
4097                trigger_price,
4098                OrderStatus::Accepted,
4099                Quantity::zero(instrument.size_precision()),
4100                &instrument,
4101                account_id,
4102                ts_init,
4103            ),
4104            HyperliquidExchangeOrderStatus::Filled { filled } => {
4105                let filled_qty =
4106                    Quantity::from_decimal_dp(filled.total_sz, instrument.size_precision())
4107                        .map_err(|e| {
4108                            Error::bad_request(format!(
4109                                "Invalid filled size {}: {e}",
4110                                filled.total_sz
4111                            ))
4112                        })?;
4113                self.create_order_status_report(
4114                    instrument_id,
4115                    Some(client_order_id),
4116                    VenueOrderId::new(filled.oid.to_string()),
4117                    order_side,
4118                    order_type,
4119                    quantity,
4120                    time_in_force,
4121                    price,
4122                    trigger_price,
4123                    OrderStatus::Filled,
4124                    filled_qty,
4125                    &instrument,
4126                    account_id,
4127                    ts_init,
4128                )
4129            }
4130            HyperliquidExchangeOrderStatus::Error { error } => {
4131                return Err(Error::bad_request(format!(
4132                    "Order {client_order_id} rejected: {error}"
4133                )));
4134            }
4135            HyperliquidExchangeOrderStatus::Tag(_) => unreachable!("handled above"),
4136        };
4137
4138        Ok(Some(report))
4139    }
4140
4141    fn reconciliation_dexes(&self, instrument_id: Option<InstrumentId>) -> Vec<Option<Ustr>> {
4142        if let Some(instrument_id) = instrument_id {
4143            return vec![perp_dex_from_symbol(instrument_id.symbol.as_str())];
4144        }
4145
4146        let cached = self.instruments.load();
4147        reconciliation_dexes_from_builders(
4148            cached
4149                .keys()
4150                .filter_map(|symbol| perp_dex_from_symbol(symbol.as_str())),
4151        )
4152    }
4153
4154    pub(crate) async fn reconciliation_dexes_from_activity(
4155        &self,
4156        historical_orders: &[HyperliquidOrderStatusEntry],
4157        fills: &HyperliquidFills,
4158    ) -> Result<Vec<Option<Ustr>>> {
4159        if historical_orders.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
4160            || fills.len() >= HYPERLIQUID_RECENT_HISTORY_LIMIT
4161        {
4162            let builder_dexes = self
4163                .inner
4164                .load_perp_dexs()
4165                .await?
4166                .into_iter()
4167                .flatten()
4168                .filter(|dex| !dex.name.is_empty())
4169                .map(|dex| Ustr::from(dex.name.as_str()));
4170            return Ok(reconciliation_dexes_from_builders(builder_dexes));
4171        }
4172
4173        let builder_dexes = historical_orders
4174            .iter()
4175            .map(|entry| entry.order.coin.as_str())
4176            .chain(fills.iter().map(|fill| fill.coin.as_str()))
4177            .filter_map(perp_dex_from_activity_coin);
4178
4179        Ok(reconciliation_dexes_from_builders(builder_dexes))
4180    }
4181}
4182
4183// A reconciliation snapshot with its completeness flag: `complete` is false when
4184// at least one venue row could not be decoded, resolved to an instrument, or
4185// converted into a report. Callers whose contract cannot carry the flag fail
4186// closed; mass-status reconciliation preserves the valid rows and reports the
4187// incompleteness via `ExecutionMassStatus::set_report_window`.
4188#[derive(Debug)]
4189pub(crate) struct ReportSweep<T> {
4190    pub reports: Vec<T>,
4191    pub complete: bool,
4192}
4193
4194/// The instrument universe with the market status of each instrument.
4195#[derive(Debug)]
4196pub(crate) struct InstrumentSnapshot {
4197    pub instruments: Vec<InstrumentAny>,
4198    pub statuses: AHashMap<InstrumentId, MarketStatusAction>,
4199    /// Product types whose metadata loaded in full.
4200    pub loaded: AHashSet<HyperliquidProductType>,
4201}
4202
4203fn reconciliation_dexes_from_builders(
4204    builder_dexes: impl IntoIterator<Item = Ustr>,
4205) -> Vec<Option<Ustr>> {
4206    let mut builder_dexes = builder_dexes.into_iter().collect::<Vec<_>>();
4207    builder_dexes.sort_unstable();
4208    builder_dexes.dedup();
4209
4210    let mut dexes = Vec::with_capacity(builder_dexes.len() + 1);
4211    dexes.push(None);
4212    dexes.extend(builder_dexes.into_iter().map(Some));
4213    dexes
4214}
4215
4216fn perp_dex_from_activity_coin(coin: &str) -> Option<Ustr> {
4217    if coin.starts_with(VAULT_TOKEN_PREFIX) {
4218        return None;
4219    }
4220
4221    let (dex, _) = coin.split_once(':')?;
4222    (!dex.is_empty()).then(|| Ustr::from(dex))
4223}
4224
4225fn perp_dex_from_symbol(symbol: &str) -> Option<Ustr> {
4226    symbol
4227        .strip_suffix("-PERP")?
4228        .split_once(':')
4229        .map(|(dex, _)| Ustr::from(dex))
4230}
4231
4232/// Extracts the order-status payload from an exchange response.
4233///
4234/// The newer response format nests the statuses under `data`; the older format
4235/// places them directly in the response body.
4236fn parse_order_response(
4237    response: HyperliquidExchangeResponse,
4238) -> Result<HyperliquidExchangeOrderResponseData> {
4239    let response_data = match response {
4240        HyperliquidExchangeResponse::Status {
4241            status,
4242            response: response_data,
4243        } if status == RESPONSE_STATUS_OK => response_data,
4244        HyperliquidExchangeResponse::Error { error } => {
4245            return Err(Error::bad_request(format!(
4246                "Order submission failed: {error}"
4247            )));
4248        }
4249        _ => return Err(Error::bad_request("Unexpected response format")),
4250    };
4251
4252    let data_value = if let Some(data) = response_data.get("data") {
4253        data.clone()
4254    } else {
4255        response_data
4256    };
4257
4258    serde_json::from_value(data_value)
4259        .map_err(|e| Error::bad_request(format!("Failed to parse order response: {e}")))
4260}
4261
4262fn resolve_perp_dex_name(
4263    dex_index: usize,
4264    meta: &PerpMeta,
4265    perp_dexs: Option<&[Option<PerpDex>]>,
4266) -> String {
4267    if dex_index == 0 {
4268        return String::new();
4269    }
4270
4271    if let Some(dex_name) = perp_dexs
4272        .and_then(|dexs| dexs.get(dex_index))
4273        .and_then(|dex| dex.as_ref())
4274        .map(|dex| dex.name.clone())
4275    {
4276        return dex_name;
4277    }
4278
4279    meta.universe
4280        .iter()
4281        .find_map(|asset| asset.name.split_once(':').map(|(dex, _)| dex.to_string()))
4282        .unwrap_or_default()
4283}
4284
4285/// Returns the asset index base for a perp dex.
4286///
4287/// Standard perps (dex 0) start at 0. HIP-3 dexes start at
4288/// 100_000 + dex_index * 10_000.
4289fn perp_dex_asset_index_base(dex_index: usize) -> u32 {
4290    if dex_index == 0 {
4291        0
4292    } else {
4293        100_000 + dex_index as u32 * 10_000
4294    }
4295}
4296
4297fn cache_coin_aliases(
4298    cache: &mut AHashMap<(Ustr, HyperliquidProductType), InstrumentAny>,
4299    instrument: &InstrumentAny,
4300    product_type: HyperliquidProductType,
4301) {
4302    let full_symbol = instrument.symbol().inner();
4303    let coin = instrument.raw_symbol().inner();
4304
4305    // Composite key allows disambiguating same coin across PERP and SPOT
4306    cache.insert((coin, product_type), instrument.clone());
4307
4308    // Secondary alias key for two distinct callers:
4309    //
4310    // * Spot raw_symbols are either `@{pair_index}` or slash format
4311    //   (e.g., "PURR/USDC"); spot balance/position reconciliation
4312    //   maps the venue token name (e.g., "PURR") to instruments via
4313    //   this alias.
4314    // * Order submission paths split `instrument_id.symbol` on `-`
4315    //   to derive a coin key. For HIP-3 perps with wildcard-bearing
4316    //   venue names, the sanitized base in `instrument_id.symbol`
4317    //   (e.g., "dex:STREAMABCDxxxx") differs from `raw_symbol` /
4318    //   `coin` (e.g., "dex:STREAMABCD****"), so an alias on the
4319    //   sanitized base lets that lookup resolve.
4320    //
4321    // For outcomes the alias is the `+<encoding>` token form
4322    // (matching the `coin` field on `spotClearinghouseState`);
4323    // for perps / spots it is the leading symbol segment.
4324    // `cache_alias_for_symbol` keeps the two rules co-located so
4325    // every caller derives the same key.
4326    //
4327    // Keep aliases on their original venue market while refreshing
4328    // its definition, canonical spot pairs are cached first.
4329    if let Some(alias_ustr) =
4330        cache_alias_for_symbol(full_symbol.as_str()).map(|alias| Ustr::from(alias.as_str()))
4331    {
4332        let key = (alias_ustr, product_type);
4333        if alias_ustr != coin
4334            && cache
4335                .get(&key)
4336                .is_none_or(|cached| cached.raw_symbol() == instrument.raw_symbol())
4337        {
4338            cache.insert(key, instrument.clone());
4339        }
4340    }
4341}
4342
4343fn validate_schedule_cancel_time(time: u64, now: u64) -> Result<()> {
4344    if time.checked_sub(now).is_none_or(|delay| delay < 5_000) {
4345        return Err(Error::bad_request(
4346            "Schedule cancel time must be at least 5000 ms after the current time",
4347        ));
4348    }
4349
4350    Ok(())
4351}
4352
4353#[cfg(test)]
4354mod tests {
4355    use std::{
4356        collections::HashMap,
4357        net::SocketAddr,
4358        sync::{
4359            Arc,
4360            atomic::{AtomicUsize, Ordering},
4361        },
4362        time::Duration,
4363    };
4364
4365    use axum::{
4366        Router,
4367        extract::State,
4368        http::StatusCode,
4369        response::{IntoResponse, Json, Response},
4370        routing::post,
4371    };
4372    use nautilus_core::{Params, UUID4, UnixNanos, hex, time::get_atomic_clock_realtime};
4373    use nautilus_model::{
4374        currencies::CURRENCY_MAP,
4375        enums::{CurrencyType, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
4376        events::{OrderEventAny, OrderInitialized},
4377        identifiers::{
4378            AccountId, ClientOrderId, InstrumentId, StrategyId, Symbol, TraderId, VenueOrderId,
4379        },
4380        instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
4381        orders::OrderAny,
4382        reports::OrderStatusReport,
4383        types::{Currency, Price, Quantity},
4384    };
4385    use nautilus_testkit::http::assert_http_redirect_rejected;
4386    use rstest::rstest;
4387    use rust_decimal_macros::dec;
4388    use serde_json::{Value, json};
4389    use ustr::Ustr;
4390
4391    use super::{
4392        HyperliquidHttpClient, HyperliquidRawHttpClient, RETRY_AFTER_HEADER, resolve_perp_dex_name,
4393        validate_schedule_cancel_time,
4394    };
4395    use crate::{
4396        common::{
4397            consts::{ASSET_INDEX_INFO_KEY, HYPERLIQUID_VENUE, NAUTILUS_BUILDER_ADDRESS},
4398            credential::EvmPrivateKey,
4399            enums::{HyperliquidEnvironment, HyperliquidProductType},
4400        },
4401        http::{
4402            error::Error,
4403            models::{
4404                Cloid, HyperliquidExchangeAction, HyperliquidExchangeResponse,
4405                HyperliquidOrderStatusEntry, PerpAsset, PerpDex, PerpMeta,
4406            },
4407            query::InfoRequest,
4408        },
4409        signing::{HyperliquidActionType, HyperliquidEip712Signer, SignRequest, nonce::TimeNonce},
4410    };
4411
4412    const TEST_PRIVATE_KEY: &str =
4413        "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
4414
4415    #[rstest]
4416    #[case(10_000, 9_999, false)]
4417    #[case(10_000, 10_000, false)]
4418    #[case(10_000, 14_999, false)]
4419    #[case(10_000, 15_000, true)]
4420    #[case(10_000, 15_001, true)]
4421    #[case(u64::MAX - 4_999, u64::MAX, false)]
4422    #[case(u64::MAX - 5_000, u64::MAX, true)]
4423    fn test_schedule_cancel_timestamp(#[case] now: u64, #[case] time: u64, #[case] valid: bool) {
4424        assert_eq!(validate_schedule_cancel_time(time, now).is_ok(), valid);
4425    }
4426
4427    #[rstest]
4428    fn test_usd_class_transfer_rejected_without_credentials(
4429        #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4430        environment: HyperliquidEnvironment,
4431    ) {
4432        let client = HyperliquidRawHttpClient::new(environment, 5, None).unwrap();
4433
4434        let action = HyperliquidExchangeAction::UsdClassTransfer {
4435            from: "spot".to_string(),
4436            to: "perp".to_string(),
4437            amount: dec!(1.25),
4438        };
4439
4440        let error = client.sign_action_exec_request(&action, None).unwrap_err();
4441
4442        assert!(matches!(error, Error::BadRequest(_)));
4443        assert_eq!(
4444            error.to_string(),
4445            "bad request: Unsupported action: usdClassTransfer"
4446        );
4447    }
4448
4449    #[rstest]
4450    fn test_usd_class_transfer_rejected_before_nonce_allocation(
4451        #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4452        environment: HyperliquidEnvironment,
4453        #[values(None, Some("0x1234567890123456789012345678901234567890"))] vault: Option<&str>,
4454        #[values(None, Some(1_900_000_000_000))] expires_after: Option<u64>,
4455    ) {
4456        let mut client =
4457            HyperliquidHttpClient::from_credentials(TEST_PRIVATE_KEY, vault, environment, 5, None)
4458                .unwrap();
4459        Arc::get_mut(&mut client.inner).unwrap().nonce_manager = None;
4460
4461        let action = HyperliquidExchangeAction::UsdClassTransfer {
4462            from: "perp".to_string(),
4463            to: "spot".to_string(),
4464            amount: dec!(2.75),
4465        };
4466
4467        let error = client
4468            .sign_action_exec_request(&action, expires_after)
4469            .unwrap_err();
4470
4471        assert!(matches!(error, Error::BadRequest(_)));
4472        assert_eq!(
4473            error.to_string(),
4474            "bad request: Unsupported action: usdClassTransfer"
4475        );
4476    }
4477
4478    #[rstest]
4479    #[tokio::test]
4480    async fn test_usd_class_transfer_rejected_without_dispatch(
4481        #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4482        environment: HyperliquidEnvironment,
4483    ) {
4484        let (addr, count, server) =
4485            start_retry_server(RetryFailure::Response(400), usize::MAX, None).await;
4486        let mut client =
4487            HyperliquidHttpClient::from_credentials(TEST_PRIVATE_KEY, None, environment, 5, None)
4488                .unwrap();
4489        client.set_base_exchange_url(format!("http://{addr}/exchange"));
4490
4491        let action = HyperliquidExchangeAction::UsdClassTransfer {
4492            from: "spot".to_string(),
4493            to: "perp".to_string(),
4494            amount: dec!(3.125),
4495        };
4496
4497        let error = client.post_action_exec(&action).await.unwrap_err();
4498
4499        server.abort();
4500
4501        assert!(matches!(error, Error::BadRequest(_)));
4502        assert_eq!(
4503            error.to_string(),
4504            "bad request: Unsupported action: usdClassTransfer"
4505        );
4506        assert_eq!(count.load(Ordering::Relaxed), 0);
4507    }
4508
4509    #[rstest]
4510    #[case(0)]
4511    #[case(1)]
4512    #[case(2)]
4513    #[case(3)]
4514    fn test_leverage_margin_signed_request(
4515        #[case] case_index: usize,
4516        #[values(HyperliquidEnvironment::Mainnet, HyperliquidEnvironment::Testnet)]
4517        environment: HyperliquidEnvironment,
4518    ) {
4519        let fixture: Value =
4520            serde_json::from_str(include_str!("../../test_data/leverage_margin_actions.json"))
4521                .unwrap();
4522        let case = &fixture["cases"][case_index];
4523        let action: HyperliquidExchangeAction =
4524            serde_json::from_str(case["json"].as_str().unwrap()).unwrap();
4525        let client =
4526            HyperliquidHttpClient::from_credentials(TEST_PRIVATE_KEY, None, environment, 60, None)
4527                .unwrap();
4528        let expires_after = Some(1_900_000_000_000);
4529        let request = client
4530            .sign_action_exec_request(&action, expires_after)
4531            .unwrap();
4532        let key = EvmPrivateKey::new(TEST_PRIVATE_KEY).unwrap();
4533        let signer = HyperliquidEip712Signer::new(&key).unwrap();
4534        let expected_bytes = hex::decode(case["msgpack"].as_str().unwrap()).unwrap();
4535
4536        let expected_signature = signer
4537            .sign(&SignRequest {
4538                action: None,
4539                action_bytes: Some(expected_bytes),
4540                time_nonce: TimeNonce::from_millis(i128::from(request.nonce)),
4541                action_type: HyperliquidActionType::L1,
4542                is_testnet: environment == HyperliquidEnvironment::Testnet,
4543                vault_address: None,
4544                expires_after,
4545            })
4546            .unwrap()
4547            .signature;
4548
4549        let expected_action: Value = serde_json::from_str(case["json"].as_str().unwrap()).unwrap();
4550
4551        assert_eq!(
4552            serde_json::to_value(&request).unwrap(),
4553            json!({
4554                "action": expected_action,
4555                "nonce": request.nonce,
4556                "signature": expected_signature,
4557                "expiresAfter": expires_after,
4558            })
4559        );
4560    }
4561
4562    #[tokio::test]
4563    async fn test_authenticated_client_rejects_redirects() {
4564        let client = HyperliquidRawHttpClient::build_http_client(3, None).unwrap();
4565        assert_http_redirect_rejected(|url| async move {
4566            client
4567                .get(url, None, None, Some(3), None)
4568                .await
4569                .unwrap()
4570                .status
4571                .as_u16()
4572        })
4573        .await;
4574    }
4575
4576    #[rstest]
4577    fn raw_clients_share_rest_limit_for_one_route() {
4578        let mut first =
4579            HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4580        let mut second =
4581            HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
4582        first.set_base_info_url("https://shared-http-limit.test/info".to_string());
4583        second.set_base_exchange_url("https://shared-http-limit.test/exchange".to_string());
4584
4585        assert!(Arc::ptr_eq(&first.info_limiter, &second.exchange_limiter));
4586    }
4587
4588    #[rstest]
4589    #[case::seconds("3", Some(3_000))]
4590    #[case::zero("0", Some(0))]
4591    #[case::past_date("Sun, 06 Nov 1994 08:49:36 GMT", Some(0))]
4592    #[case::overflow("18446744073709551615", Some(u64::MAX))]
4593    #[case::invalid("soon", None)]
4594    #[case::negative("-1", None)]
4595    fn retry_after_ms_parses_header(#[case] value: &str, #[case] expected: Option<u64>) {
4596        let headers = HashMap::from([(RETRY_AFTER_HEADER.to_string(), value.to_string())]);
4597
4598        assert_eq!(HyperliquidRawHttpClient::retry_after_ms(&headers), expected);
4599    }
4600
4601    #[rstest]
4602    #[case::transport(RetryFailure::Transport)]
4603    #[case::timeout(RetryFailure::Timeout)]
4604    #[case::request_timeout(RetryFailure::Response(408))]
4605    #[case::rate_limit(RetryFailure::Response(429))]
4606    #[case::server_error(RetryFailure::Response(500))]
4607    #[case::unavailable(RetryFailure::Response(503))]
4608    #[tokio::test]
4609    async fn info_retry_recovers(#[case] failure: RetryFailure) {
4610        let (addr, count, server) = start_retry_server(failure, 1, None).await;
4611        let mut client =
4612            HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4613        client.set_base_info_url(format!("http://{addr}/info"));
4614
4615        let result = client
4616            .send_info_request_raw(&InfoRequest::meta())
4617            .await
4618            .unwrap();
4619        let expected: Value =
4620            serde_json::from_str(include_str!("../../test_data/http_meta_perp_sample.json"))
4621                .unwrap();
4622
4623        server.abort();
4624        assert_eq!(result, expected);
4625        assert_eq!(count.load(Ordering::Relaxed), 2);
4626    }
4627
4628    #[rstest]
4629    #[case::transport(RetryFailure::Transport)]
4630    #[case::timeout(RetryFailure::Timeout)]
4631    #[case::request_timeout(RetryFailure::Response(408))]
4632    #[case::rate_limit(RetryFailure::Response(429))]
4633    #[case::server_error(RetryFailure::Response(503))]
4634    #[tokio::test]
4635    async fn info_retry_exhausts_budget(#[case] failure: RetryFailure) {
4636        let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("0")).await;
4637        let mut client =
4638            HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4639        client.set_base_info_url(format!("http://{addr}/info"));
4640
4641        let error = client.info_meta().await.unwrap_err();
4642
4643        server.abort();
4644        assert_retry_failure(error, failure, ("info", 20, Some(0)));
4645        assert_eq!(count.load(Ordering::Relaxed), 4);
4646    }
4647
4648    #[rstest]
4649    #[case::bad_request(RetryFailure::Response(400))]
4650    #[case::unauthorized(RetryFailure::Response(401))]
4651    #[case::forbidden(RetryFailure::Response(403))]
4652    #[case::unprocessable(RetryFailure::Response(422))]
4653    #[case::invalid_json(RetryFailure::InvalidJson)]
4654    #[tokio::test]
4655    async fn info_retry_rejects_permanent_failures(#[case] failure: RetryFailure) {
4656        let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("0")).await;
4657        let mut client =
4658            HyperliquidRawHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4659        client.set_base_info_url(format!("http://{addr}/info"));
4660
4661        let error = client.info_meta().await.unwrap_err();
4662
4663        server.abort();
4664        assert_retry_failure(error, failure, ("info", 20, Some(0)));
4665        assert_eq!(count.load(Ordering::Relaxed), 1);
4666    }
4667
4668    #[rstest]
4669    #[case(0)]
4670    #[case(1)]
4671    #[case(2)]
4672    #[tokio::test]
4673    async fn info_retry_uses_configured_budget(#[case] max_retries: u32) {
4674        let failure = RetryFailure::Response(503);
4675        let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("invalid")).await;
4676        let mut client =
4677            HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 1, None).unwrap();
4678        client.set_base_info_url(format!("http://{addr}/info"));
4679        client.set_info_retry_config(max_retries, Duration::ZERO, Duration::ZERO);
4680
4681        let error = client.info_meta().await.unwrap_err();
4682
4683        server.abort();
4684        assert_retry_failure(error, failure, ("info", 20, None));
4685        assert_eq!(count.load(Ordering::Relaxed), max_retries as usize + 1);
4686    }
4687
4688    #[rstest]
4689    #[case::rate_limit(429)]
4690    #[case::unavailable(503)]
4691    #[tokio::test]
4692    async fn info_retry_honors_retry_after_above_backoff_cap(#[case] status: u16) {
4693        let (addr, count, server) =
4694            start_retry_server(RetryFailure::Response(status), 1, Some("1")).await;
4695        let mut client =
4696            HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 2, None).unwrap();
4697        client.set_base_info_url(format!("http://{addr}/info"));
4698        client.set_info_retry_config(1, Duration::ZERO, Duration::ZERO);
4699        let started = tokio::time::Instant::now();
4700
4701        let result = client.info_meta().await;
4702        let elapsed = started.elapsed();
4703
4704        server.abort();
4705        assert!(result.is_ok(), "{result:?}");
4706        assert_eq!(count.load(Ordering::Relaxed), 2);
4707        assert!(elapsed >= Duration::from_secs(1), "{elapsed:?}");
4708    }
4709
4710    #[rstest]
4711    #[case::transport(RetryFailure::Transport)]
4712    #[case::timeout(RetryFailure::Timeout)]
4713    #[case::rate_limit(RetryFailure::Response(429))]
4714    #[case::server_error(RetryFailure::Response(503))]
4715    #[tokio::test]
4716    async fn exchange_does_not_retry_unknown_outcome(
4717        #[case] failure: RetryFailure,
4718        #[values("submit", "modify", "cancel")] operation: &str,
4719    ) {
4720        let (addr, count, server) = start_retry_server(failure, usize::MAX, Some("0")).await;
4721        let mut client = HyperliquidHttpClient::from_credentials(
4722            TEST_PRIVATE_KEY,
4723            None,
4724            HyperliquidEnvironment::Testnet,
4725            1,
4726            None,
4727        )
4728        .unwrap();
4729        client.set_base_exchange_url(format!("http://{addr}/exchange"));
4730        client.set_info_retry_config(9, Duration::ZERO, Duration::ZERO);
4731        let signing: Value =
4732            serde_json::from_str(include_str!("../../test_data/order_decimal_signing.json"))
4733                .unwrap();
4734
4735        let action = match operation {
4736            "submit" => serde_json::from_value(signing["cases"][0]["action"].clone()).unwrap(),
4737            "modify" => HyperliquidExchangeAction::Modify {
4738                modify: serde_json::from_value(
4739                    signing["cases"][2]["action"]["modifies"][0].clone(),
4740                )
4741                .unwrap(),
4742            },
4743            "cancel" => {
4744                let cancels: Value =
4745                    serde_json::from_str(include_str!("../../test_data/cancel_actions.json"))
4746                        .unwrap();
4747                serde_json::from_value(cancels["cancel"]["normal"]["json"].clone()).unwrap()
4748            }
4749            _ => unreachable!(),
4750        };
4751
4752        let error = client.post_action_exec(&action).await.unwrap_err();
4753
4754        server.abort();
4755
4756        assert_retry_failure(error, failure, ("exchange", 1, Some(0)));
4757        assert_eq!(count.load(Ordering::Relaxed), 1);
4758    }
4759
4760    #[derive(Debug, Clone, Copy)]
4761    enum RetryFailure {
4762        Transport,
4763        Timeout,
4764        Response(u16),
4765        InvalidJson,
4766    }
4767
4768    async fn start_retry_server(
4769        failure: RetryFailure,
4770        failures: usize,
4771        retry_after: Option<&'static str>,
4772    ) -> (SocketAddr, Arc<AtomicUsize>, tokio::task::JoinHandle<()>) {
4773        let count = Arc::new(AtomicUsize::new(0));
4774        let requests = count.clone();
4775        let handler = post(move |_body: axum::body::Bytes| {
4776            let count = requests.clone();
4777            async move {
4778                if count.fetch_add(1, Ordering::Relaxed) >= failures {
4779                    return include_str!("../../test_data/http_meta_perp_sample.json")
4780                        .into_response();
4781                }
4782
4783                let mut response = match failure {
4784                    RetryFailure::Transport => {
4785                        let stream = futures_util::stream::once(async {
4786                            Err::<axum::body::Bytes, _>(std::io::Error::other("connection dropped"))
4787                        });
4788
4789                        Response::new(axum::body::Body::from_stream(stream))
4790                    }
4791                    RetryFailure::Timeout => {
4792                        tokio::time::sleep(Duration::from_secs(2)).await;
4793                        "delayed response".into_response()
4794                    }
4795                    RetryFailure::Response(status) => {
4796                        (StatusCode::from_u16(status).unwrap(), "retry failure").into_response()
4797                    }
4798                    RetryFailure::InvalidJson => "invalid json".into_response(),
4799                };
4800
4801                if let Some(value) = retry_after {
4802                    response
4803                        .headers_mut()
4804                        .insert(RETRY_AFTER_HEADER, value.parse().unwrap());
4805                }
4806
4807                response
4808            }
4809        });
4810
4811        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4812        let addr = listener.local_addr().unwrap();
4813        let router = Router::new()
4814            .route("/info", handler.clone())
4815            .route("/exchange", handler);
4816
4817        let server = tokio::spawn(async move {
4818            axum::serve(listener, router).await.unwrap();
4819        });
4820
4821        (addr, count, server)
4822    }
4823
4824    fn assert_retry_failure(
4825        error: Error,
4826        failure: RetryFailure,
4827        rate_limit: (&str, u32, Option<u64>),
4828    ) {
4829        match (failure, error) {
4830            (RetryFailure::Transport, Error::Transport(_))
4831            | (RetryFailure::Timeout, Error::Timeout)
4832            | (RetryFailure::InvalidJson, Error::Serde(_)) => {}
4833            (
4834                RetryFailure::Response(429),
4835                Error::RateLimit {
4836                    scope,
4837                    weight,
4838                    retry_after_ms,
4839                },
4840            ) => {
4841                assert_eq!((scope, weight, retry_after_ms), rate_limit);
4842            }
4843            (
4844                RetryFailure::Response(status),
4845                Error::Http {
4846                    status: actual,
4847                    message,
4848                },
4849            ) => {
4850                assert_eq!((actual, message.as_str()), (status, "retry failure"));
4851            }
4852            (_, e) => panic!("unexpected info failure: {e:?}"),
4853        }
4854    }
4855
4856    fn perp_meta_with_assets(names: &[&str]) -> PerpMeta {
4857        PerpMeta {
4858            universe: names
4859                .iter()
4860                .map(|name| PerpAsset {
4861                    name: (*name).to_string(),
4862                    ..Default::default()
4863                })
4864                .collect(),
4865            margin_tables: Vec::new(),
4866            collateral_token: None,
4867        }
4868    }
4869
4870    #[rstest]
4871    fn resolve_perp_dex_name_uses_empty_string_for_default_dex() {
4872        let meta = perp_meta_with_assets(&["BTC", "ETH"]);
4873        assert_eq!(resolve_perp_dex_name(0, &meta, None), "");
4874    }
4875
4876    #[rstest]
4877    fn resolve_perp_dex_name_prefers_perp_dexs_entry() {
4878        let meta = perp_meta_with_assets(&["xyz:TSLA"]);
4879        let perp_dexs = vec![
4880            None,
4881            Some(PerpDex {
4882                name: "xyz".to_string(),
4883            }),
4884        ];
4885        assert_eq!(resolve_perp_dex_name(1, &meta, Some(&perp_dexs)), "xyz");
4886    }
4887
4888    #[rstest]
4889    fn resolve_perp_dex_name_infers_from_asset_name_when_perp_dexs_missing() {
4890        let meta = perp_meta_with_assets(&["abc:TSLA", "abc:NVDA"]);
4891        assert_eq!(resolve_perp_dex_name(1, &meta, None), "abc");
4892    }
4893
4894    #[rstest]
4895    fn test_build_submit_order_report_elides_waiting_for_fill_tag() {
4896        // A `Tag` status (for example the `waitingForFill` trigger child of a
4897        // `normalTpsl` bracket) must surface as `Ok(None)` so the caller leaves
4898        // the order SUBMITTED until the user-events stream confirms a real oid.
4899        let mut client =
4900            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4901        client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4902
4903        let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4904            "status": "ok",
4905            "response": {
4906                "type": "order",
4907                "data": {
4908                    "statuses": ["waitingForFill"]
4909                }
4910            }
4911        }))
4912        .unwrap();
4913
4914        let result = client
4915            .build_submit_order_report(
4916                InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4917                ClientOrderId::from("O-WAITING-CHILD"),
4918                OrderSide::Buy,
4919                OrderType::StopMarket,
4920                Quantity::from("100"),
4921                TimeInForce::Gtc,
4922                None,
4923                Some(Price::from("0.16136")),
4924                response,
4925            )
4926            .unwrap();
4927
4928        assert!(
4929            result.is_none(),
4930            "Tag status must elide so the order stays SUBMITTED, was {result:?}"
4931        );
4932    }
4933
4934    #[rstest]
4935    fn test_build_submit_order_report_filled_uses_total_sz_decimal() {
4936        // An atomic `filled` submit response must surface as a FILLED report
4937        // carrying the venue oid and the total filled size built from the
4938        // Decimal `totalSz` at the instrument's size precision.
4939        let mut client =
4940            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
4941        client.set_account_id(AccountId::from("HYPERLIQUID-001"));
4942
4943        let base = Currency::new("ARB", 8, 0, "ARB", CurrencyType::Crypto);
4944        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
4945        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
4946        let clock = get_atomic_clock_realtime();
4947        let ts = clock.get_time_ns();
4948        let perp = InstrumentAny::CryptoPerpetual(
4949            CryptoPerpetual::builder()
4950                .instrument_id(InstrumentId::new(
4951                    Symbol::new("ARB-USD-PERP"),
4952                    *HYPERLIQUID_VENUE,
4953                ))
4954                .raw_symbol(Symbol::new("ARB"))
4955                .base_currency(base)
4956                .quote_currency(usd)
4957                .settlement_currency(usdc)
4958                .is_inverse(false)
4959                .price_precision(5)
4960                .size_precision(2)
4961                .price_increment(Price::from("0.00001"))
4962                .size_increment(Quantity::from("0.01"))
4963                .ts_event(ts)
4964                .ts_init(ts)
4965                .build()
4966                .unwrap(),
4967        );
4968        client.cache_instrument(&perp);
4969
4970        let response: HyperliquidExchangeResponse = serde_json::from_value(json!({
4971            "status": "ok",
4972            "response": {
4973                "type": "order",
4974                "data": {
4975                    "statuses": [{
4976                        "filled": {"totalSz": "0.5", "avgPx": "1.2345", "oid": 778899}
4977                    }]
4978                }
4979            }
4980        }))
4981        .unwrap();
4982
4983        let report = client
4984            .build_submit_order_report(
4985                InstrumentId::from("ARB-USD-PERP.HYPERLIQUID"),
4986                ClientOrderId::from("O-FILLED-001"),
4987                OrderSide::Buy,
4988                OrderType::Market,
4989                Quantity::from("0.5"),
4990                TimeInForce::Ioc,
4991                None,
4992                None,
4993                response,
4994            )
4995            .unwrap()
4996            .expect("filled status must produce a report");
4997
4998        assert_eq!(report.order_status, OrderStatus::Filled);
4999        assert_eq!(report.venue_order_id.as_str(), "778899");
5000        assert_eq!(report.filled_qty.as_decimal(), dec!(0.5));
5001    }
5002
5003    #[derive(Clone, Default)]
5004    struct OutcomeMetaServerState {
5005        last_request_body: Arc<tokio::sync::Mutex<Option<Value>>>,
5006    }
5007
5008    async fn handle_outcome_meta_info(
5009        State(state): State<OutcomeMetaServerState>,
5010        body: axum::body::Bytes,
5011    ) -> Response {
5012        let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
5013            return (
5014                StatusCode::BAD_REQUEST,
5015                Json(json!({"error": "Invalid JSON body"})),
5016            )
5017                .into_response();
5018        };
5019
5020        *state.last_request_body.lock().await = Some(request_body.clone());
5021
5022        if request_body.get("type").and_then(|value| value.as_str()) != Some("outcomeMeta") {
5023            return (
5024                StatusCode::BAD_REQUEST,
5025                Json(json!({"error": "Expected outcomeMeta request"})),
5026            )
5027                .into_response();
5028        }
5029
5030        Json(json!({
5031            "outcomes": [
5032                {
5033                    "outcome": 123,
5034                    "quoteToken": "USDC",
5035                    "name": "Recurring",
5036                    "description": "class:priceBinary|underlying:HYPE|expiry:20260310-1100|targetPrice:34.5|period:3m",
5037                    "sideSpecs": [
5038                        {"name": "Yes"},
5039                        {"name": "No"}
5040                    ]
5041                }
5042            ]
5043        }))
5044        .into_response()
5045    }
5046
5047    async fn start_outcome_meta_server(state: OutcomeMetaServerState) -> SocketAddr {
5048        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5049        let addr = listener.local_addr().unwrap();
5050        let router = Router::new()
5051            .route("/info", post(handle_outcome_meta_info))
5052            .with_state(state);
5053
5054        tokio::spawn(async move {
5055            axum::serve(listener, router).await.unwrap();
5056        });
5057
5058        addr
5059    }
5060
5061    async fn handle_unresolved_collateral_info(body: axum::body::Bytes) -> Response {
5062        let Ok(request_body): Result<Value, _> = serde_json::from_slice(&body) else {
5063            return (
5064                StatusCode::BAD_REQUEST,
5065                Json(json!({"error": "Invalid JSON body"})),
5066            )
5067                .into_response();
5068        };
5069
5070        match request_body.get("type").and_then(|value| value.as_str()) {
5071            Some("spotMeta") => (
5072                StatusCode::INTERNAL_SERVER_ERROR,
5073                Json(json!({"error": "spot metadata unavailable"})),
5074            )
5075                .into_response(),
5076            Some("allPerpMetas") => Json(json!([
5077                {
5078                    "collateralToken": 360,
5079                    "marginTables": [],
5080                    "universe": [
5081                        {
5082                            "maxLeverage": 20,
5083                            "name": "km:US500",
5084                            "szDecimals": 3
5085                        }
5086                    ]
5087                }
5088            ]))
5089            .into_response(),
5090            _ => Json(json!({"universe": [], "marginTables": []})).into_response(),
5091        }
5092    }
5093
5094    async fn start_unresolved_collateral_server() -> SocketAddr {
5095        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5096        let addr = listener.local_addr().unwrap();
5097        let router = Router::new().route("/info", post(handle_unresolved_collateral_info));
5098
5099        tokio::spawn(async move {
5100            axum::serve(listener, router).await.unwrap();
5101        });
5102
5103        addr
5104    }
5105
5106    #[rstest]
5107    fn stable_json_roundtrips() {
5108        let v = serde_json::json!({"type":"l2Book","coin":"BTC"});
5109        let s = serde_json::to_string(&v).unwrap();
5110        // Parse back to ensure JSON structure is correct, regardless of field order
5111        let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
5112        assert_eq!(parsed["type"], "l2Book");
5113        assert_eq!(parsed["coin"], "BTC");
5114        assert_eq!(parsed, v);
5115    }
5116
5117    #[rstest]
5118    fn info_pretty_shape() {
5119        let r = InfoRequest::l2_book("BTC");
5120        let val = serde_json::to_value(&r).unwrap();
5121        let pretty = serde_json::to_string_pretty(&val).unwrap();
5122        assert!(pretty.contains("\"type\": \"l2Book\""));
5123        assert!(pretty.contains("\"coin\": \"BTC\""));
5124    }
5125
5126    #[rstest]
5127    fn test_client_order_id_cloid_cache_is_stable_and_first_write_wins() {
5128        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5129        let client_order_id = ClientOrderId::new("O-CLOID-CACHE");
5130        let other_client_order_id = ClientOrderId::new("O-CLOID-CACHE-OTHER");
5131        let duplicate_client_order_id = ClientOrderId::new("O-CLOID-CACHE-DUPLICATE");
5132        let explicit_cloid = Cloid::from_hex("0x1234567890abcdef1234567890abcdef").unwrap();
5133
5134        let first = client.get_or_generate_client_order_id_cloid(client_order_id);
5135        let second = client.get_or_generate_client_order_id_cloid(client_order_id);
5136        client.cache_client_order_id_cloid(client_order_id, explicit_cloid);
5137        client.cache_client_order_id_cloid(other_client_order_id, explicit_cloid);
5138        client.cache_client_order_id_cloid(duplicate_client_order_id, explicit_cloid);
5139
5140        assert_eq!(first, Cloid::from_client_order_id(client_order_id));
5141        assert_eq!(first, second);
5142        assert_eq!(
5143            client.cached_client_order_id_cloid(&client_order_id),
5144            Some(first),
5145            "cache insert must not overwrite an existing generated CLOID",
5146        );
5147        assert_eq!(
5148            client.unique_cached_client_order_id_cloid(&client_order_id),
5149            Some(first),
5150        );
5151        assert_eq!(
5152            client.cached_client_order_id_cloid(&other_client_order_id),
5153            Some(explicit_cloid),
5154        );
5155        assert_eq!(
5156            client.unique_cached_client_order_id_cloid(&other_client_order_id),
5157            None,
5158            "duplicate CLOID mappings are not safe modify targets",
5159        );
5160        assert_eq!(
5161            client.remove_client_order_id_cloid(&client_order_id),
5162            Some(first),
5163        );
5164        assert_eq!(client.cached_client_order_id_cloid(&client_order_id), None);
5165    }
5166
5167    #[rstest]
5168    fn test_builder_attribution_defaults_to_mainnet_builder() {
5169        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5170        let builder = client
5171            .builder_attribution()
5172            .expect("mainnet client should include builder attribution by default");
5173
5174        assert!(client.include_builder_attribution());
5175        assert_eq!(builder.address, NAUTILUS_BUILDER_ADDRESS);
5176        assert_eq!(builder.fee_tenths_bp, 0);
5177    }
5178
5179    #[rstest]
5180    fn test_builder_attribution_disabled_returns_none() {
5181        let mut client =
5182            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5183        client.set_include_builder_attribution(false);
5184
5185        assert!(!client.include_builder_attribution());
5186        assert!(client.builder_attribution().is_none());
5187    }
5188
5189    #[rstest]
5190    fn test_builder_attribution_omitted_on_testnet() {
5191        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Testnet, 60, None).unwrap();
5192
5193        assert!(client.include_builder_attribution());
5194        assert!(client.builder_attribution().is_none());
5195    }
5196
5197    #[rstest]
5198    #[tokio::test]
5199    async fn test_production_client_get_outcome_meta_uses_outcome_meta_request() {
5200        let state = OutcomeMetaServerState::default();
5201        let addr = start_outcome_meta_server(state.clone()).await;
5202        let mut client =
5203            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5204        client.set_base_info_url(format!("http://{addr}/info"));
5205
5206        let meta = client.get_outcome_meta().await.unwrap();
5207        let request_body = state.last_request_body.lock().await.clone().unwrap();
5208
5209        assert_eq!(request_body, json!({"type": "outcomeMeta"}));
5210        assert_eq!(meta.outcomes.len(), 1);
5211        assert_eq!(meta.outcomes[0].outcome, 123);
5212        assert_eq!(meta.outcomes[0].quote_token.as_deref(), Some("USDC"));
5213        assert_eq!(meta.outcomes[0].name, "Recurring");
5214        assert_eq!(meta.outcomes[0].side_specs.len(), 2);
5215        assert_eq!(meta.outcomes[0].side_specs[0].name, "Yes");
5216        assert_eq!(meta.outcomes[0].side_specs[1].name, "No");
5217    }
5218
5219    #[rstest]
5220    #[tokio::test]
5221    async fn test_request_instrument_defs_errors_when_non_usdc_collateral_unresolved() {
5222        let addr = start_unresolved_collateral_server().await;
5223        let mut client =
5224            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5225        client.set_base_info_url(format!("http://{addr}/info"));
5226
5227        let err = client.request_instrument_defs().await.unwrap_err();
5228
5229        assert_eq!(
5230            err.to_string(),
5231            "decode error: failed to resolve perp settlement currency for dex 0: \
5232             Spot metadata required to resolve perp collateral token 360",
5233        );
5234    }
5235
5236    #[rstest]
5237    fn test_with_credentials_preserves_explicit_account_address() {
5238        let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
5239        let client = HyperliquidHttpClient::with_credentials(
5240            Some(TEST_PRIVATE_KEY.to_string()),
5241            None,
5242            Some(account_address),
5243            HyperliquidEnvironment::Mainnet,
5244            60,
5245            None,
5246        )
5247        .unwrap();
5248
5249        assert_eq!(client.get_account_address().unwrap(), account_address);
5250    }
5251
5252    #[rstest]
5253    fn test_from_resolved_credentials_preserves_account_address_without_private_key() {
5254        let account_address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
5255        let client = HyperliquidHttpClient::from_resolved_credentials(
5256            None,
5257            None,
5258            Some(account_address.to_string()),
5259            HyperliquidEnvironment::Mainnet,
5260            60,
5261            None,
5262        )
5263        .unwrap();
5264
5265        assert_eq!(client.get_account_address().unwrap(), account_address);
5266    }
5267
5268    #[rstest]
5269    fn test_with_credentials_rejects_invalid_account_address() {
5270        let e = HyperliquidHttpClient::with_credentials(
5271            Some(TEST_PRIVATE_KEY.to_string()),
5272            None,
5273            Some("0xinvalid"),
5274            HyperliquidEnvironment::Mainnet,
5275            60,
5276            Some("invalid proxy URL".to_string()),
5277        )
5278        .unwrap_err();
5279
5280        assert_eq!(
5281            e.to_string(),
5282            "bad request: Account address must be 20 bytes of valid hex"
5283        );
5284    }
5285
5286    #[rstest]
5287    fn test_set_account_address_normalizes_and_preserves_override_on_error() {
5288        let mut client =
5289            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5290        client
5291            .set_account_address(Some(
5292                " \t0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 ".to_string(),
5293            ))
5294            .unwrap();
5295        let e = client
5296            .set_account_address(Some("0xinvalid".to_string()))
5297            .unwrap_err();
5298
5299        assert_eq!(
5300            e.to_string(),
5301            "bad request: Account address must be 20 bytes of valid hex"
5302        );
5303        assert_eq!(
5304            client.get_account_address().unwrap(),
5305            "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
5306        );
5307    }
5308
5309    #[rstest]
5310    #[case(None)]
5311    #[case(Some("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))]
5312    fn test_set_account_address_clears_override(#[case] vault_address: Option<&str>) {
5313        let mut client = HyperliquidHttpClient::from_resolved_credentials(
5314            Some(TEST_PRIVATE_KEY.to_string()),
5315            vault_address,
5316            Some("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
5317            HyperliquidEnvironment::Mainnet,
5318            60,
5319            None,
5320        )
5321        .unwrap();
5322
5323        let expected_address = match vault_address {
5324            Some(vault) => vault.to_string(),
5325            None => client.get_user_address().unwrap(),
5326        };
5327
5328        client.set_account_address(None).unwrap();
5329
5330        assert_eq!(client.account_address, None);
5331        assert_eq!(client.get_account_address().unwrap(), expected_address);
5332    }
5333
5334    #[rstest]
5335    fn test_cache_instrument_by_raw_symbol() {
5336        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5337
5338        // Create a test instrument with base currency "vntls:vCURSOR"
5339        let base_code = "vntls:vCURSOR";
5340        let quote_code = "USDC";
5341
5342        // Register the custom currency
5343        {
5344            let mut currency_map = CURRENCY_MAP.lock();
5345            if !currency_map.contains_key(base_code) {
5346                currency_map.insert(
5347                    base_code.to_string(),
5348                    Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto),
5349                );
5350            }
5351        }
5352
5353        let base_currency = Currency::new(base_code, 8, 0, base_code, CurrencyType::Crypto);
5354        let quote_currency = Currency::new(quote_code, 6, 0, quote_code, CurrencyType::Crypto);
5355
5356        // Nautilus symbol is "vntls:vCURSOR-USDC-SPOT"
5357        let symbol = Symbol::new("vntls:vCURSOR-USDC-SPOT");
5358        let venue = *HYPERLIQUID_VENUE;
5359        let instrument_id = InstrumentId::new(symbol, venue);
5360
5361        // raw_symbol is set to the base currency "vntls:vCURSOR" (see parse.rs)
5362        let raw_symbol = Symbol::new(base_code);
5363
5364        let clock = get_atomic_clock_realtime();
5365        let ts = clock.get_time_ns();
5366
5367        let instrument = InstrumentAny::CurrencyPair(
5368            CurrencyPair::builder()
5369                .instrument_id(instrument_id)
5370                .raw_symbol(raw_symbol)
5371                .base_currency(base_currency)
5372                .quote_currency(quote_currency)
5373                .price_precision(8)
5374                .size_precision(8)
5375                .price_increment(Price::from("0.00000001"))
5376                .size_increment(Quantity::from("0.00000001"))
5377                .ts_event(ts)
5378                .ts_init(ts)
5379                .build()
5380                .unwrap(),
5381        );
5382
5383        // Cache the instrument
5384        client.cache_instrument(&instrument);
5385
5386        // Verify it can be looked up by full symbol
5387        let instruments = client.instruments.load();
5388        let by_full_symbol = instruments.get(&Ustr::from("vntls:vCURSOR-USDC-SPOT"));
5389        assert!(
5390            by_full_symbol.is_some(),
5391            "Instrument should be accessible by full symbol"
5392        );
5393        assert_eq!(by_full_symbol.unwrap().id(), instrument.id());
5394
5395        // Verify it can be looked up by raw_symbol (coin) - backward compatibility
5396        let by_raw_symbol = instruments.get(&Ustr::from("vntls:vCURSOR"));
5397        assert!(
5398            by_raw_symbol.is_some(),
5399            "Instrument should be accessible by raw_symbol (Hyperliquid coin identifier)"
5400        );
5401        assert_eq!(by_raw_symbol.unwrap().id(), instrument.id());
5402        drop(instruments);
5403
5404        // Verify it can be looked up by composite key (coin, product_type)
5405        let instruments_by_coin = client.instruments_by_coin.load();
5406        let by_coin =
5407            instruments_by_coin.get(&(Ustr::from("vntls:vCURSOR"), HyperliquidProductType::Spot));
5408        assert!(
5409            by_coin.is_some(),
5410            "Instrument should be accessible by coin and product type"
5411        );
5412        assert_eq!(by_coin.unwrap().id(), instrument.id());
5413        drop(instruments_by_coin);
5414
5415        // Verify get_or_create_instrument works with product type
5416        let retrieved_with_type = client.get_or_create_instrument(
5417            &Ustr::from("vntls:vCURSOR"),
5418            Some(HyperliquidProductType::Spot),
5419        );
5420        assert!(retrieved_with_type.is_some());
5421        assert_eq!(retrieved_with_type.unwrap().id(), instrument.id());
5422
5423        // Verify get_or_create_instrument works without product type (fallback)
5424        let retrieved_without_type =
5425            client.get_or_create_instrument(&Ustr::from("vntls:vCURSOR"), None);
5426        assert!(retrieved_without_type.is_some());
5427        assert_eq!(retrieved_without_type.unwrap().id(), instrument.id());
5428    }
5429
5430    #[rstest]
5431    fn test_get_or_create_instrument_outcome_fallback_no_product_type() {
5432        // HTTP fill payloads for HIP-4 outcomes arrive with `coin = "#E"` and
5433        // no product-type context, so the no-product fallback in
5434        // `get_or_create_instrument` must check the Outcome bucket. Without
5435        // this, venue Settlement and userOutcome fills are silently dropped
5436        // from request_fill_reports / request_order_status_reports.
5437        use nautilus_core::time::get_atomic_clock_realtime;
5438        use nautilus_model::{
5439            enums::AssetClass,
5440            identifiers::{InstrumentId, Symbol},
5441            instruments::{BinaryOption, InstrumentAny},
5442            types::{Currency, Price, Quantity},
5443        };
5444
5445        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5446        let coin = "#500";
5447        let token = "+500";
5448
5449        let usdh = Currency::new("USDH", 8, 0, "Hyperliquid USD", CurrencyType::Crypto);
5450        let symbol = Symbol::new(token);
5451        let raw_symbol = Symbol::new(coin);
5452        let venue = *HYPERLIQUID_VENUE;
5453        let instrument_id = InstrumentId::new(symbol, venue);
5454
5455        let clock = get_atomic_clock_realtime();
5456        let ts = clock.get_time_ns();
5457
5458        let binary = InstrumentAny::BinaryOption(
5459            BinaryOption::builder()
5460                .instrument_id(instrument_id)
5461                .raw_symbol(raw_symbol)
5462                .asset_class(AssetClass::Alternative)
5463                .currency(usdh)
5464                .activation_ns(Default::default())
5465                .expiration_ns(Default::default())
5466                .price_precision(4)
5467                .size_precision(2)
5468                .price_increment(Price::from("0.0001"))
5469                .size_increment(Quantity::from("0.01"))
5470                .ts_event(ts)
5471                .ts_init(ts)
5472                .build()
5473                .unwrap(),
5474        );
5475
5476        client.cache_instrument(&binary);
5477
5478        let with_type = client
5479            .get_or_create_instrument(&Ustr::from(coin), Some(HyperliquidProductType::Outcome));
5480        assert!(with_type.is_some());
5481        let with_type = with_type.unwrap();
5482        assert_eq!(with_type.id(), instrument_id);
5483        assert_eq!(with_type.quote_currency(), usdh);
5484
5485        let no_type = client.get_or_create_instrument(&Ustr::from(coin), None);
5486        assert!(
5487            no_type.is_some(),
5488            "Outcome coin must resolve through the no-product fallback",
5489        );
5490        let no_type = no_type.unwrap();
5491        assert_eq!(no_type.id(), instrument_id);
5492        assert_eq!(no_type.quote_currency(), usdh);
5493
5494        let missing = client.get_or_create_instrument(&Ustr::from("#9999"), None);
5495        assert!(missing.is_none());
5496    }
5497
5498    #[rstest]
5499    #[case::coin_form("#200", None, "20-YES-OUTCOME", "#200", 100_000_200)]
5500    #[case::token_form(
5501        "+201",
5502        Some(HyperliquidProductType::Outcome),
5503        "20-NO-OUTCOME",
5504        "#201",
5505        100_000_201
5506    )]
5507    fn test_get_or_create_instrument_resolves_unlisted_outcome(
5508        #[case] coin: &str,
5509        #[case] product_type: Option<HyperliquidProductType>,
5510        #[case] symbol: &str,
5511        #[case] raw_symbol: &str,
5512        #[case] asset_index: u32,
5513    ) {
5514        // Settled outcomes drop out of `outcomeMeta`, so fills carry `#E` coins
5515        // and spot balances carry `+E` tokens for markets never loaded
5516        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5517
5518        let instrument = client
5519            .get_or_create_instrument(&Ustr::from(coin), product_type)
5520            .expect("unlisted outcome coin must resolve from its encoding");
5521
5522        assert_eq!(
5523            instrument.id(),
5524            InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE)
5525        );
5526        assert_eq!(instrument.raw_symbol(), Symbol::new(raw_symbol));
5527        assert_eq!(instrument.price_precision(), 5);
5528        assert_eq!(instrument.size_precision(), 0);
5529        assert_eq!(instrument.quote_currency(), Currency::USDC());
5530        assert_eq!(instrument.settlement_currency(), Currency::USDC());
5531        assert_eq!(client.get_asset_index(symbol), Some(asset_index));
5532    }
5533
5534    #[rstest]
5535    fn test_cache_instrument_base_alias_first_write_wins_for_spot() {
5536        // Two spot pairs share the base token "HYPE": the canonical pair is
5537        // cached first; a subsequent non-canonical pair must not overwrite the
5538        // base-token alias so lookups by "HYPE" keep resolving to the canonical
5539        // instrument.
5540        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5541
5542        let hype = Currency::new("HYPE", 8, 0, "HYPE", CurrencyType::Crypto);
5543        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5544        let clock = get_atomic_clock_realtime();
5545        let ts = clock.get_time_ns();
5546
5547        let canonical = InstrumentAny::CurrencyPair(
5548            CurrencyPair::builder()
5549                .instrument_id(InstrumentId::new(
5550                    Symbol::new("HYPE-USDC-SPOT"),
5551                    *HYPERLIQUID_VENUE,
5552                ))
5553                .raw_symbol(Symbol::new("@107"))
5554                .base_currency(hype)
5555                .quote_currency(usdc)
5556                .price_precision(5)
5557                .size_precision(2)
5558                .price_increment(Price::from("0.00001"))
5559                .size_increment(Quantity::from("0.01"))
5560                .ts_event(ts)
5561                .ts_init(ts)
5562                .build()
5563                .unwrap(),
5564        );
5565
5566        let non_canonical = InstrumentAny::CurrencyPair(
5567            CurrencyPair::builder()
5568                .instrument_id(InstrumentId::new(
5569                    Symbol::new("HYPE-USDC-SPOT"),
5570                    *HYPERLIQUID_VENUE,
5571                ))
5572                .raw_symbol(Symbol::new("@999"))
5573                .base_currency(hype)
5574                .quote_currency(usdc)
5575                .price_precision(5)
5576                .size_precision(2)
5577                .price_increment(Price::from("0.00001"))
5578                .size_increment(Quantity::from("0.01"))
5579                .ts_event(ts)
5580                .ts_init(ts)
5581                .build()
5582                .unwrap(),
5583        );
5584
5585        client.merge_instruments(&[canonical.clone(), non_canonical.clone()]);
5586
5587        let instruments_by_coin = client.instruments_by_coin.load();
5588        let by_base = instruments_by_coin
5589            .get(&(Ustr::from("HYPE"), HyperliquidProductType::Spot))
5590            .expect("base alias must resolve");
5591        assert_eq!(
5592            by_base.raw_symbol().inner().as_str(),
5593            "@107",
5594            "base alias must point to the canonical pair, not the one cached later",
5595        );
5596        drop(instruments_by_coin);
5597
5598        let InstrumentAny::CurrencyPair(mut refreshed) = canonical else {
5599            unreachable!();
5600        };
5601
5602        refreshed.price_precision = 4;
5603        refreshed.price_increment = Price::from("0.0001");
5604        refreshed.size_precision = 3;
5605        refreshed.size_increment = Quantity::from("0.001");
5606        let refreshed = InstrumentAny::CurrencyPair(refreshed);
5607        client.merge_instruments(&[refreshed.clone(), non_canonical]);
5608
5609        let resolved = client
5610            .get_or_create_instrument(&Ustr::from("HYPE"), Some(HyperliquidProductType::Spot))
5611            .unwrap();
5612        assert_eq!(
5613            serde_json::to_value(&resolved).unwrap(),
5614            serde_json::to_value(&refreshed).unwrap(),
5615        );
5616    }
5617
5618    #[rstest]
5619    fn test_cache_instrument_perp_aliases_sanitized_base() {
5620        // HIP-3 perp with wildcard-bearing venue name: `instrument_id.symbol`
5621        // is sanitized but order paths derive a coin key by splitting that
5622        // sanitized symbol on `-`. The cache must alias on the sanitized base
5623        // so those lookups resolve to the same instrument cached under
5624        // `raw_symbol` (the venue-official name).
5625        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5626
5627        let base_currency = Currency::new(
5628            "dex:STREAMABCD****",
5629            8,
5630            0,
5631            "dex:STREAMABCD****",
5632            CurrencyType::Crypto,
5633        );
5634        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
5635        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5636        let clock = get_atomic_clock_realtime();
5637        let ts = clock.get_time_ns();
5638
5639        let hip3 = InstrumentAny::CryptoPerpetual(
5640            CryptoPerpetual::builder()
5641                .instrument_id(InstrumentId::new(
5642                    Symbol::new("dex:STREAMABCDxxxx-USD-PERP"),
5643                    *HYPERLIQUID_VENUE,
5644                ))
5645                .raw_symbol(Symbol::new("dex:STREAMABCD****"))
5646                .base_currency(base_currency)
5647                .quote_currency(usd)
5648                .settlement_currency(usdc)
5649                .is_inverse(false)
5650                .price_precision(6)
5651                .size_precision(3)
5652                .price_increment(Price::from("0.000001"))
5653                .size_increment(Quantity::from("0.001"))
5654                .ts_event(ts)
5655                .ts_init(ts)
5656                .build()
5657                .unwrap(),
5658        );
5659
5660        client.cache_instrument(&hip3);
5661
5662        let instruments_by_coin = client.instruments_by_coin.load();
5663        let by_raw = instruments_by_coin
5664            .get(&(
5665                Ustr::from("dex:STREAMABCD****"),
5666                HyperliquidProductType::Perp,
5667            ))
5668            .expect("venue coin lookup must resolve");
5669        assert_eq!(by_raw.id(), hip3.id());
5670
5671        let by_sanitized = instruments_by_coin
5672            .get(&(
5673                Ustr::from("dex:STREAMABCDxxxx"),
5674                HyperliquidProductType::Perp,
5675            ))
5676            .expect("sanitized base lookup must resolve");
5677        assert_eq!(by_sanitized.id(), hip3.id());
5678        drop(instruments_by_coin);
5679
5680        // Confirm the order-submission lookup path resolves through the alias.
5681        let resolved = client
5682            .get_or_create_instrument(
5683                &Ustr::from("dex:STREAMABCDxxxx"),
5684                Some(HyperliquidProductType::Perp),
5685            )
5686            .expect("get_or_create_instrument must resolve sanitized base for HIP-3");
5687        assert_eq!(resolved.id(), hip3.id());
5688
5689        let InstrumentAny::CryptoPerpetual(mut refreshed) = hip3 else {
5690            unreachable!();
5691        };
5692
5693        refreshed.price_precision = 4;
5694        refreshed.price_increment = Price::from("0.0001");
5695        refreshed.size_precision = 2;
5696        refreshed.size_increment = Quantity::from("0.01");
5697        let refreshed = InstrumentAny::CryptoPerpetual(refreshed);
5698        client.cache_instrument(&refreshed);
5699
5700        let resolved = client
5701            .get_or_create_instrument(
5702                &Ustr::from("dex:STREAMABCDxxxx"),
5703                Some(HyperliquidProductType::Perp),
5704            )
5705            .unwrap();
5706        assert_eq!(
5707            serde_json::to_value(&resolved).unwrap(),
5708            serde_json::to_value(&refreshed).unwrap(),
5709        );
5710    }
5711
5712    #[tokio::test]
5713    #[ignore = "connects to Hyperliquid mainnet public instrument metadata"]
5714    async fn test_live_mainnet_instrument_refresh_updates_aliases() {
5715        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5716        let original = client.request_instruments().await.unwrap();
5717        assert!(!original.is_empty());
5718
5719        for instrument in original {
5720            client.cache_instrument(&instrument);
5721        }
5722
5723        let refreshed = client.request_instruments().await.unwrap();
5724        let mut spot_aliases = 0;
5725        let mut perp_aliases = 0;
5726
5727        for instrument in &refreshed {
5728            client.cache_instrument(instrument);
5729            let symbol = instrument.symbol();
5730            let product = HyperliquidProductType::from_symbol(symbol.as_str()).unwrap();
5731            let alias = super::cache_alias_for_symbol(symbol.as_str()).unwrap();
5732            let resolved = client
5733                .get_or_create_instrument(&Ustr::from(alias.as_str()), Some(product))
5734                .unwrap();
5735
5736            if resolved.raw_symbol() != instrument.raw_symbol() {
5737                continue;
5738            }
5739
5740            assert_eq!(
5741                serde_json::to_value(&resolved).unwrap(),
5742                serde_json::to_value(instrument).unwrap(),
5743            );
5744            assert_eq!(
5745                client.get_price_precision(symbol.as_str()),
5746                Some(instrument.price_precision()),
5747            );
5748
5749            if alias != instrument.raw_symbol().as_str() {
5750                match product {
5751                    HyperliquidProductType::Spot => spot_aliases += 1,
5752                    HyperliquidProductType::Perp => perp_aliases += 1,
5753                    HyperliquidProductType::Outcome => {}
5754                }
5755            }
5756        }
5757
5758        assert!(spot_aliases > 0);
5759        println!(
5760            "Validated {} refreshed definitions, {spot_aliases} spot aliases, and {perp_aliases} perp aliases",
5761            refreshed.len()
5762        );
5763    }
5764
5765    fn perp_with_asset_index(symbol: &str, asset_index: Option<u32>) -> InstrumentAny {
5766        let base_currency = Currency::new("NEW", 8, 0, "NEW", CurrencyType::Crypto);
5767        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
5768        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5769        let ts = get_atomic_clock_realtime().get_time_ns();
5770
5771        let info = asset_index.map(|asset_index| {
5772            Params::new([(ASSET_INDEX_INFO_KEY.to_string(), asset_index.into())].into())
5773        });
5774
5775        InstrumentAny::CryptoPerpetual(
5776            CryptoPerpetual::builder()
5777                .instrument_id(InstrumentId::new(Symbol::new(symbol), *HYPERLIQUID_VENUE))
5778                .raw_symbol(Symbol::new("NEW"))
5779                .base_currency(base_currency)
5780                .quote_currency(usd)
5781                .settlement_currency(usdc)
5782                .is_inverse(false)
5783                .price_precision(6)
5784                .size_precision(3)
5785                .price_increment(Price::from("0.000001"))
5786                .size_increment(Quantity::from("0.001"))
5787                .maybe_info(info)
5788                .ts_event(ts)
5789                .ts_init(ts)
5790                .build()
5791                .unwrap(),
5792        )
5793    }
5794
5795    #[rstest]
5796    fn test_cache_instrument_registers_asset_index_from_info() {
5797        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5798
5799        client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
5800
5801        assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
5802    }
5803
5804    #[rstest]
5805    fn test_cache_instrument_without_asset_index_info_retains_existing_index() {
5806        // Guessing an index would route orders to the wrong asset, so an
5807        // instrument missing the info key must leave the map untouched.
5808        let client = HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5809
5810        client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", Some(42)));
5811        let asset_indices = client.asset_indices.load();
5812        client.cache_instrument(&perp_with_asset_index("NEW-USD-PERP", None));
5813        client.cache_instrument(&perp_with_asset_index("OTHER-USD-PERP", None));
5814
5815        assert_eq!(client.get_asset_index("NEW-USD-PERP"), Some(42));
5816        assert_eq!(client.get_asset_index("OTHER-USD-PERP"), None);
5817        assert!(Arc::ptr_eq(&asset_indices, &client.asset_indices.load()));
5818    }
5819
5820    fn btc_history_client() -> HyperliquidHttpClient {
5821        let mut client =
5822            HyperliquidHttpClient::new(HyperliquidEnvironment::Mainnet, 60, None).unwrap();
5823        client.set_account_id(AccountId::from("HYPERLIQUID-001"));
5824
5825        let base = Currency::new("BTC", 8, 0, "BTC", CurrencyType::Crypto);
5826        let usd = Currency::new("USD", 8, 0, "USD", CurrencyType::Crypto);
5827        let usdc = Currency::new("USDC", 6, 0, "USDC", CurrencyType::Crypto);
5828        let ts = get_atomic_clock_realtime().get_time_ns();
5829        let perp = InstrumentAny::CryptoPerpetual(
5830            CryptoPerpetual::builder()
5831                .instrument_id(InstrumentId::new(
5832                    Symbol::new("BTC-USD-PERP"),
5833                    *HYPERLIQUID_VENUE,
5834                ))
5835                .raw_symbol(Symbol::new("BTC"))
5836                .base_currency(base)
5837                .quote_currency(usd)
5838                .settlement_currency(usdc)
5839                .is_inverse(false)
5840                .price_precision(1)
5841                .size_precision(5)
5842                .price_increment(Price::from("0.1"))
5843                .size_increment(Quantity::from("0.00001"))
5844                .ts_event(ts)
5845                .ts_init(ts)
5846                .build()
5847                .unwrap(),
5848        );
5849        client.cache_instrument(&perp);
5850        client
5851    }
5852
5853    fn history_rows(rows: Value) -> Vec<HyperliquidOrderStatusEntry> {
5854        serde_json::from_value(rows).unwrap()
5855    }
5856
5857    // Builds an order from the report the way reconciliation does for an external order
5858    fn assert_initializes_order(report: &OrderStatusReport) -> OrderAny {
5859        let initialized = OrderInitialized::new_checked(
5860            TraderId::from("TESTER-001"),
5861            StrategyId::from("EXTERNAL"),
5862            report.instrument_id,
5863            ClientOrderId::from(report.venue_order_id.as_str()),
5864            report.order_side.expect("report has an order side"),
5865            report.order_type,
5866            report.quantity,
5867            report.time_in_force,
5868            report.post_only,
5869            report.reduce_only,
5870            false,
5871            true,
5872            UUID4::new(),
5873            UnixNanos::default(),
5874            UnixNanos::default(),
5875            report.price,
5876            report.activation_price,
5877            report.trigger_price,
5878            report.trigger_type,
5879            report.limit_offset,
5880            report.trailing_offset,
5881            report.trailing_offset_type,
5882            report.expire_time,
5883            report.display_qty,
5884            None,
5885            None,
5886            report.contingency_type,
5887            report.order_list_id,
5888            report.linked_order_ids.clone(),
5889            report.parent_order_id,
5890            None,
5891            None,
5892            None,
5893            None,
5894        )
5895        .expect("report must initialize an order");
5896        OrderAny::from_events(vec![OrderEventAny::Initialized(initialized)])
5897            .expect("order must replay from its initialization")
5898    }
5899
5900    // `historicalOrders` rows for a position stop that triggered and filled: once filled, the
5901    // venue zeroes `triggerPx` and clears `isTrigger`, so only the earlier rows carry the trigger
5902    fn triggered_stop_rows() -> Value {
5903        let trigger_row = |status: &str, ts: u64| {
5904            json!({
5905                "order": {
5906                    "coin": "BTC", "side": "A", "limitPx": "58800.0", "sz": "0.0",
5907                    "oid": 900000001_u64, "timestamp": 1700000000000_u64,
5908                    "triggerCondition": "Price below 59500", "isTrigger": true,
5909                    "triggerPx": "59500.0", "children": [], "isPositionTpsl": true,
5910                    "reduceOnly": true, "orderType": "Stop Market", "origSz": "0.0", "tif": null,
5911                    "cloid": "0x00000000000000000000000000000a01"
5912                },
5913                "status": status,
5914                "statusTimestamp": ts
5915            })
5916        };
5917        json!([
5918            filled_stop_row(),
5919            trigger_row("triggered", 1700000600000),
5920            trigger_row("open", 1700000000000),
5921        ])
5922    }
5923
5924    fn filled_stop_row() -> Value {
5925        json!({
5926            "order": {
5927                "coin": "BTC", "side": "A", "limitPx": "58200.0", "sz": "0.0",
5928                "oid": 900000001_u64, "timestamp": 1700000600000_u64,
5929                "triggerCondition": "Triggered", "isTrigger": false, "triggerPx": "0.0",
5930                "children": [], "isPositionTpsl": true, "reduceOnly": true,
5931                "orderType": "Stop Market", "origSz": "0.02", "tif": "Gtc",
5932                "cloid": "0x00000000000000000000000000000a01"
5933            },
5934            "status": "filled",
5935            "statusTimestamp": 1700000600000_u64
5936        })
5937    }
5938
5939    #[rstest]
5940    fn test_historical_reports_triggered_stop_keeps_trigger_from_earlier_rows() {
5941        let client = btc_history_client();
5942
5943        let sweep = client
5944            .historical_order_status_reports_from_response(
5945                history_rows(triggered_stop_rows()),
5946                None,
5947            )
5948            .unwrap();
5949
5950        assert!(sweep.complete);
5951        assert_eq!(sweep.reports.len(), 1);
5952        let report = &sweep.reports[0];
5953        assert_eq!(report.venue_order_id, VenueOrderId::new("900000001"));
5954        assert_eq!(report.order_type, OrderType::StopMarket);
5955        assert_eq!(report.order_status, OrderStatus::Filled);
5956        assert_eq!(report.filled_qty, Quantity::from("0.02"));
5957        assert_eq!(report.trigger_price, Some(Price::from("59500.0")));
5958        assert_eq!(report.trigger_type, Some(TriggerType::Default));
5959        assert_initializes_order(report);
5960    }
5961
5962    #[rstest]
5963    fn test_historical_reports_triggered_stop_limit_keeps_trigger_and_price() {
5964        let client = btc_history_client();
5965        let mut rows = triggered_stop_rows();
5966        for row in rows.as_array_mut().unwrap() {
5967            row["order"]["orderType"] = json!("Stop Limit");
5968        }
5969
5970        let sweep = client
5971            .historical_order_status_reports_from_response(history_rows(rows), None)
5972            .unwrap();
5973
5974        assert_eq!(sweep.reports.len(), 1);
5975        let report = &sweep.reports[0];
5976        assert_eq!(report.order_type, OrderType::StopLimit);
5977        // The filled row carries no price; the merge takes it from the earlier rows
5978        assert_eq!(report.price, Some(Price::from("58800.0")));
5979        assert_eq!(report.trigger_price, Some(Price::from("59500.0")));
5980        assert_eq!(report.trigger_type, Some(TriggerType::Default));
5981        assert_initializes_order(report);
5982    }
5983
5984    #[rstest]
5985    #[case("Stop Market", "filled", OrderType::Market)]
5986    #[case("Take Profit Market", "filled", OrderType::Market)]
5987    #[case("Stop Limit", "filled", OrderType::Market)]
5988    #[case("Take Profit Limit", "filled", OrderType::Market)]
5989    #[case("Stop Market", "canceled", OrderType::Market)]
5990    #[case("Take Profit Market", "canceled", OrderType::Market)]
5991    #[case("Stop Limit", "canceled", OrderType::Limit)]
5992    #[case("Take Profit Limit", "canceled", OrderType::Limit)]
5993    fn test_historical_reports_closed_trigger_without_trigger_rows_is_untriggered(
5994        #[case] label: &str,
5995        #[case] status: &str,
5996        #[case] expected: OrderType,
5997    ) {
5998        // Only the zeroed final row survives (older rows aged out of the history window). A
5999        // filled row carries no price, so only a canceled limit-style row can stay a limit order.
6000        let client = btc_history_client();
6001        let mut row = filled_stop_row();
6002        row["order"]["orderType"] = json!(label);
6003        row["status"] = json!(status);
6004        // A canceled order's `sz` holds its unfilled size
6005        let expected_filled = if status == "canceled" {
6006            row["order"]["sz"] = json!("0.02");
6007            "0"
6008        } else {
6009            "0.02"
6010        };
6011
6012        let sweep = client
6013            .historical_order_status_reports_from_response(history_rows(json!([row])), None)
6014            .unwrap();
6015
6016        assert_eq!(sweep.reports.len(), 1);
6017        let report = &sweep.reports[0];
6018        assert_eq!(report.order_type, expected);
6019        assert_eq!(report.filled_qty, Quantity::from(expected_filled));
6020        assert!(report.trigger_price.is_none());
6021        assert!(report.trigger_type.is_none());
6022        assert_eq!(report.price.is_some(), expected == OrderType::Limit);
6023        assert!(report.reduce_only);
6024        assert_initializes_order(report);
6025    }
6026
6027    #[rstest]
6028    fn test_historical_reports_triggered_stop_filled_with_remainder() {
6029        // Both fixes on one row: a triggered stop's zeroed final row that also left a remainder
6030        let client = btc_history_client();
6031        let mut row = filled_stop_row();
6032        row["order"]["sz"] = json!("0.0008");
6033
6034        let sweep = client
6035            .historical_order_status_reports_from_response(history_rows(json!([row])), None)
6036            .unwrap();
6037
6038        assert_eq!(sweep.reports.len(), 1);
6039        let report = &sweep.reports[0];
6040        assert_eq!(report.order_status, OrderStatus::Canceled);
6041        assert_eq!(report.order_type, OrderType::Market);
6042        assert_eq!(report.filled_qty, Quantity::from("0.0192"));
6043        assert!(report.price.is_none());
6044        assert_initializes_order(report);
6045    }
6046
6047    #[rstest]
6048    fn test_historical_reports_open_trigger_without_trigger_price_keeps_order_type() {
6049        // An order that is still working keeps its trigger type even when its row lacks a
6050        // trigger price
6051        let client = btc_history_client();
6052        let mut row = filled_stop_row();
6053        row["status"] = json!("triggered");
6054
6055        let sweep = client
6056            .historical_order_status_reports_from_response(history_rows(json!([row])), None)
6057            .unwrap();
6058
6059        assert_eq!(sweep.reports.len(), 1);
6060        assert_eq!(sweep.reports[0].order_type, OrderType::StopMarket);
6061    }
6062
6063    // An IOC order the venue marks `filled` although 0.0005 of 0.015 went unfilled: `sz`
6064    // carries the canceled remainder
6065    #[rstest]
6066    fn test_historical_reports_partially_filled_ioc_is_canceled() {
6067        let client = btc_history_client();
6068        let row = |status: &str, sz: &str| {
6069            json!({
6070                "order": {
6071                    "coin": "BTC", "side": "A", "limitPx": "60100.0", "sz": sz,
6072                    "oid": 900000002_u64, "timestamp": 1700001000000_u64,
6073                    "triggerCondition": "N/A", "isTrigger": false, "triggerPx": "0.0",
6074                    "children": [], "isPositionTpsl": false, "reduceOnly": false,
6075                    "orderType": "Limit", "origSz": "0.015", "tif": "Ioc",
6076                    "cloid": "0x00000000000000000000000000000a02"
6077                },
6078                "status": status,
6079                "statusTimestamp": 1700001000000_u64
6080            })
6081        };
6082
6083        let sweep = client
6084            .historical_order_status_reports_from_response(
6085                history_rows(json!([row("filled", "0.0005"), row("open", "0.015")])),
6086                None,
6087            )
6088            .unwrap();
6089
6090        assert_eq!(sweep.reports.len(), 1);
6091        let report = &sweep.reports[0];
6092        assert_eq!(report.order_status, OrderStatus::Canceled);
6093        assert_eq!(report.order_type, OrderType::Limit);
6094        assert_eq!(report.quantity, Quantity::from("0.015"));
6095        assert_eq!(report.filled_qty, Quantity::from("0.0145"));
6096        // The filled row carries no price; the merge takes the limit price from the open row
6097        assert_eq!(report.price, Some(Price::from("60100.0")));
6098        assert_eq!(
6099            report.cancel_reason.as_deref(),
6100            Some("Unfilled remainder canceled")
6101        );
6102        assert_initializes_order(report);
6103    }
6104}