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