Skip to main content

nautilus_hyperliquid/http/
client.rs

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