Skip to main content

nautilus_hyperliquid/http/
client.rs

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