Skip to main content

rustrade_execution/client/hyperliquid/
mod.rs

1//! Hyperliquid ExecutionClient implementations for perpetual futures and spot trading.
2//!
3//! Uses the official `hyperliquid_rust_sdk` crate for REST and WebSocket API access.
4//! Gated behind the "hyperliquid" feature flag.
5//!
6//! # Modules
7//!
8//! - [`HyperliquidClient`]: Perpetual futures client
9//! - [`spot::HyperliquidSpotClient`]: Spot trading client
10//!
11//! # Authentication
12//!
13//! Hyperliquid uses EVM-based authentication (Ethereum private key + EIP-712 signatures)
14//! instead of traditional API key/secret. The SDK handles all signing internally.
15//!
16//! # Architecture
17//!
18//! - REST (`InfoClient`): account_snapshot, fetch_balances, fetch_open_orders, fetch_trades.
19//!   `fetch_trades` posts `userFillsByTime` to `/info` through the SDK's HTTP client but parses
20//!   the response into [`common::UserFill`] rather than the SDK's type, which drops `tid` and
21//!   `feeToken`.
22//! - REST (`ExchangeClient`): open_order, cancel_order
23//! - WebSocket (`InfoClient` with `with_reconnect`): account_stream via UserFills + OrderUpdates +
24//!   UserFundings subscriptions
25//! - REST (`InfoClient`), after each reconnect and through [`OrderStatusClient`]: how the orders
26//!   held as live ended, by `openOrders` and `orderStatus`
27//!
28//! # SDK Delegation Model
29//!
30//! This client delegates connection management to the official `hyperliquid_rust_sdk`:
31//!
32//! | Responsibility | Implementation |
33//! |----------------|----------------|
34//! | **Reconnection** | `InfoClient::with_reconnect()` handles WebSocket reconnection automatically, every 1 s with no limit on attempts (see below) |
35//! | **Heartbeat** | SDK manages WebSocket ping/pong internally |
36//! | **Deduplication** | This client's own, on the fills and funding payments. See below and [Funding](self#funding). |
37//! | **Fill recovery** | Only the recent fills the venue's `userFills` snapshot redelivers on each resubscription — use [`ExecutionClient::fetch_trades`] for older ones |
38//! | **Ended-order recovery** | This client's own, after each reconnect: see [`HyperliquidClient`]'s `account_stream` and [`OrderStatusClient`] |
39//!
40//! The SDK's reconnection has no limit, so a lost connection never ends the account stream: the
41//! SDK (`hyperliquid_rust_sdk` 0.6) retries it once a second until it succeeds, and this client
42//! sets no budget of its own. The stream ends, with
43//! [`StreamTerminated`](crate::AccountEventKind::StreamTerminated) carrying
44//! [`StreamTerminationReason::Error`], only if the SDK closes a subscription's channel. Giving up
45//! on a venue that stays unreachable is the caller's decision.
46//!
47//! **Caller responsibilities**:
48//! - If fill recovery is critical, monitor for reconnection events and call `fetch_trades()`
49//! - REST clients (`InfoClient::new()`, `ExchangeClient::new()`) do NOT auto-reconnect;
50//!   only WebSocket streams via `with_reconnect()` do
51//!
52//! # Trade identity and duplicate fills
53//!
54//! A [`Trade`]'s id is the venue's `tid`, which identifies the fill. It is deliberately **not**
55//! `hash`, which identifies the transaction: one aggressive order sweeping several resting orders
56//! produces several fills under a single hash, and keying on the hash makes them indistinguishable.
57//! Hyperliquid documents `tid` as unique per fill but qualified by coin rather than globally, so
58//! callers reconciling across instruments should qualify it the same way.
59//!
60//! The fills subscription opens with a snapshot of recent fills and the SDK resubscribes on every
61//! reconnect, so the same fill is redelivered each time the socket comes back. A trade is a delta
62//! the consumer accumulates, so this client deduplicates the stream itself — the cache is scoped to
63//! one [`ExecutionClient::account_stream`] call and survives reconnects within it.
64//!
65//! Order updates are deliberately **not** deduplicated. They assert absolute state rather than a
66//! delta, so a replayed one is idempotent while a dropped one could strand a consumer on stale
67//! state.
68//!
69//! [`ExecutionClient::fetch_trades`] does not deduplicate: it answers the window it was asked for,
70//! and its results reach the caller rather than the stream. A caller feeding both into one consumer
71//! should expect overlap and reconcile on the trade id.
72//!
73//! # Perpetual and spot coins
74//!
75//! Hyperliquid reports every market a wallet trades through the same account streams and
76//! open-order and fill endpoints, each order and fill under its *coin*. Each client keeps only its
77//! own, judged by [`CoinKind::of`](rustrade_instrument::hyperliquid::CoinKind::of):
78//! - [`HyperliquidClient`] keeps perpetuals (`BTC`, `kPEPE`, and HIP-3 perpetuals such as
79//!   `xyz:TSLA` on the DEXs it is configured with), named `{coin}-{collateral}-PERP`; see [HIP-3
80//!   perpetuals](#hip-3-perpetuals);
81//! - [`spot::HyperliquidSpotClient`] keeps spot pairs (`@107`, `PURR/USDC`), named from their
82//!   tokens; see its [Spot coins](spot#spot-coins).
83//!
84//! A coin of a kind neither recognises, such as an outcome coin (`#12`), is left out by both, and
85//! logged with `warn!` by the perpetuals client.
86//!
87//! # HIP-3 perpetuals
88//!
89//! Besides its default perpetuals DEX, Hyperliquid hosts builder-deployed (HIP-3) DEXs, each
90//! with its own perpetuals, named `{dex}:{asset}` (`xyz:TSLA`), its own margin, and its own
91//! collateral token, which need not be USDC. [`HyperliquidClient`] trades the default DEX and the
92//! HIP-3 DEXs named in [`HyperliquidConfig::dexes`], which it reads when it connects.
93//!
94//! - **Names.** Every perpetual is named after the token its DEX settles in:
95//!   `{coin}-{collateral}-PERP`, such as `BTC-USDC-PERP`, `xyz:TSLA-USDC-PERP` and
96//!   `flx:TSLA-USDH-PERP`. An order, cancel or filtered read on any other name, a perpetual on an
97//!   unconfigured DEX included, is refused with
98//!   [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid).
99//! - **Unconfigured DEXs.** Their fills and orders still arrive on the account stream and in the
100//!   venue's listings, but without the DEX's metadata they cannot be named, so they are left out,
101//!   logged at `warn` once per DEX.
102//! - **New listings.** The SDK addresses an order by the asset ids read at connect, so a
103//!   perpetual listed after that, on any DEX, cannot be ordered until the client connects again.
104//! - **Balances.** Where collateral is held depends on the account's abstraction mode; see
105//!   [`account_snapshot`](HyperliquidClient#method.account_snapshot).
106//! - **Cost.** Connecting reads `perpDexs`, `spotMeta` and each configured DEX's `meta` (weight
107//!   20 each) when any DEX is configured. Each read of open orders or positions makes one
108//!   request per DEX, the default one included: Hyperliquid lists a HIP-3 DEX's only when asked
109//!   for it. Fills need no such request.
110//!
111//! # Client order ids
112//!
113//! **Every order must be placed under a client id in
114//! [`ClientOrderId::uuid()`](crate::order::id::ClientOrderId::uuid) form**: a UUID, lowercase and
115//! hyphenated. Any other id is refused at placement with [`OrderError::Rejected`], before anything
116//! is sent.
117//!
118//! Hyperliquid tracks an order by its venue `oid` and by an optional 16-byte `cloid`, and reports
119//! the `cloid` back on every order record, REST and WebSocket alike. This client sends the client
120//! id as the `cloid` and turns the one reported back into the same id, so every account snapshot,
121//! open-order listing and order update names an order by the id it was placed with. Only that
122//! form of UUID comes back unchanged, which is why nothing else is accepted.
123//!
124//! An order placed some other way, such as the web app, has no `cloid` and is reported under its
125//! `oid`. It cannot be one of this client's orders, which is what lets an account snapshot declare
126//! its order list complete (see [`InstrumentAccountSnapshot::orders_complete`]).
127//!
128//! # Conditional Orders (Stop, TakeProfit)
129//!
130//! Hyperliquid supports conditional (trigger) orders via the `ClientOrder::Trigger` variant.
131//!
132//! **Supported order kinds**:
133//! - `Stop` → market order triggered at stop price (`tpsl: "sl"`, `is_market: true`)
134//! - `StopLimit` → limit order triggered at stop price (`tpsl: "sl"`, `is_market: false`)
135//! - `TakeProfit` → market order triggered at take-profit price (`tpsl: "tp"`, `is_market: true`)
136//! - `TakeProfitLimit` → limit order triggered at take-profit price (`tpsl: "tp"`, `is_market: false`)
137//!
138//! **Unsupported**: `TrailingStop`, `TrailingStopLimit`, `TrailingStopFixedLimit` (Hyperliquid
139//! does not support trailing stops)
140//!
141//! **SDK limitations** (hyperliquid_rust_sdk 0.6.x): neither the open orders this client reads
142//! nor `OrderUpdate` carry an order's trigger fields or time in force, so orders from both are
143//! reported as `OrderKind::Limit`, `GoodUntilCancelled`. Track `OrderKind` from the placement
144//! request.
145//!
146//! # Funding
147//!
148//! Hyperliquid settles perpetual funding every hour, as a payment into or out of the collateral
149//! that margins the position. The perpetuals account stream reports each payment, from the
150//! `userFundings` subscription, as an [`AccountEventKind::CashFlow`] of
151//! [`CashFlowKind::Funding`](crate::cash_flow::CashFlowKind::Funding):
152//! - `amount` is Hyperliquid's `usdc`, signed positive when the account received it. Its asset is
153//!   the collateral of the perpetual's DEX. Hyperliquid names the field `usdc` on every DEX, and
154//!   that it is in the collateral on a HIP-3 DEX settling in another token is this client's
155//!   reading, not documented. `rate` is `fundingRate` and `position_quantity` is the signed `szi`.
156//! - Hyperliquid gives a payment no id, so `id` is `None`. The instrument and `time_exchange`
157//!   identify it.
158//! - The subscription opens with a snapshot of recent payments, so a stream's first events can
159//!   predate it: filter on `time_exchange` to take only new ones. The SDK resubscribes on every
160//!   reconnect and the snapshot comes again, so this client sends each payment once per
161//!   [`ExecutionClient::account_stream`] call, as it does fills. Hyperliquid does not document
162//!   how far back the snapshot reaches, so payments made during a long outage can be missing;
163//!   Hyperliquid's `userFunding` history has them.
164//! - The spot client's stream carries none.
165//!
166//! Under the standard account mode a DEX's balance is its `accountValue`, which includes the
167//! funding paid and received, and the positions' unrealised PnL too, so adding payments to a
168//! balance read after them counts them twice. Under a unified account or portfolio margin a
169//! balance is the collateral token's spot balance; whether Hyperliquid books funding there at
170//! once has not been checked. The stream carries no balance: balances are read only by
171//! [`account_snapshot`](HyperliquidClient#method.account_snapshot) and
172//! [`fetch_balances`](ExecutionClient::fetch_balances).
173//!
174//! Positions report funding apart from their PnL:
175//! - A position's [`unrealized_pnl`](crate::position::Position::unrealized_pnl) is Hyperliquid's
176//!   `unrealizedPnl`, which comes from price alone.
177//! - Its [`carry`](crate::position::Position::carry) is the funding since the position opened,
178//!   from Hyperliquid's `cumFunding.sinceOpen` with the sign flipped: Hyperliquid counts funding
179//!   paid as positive, and `carry` counts it as negative, as a payment's `amount` does.
180//! - Fills carry no funding, so PnL computed from them excludes it too.
181//!
182//! # Limitations
183//!
184//! - **Order precision**: Hyperliquid limits a size to the asset's `szDecimals` decimal places,
185//!   and a price to 5 significant figures and `6 - szDecimals` decimal places (integers always
186//!   allowed). An order whose quantity, price or trigger price breaks these is refused with
187//!   [`OrderError::InvalidPrecision`] before anything is sent; the client never rounds a value,
188//!   since that would place an order other than the one requested. Read the rules with
189//!   [`HyperliquidClient::order_precision`] and round first. See [`precision`].
190
191pub mod common;
192pub mod config;
193pub mod error;
194mod funding;
195mod order_recovery;
196mod perp_account;
197mod perp_dexes;
198pub mod precision;
199pub mod spot;
200mod spot_coins;
201
202use crate::client::dedup::new_dedup_cache;
203use crate::client::order_recovery::{
204    KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
205};
206use crate::{
207    AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
208    UnindexedAccountEvent, UnindexedAccountSnapshot,
209    balance::AssetBalance,
210    client::{ExecutionClient, OrderStatusClient, VenueOrderStatus},
211    emit_stream_terminated,
212    error::{
213        ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
214        UnindexedOrderError,
215    },
216    order::{
217        Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
218        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
219        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
220        state::{Filled, Open, OrderState, UnindexedOrderState},
221    },
222    position::{Position, PositionReport},
223    trade::{AssetFees, TimeSource, Trade, TradeId, TradeOrigin, TradesRead},
224};
225use chrono::{DateTime, Utc};
226use common::{
227    CLOID_REQUIRED, CancelOnDropStream, InstrumentFilter, OpenOrderListing, UnknownCoins,
228    cancel_outcome, cid_to_cloid, cloid_to_cid, map_tif, millis_to_datetime, open_order_to_order,
229    parse_decimal, parse_side, span_millis, user_fills_by_time, warn_unknown_coins,
230};
231pub use config::{HyperliquidConfig, HyperliquidConfigError, Network};
232pub use error::HyperliquidConnectError;
233use error::{map_order_error, order_outcome_unanswered, order_rejection};
234use ethers::signers::Signer;
235use fnv::FnvHashSet;
236use funding::{funding_cash_flow, send_fundings};
237use futures::{StreamExt, stream::BoxStream};
238use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
239use order_recovery::{
240    ReconnectWatch, fetch_order_record, listed_cids, lookup_of, remember_open, remember_snapshot,
241    send_fills, send_observed, spawn_order_checks, status_from_record, unreadable_record,
242};
243use perp_account::{account_mode, dex_states};
244use perp_dexes::{PerpDexes, UnconfiguredDexes, warn_unconfigured_dexes};
245pub use precision::OrderPrecision;
246use precision::{WireOrderError, wire_order};
247use rust_decimal::Decimal;
248use rustrade_instrument::{
249    Side,
250    asset::name::AssetNameExchange,
251    exchange::ExchangeId,
252    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
253};
254use smol_str::format_smolstr;
255use std::sync::{
256    Arc,
257    atomic::{AtomicBool, Ordering},
258};
259use tokio::sync::{Notify, mpsc};
260use tokio_util::sync::CancellationToken;
261use tracing::{debug, error, info, warn};
262
263/// Hyperliquid perpetual futures execution client.
264///
265/// Wraps the official `hyperliquid_rust_sdk` to implement the `ExecutionClient` trait.
266/// Supports perpetual futures trading on Hyperliquid DEX.
267#[derive(Debug, Clone)]
268pub struct HyperliquidClient {
269    config: HyperliquidConfig,
270    info_client: Arc<InfoClient>,
271    exchange_client: Arc<ExchangeClient>,
272    /// The DEXs traded, and the names of their perpetuals.
273    dexes: Arc<PerpDexes>,
274    /// The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
275    /// clone and every account stream, since an order placed through one ends on any of them.
276    known_live: SharedKnownLiveOrders,
277}
278
279impl HyperliquidClient {
280    /// Create a new client asynchronously.
281    ///
282    /// Use this when calling from an async context (e.g., tokio tests).
283    /// For sync contexts, use `ExecutionClient::new()`.
284    ///
285    /// Reads the markets of each DEX in [`HyperliquidConfig::dexes`]; see [HIP-3
286    /// perpetuals](self#hip-3-perpetuals).
287    ///
288    /// # Errors
289    ///
290    /// - [`HyperliquidConnectError::Connectivity`] when the SDK clients cannot be created, or a
291    ///   request for the DEXs' markets fails in transit.
292    /// - [`HyperliquidConnectError::UnknownDex`] when Hyperliquid lists no DEX by a configured
293    ///   name on the configured network.
294    /// - [`HyperliquidConnectError::Metadata`] when a DEX's markets cannot be read.
295    ///
296    /// # Cost
297    ///
298    /// With any DEX configured: `perpDexs`, `spotMeta` and each DEX's `meta`, weight 20 each, so
299    /// `40 + 20 × dexes`, besides what the SDK reads to build its own clients.
300    pub async fn connect(config: HyperliquidConfig) -> Result<Self, HyperliquidConnectError> {
301        let base_url = config.base_url();
302
303        let info_client = InfoClient::new(None, Some(base_url))
304            .await
305            .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
306
307        let mut dexes = PerpDexes::fetch(&info_client, &config.dexes).await?;
308
309        let wallet = config.wallet.clone();
310        let mut exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
311            .await
312            .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
313        // The SDK addresses an order by the coin's asset id, and reads only the default DEX's.
314        exchange_client.coin_to_asset.extend(dexes.asset_ids());
315        dexes.add_default_perps(&exchange_client.meta.universe);
316
317        info!(
318            network = ?config.network,
319            dexes = ?config.dexes,
320            wallet = %config.wallet_address_hex(),
321            "Created HyperliquidClient"
322        );
323
324        Ok(Self {
325            config,
326            info_client: Arc::new(info_client),
327            exchange_client: Arc::new(exchange_client),
328            dexes: Arc::new(dexes),
329            known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
330        })
331    }
332
333    /// Returns the wallet address as a hex string (for logging/debugging).
334    pub fn wallet_address(&self) -> String {
335        self.config.wallet_address_hex()
336    }
337
338    /// The precision rules for orders on the perpetual `instrument`, from its `szDecimals`.
339    ///
340    /// [`open_order`](ExecutionClient::open_order) refuses an order that breaks them with
341    /// [`OrderError::InvalidPrecision`] and sends nothing. Round with these rules first. See
342    /// [`OrderPrecision`].
343    ///
344    /// `None` when `instrument` names no perpetual this client trades, or one listed after the
345    /// client connected: the client reads each DEX's perpetuals once, at
346    /// [`connect`](Self::connect), so a perpetual listed since needs a new client.
347    pub fn order_precision(&self, instrument: &InstrumentNameExchange) -> Option<OrderPrecision> {
348        let coin = self.dexes.coin(instrument).ok()?;
349        self.dexes.sz_decimals(coin).map(OrderPrecision::perp)
350    }
351
352    /// Returns the wallet address as ethers H160.
353    fn wallet_h160(&self) -> ethers::types::H160 {
354        self.config.wallet.address()
355    }
356
357    /// Fail with [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) on
358    /// the first of `instruments` that names no perpetual this client trades. A read filtered to
359    /// it would otherwise report it empty without having looked.
360    fn check_instruments(
361        &self,
362        instruments: &[InstrumentNameExchange],
363    ) -> Result<(), UnindexedClientError> {
364        for instrument in instruments {
365            self.dexes.coin(instrument).map_err(|reason| {
366                UnindexedClientError::Api(crate::error::ApiError::InstrumentInvalid(
367                    instrument.clone(),
368                    reason,
369                ))
370            })?;
371        }
372        Ok(())
373    }
374}
375
376impl ExecutionClient for HyperliquidClient {
377    const EXCHANGE: ExchangeId = ExchangeId::HyperliquidPerp;
378
379    // Perpetuals only — spot is served by `HyperliquidSpotClient`, which uses different coin
380    // naming, asset indices and balance endpoints.
381    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
382        &[InstrumentKindDiscriminant::Perpetual];
383
384    type Config = HyperliquidConfig;
385    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
386
387    /// Creates a new Hyperliquid client synchronously.
388    ///
389    /// # Panics
390    ///
391    /// - If no Tokio runtime is available on the current thread
392    /// - If called from within an async context (e.g., inside `async fn`, `spawn`, or `block_on`)
393    /// - Whenever [`HyperliquidClient::connect`] would fail: SDK initialization fails (network
394    ///   error, invalid credentials), or a configured DEX is unknown or unreadable
395    ///
396    /// # Recommended Usage
397    ///
398    /// Use [`HyperliquidClient::connect`] instead — it's async-safe and returns `Result`.
399    /// This method exists only for trait compliance; prefer `connect()` in all new code.
400    fn new(config: Self::Config) -> Self {
401        // SDK initialization is async; block on it since ExecutionClient::new is sync.
402        // WARNING: This will panic if called from within an async context.
403        tokio::runtime::Handle::current()
404            .block_on(Self::connect(config))
405            .unwrap_or_else(|e| panic!("Failed to create HyperliquidClient: {e}"))
406    }
407
408    /// The collateral balances, and each perpetual's open orders and position, across the default
409    /// DEX and every configured HIP-3 DEX.
410    ///
411    /// # Balances
412    ///
413    /// Read where the account's abstraction mode holds them (see [HIP-3
414    /// perpetuals](self#hip-3-perpetuals)):
415    /// - **Unified account or portfolio margin**: one balance per collateral token of the DEXs
416    ///   traded (`USDC`, `USDH`), from the spot clearinghouse, free of what is on hold. A token not
417    ///   held is reported at zero. Other tokens, which portfolio margin may also count as
418    ///   collateral, are left to [`spot::HyperliquidSpotClient`].
419    /// - **Standard** (the default for an account that never chose a mode): one balance per DEX,
420    ///   from its margin summary, as each margins separately: the default DEX's as `USDC`, each
421    ///   HIP-3 DEX's as `{dex}:{collateral}` (`xyz:USDC`, `flx:USDH`). The free balance is what
422    ///   margin does not use, never below zero.
423    ///
424    /// A mode this version does not know fails the snapshot rather than report balances read from
425    /// the wrong place.
426    ///
427    /// Positions on a HIP-3 DEX the client is not configured with are not read, and so not
428    /// reported: name the DEX in [`HyperliquidConfig::dexes`] to see them.
429    ///
430    /// # Positions
431    ///
432    /// Every requested perpetual is listed. Its position is [`PositionReport::Open`] when
433    /// Hyperliquid reports a non-zero size for it, and [`PositionReport::Flat`] otherwise, since
434    /// each DEX's state holds every open position on it. A size that does not parse is
435    /// [`PositionReport::Unreported`], with a warning.
436    ///
437    /// # Errors
438    ///
439    /// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for a requested
440    /// instrument that names no perpetual this client trades, such as one on a HIP-3 DEX it is not
441    /// configured with: it was not read, so nothing can be said about it.
442    ///
443    /// # Cost
444    ///
445    /// At once, `userAbstraction` (weight 20) and, for each DEX traded, the default one included,
446    /// a `clearinghouseState` (weight 2) and an `openOrders` (weight 20); then, under a unified
447    /// account or portfolio margin, `spotClearinghouseState` (weight 2). With `n` HIP-3 DEXs
448    /// that is `42 + 22n`, or `44 + 22n`, against Hyperliquid's limit of 1,200 a minute per IP
449    /// address.
450    async fn account_snapshot(
451        &self,
452        _assets: &[AssetNameExchange],
453        instruments: &[InstrumentNameExchange],
454    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
455        self.check_instruments(instruments)?;
456        let address = self.wallet_h160();
457        let info_client = &*self.info_client;
458        let dexes = &*self.dexes;
459
460        // The balances' time: the reads describe the account no earlier than this.
461        let sent = Utc::now();
462        let (mode, states, open_orders) = tokio::try_join!(
463            account_mode(info_client, address),
464            dex_states(info_client, address, dexes),
465            perp_account::open_orders(info_client, address, dexes),
466        )?;
467        let now = Utc::now();
468        let balances =
469            perp_account::snapshot_balances(info_client, address, dexes, mode, &states, sent)
470                .await?;
471
472        let instrument_filter = InstrumentFilter::new(instruments);
473
474        warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
475        let mut listing = OpenOrderListing::new(
476            &open_orders,
477            ExchangeId::HyperliquidPerp,
478            instruments,
479            |coin| dexes.instrument(coin),
480        );
481
482        // Build positions from each DEX's asset_positions
483        let mut instrument_snapshots = Vec::new();
484        for asset_pos in states.iter().flat_map(|(_, state)| &state.asset_positions) {
485            let pos = &asset_pos.position;
486            let Some(instrument) = dexes.instrument(&pos.coin) else {
487                continue;
488            };
489
490            if !instrument_filter.matches(&instrument) {
491                continue;
492            }
493
494            let position = perp_position_report(pos, now);
495
496            let (orders, orders_complete) = match listing.remove(&instrument) {
497                Some(listed) => (listed.orders, listed.orders_complete),
498                // The listing has an entry for every requested instrument, so this is an
499                // unfiltered read with no open order on the instrument.
500                None => (Vec::new(), true),
501            };
502
503            instrument_snapshots.push(InstrumentAccountSnapshot {
504                instrument,
505                orders,
506                orders_complete,
507                position,
508                isolated: None,
509            });
510        }
511
512        // Every other instrument listed: open orders but no position, or requested with neither.
513        // Each DEX's `asset_positions` holds every open position on it, so these are flat.
514        instrument_snapshots.extend(listing.into_snapshots().map(|mut snapshot| {
515            snapshot.position = PositionReport::Flat;
516            snapshot
517        }));
518
519        let snapshot = AccountSnapshot {
520            exchange: ExchangeId::HyperliquidPerp,
521            balances,
522            instruments: instrument_snapshots,
523        };
524        remember_snapshot(&self.known_live, &snapshot);
525        Ok(snapshot)
526    }
527
528    /// Returns a live stream of account events (fills, order updates, funding payments).
529    ///
530    /// Funding payments arrive as [`AccountEventKind::CashFlow`]; see the [module
531    /// docs](self#funding).
532    ///
533    /// # Instrument filtering
534    ///
535    /// The `instruments` parameter is **ignored** — Hyperliquid's WebSocket API does not
536    /// support per-instrument subscriptions for user events. All fills, order updates and
537    /// funding payments across all instruments are delivered. Consumers requiring instrument
538    /// filtering must filter client-side.
539    ///
540    /// # Task lifecycle
541    ///
542    /// Spawns four background tasks (fills, orders, funding payments, and the reconnect's order
543    /// check below) that are automatically cancelled when the returned stream is dropped. The
544    /// `ws_client` is held by the orders task; when cancelled, the tasks exit and the WebSocket
545    /// connection closes.
546    ///
547    /// # Orders that ended while disconnected
548    ///
549    /// The SDK reconnects the socket underneath this stream and resubscribes, but `orderUpdates`
550    /// opens with no snapshot, so an order that ended while the socket was down is never reported
551    /// on it. After each reconnect the stream therefore reports how each order the client holds as
552    /// live ended, where it did, as an [`AccountEventKind::OrderSnapshot`] of its inactive state,
553    /// read as [`OrderStatusClient::fetch_ended_orders`] reads it: filled (without an average
554    /// price), cancelled (with what filled before) or rejected. Each carries
555    /// [`StrategyId::unknown`], since Hyperliquid records no strategy; the engine matches it to the
556    /// order it tracks by client order id.
557    ///
558    /// - **When.** The SDK tells each subscription that the socket dropped, and the venue opens
559    ///   the resubscribed `userFills` with a snapshot of recent fills. The check starts once that
560    ///   snapshot's fills have been sent on, so the fills of an order that it carries arrive before
561    ///   how the order ended. The snapshot is all the fill recovery there is (see the [module
562    ///   docs](self#sdk-delegation-model)), and Hyperliquid does not document how far back it
563    ///   reaches, so after a long outage an order can be reported filled before, or without, fills
564    ///   older than that. The SDK resubscribes in no fixed order and only logs a resubscription
565    ///   that fails: an order that ends after the check lists the open orders but before
566    ///   `orderUpdates` is subscribed again is missed, and if `userFills` is not subscribed again
567    ///   no check runs until a later reconnect.
568    /// - **Which orders.** The client holds an order as live from the response to placing it,
569    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
570    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
571    ///   any of its account streams, until it sees the order end, which a successful cancel does.
572    ///   It holds up to 4,096 orders and forgets the oldest past that, logged at `warn`. An order
573    ///   placed outside this client and never listed or reported to it is not covered. Every
574    ///   instrument is checked, as the stream ignores `instruments`.
575    /// - **Cost.** One `openOrders` request (weight 20) per DEX traded, together listing every
576    ///   open order, then one `orderStatus` request (weight 2) for each held order the listing no
577    ///   longer shows, 8 at a time. Both count against Hyperliquid's REST limit of 1,200 a minute
578    ///   per IP address. The cost is per stream: a perpetuals and a spot stream on one wallet each
579    ///   make their own.
580    /// - **Failures.** Each order's lookup is settled as it ends. An instrument whose listing or
581    ///   lookup fails, or whose check is still running after 30 s, is retried while the stream is
582    ///   open 1, 2, 4, 8 and 16 minutes later, asking only about the orders still held, then given
583    ///   up, logged at `error`; its orders are asked about again at the next reconnect. A listing
584    ///   that fails charges every instrument in it. An order Hyperliquid does not know stops being
585    ///   held, logged at `warn`; one still live, or in a state this version cannot read, stays
586    ///   held.
587    ///
588    /// # Errors
589    ///
590    /// [`ConnectivityError::Socket`] if the WebSocket cannot be opened, or if any of the
591    /// `userFills`, `orderUpdates` and `userFundings` subscriptions fails: the stream is not
592    /// opened with only some of them.
593    async fn account_stream(
594        &self,
595        _assets: &[AssetNameExchange],
596        _instruments: &[InstrumentNameExchange],
597    ) -> Result<Self::AccountStream, UnindexedClientError> {
598        let user = self.wallet_h160();
599        let base_url = self.config.base_url();
600
601        // Create a dedicated InfoClient for WebSocket streaming.
602        // Using with_reconnect() enables SDK-managed reconnection.
603        let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
604            .await
605            .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
606
607        // Create channels for subscriptions
608        let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
609        let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
610        let (fundings_tx, mut fundings_rx) = mpsc::unbounded_channel::<Message>();
611
612        // Subscribe to user fills
613        ws_client
614            .subscribe(Subscription::UserFills { user }, fills_tx)
615            .await
616            .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
617
618        // Subscribe to order updates
619        ws_client
620            .subscribe(Subscription::OrderUpdates { user }, orders_tx)
621            .await
622            .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
623
624        // Subscribe to funding payments
625        ws_client
626            .subscribe(Subscription::UserFundings { user }, fundings_tx)
627            .await
628            .map_err(|e| ConnectivityError::Socket(format!("UserFundings subscribe: {e}")))?;
629
630        info!(%user, "Subscribed to Hyperliquid account stream");
631
632        // Create output channel for merged events
633        let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
634
635        // CancellationToken ensures tasks exit when stream is dropped
636        let cancel_token = CancellationToken::new();
637
638        // Both tasks share `event_tx` and both observe `recv() -> None` when the SDK gives up on the
639        // socket, so guard the terminal emit with a shared flag — only the first non-cancellation
640        // terminal sends `StreamTerminated`, avoiding a double-emit. Relaxed ordering suffices: the
641        // flag has no associated payload to synchronise; the channel send is the synchronisation point.
642        let terminated = Arc::new(AtomicBool::new(false));
643
644        // Scoped to this stream rather than to the client: a fresh `account_stream` opens a fresh
645        // subscription, whose snapshot the new consumer has not seen. It must outlive a reconnect,
646        // which it does -- the SDK reconnects underneath this task, not around it.
647        let fills_dedup = new_dedup_cache();
648
649        // How the orders held as live ended while the socket was down: checked after each
650        // reconnect, once the fills snapshot that opens the resubscription has been sent on. Its
651        // own token also stops it promptly when the stream terminates.
652        let reconnected = Arc::new(Notify::new());
653        let checks_cancel = cancel_token.child_token();
654        let (list_client, lookup_client) = (self.info_client.clone(), self.info_client.clone());
655        let (list_dexes, lookup_dexes) = (self.dexes.clone(), self.dexes.clone());
656        spawn_order_checks(
657            ExchangeId::HyperliquidPerp,
658            self.known_live.clone(),
659            reconnected.clone(),
660            checks_cancel.clone(),
661            event_tx.clone(),
662            move |instruments| {
663                perp_listed_cids(list_client.clone(), list_dexes.clone(), user, instruments)
664            },
665            move |key| perp_order_lookup(lookup_client.clone(), lookup_dexes.clone(), user, key),
666        );
667
668        // Spawn task to process fills
669        let fills_event_tx = event_tx.clone();
670        let fills_cancel = cancel_token.clone();
671        let fills_terminated = terminated.clone();
672        let fills_checks_cancel = checks_cancel.clone();
673        let fills_dexes = self.dexes.clone();
674        tokio::spawn(async move {
675            let mut unknown_coins = UnknownCoins::default();
676            let mut unconfigured = UnconfiguredDexes::default();
677            let mut reconnects = ReconnectWatch::new(reconnected);
678            loop {
679                tokio::select! {
680                    biased;
681                    () = fills_cancel.cancelled() => {
682                        debug!("Fills task cancelled");
683                        return;
684                    }
685                    msg = fills_rx.recv() => {
686                        let Some(msg) = msg else {
687                            debug!("Fills receiver closed");
688                            fills_checks_cancel.cancel();
689                            // SDK gave up on the stream (channel closed). Emit a single terminal
690                            // StreamTerminated across both tasks (guarded by the shared flag).
691                            if fills_terminated
692                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
693                                .is_ok()
694                            {
695                                emit_stream_terminated(
696                                    &fills_event_tx,
697                                    ExchangeId::HyperliquidPerp,
698                                    StreamTerminationReason::Error(
699                                        "hyperliquid account stream closed".to_string(),
700                                    ),
701                                );
702                            }
703                            return;
704                        };
705                        match msg {
706                            Message::UserFills(fills) => {
707                                let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
708                                    unknown_coins.warn_once(&fill.coin);
709                                    unconfigured.warn_once(&fills_dexes, &fill.coin);
710                                    fill_to_account_event(fill, &fills_dexes)
711                                };
712                                if !send_fills(
713                                    &fills.data,
714                                    convert,
715                                    &fills_dedup,
716                                    &fills_event_tx,
717                                    &mut reconnects,
718                                ) {
719                                    debug!("Fills event channel closed");
720                                    return;
721                                }
722                            }
723                            Message::NoData => {
724                                warn!("UserFills WebSocket disconnected");
725                                reconnects.dropped();
726                            }
727                            Message::HyperliquidError(e) => {
728                                // Transient, non-terminal: the loop continues. Log only — no
729                                // in-band event (consumers took no action on the old StreamError).
730                                error!(%e, "UserFills WebSocket error");
731                            }
732                            _ => {}
733                        }
734                    }
735                }
736            }
737        });
738
739        // Spawn task to process funding payments. It sends no terminal event: the SDK closes every
740        // subscription's channel together, and the fills and orders tasks report that.
741        let fundings_event_tx = event_tx.clone();
742        let fundings_cancel = cancel_token.clone();
743        let fundings_dexes = self.dexes.clone();
744        // Like the fills cache, scoped to this stream and surviving reconnects within it.
745        let fundings_dedup = new_dedup_cache();
746        tokio::spawn(async move {
747            let mut unknown_coins = UnknownCoins::default();
748            let mut unconfigured = UnconfiguredDexes::default();
749            loop {
750                tokio::select! {
751                    biased;
752                    () = fundings_cancel.cancelled() => {
753                        debug!("Fundings task cancelled");
754                        return;
755                    }
756                    msg = fundings_rx.recv() => {
757                        let Some(msg) = msg else {
758                            debug!("Fundings receiver closed");
759                            return;
760                        };
761                        match msg {
762                            Message::UserFundings(fundings) => {
763                                let convert = |funding: &hyperliquid_rust_sdk::UserFunding| {
764                                    unknown_coins.warn_once(&funding.coin);
765                                    unconfigured.warn_once(&fundings_dexes, &funding.coin);
766                                    funding_cash_flow(funding, &fundings_dexes)
767                                };
768                                if !send_fundings(
769                                    &fundings.data,
770                                    convert,
771                                    &fundings_dedup,
772                                    &fundings_event_tx,
773                                ) {
774                                    debug!("Fundings event channel closed");
775                                    return;
776                                }
777                            }
778                            Message::NoData => {
779                                warn!("UserFundings WebSocket disconnected");
780                            }
781                            Message::HyperliquidError(e) => {
782                                // Transient, non-terminal: the loop continues.
783                                error!(%e, "UserFundings WebSocket error");
784                            }
785                            _ => {}
786                        }
787                    }
788                }
789            }
790        });
791
792        // Spawn task to process order updates
793        // NOTE: ws_client is moved here to keep the WebSocket alive. When this task
794        // exits (via cancellation or channel close), the WebSocket connection closes,
795        // which causes fills_rx to also close.
796        let orders_event_tx = event_tx;
797        let orders_cancel = cancel_token.clone();
798        let orders_terminated = terminated;
799        let orders_known = self.known_live.clone();
800        let orders_dexes = self.dexes.clone();
801        tokio::spawn(async move {
802            let _ws_client = ws_client;
803            let mut unknown_coins = UnknownCoins::default();
804            let mut unconfigured = UnconfiguredDexes::default();
805
806            loop {
807                tokio::select! {
808                    biased;
809                    () = orders_cancel.cancelled() => {
810                        debug!("Orders task cancelled");
811                        return;
812                    }
813                    msg = orders_rx.recv() => {
814                        let Some(msg) = msg else {
815                            debug!("Orders receiver closed");
816                            checks_cancel.cancel();
817                            // SDK gave up on the stream (channel closed). Emit a single terminal
818                            // StreamTerminated across both tasks (guarded by the shared flag).
819                            if orders_terminated
820                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
821                                .is_ok()
822                            {
823                                emit_stream_terminated(
824                                    &orders_event_tx,
825                                    ExchangeId::HyperliquidPerp,
826                                    StreamTerminationReason::Error(
827                                        "hyperliquid account stream closed".to_string(),
828                                    ),
829                                );
830                            }
831                            return;
832                        };
833                        match msg {
834                            Message::OrderUpdates(updates) => {
835                                for update in updates.data {
836                                    unknown_coins.warn_once(&update.order.coin);
837                                    unconfigured.warn_once(&orders_dexes, &update.order.coin);
838                                    if let Some(event) =
839                                        order_update_to_account_event(&update, &orders_dexes)
840                                        && !send_observed(&orders_known, &orders_event_tx, event)
841                                    {
842                                        debug!("Orders event channel closed");
843                                        return;
844                                    }
845                                }
846                            }
847                            Message::NoData => {
848                                warn!("OrderUpdates WebSocket disconnected");
849                            }
850                            Message::HyperliquidError(e) => {
851                                // Transient, non-terminal: the loop continues. Log only — no
852                                // in-band event (consumers took no action on the old StreamError).
853                                error!(%e, "OrderUpdates WebSocket error");
854                            }
855                            _ => {}
856                        }
857                    }
858                }
859            }
860        });
861
862        // Wrap stream with drop guard that cancels tasks
863        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
864        let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
865        Ok(guarded_stream.boxed())
866    }
867
868    async fn cancel_order(
869        &self,
870        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
871    ) -> UnindexedOrderResponseCancel {
872        use crate::order::{request::OrderResponseCancel, state::Cancelled};
873        use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
874        use uuid::Uuid;
875
876        let coin = match self.dexes.coin(request.key.instrument) {
877            Ok(coin) => coin.to_owned(),
878            Err(reason) => {
879                warn!(%reason, cid = %request.key.cid, "Cannot cancel order on this instrument");
880                return OrderResponseCancel {
881                    key: OrderKey {
882                        exchange: ExchangeId::HyperliquidPerp,
883                        instrument: request.key.instrument.clone(),
884                        strategy: request.key.strategy.clone(),
885                        cid: request.key.cid.clone(),
886                    },
887                    state: Err(UnindexedOrderError::Rejected(
888                        crate::error::ApiError::InstrumentInvalid(
889                            request.key.instrument.clone(),
890                            reason,
891                        ),
892                    )),
893                };
894            }
895        };
896
897        enum CancelMethod {
898            ByOid(u64),
899            ByCloid(Uuid),
900        }
901
902        // How the venue addresses the order decides which endpoint cancels it. This reads the
903        // identifier's kind rather than inferring it from the string's shape: an oid and a cloid
904        // are different identifiers that happen to both be text, and telling them apart by parsing
905        // means a cloid which parses as a number gets cancelled as somebody else's oid.
906        let cancel_method = match &request.state.id {
907            Some(VenueOrderId::Assigned(id)) => {
908                id.0.parse::<u64>()
909                    .map(CancelMethod::ByOid)
910                    .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
911            }
912            // The venue accepted the order without assigning an oid, so the only handle on it is
913            // the cloid it was placed with -- which placement derives from the client id the same
914            // way, and refuses the order if it cannot.
915            Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
916                .map(CancelMethod::ByCloid)
917                .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
918            None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
919        };
920
921        let cancel_method = match cancel_method {
922            Ok(method) => method,
923            Err(reason) => {
924                warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
925                return OrderResponseCancel {
926                    key: OrderKey {
927                        exchange: ExchangeId::HyperliquidPerp,
928                        instrument: request.key.instrument.clone(),
929                        strategy: request.key.strategy.clone(),
930                        cid: request.key.cid.clone(),
931                    },
932                    state: Err(UnindexedOrderError::Rejected(
933                        crate::error::ApiError::OrderRejected(reason.to_string()),
934                    )),
935                };
936            }
937        };
938
939        // Terminal states keep a plain `OrderId`: nothing compares them for identity, so an order
940        // the venue never named records the client id it was addressed by instead.
941        let cancelled_id = match &request.state.id {
942            Some(venue_id) => venue_id.or_client_id(&request.key.cid),
943            None => OrderId(request.key.cid.0.clone()),
944        };
945
946        let response = match cancel_method {
947            CancelMethod::ByOid(oid) => {
948                debug!(oid, "Cancelling by OID");
949                let cancel_request = ClientCancelRequest { asset: coin, oid };
950                self.exchange_client.cancel(cancel_request, None).await
951            }
952            CancelMethod::ByCloid(cloid) => {
953                debug!(%cloid, "Cancelling by cloid (no oid assigned)");
954                let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
955                self.exchange_client
956                    .cancel_by_cloid(cancel_request, None)
957                    .await
958            }
959        };
960
961        let response = match response {
962            Ok(r) => r,
963            Err(e) => {
964                warn!(%e, "Cancel order failed");
965                return OrderResponseCancel {
966                    key: OrderKey {
967                        exchange: ExchangeId::HyperliquidPerp,
968                        instrument: request.key.instrument.clone(),
969                        strategy: request.key.strategy.clone(),
970                        cid: request.key.cid.clone(),
971                    },
972                    state: Err(map_order_error(e, request.key.instrument)),
973                };
974            }
975        };
976
977        match cancel_outcome(response) {
978            Ok(()) => {
979                debug!("Cancel order accepted");
980                // Hyperliquid answers a cancel once the order has left the book.
981                self.known_live.lock().ended(&request.key.cid);
982                // Hyperliquid cancel response doesn't include an exchange timestamp
983                OrderResponseCancel {
984                    key: OrderKey {
985                        exchange: ExchangeId::HyperliquidPerp,
986                        instrument: request.key.instrument.clone(),
987                        strategy: request.key.strategy.clone(),
988                        cid: request.key.cid.clone(),
989                    },
990                    state: Ok(Cancelled::new(
991                        cancelled_id.clone(),
992                        Utc::now(),
993                        None, // Cancel response doesn't include filled quantity
994                    )),
995                }
996            }
997            // Not reported ended: if the order did end, the account stream or a lookup says how.
998            Err(reason) => {
999                warn!(%reason, cid = %request.key.cid, "Cancel rejected by exchange");
1000                OrderResponseCancel {
1001                    key: OrderKey {
1002                        exchange: ExchangeId::HyperliquidPerp,
1003                        instrument: request.key.instrument.clone(),
1004                        strategy: request.key.strategy.clone(),
1005                        cid: request.key.cid.clone(),
1006                    },
1007                    state: Err(UnindexedOrderError::Rejected(
1008                        crate::error::ApiError::OrderRejected(reason),
1009                    )),
1010                }
1011            }
1012        }
1013    }
1014
1015    async fn open_order(
1016        &self,
1017        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1018    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1019        use hyperliquid_rust_sdk::{
1020            ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
1021        };
1022
1023        let make_inactive =
1024            |error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1025                Order {
1026                    key: OrderKey {
1027                        exchange: ExchangeId::HyperliquidPerp,
1028                        instrument: request.key.instrument.clone(),
1029                        strategy: request.key.strategy.clone(),
1030                        cid: request.key.cid.clone(),
1031                    },
1032                    side: request.state.side,
1033                    price: request.state.price,
1034                    quantity: request.state.quantity,
1035                    kind: request.state.kind,
1036                    time_in_force: request.state.time_in_force,
1037                    state: OrderState::inactive(error),
1038                }
1039            };
1040        let make_rejected = |msg: String| {
1041            make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
1042                msg,
1043            )))
1044        };
1045        let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
1046
1047        // Hyperliquid has no order flag for borrowing, and under portfolio margin an order borrows
1048        // implicitly when it falls short, so the client cannot promise either way: no effect,
1049        // not even `NoSideEffect`, is honoured.
1050        if let Some(effect) = request.state.margin_effect {
1051            return make_inactive(OrderError::UnsupportedMarginEffect(effect));
1052        }
1053        let make_invalid_instrument = |reason: String| {
1054            make_inactive(OrderError::Rejected(
1055                crate::error::ApiError::InstrumentInvalid(request.key.instrument.clone(), reason),
1056            ))
1057        };
1058
1059        let coin = match self.dexes.coin(request.key.instrument) {
1060            Ok(coin) => coin.to_owned(),
1061            Err(reason) => return make_invalid_instrument(reason),
1062        };
1063        let is_buy = request.state.side == Side::Buy;
1064
1065        match request.state.kind {
1066            OrderKind::Market => {
1067                return make_unsupported(
1068                    "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
1069                        .to_string(),
1070                );
1071            }
1072            OrderKind::TrailingStop { .. }
1073            | OrderKind::TrailingStopLimit { .. }
1074            | OrderKind::TrailingStopFixedLimit { .. } => {
1075                return make_unsupported(
1076                    "Hyperliquid does not support trailing stop orders".to_string(),
1077                );
1078            }
1079            OrderKind::ConditionalUnresolved { .. } => {
1080                return make_unsupported(
1081                    "an unresolved order kind is only read back from a venue, never placed"
1082                        .to_string(),
1083                );
1084            }
1085            OrderKind::Limit
1086            | OrderKind::Stop { .. }
1087            | OrderKind::StopLimit { .. }
1088            | OrderKind::TakeProfit { .. }
1089            | OrderKind::TakeProfitLimit { .. } => {}
1090        }
1091
1092        // The venue reports every order under its cloid, so an order placed without one could
1093        // not be matched to the id the caller tracks it by. See the module docs.
1094        let Some(cloid) = cid_to_cloid(&request.key.cid) else {
1095            return make_rejected(CLOID_REQUIRED.to_string());
1096        };
1097
1098        let tif = map_tif(&request.state.time_in_force).to_string();
1099
1100        let Some(precision) = self.order_precision(request.key.instrument) else {
1101            return make_invalid_instrument(format!(
1102                "{coin} was not listed when the client connected; reconnect to trade a perpetual \
1103                 listed since"
1104            ));
1105        };
1106        let wire = match wire_order(
1107            &precision,
1108            request.state.kind,
1109            tif,
1110            request.state.price,
1111            request.state.quantity,
1112        ) {
1113            Ok(wire) => wire,
1114            Err(WireOrderError::Precision(violation)) => {
1115                return make_inactive(OrderError::InvalidPrecision(violation));
1116            }
1117            Err(WireOrderError::MissingPrice) => {
1118                return make_rejected(
1119                    "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
1120                        .to_string(),
1121                );
1122            }
1123            Err(WireOrderError::UnsupportedKind) => {
1124                return make_unsupported(format!(
1125                    "Hyperliquid does not support {} orders",
1126                    request.state.kind
1127                ));
1128            }
1129        };
1130
1131        // Logged only for an order about to be sent.
1132        if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
1133            warn!(
1134                instrument = %request.key.instrument,
1135                "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
1136            );
1137        }
1138
1139        let order_request = ClientOrderRequest {
1140            asset: coin,
1141            is_buy,
1142            reduce_only: request.state.reduce_only.is_reduce_only(),
1143            limit_px: wire.limit_px,
1144            sz: wire.sz,
1145            cloid: Some(cloid),
1146            order_type: wire.order_type,
1147        };
1148
1149        let response = match self.exchange_client.order(order_request, None).await {
1150            Ok(r) => r,
1151            Err(e) => {
1152                warn!(%e, "Open order failed");
1153                return Order {
1154                    key: OrderKey {
1155                        exchange: ExchangeId::HyperliquidPerp,
1156                        instrument: request.key.instrument.clone(),
1157                        strategy: request.key.strategy.clone(),
1158                        cid: request.key.cid.clone(),
1159                    },
1160                    side: request.state.side,
1161                    price: request.state.price,
1162                    quantity: request.state.quantity,
1163                    kind: request.state.kind,
1164                    time_in_force: request.state.time_in_force,
1165                    state: OrderState::inactive(map_order_error(e, request.key.instrument)),
1166                };
1167            }
1168        };
1169
1170        // Parse response
1171        let state = match response {
1172            ExchangeResponseStatus::Ok(exchange_resp) => {
1173                // Check status from response data
1174                let status = exchange_resp
1175                    .data
1176                    .and_then(|d| d.statuses.into_iter().next());
1177
1178                match status {
1179                    Some(ExchangeDataStatus::Resting(resting)) => {
1180                        debug!(oid = resting.oid, "Order resting");
1181                        OrderState::active(Open {
1182                            id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
1183                            time_exchange: Utc::now(),
1184                            filled_quantity: Decimal::ZERO,
1185                        })
1186                    }
1187                    Some(ExchangeDataStatus::Filled(filled)) => {
1188                        debug!(oid = filled.oid, avg_px = %filled.avg_px, "Order filled");
1189                        // Hyperliquid provides avg_px for filled orders
1190                        let avg_price = parse_decimal(&filled.avg_px, "avg_px");
1191                        OrderState::fully_filled(Filled::new(
1192                            OrderId(format_smolstr!("{}", filled.oid)),
1193                            Utc::now(),
1194                            parse_decimal(&filled.total_sz, "total_sz")
1195                                .unwrap_or(request.state.quantity),
1196                            avg_price,
1197                        ))
1198                    }
1199                    Some(ExchangeDataStatus::Error(msg)) => {
1200                        warn!(%msg, "Order rejected by exchange");
1201                        OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1202                    }
1203                    Some(
1204                        ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
1205                    ) => {
1206                        // The venue answered without an oid, so the order's only handle is the
1207                        // cloid it was placed with. A standalone trigger order is answered
1208                        // `resting` with an oid; these statuses belong to grouped orders.
1209                        debug!(cid = %request.key.cid, "Order waiting under its cloid");
1210                        OrderState::active(Open {
1211                            id: VenueOrderId::ClientAssigned,
1212                            time_exchange: Utc::now(),
1213                            filled_quantity: Decimal::ZERO,
1214                        })
1215                    }
1216                    Some(ExchangeDataStatus::Success) | None => {
1217                        // The venue took the request without saying what became of the order,
1218                        // so it may be live: the outcome is unknown, not a refusal.
1219                        warn!(cid = %request.key.cid, "Order answered without its status; outcome unknown");
1220                        OrderState::inactive(order_outcome_unanswered())
1221                    }
1222                }
1223            }
1224            ExchangeResponseStatus::Err(msg) => {
1225                warn!(%msg, "Order rejected");
1226                OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1227            }
1228        };
1229        let order = Order {
1230            key: OrderKey {
1231                exchange: ExchangeId::HyperliquidPerp,
1232                instrument: request.key.instrument.clone(),
1233                strategy: request.key.strategy.clone(),
1234                cid: request.key.cid.clone(),
1235            },
1236            side: request.state.side,
1237            price: request.state.price,
1238            quantity: request.state.quantity,
1239            kind: request.state.kind,
1240            time_in_force: request.state.time_in_force,
1241            state,
1242        };
1243        self.known_live
1244            .lock()
1245            .placed(&order.key, order.quantity, order.kind, &order.state);
1246        order
1247    }
1248
1249    /// The collateral balances, read as [`account_snapshot`](ExecutionClient::account_snapshot)
1250    /// reads them: `userAbstraction` (weight 20), then either `spotClearinghouseState` or each
1251    /// DEX's `clearinghouseState` (weight 2 each).
1252    async fn fetch_balances(
1253        &self,
1254        _assets: &[AssetNameExchange],
1255    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1256        perp_account::fetch_balances(&self.info_client, self.wallet_h160(), &self.dexes).await
1257    }
1258
1259    /// Every open order on each DEX traded, with one `openOrders` (weight 20) per DEX.
1260    ///
1261    /// # Errors
1262    ///
1263    /// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for an instrument
1264    /// in `instruments` that names no perpetual this client trades.
1265    async fn fetch_open_orders(
1266        &self,
1267        instruments: &[InstrumentNameExchange],
1268    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1269        self.check_instruments(instruments)?;
1270        let address = self.wallet_h160();
1271
1272        let open_orders =
1273            perp_account::open_orders(&self.info_client, address, &self.dexes).await?;
1274        warn_unknown_coins(open_orders.iter().map(|order| order.coin.as_str()));
1275
1276        let instrument_filter = InstrumentFilter::new(instruments);
1277
1278        let orders: Vec<_> = open_orders
1279            .iter()
1280            .filter_map(|order| {
1281                let instrument = self.dexes.instrument(&order.coin)?;
1282                if !instrument_filter.matches(&instrument) {
1283                    return None;
1284                }
1285                open_order_to_order(order, ExchangeId::HyperliquidPerp, instrument)
1286            })
1287            .collect();
1288        remember_open(&self.known_live, &orders);
1289        Ok(orders)
1290    }
1291
1292    /// Reads the span with `userFillsByTime`, to its end within the call, so the read is always
1293    /// complete (`resume: None`). Hyperliquid keeps only each wallet's 10,000 most recent fills,
1294    /// so a span reaching further back is read only as far as those go, and
1295    /// [`held_since`](TradesRead::held_since) says where that is. One read covers every
1296    /// DEX; fills on a HIP-3 DEX this client is not configured with are left out.
1297    ///
1298    /// # Errors
1299    ///
1300    /// [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) for an instrument
1301    /// in `instruments` that names no perpetual this client trades.
1302    async fn fetch_trades(
1303        &self,
1304        start: DateTime<Utc>,
1305        end: DateTime<Utc>,
1306        instruments: &[InstrumentNameExchange],
1307    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1308        self.check_instruments(instruments)?;
1309        let Some((start_ms, end_ms)) = span_millis(start, end) else {
1310            return Ok(TradesRead::complete(Vec::new()));
1311        };
1312        let address = self.wallet_h160();
1313
1314        let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1315        let held_since = common::held_since(&fills, start_ms);
1316        warn_unknown_coins(fills.iter().map(|fill| fill.coin.as_str()));
1317        warn_unconfigured_dexes(&self.dexes, fills.iter().map(|fill| fill.coin.as_str()));
1318
1319        let instrument_filter = InstrumentFilter::new(instruments);
1320
1321        let mut result = Vec::new();
1322        for fill in fills {
1323            let Some((instrument, collateral)) = self.dexes.perp(&fill.coin) else {
1324                continue;
1325            };
1326
1327            if !instrument_filter.matches(&instrument) {
1328                continue;
1329            }
1330
1331            let Some(side) = parse_side(&fill.side) else {
1332                continue;
1333            };
1334            let Some(price) = parse_decimal(&fill.px, "px") else {
1335                continue;
1336            };
1337            let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1338                continue;
1339            };
1340            let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1341
1342            let Some(time_exchange) = millis_to_datetime(fill.time) else {
1343                warn!(time = fill.time, "Invalid fill timestamp, skipping");
1344                continue;
1345            };
1346            if !(start..=end).contains(&time_exchange) {
1347                continue;
1348            }
1349
1350            result.push(Trade {
1351                id: TradeId(format_smolstr!("{}", fill.tid)),
1352                order_id: OrderId(format_smolstr!("{}", fill.oid)),
1353                instrument,
1354                strategy: StrategyId::unknown(),
1355                time_exchange,
1356                side,
1357                price,
1358                quantity,
1359                // `TradeInfo` carries no cumulative filled quantity; Hyperliquid reports order
1360                // state as its own `OrderUpdate` message.
1361                order_filled_quantity: None,
1362                fees: perp_fill_fees(fill.fee_token.as_deref(), fee, collateral),
1363                // As on the stream, whose copy of a fill usually arrives first; see `TradeOrigin`.
1364                origin: TradeOrigin::Order,
1365                position_side: None,
1366                client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1367                time_source: TimeSource::Venue,
1368            });
1369        }
1370
1371        Ok(TradesRead::complete(result).with_held_since(held_since))
1372    }
1373}
1374
1375/// Looks each order up with `orderStatus` (weight 2), by the cloid it was placed with, or by its
1376/// `oid` for an order placed without one, which this client reports under its `oid`. Up to 8 run
1377/// at once.
1378///
1379/// `filled` is reported as fully filled, without an average price, which the record does not
1380/// carry; every status ending in `Canceled`, and `scheduledCancel`, as cancelled with what filled
1381/// before it; and every status ending in `Rejected` as
1382/// [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed). Hyperliquid has no expiry.
1383/// An order the venue does not know, one on another instrument than the key's, and an id that is
1384/// neither a canonical UUID nor an `oid` are unknown. An `oid` whose order carries a cloid is
1385/// unknown too, since this client reports that order under its cloid.
1386///
1387/// The account stream runs the same lookup itself after a reconnect; see
1388/// [`account_stream`](ExecutionClient::account_stream).
1389impl OrderStatusClient for HyperliquidClient {
1390    async fn fetch_ended_orders(
1391        &self,
1392        orders: &[UnindexedOrderKey],
1393    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1394        let address = self.wallet_h160();
1395        fetch_ended_by_key(orders, |key| {
1396            perp_order_lookup(self.info_client.clone(), self.dexes.clone(), address, key)
1397        })
1398        .await
1399    }
1400
1401    /// One `orderStatus` (weight 2), which reports a live order as well as an ended one, read
1402    /// as [`fetch_ended_orders`](OrderStatusClient::fetch_ended_orders) reads it.
1403    ///
1404    /// # Errors
1405    ///
1406    /// When the lookup fails, or Hyperliquid's record of the order does not convert.
1407    async fn fetch_order_status(
1408        &self,
1409        key: &UnindexedOrderKey,
1410    ) -> Result<VenueOrderStatus, UnindexedClientError> {
1411        perp_order_status(
1412            self.info_client.clone(),
1413            self.dexes.clone(),
1414            self.wallet_h160(),
1415            key.clone(),
1416        )
1417        .await?
1418        .ok_or_else(|| unreadable_record(&key.cid))
1419    }
1420}
1421
1422/// Look the order under `key` up with `orderStatus`, on the perpetual its coin names.
1423async fn perp_order_lookup(
1424    info_client: Arc<InfoClient>,
1425    dexes: Arc<PerpDexes>,
1426    address: ethers::types::H160,
1427    key: UnindexedOrderKey,
1428) -> Result<OrderLookup, UnindexedClientError> {
1429    Ok(lookup_of(
1430        perp_order_status(info_client, dexes, address, key).await?,
1431    ))
1432}
1433
1434/// What `orderStatus` says of the order under `key`, on the perpetual its coin names: see
1435/// `status_from_record`.
1436async fn perp_order_status(
1437    info_client: Arc<InfoClient>,
1438    dexes: Arc<PerpDexes>,
1439    address: ethers::types::H160,
1440    key: UnindexedOrderKey,
1441) -> Result<Option<VenueOrderStatus>, UnindexedClientError> {
1442    let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1443        return Ok(Some(VenueOrderStatus::Unknown));
1444    };
1445    let instrument = dexes.instrument(&record.order.coin);
1446    Ok(status_from_record(key, &record, instrument))
1447}
1448
1449/// The client order ids `openOrders` (weight 20 per DEX traded) lists on the perpetuals
1450/// `instruments`, for a reconnect's check of the orders held as live.
1451async fn perp_listed_cids(
1452    info_client: Arc<InfoClient>,
1453    dexes: Arc<PerpDexes>,
1454    address: ethers::types::H160,
1455    instruments: Vec<InstrumentNameExchange>,
1456) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1457    let rows = perp_account::open_orders(&info_client, address, &dexes).await?;
1458    Ok(listed_cids(&rows, &instruments, |coin| {
1459        dexes.instrument(coin)
1460    }))
1461}
1462
1463/// Report one perpetual position from Hyperliquid's user state.
1464///
1465/// [`PositionReport::Open`] for a non-zero size, [`PositionReport::Flat`] for zero, and
1466/// [`PositionReport::Unreported`] when the size does not parse (with a warning): an unreadable
1467/// size establishes nothing, so it must not read as flat.
1468fn perp_position_report(
1469    position: &hyperliquid_rust_sdk::PositionData,
1470    now: DateTime<Utc>,
1471) -> PositionReport {
1472    let Some(quantity) = parse_decimal(&position.szi, "szi") else {
1473        return PositionReport::Unreported;
1474    };
1475    PositionReport::from_position(Position::new(
1476        quantity,
1477        position
1478            .entry_px
1479            .as_ref()
1480            .and_then(|p| parse_decimal(p, "entry_px")),
1481        parse_decimal(&position.unrealized_pnl, "unrealized_pnl"),
1482        parse_decimal(&position.margin_used, "margin_used"),
1483        position
1484            .liquidation_px
1485            .as_ref()
1486            .and_then(|p| parse_decimal(p, "liquidation_px")),
1487        Some(Decimal::from(position.leverage.value)),
1488        // Hyperliquid counts funding paid as positive; carry counts it as negative.
1489        parse_decimal(&position.cum_funding.since_open, "cum_funding.since_open").map(|paid| -paid),
1490        now,
1491    ))
1492}
1493
1494/// The fee of a fill on a perpetual whose DEX settles in `collateral`, in the asset `fee_token`
1495/// names.
1496///
1497/// The venue's `feeToken` is read rather than assumed; only its absence, which has not been
1498/// observed, falls back to `collateral`.
1499///
1500/// The quote-equivalent is set when the fee is in `collateral`, which is the instrument's quote,
1501/// and is `None` otherwise. It serves unindexed consumers: the indexer recomputes it either way,
1502/// from the instrument's own quote and base.
1503fn perp_fill_fees(
1504    fee_token: Option<&str>,
1505    fee: Decimal,
1506    collateral: &str,
1507) -> AssetFees<AssetNameExchange> {
1508    let asset = fee_token.unwrap_or(collateral);
1509    let fees_quote = (asset == collateral).then_some(fee);
1510    AssetFees::new(AssetNameExchange::from(asset), fee, fees_quote)
1511}
1512
1513/// Convert SDK TradeInfo (fill) to AccountEvent::Trade, `None` if its coin names no perpetual
1514/// this client trades.
1515fn fill_to_account_event(
1516    fill: &hyperliquid_rust_sdk::TradeInfo,
1517    dexes: &PerpDexes,
1518) -> Option<UnindexedAccountEvent> {
1519    let (instrument, collateral) = dexes.perp(&fill.coin)?;
1520    let side = parse_side(&fill.side)?;
1521    let price = parse_decimal(&fill.px, "fill.px")?;
1522    let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1523    let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1524    let time_exchange = millis_to_datetime(fill.time)?;
1525    let order_id = OrderId(format_smolstr!("{}", fill.oid));
1526
1527    let trade = Trade {
1528        id: TradeId(format_smolstr!("{}", fill.tid)),
1529        order_id,
1530        instrument,
1531        strategy: StrategyId::unknown(),
1532        time_exchange,
1533        side,
1534        price,
1535        quantity,
1536        // `TradeInfo` carries no cumulative filled quantity, so the order's state must be learned
1537        // from an `OrderUpdate` -- which Hyperliquid sends as its own message.
1538        order_filled_quantity: None,
1539        fees: perp_fill_fees(Some(&fill.fee_token), fee, collateral),
1540        // The SDK's `TradeInfo` drops the fill's liquidation marker, so every fill is reported
1541        // as `Order`; see `TradeOrigin`.
1542        origin: TradeOrigin::Order,
1543        position_side: None,
1544        client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1545        time_source: TimeSource::Venue,
1546    };
1547
1548    Some(AccountEvent::new(
1549        ExchangeId::HyperliquidPerp,
1550        AccountEventKind::Trade(trade),
1551    ))
1552}
1553
1554/// Convert SDK OrderUpdate to AccountEvent::OrderSnapshot, `None` if its coin names no
1555/// perpetual this client trades.
1556fn order_update_to_account_event(
1557    update: &hyperliquid_rust_sdk::OrderUpdate,
1558    dexes: &PerpDexes,
1559) -> Option<UnindexedAccountEvent> {
1560    common::order_update_to_account_event(
1561        update,
1562        ExchangeId::HyperliquidPerp,
1563        dexes.instrument(&update.order.coin)?,
1564    )
1565}
1566
1567#[cfg(test)]
1568// Test code: panics on bad input are acceptable
1569#[allow(clippy::unwrap_used)]
1570mod tests {
1571    use super::*;
1572    use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1573    use rust_decimal_macros::dec;
1574    use rustrade_integration::collection::snapshot::Snapshot;
1575    use std::collections::HashMap;
1576
1577    /// Only Hyperliquid's default DEX.
1578    fn default_dex() -> PerpDexes {
1579        PerpDexes::default()
1580    }
1581
1582    mod order_recovery {
1583        use super::super::common::info_tests::info_client_against;
1584        use super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1585        use super::*;
1586        use crate::order::state::InactiveOrderState;
1587        use wiremock::matchers::{method, path};
1588        use wiremock::{Mock, MockServer, ResponseTemplate};
1589
1590        async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>) {
1591            let server = MockServer::start().await;
1592            Mock::given(method("POST"))
1593                .and(path("/info"))
1594                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1595                .mount(&server)
1596                .await;
1597            let client = Arc::new(info_client_against(server.uri()).await);
1598            (server, client)
1599        }
1600
1601        fn key() -> UnindexedOrderKey {
1602            OrderKey {
1603                exchange: ExchangeId::HyperliquidPerp,
1604                instrument: InstrumentNameExchange::new("BTC-USDC-PERP"),
1605                strategy: StrategyId::new("strategy"),
1606                cid: ClientOrderId::new(CID),
1607            }
1608        }
1609
1610        fn order_status(coin: &str) -> serde_json::Value {
1611            serde_json::json!({
1612                "status": "order",
1613                "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1614            })
1615        }
1616
1617        #[test]
1618        fn a_reconnect_snapshot_wakes_the_check_once_its_fills_are_sent() {
1619            use futures::FutureExt as _;
1620            let notified = |reconnected: &Notify| reconnected.notified().now_or_never().is_some();
1621            let snapshot = |fills| hyperliquid_rust_sdk::UserFillsData {
1622                is_snapshot: Some(true),
1623                user: ethers::types::H160::zero(),
1624                fills,
1625            };
1626            let reconnected = Arc::new(Notify::new());
1627            let mut reconnects = ReconnectWatch::new(reconnected.clone());
1628            let dedup = new_dedup_cache();
1629            let (tx, mut rx) = mpsc::unbounded_channel();
1630            let tids = |rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>| {
1631                std::iter::from_fn(|| rx.try_recv().ok())
1632                    .map(|event| match event.kind {
1633                        AccountEventKind::Trade(trade) => trade.id.0.to_string(),
1634                        other => panic!("expected a trade, got {other:?}"),
1635                    })
1636                    .collect::<Vec<_>>()
1637            };
1638
1639            // The snapshot opening the first subscription.
1640            let first = snapshot(vec![sweep_fill(1, "60000")]);
1641            assert!(send_fills(
1642                &first,
1643                |fill| fill_to_account_event(fill, &default_dex()),
1644                &dedup,
1645                &tx,
1646                &mut reconnects
1647            ));
1648            assert_eq!(tids(&mut rx), ["1"]);
1649            assert!(!notified(&reconnected));
1650
1651            // A drop, then the resubscription's snapshot: its new fill is sent, then the check
1652            // woken.
1653            reconnects.dropped();
1654            let resubscribed = snapshot(vec![sweep_fill(1, "60000"), sweep_fill(2, "60001")]);
1655            assert!(send_fills(
1656                &resubscribed,
1657                |fill| fill_to_account_event(fill, &default_dex()),
1658                &dedup,
1659                &tx,
1660                &mut reconnects
1661            ));
1662            assert_eq!(
1663                tids(&mut rx),
1664                ["2"],
1665                "the fill already sent is not sent again"
1666            );
1667            assert!(notified(&reconnected));
1668
1669            // With the consumer gone the fills cannot be sent, and the check is not woken.
1670            drop(rx);
1671            reconnects.dropped();
1672            let unsent = snapshot(vec![sweep_fill(3, "60002")]);
1673            assert!(!send_fills(
1674                &unsent,
1675                |fill| fill_to_account_event(fill, &default_dex()),
1676                &dedup,
1677                &tx,
1678                &mut reconnects
1679            ));
1680            assert!(!notified(&reconnected));
1681        }
1682
1683        #[tokio::test]
1684        async fn a_perp_lookup_reads_how_the_order_ended() {
1685            let (_server, client) = serve(order_status("BTC")).await;
1686
1687            let lookup =
1688                perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1689                    .await
1690                    .unwrap();
1691
1692            let OrderLookup::Ended(order) = lookup else {
1693                panic!("expected an ended order, got {lookup:?}");
1694            };
1695            assert_eq!(order.key, key());
1696            assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1697        }
1698
1699        /// One `orderStatus` answers a lookup, live, ended or unknown, without listing the open
1700        /// orders; a record that does not convert is an error, never `Unknown`.
1701        #[tokio::test]
1702        async fn a_perp_order_status_is_one_order_status_request() {
1703            let answer = |status: &str| {
1704                serde_json::json!({
1705                    "status": "order",
1706                    "order": record_json("BTC", status, "0.01", Some(CLOID)),
1707                })
1708            };
1709            for (body, expected) in [
1710                (answer("open"), "live"),
1711                (answer("canceled"), "ended"),
1712                (serde_json::json!({"status": "unknownOid"}), "unknown"),
1713                (answer("aStatusAddedLater"), "error"),
1714            ] {
1715                let server = MockServer::start().await;
1716                Mock::given(method("POST"))
1717                    .and(path("/info"))
1718                    .respond_with(ResponseTemplate::new(200).set_body_json(body))
1719                    .mount(&server)
1720                    .await;
1721                let client = client_against(&server, HashMap::new()).await;
1722
1723                let status = client.fetch_order_status(&key()).await;
1724
1725                let found = match &status {
1726                    Ok(VenueOrderStatus::Live(order)) if order.key == key() => "live",
1727                    Ok(VenueOrderStatus::Ended(order)) if order.key == key() => "ended",
1728                    Ok(VenueOrderStatus::Unknown) => "unknown",
1729                    Err(UnindexedClientError::Internal(_)) => "error",
1730                    _ => "other",
1731                };
1732                assert_eq!(found, expected, "{status:?}");
1733                assert_eq!(server.received_requests().await.unwrap().len(), 1);
1734            }
1735        }
1736
1737        #[tokio::test]
1738        async fn a_perp_lookup_of_a_spot_order_is_unknown() {
1739            let (_server, client) = serve(order_status("@107")).await;
1740
1741            let lookup =
1742                perp_order_lookup(client, Arc::default(), ethers::types::H160::zero(), key())
1743                    .await
1744                    .unwrap();
1745
1746            assert!(matches!(lookup, OrderLookup::Unknown));
1747        }
1748
1749        #[tokio::test]
1750        async fn a_perp_listing_names_only_the_orders_on_the_perpetuals_asked_about() {
1751            let rows = serde_json::json!([
1752                row("BTC", 1, Some(CLOID)),
1753                row("ETH", 2, None),
1754                row("@107", 3, None),
1755            ]);
1756            let (_server, client) = serve(rows).await;
1757
1758            let listed = perp_listed_cids(
1759                client,
1760                Arc::default(),
1761                ethers::types::H160::zero(),
1762                vec![InstrumentNameExchange::new("BTC-USDC-PERP")],
1763            )
1764            .await
1765            .unwrap();
1766
1767            assert_eq!(
1768                listed.into_iter().collect::<Vec<_>>(),
1769                [ClientOrderId::new(CID)]
1770            );
1771        }
1772    }
1773
1774    /// A client trading `xyz` and `flx` against `server`, built without the network, that places
1775    /// and cancels on the assets in `coin_to_asset`. The default DEX lists `BTC`, with
1776    /// `szDecimals` 5 as on mainnet.
1777    async fn client_against(
1778        server: &wiremock::MockServer,
1779        coin_to_asset: HashMap<String, u32>,
1780    ) -> HyperliquidClient {
1781        use super::common::info_tests::{
1782            exchange_client_against, info_client_against, test_wallet,
1783        };
1784        let mut dexes = super::perp_dexes::tests::xyz_and_flx().await;
1785        dexes.add_default_perps(&[hyperliquid_rust_sdk::AssetMeta {
1786            name: "BTC".to_owned(),
1787            sz_decimals: 5,
1788        }]);
1789        HyperliquidClient {
1790            config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1791            info_client: Arc::new(info_client_against(server.uri()).await),
1792            exchange_client: Arc::new(exchange_client_against(server.uri(), coin_to_asset).await),
1793            dexes: Arc::new(dexes),
1794            known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidPerp),
1795        }
1796    }
1797
1798    /// An account snapshot's balances are stamped no later than the first request that read
1799    /// them was sent, never when the answers arrive, in either account mode.
1800    #[tokio::test]
1801    async fn account_snapshot_balances_are_stamped_before_their_reads_were_sent() {
1802        const ANSWER_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
1803        for mode in ["unifiedAccount", "default"] {
1804            let server =
1805                perp_account::tests::balance_server_answering_after(mode, ANSWER_DELAY).await;
1806            let client = client_against(&server, HashMap::new()).await;
1807            let before = Utc::now();
1808            let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
1809            let after = Utc::now();
1810            assert!(!snapshot.balances.is_empty(), "{mode}");
1811            for balance in snapshot.balances {
1812                assert!(
1813                    before <= balance.time_exchange
1814                        && balance.time_exchange + ANSWER_DELAY <= after,
1815                    "{mode}: {balance:?} not within {before} ..= {after} - {ANSWER_DELAY:?}"
1816                );
1817            }
1818        }
1819    }
1820
1821    mod open_order {
1822        use super::*;
1823        use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
1824        use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1825        use wiremock::matchers::{method, path};
1826        use wiremock::{Mock, MockServer, ResponseTemplate};
1827
1828        /// A server whose exchange endpoint rests every order it is sent.
1829        async fn resting_server() -> MockServer {
1830            server_responding(serde_json::json!({
1831                "status": "ok",
1832                "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1833            }))
1834            .await
1835        }
1836
1837        /// A server whose exchange endpoint answers every request with `body`.
1838        async fn server_responding(body: serde_json::Value) -> MockServer {
1839            server_with(ResponseTemplate::new(200).set_body_json(body)).await
1840        }
1841
1842        /// A server whose exchange endpoint gives `response` to every request.
1843        async fn server_with(response: ResponseTemplate) -> MockServer {
1844            let server = MockServer::start().await;
1845            Mock::given(method("POST"))
1846                .and(path("/exchange"))
1847                .respond_with(response)
1848                .mount(&server)
1849                .await;
1850            server
1851        }
1852
1853        /// Place a `BTC-USDC-PERP` limit order through a client against `server`.
1854        async fn place_on(
1855            server: &MockServer,
1856        ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1857            let client = client_against(server, HashMap::from([("BTC".to_owned(), 0)])).await;
1858            place(
1859                &client,
1860                &InstrumentNameExchange::from("BTC-USDC-PERP"),
1861                OrderKind::Limit,
1862                Some(dec!(50000)),
1863                dec!(0.001),
1864            )
1865            .await
1866        }
1867
1868        /// An open the venue may have acted on keeps an unknown outcome, so the engine looks it
1869        /// up rather than ending it: a 5xx, an answer that does not parse, no answer at all, and
1870        /// an `ok` answer that does not say what became of the order.
1871        #[tokio::test]
1872        async fn an_open_the_venue_may_have_acted_on_has_an_unknown_outcome() {
1873            let no_status = serde_json::json!({"status": "ok", "response": {"type": "order"}});
1874            let success = serde_json::json!({
1875                "status": "ok",
1876                "response": {"type": "order", "data": {"statuses": ["success"]}},
1877            });
1878            for (case, response) in [
1879                (
1880                    "5xx",
1881                    ResponseTemplate::new(502).set_body_string("Bad Gateway"),
1882                ),
1883                (
1884                    "unparsable",
1885                    ResponseTemplate::new(200).set_body_string("<html>"),
1886                ),
1887                (
1888                    "no status",
1889                    ResponseTemplate::new(200).set_body_json(no_status),
1890                ),
1891                ("success", ResponseTemplate::new(200).set_body_json(success)),
1892            ] {
1893                let order = place_on(&server_with(response).await).await;
1894                assert!(
1895                    matches!(
1896                        order.state,
1897                        OrderState::Inactive(InactiveOrderState::OpenFailed(
1898                            OrderError::Connectivity(_)
1899                        ))
1900                    ),
1901                    "{case}: {:?}",
1902                    order.state
1903                );
1904                assert!(order.state.is_outcome_unknown(), "{case}");
1905            }
1906
1907            // Nothing listening: the send itself fails. A port bound and released, since a
1908            // dropped mock server goes back to wiremock's pool still listening.
1909            let server = MockServer::start().await;
1910            let mut client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1911            let closed = std::net::TcpListener::bind("127.0.0.1:0")
1912                .and_then(|listener| listener.local_addr())
1913                .unwrap();
1914            client.exchange_client = Arc::new(
1915                super::super::common::info_tests::exchange_client_against(
1916                    format!("http://{closed}"),
1917                    HashMap::from([("BTC".to_owned(), 0)]),
1918                )
1919                .await,
1920            );
1921            let order = place(
1922                &client,
1923                &InstrumentNameExchange::from("BTC-USDC-PERP"),
1924                OrderKind::Limit,
1925                Some(dec!(50000)),
1926                dec!(0.001),
1927            )
1928            .await;
1929            assert!(order.state.is_outcome_unknown(), "{:?}", order.state);
1930        }
1931
1932        /// A 4xx turned the request away, so the open ends as refused; a 429 as rate-limited.
1933        #[tokio::test]
1934        async fn an_open_turned_away_with_a_4xx_is_refused() {
1935            let order = place_on(
1936                &server_with(ResponseTemplate::new(422).set_body_string("Failed to deserialize"))
1937                    .await,
1938            )
1939            .await;
1940            assert!(
1941                matches!(
1942                    order.state,
1943                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1944                        ApiError::OrderRejected(_)
1945                    )))
1946                ),
1947                "{:?}",
1948                order.state
1949            );
1950
1951            let order = place_on(&server_with(ResponseTemplate::new(429)).await).await;
1952            assert_eq!(
1953                order.state,
1954                OrderState::inactive(OrderError::Rejected(ApiError::rate_limited(None)))
1955            );
1956        }
1957
1958        /// A refusal for want of margin is a balance shortfall, as on other venues; any other
1959        /// refusal stays a rejection. Hyperliquid names no asset. The refusal is read both as an
1960        /// order's status and as the error of the whole response.
1961        #[tokio::test]
1962        async fn an_order_refused_for_margin_is_balance_insufficient() {
1963            const MARGIN: &str = "Insufficient margin to place order. asset=0";
1964            const MINIMUM: &str = "Order must have minimum value of $10. asset=0";
1965            let in_status = |error: &str| {
1966                serde_json::json!({
1967                    "status": "ok",
1968                    "response": {"type": "order", "data": {"statuses": [{"error": error}]}},
1969                })
1970            };
1971            let in_response = |error: &str| serde_json::json!({"status": "err", "response": error});
1972            for (body, expected) in [
1973                (
1974                    in_status(MARGIN),
1975                    ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
1976                ),
1977                (
1978                    in_response(MARGIN),
1979                    ApiError::BalanceInsufficient(None, MARGIN.to_owned()),
1980                ),
1981                (
1982                    in_status(MINIMUM),
1983                    ApiError::OrderRejected(MINIMUM.to_owned()),
1984                ),
1985                (
1986                    in_response(MINIMUM),
1987                    ApiError::OrderRejected(MINIMUM.to_owned()),
1988                ),
1989            ] {
1990                let server = server_responding(body.clone()).await;
1991                let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
1992                let order = place(
1993                    &client,
1994                    &InstrumentNameExchange::from("BTC-USDC-PERP"),
1995                    OrderKind::Limit,
1996                    Some(dec!(50000)),
1997                    dec!(0.001),
1998                )
1999                .await;
2000                let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
2001                    reported,
2002                ))) = &order.state
2003                else {
2004                    panic!("{body}: expected a rejection, got {:?}", order.state);
2005                };
2006                assert_eq!(reported, &expected, "{body}");
2007            }
2008        }
2009
2010        async fn place(
2011            client: &HyperliquidClient,
2012            instrument: &InstrumentNameExchange,
2013            kind: OrderKind,
2014            price: Option<Decimal>,
2015            quantity: Decimal,
2016        ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
2017            client
2018                .open_order(OrderEvent {
2019                    key: OrderKey {
2020                        exchange: ExchangeId::HyperliquidPerp,
2021                        instrument,
2022                        strategy: StrategyId::new("strategy"),
2023                        cid: ClientOrderId::uuid(),
2024                    },
2025                    state: RequestOpen::new(
2026                        Side::Buy,
2027                        price,
2028                        quantity,
2029                        kind,
2030                        TimeInForce::GoodUntilCancelled { post_only: false },
2031                    ),
2032                })
2033                .await
2034        }
2035
2036        /// The orders the server was sent, as the SDK wrote them.
2037        async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
2038            server
2039                .received_requests()
2040                .await
2041                .unwrap_or_default()
2042                .into_iter()
2043                .filter(|request| request.url.path() == "/exchange")
2044                .map(|request| {
2045                    let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
2046                    body["action"]["orders"][0].clone()
2047                })
2048                .collect()
2049        }
2050
2051        #[tokio::test]
2052        async fn the_client_reports_each_perpetuals_precision() {
2053            let server = MockServer::start().await;
2054            let client = client_against(&server, HashMap::new()).await;
2055
2056            assert_eq!(
2057                client.order_precision(&InstrumentNameExchange::from("BTC-USDC-PERP")),
2058                Some(OrderPrecision::perp(5))
2059            );
2060            assert_eq!(
2061                client.order_precision(&InstrumentNameExchange::from("xyz:TSLA-USDC-PERP")),
2062                Some(OrderPrecision::perp(2)),
2063                "a HIP-3 perpetual's szDecimals come from its DEX's meta"
2064            );
2065            assert_eq!(
2066                client.order_precision(&InstrumentNameExchange::from("ETH-USDC-PERP")),
2067                None,
2068                "not listed when the client connected"
2069            );
2070            assert_eq!(
2071                client.order_precision(&InstrumentNameExchange::from("HYPE-USDC-SPOT")),
2072                None
2073            );
2074        }
2075
2076        #[tokio::test]
2077        async fn an_order_breaking_the_precision_rules_is_refused_unsent() {
2078            let server = resting_server().await;
2079            let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
2080            let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
2081
2082            let cases = [
2083                (
2084                    OrderKind::Limit,
2085                    dec!(60000),
2086                    dec!(0.000001),
2087                    OrderField::Quantity,
2088                    PrecisionLimit::DecimalPlaces { max: 5 },
2089                ),
2090                (
2091                    OrderKind::Limit,
2092                    dec!(60000),
2093                    dec!(0),
2094                    OrderField::Quantity,
2095                    PrecisionLimit::NotPositive,
2096                ),
2097                (
2098                    OrderKind::Limit,
2099                    dec!(65432.1),
2100                    dec!(0.001),
2101                    OrderField::Price,
2102                    PrecisionLimit::SignificantFigures { max: 5 },
2103                ),
2104                (
2105                    OrderKind::Limit,
2106                    dec!(6543.21),
2107                    dec!(0.001),
2108                    OrderField::Price,
2109                    PrecisionLimit::DecimalPlaces { max: 1 },
2110                ),
2111                (
2112                    OrderKind::StopLimit {
2113                        trigger_price: dec!(6543.21),
2114                    },
2115                    dec!(6543),
2116                    dec!(0.001),
2117                    OrderField::TriggerPrice,
2118                    PrecisionLimit::DecimalPlaces { max: 1 },
2119                ),
2120            ];
2121            for (kind, price, quantity, field, limit) in cases {
2122                let order = place(&client, &btc, kind, Some(price), quantity).await;
2123                let expected = PrecisionViolation {
2124                    field,
2125                    value: match field {
2126                        OrderField::Quantity => quantity,
2127                        OrderField::TriggerPrice => dec!(6543.21),
2128                        _ => price,
2129                    },
2130                    limit,
2131                };
2132                assert_eq!(
2133                    order.state,
2134                    OrderState::Inactive(InactiveOrderState::OpenFailed(
2135                        OrderError::InvalidPrecision(expected)
2136                    )),
2137                    "{kind} at {price} for {quantity}"
2138                );
2139            }
2140            assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
2141        }
2142
2143        #[tokio::test]
2144        async fn a_valid_order_is_sent_unchanged() {
2145            let server = resting_server().await;
2146            let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
2147            let btc = InstrumentNameExchange::from("BTC-USDC-PERP");
2148
2149            // An integer price past 5 significant figures, which the client once rounded to 123460.
2150            let order = place(
2151                &client,
2152                &btc,
2153                OrderKind::Limit,
2154                Some(dec!(123456)),
2155                dec!(0.00123),
2156            )
2157            .await;
2158            assert!(
2159                matches!(order.state, OrderState::Active(_)),
2160                "{:?}",
2161                order.state
2162            );
2163
2164            let sent = sent_orders(&server).await;
2165            assert_eq!(sent.len(), 1);
2166            assert_eq!(
2167                (&sent[0]["p"], &sent[0]["s"]),
2168                (&serde_json::json!("123456"), &serde_json::json!("0.00123"))
2169            );
2170        }
2171
2172        #[tokio::test]
2173        async fn an_order_on_a_perpetual_listed_since_connecting_is_refused_unsent() {
2174            let server = resting_server().await;
2175            let client = client_against(&server, HashMap::from([("ETH".to_owned(), 1)])).await;
2176
2177            let order = place(
2178                &client,
2179                &InstrumentNameExchange::from("ETH-USDC-PERP"),
2180                OrderKind::Limit,
2181                Some(dec!(3000)),
2182                dec!(1),
2183            )
2184            .await;
2185            let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
2186                crate::error::ApiError::InstrumentInvalid(_, reason),
2187            ))) = &order.state
2188            else {
2189                panic!("expected InstrumentInvalid, got {:?}", order.state);
2190            };
2191            assert!(reason.contains("reconnect"), "{reason}");
2192            assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
2193        }
2194    }
2195
2196    mod cancel {
2197        use super::super::common::info_tests::{CANCEL_NOT_APPLIED, cancel_answer};
2198        use super::*;
2199        use crate::error::ApiError;
2200        use crate::order::{OrderEvent, request::RequestCancel};
2201        use wiremock::matchers::{method, path};
2202        use wiremock::{Mock, MockServer, ResponseTemplate};
2203
2204        /// Cancel a live `BTC-USDC-PERP` order through a client whose exchange endpoint answers
2205        /// `answer`. Returns the response, and whether the order is still held live afterwards.
2206        async fn cancel_answered(
2207            answer: serde_json::Value,
2208        ) -> (UnindexedOrderResponseCancel, bool) {
2209            cancel_responded(ResponseTemplate::new(200).set_body_json(answer)).await
2210        }
2211
2212        /// [`cancel_answered`], with the exchange endpoint giving `response`.
2213        async fn cancel_responded(
2214            response: ResponseTemplate,
2215        ) -> (UnindexedOrderResponseCancel, bool) {
2216            let server = MockServer::start().await;
2217            Mock::given(method("POST"))
2218                .and(path("/exchange"))
2219                .respond_with(response)
2220                .mount(&server)
2221                .await;
2222            let client = client_against(&server, HashMap::from([("BTC".to_owned(), 0)])).await;
2223            let instrument = InstrumentNameExchange::new("BTC-USDC-PERP");
2224            let key = OrderKey {
2225                exchange: ExchangeId::HyperliquidPerp,
2226                instrument: instrument.clone(),
2227                strategy: StrategyId::new("strategy"),
2228                cid: ClientOrderId::uuid(),
2229            };
2230            let id = VenueOrderId::Assigned(OrderId::new("7"));
2231            let open = Open {
2232                id: id.clone(),
2233                time_exchange: Utc::now(),
2234                filled_quantity: Decimal::ZERO,
2235            };
2236            client
2237                .known_live
2238                .lock()
2239                .live(&key, dec!(1), OrderKind::Limit, &open);
2240
2241            let response = client
2242                .cancel_order(OrderEvent {
2243                    key: OrderKey {
2244                        exchange: key.exchange,
2245                        instrument: &instrument,
2246                        strategy: key.strategy.clone(),
2247                        cid: key.cid.clone(),
2248                    },
2249                    state: RequestCancel { id: Some(id) },
2250                })
2251                .await;
2252            let live = client.known_live.lock().contains(&key.cid);
2253            (response, live)
2254        }
2255
2256        #[tokio::test]
2257        async fn a_success_status_cancels_the_order() {
2258            let (response, live) =
2259                cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
2260            assert!(response.state.is_ok(), "{:?}", response.state);
2261            assert!(!live, "a cancelled order is no longer held live");
2262        }
2263
2264        /// An order that filled before the cancel arrived is answered this way, so it must not be
2265        /// reported cancelled, and stays held live until the account stream or a lookup ends it.
2266        #[tokio::test]
2267        async fn an_error_status_is_a_rejected_cancel_and_the_order_stays_live() {
2268            let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
2269                {"error": CANCEL_NOT_APPLIED}
2270            ])))
2271            .await;
2272            let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
2273                &response.state
2274            else {
2275                panic!("expected a rejected cancel, got {:?}", response.state);
2276            };
2277            assert_eq!(reason, CANCEL_NOT_APPLIED);
2278            assert!(live, "the order is still held live");
2279        }
2280
2281        /// A cancel the venue may have acted on is not reported refused, and one turned away
2282        /// with a 4xx is. Either way the order stays held live until a report ends it.
2283        #[tokio::test]
2284        async fn a_cancel_failing_after_the_send_has_an_unknown_outcome() {
2285            let (response, live) = cancel_responded(ResponseTemplate::new(503)).await;
2286            assert!(
2287                matches!(response.state, Err(UnindexedOrderError::Connectivity(_))),
2288                "{:?}",
2289                response.state
2290            );
2291            assert!(live, "the order is still held live");
2292
2293            let (response, live) =
2294                cancel_responded(ResponseTemplate::new(422).set_body_string("bad request")).await;
2295            assert!(
2296                matches!(
2297                    response.state,
2298                    Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
2299                ),
2300                "{:?}",
2301                response.state
2302            );
2303            assert!(live, "the order is still held live");
2304        }
2305
2306        #[tokio::test]
2307        async fn an_answer_without_a_status_is_a_rejected_cancel() {
2308            let (response, live) = cancel_answered(serde_json::json!({
2309                "status": "ok",
2310                "response": {"type": "cancel"},
2311            }))
2312            .await;
2313            assert!(
2314                matches!(
2315                    response.state,
2316                    Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
2317                ),
2318                "{:?}",
2319                response.state
2320            );
2321            assert!(live, "the order is still held live");
2322        }
2323    }
2324
2325    mod instrument_names {
2326        use super::*;
2327        use crate::error::ApiError;
2328        use crate::order::{
2329            OrderEvent,
2330            request::{RequestCancel, RequestOpen},
2331        };
2332        use wiremock::MockServer;
2333
2334        /// A client trading `xyz` and `flx`, built without the network, against a server with
2335        /// nothing mounted: any request it sent would fail with something other than
2336        /// `InstrumentInvalid`.
2337        async fn offline_client() -> (MockServer, HyperliquidClient) {
2338            let server = MockServer::start().await;
2339            let client = client_against(&server, HashMap::new()).await;
2340            (server, client)
2341        }
2342
2343        /// Names no perpetual the client trades: the old `-USD-` name, a quote other than the
2344        /// DEX's collateral, a DEX not configured, and a spot pair.
2345        const REFUSED: [&str; 4] = [
2346            "BTC-USD-PERP",
2347            "flx:TSLA-USDC-PERP",
2348            "km:TSLA-USDH-PERP",
2349            "HYPE-USDC-SPOT",
2350        ];
2351
2352        fn is_invalid(
2353            error: &ApiError<AssetNameExchange, InstrumentNameExchange>,
2354            name: &str,
2355        ) -> bool {
2356            matches!(error, ApiError::InstrumentInvalid(instrument, _) if instrument.as_ref() == name)
2357        }
2358
2359        #[tokio::test]
2360        async fn an_order_on_a_name_the_client_does_not_trade_is_refused_unsent() {
2361            let (_server, client) = offline_client().await;
2362            for name in REFUSED {
2363                let instrument = InstrumentNameExchange::from(name);
2364                let order = client
2365                    .open_order(OrderEvent {
2366                        key: OrderKey {
2367                            exchange: ExchangeId::HyperliquidPerp,
2368                            instrument: &instrument,
2369                            strategy: StrategyId::new("strategy"),
2370                            cid: ClientOrderId::uuid(),
2371                        },
2372                        state: RequestOpen::new(
2373                            Side::Buy,
2374                            Some(dec!(1)),
2375                            dec!(1),
2376                            OrderKind::Limit,
2377                            TimeInForce::GoodUntilCancelled { post_only: false },
2378                        ),
2379                    })
2380                    .await;
2381                let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
2382                    OrderError::Rejected(error),
2383                )) = &order.state
2384                else {
2385                    panic!("{name}: expected a rejection, got {:?}", order.state);
2386                };
2387                assert!(is_invalid(error, name), "{name}: {error:?}");
2388            }
2389        }
2390
2391        #[tokio::test]
2392        async fn a_cancel_on_a_name_the_client_does_not_trade_is_refused_unsent() {
2393            let (_server, client) = offline_client().await;
2394            for name in REFUSED {
2395                let instrument = InstrumentNameExchange::from(name);
2396                let response = client
2397                    .cancel_order(OrderEvent {
2398                        key: OrderKey {
2399                            exchange: ExchangeId::HyperliquidPerp,
2400                            instrument: &instrument,
2401                            strategy: StrategyId::new("strategy"),
2402                            cid: ClientOrderId::uuid(),
2403                        },
2404                        state: RequestCancel {
2405                            id: Some(VenueOrderId::Assigned(OrderId::new("1"))),
2406                        },
2407                    })
2408                    .await;
2409                let Err(UnindexedOrderError::Rejected(error)) = &response.state else {
2410                    panic!("{name}: expected a rejection, got {:?}", response.state);
2411                };
2412                assert!(is_invalid(error, name), "{name}: {error:?}");
2413            }
2414        }
2415
2416        #[tokio::test]
2417        async fn a_read_filtered_to_a_name_the_client_does_not_trade_fails_unsent() {
2418            let (_server, client) = offline_client().await;
2419            for name in REFUSED {
2420                let instruments = [InstrumentNameExchange::from(name)];
2421                let snapshot = client.account_snapshot(&[], &instruments).await;
2422                let open = client.fetch_open_orders(&instruments).await;
2423                let trades = client
2424                    .fetch_trades(
2425                        Utc::now() - chrono::Duration::hours(1),
2426                        Utc::now(),
2427                        &instruments,
2428                    )
2429                    .await;
2430                for error in [snapshot.err(), open.err(), trades.err()] {
2431                    let Some(UnindexedClientError::Api(error)) = &error else {
2432                        panic!("{name}: expected an API error, got {error:?}");
2433                    };
2434                    assert!(is_invalid(error, name), "{name}: {error:?}");
2435                }
2436            }
2437        }
2438    }
2439
2440    fn perp_position(szi: &str) -> hyperliquid_rust_sdk::PositionData {
2441        serde_json::from_value(serde_json::json!({
2442            "coin": "BTC",
2443            "entryPx": "50000.0",
2444            "leverage": {"type": "cross", "value": 5},
2445            "liquidationPx": null,
2446            "marginUsed": "100.0",
2447            "positionValue": "500.0",
2448            "returnOnEquity": "0.0",
2449            "szi": szi,
2450            "unrealizedPnl": "1.5",
2451            "maxLeverage": 50,
2452            "cumFunding": {"allTime": "3.5", "sinceOpen": "1.25", "sinceChange": "0"}
2453        }))
2454        .unwrap()
2455    }
2456
2457    #[test]
2458    fn perp_position_report_is_open_flat_or_unreported() {
2459        let now = Utc::now();
2460        let open = perp_position_report(&perp_position("-0.01"), now);
2461        let position = open.open().unwrap();
2462        // Funding paid since open is positive on Hyperliquid and negative as carry.
2463        assert_eq!(
2464            (
2465                position.quantity,
2466                position.entry_price,
2467                position.leverage,
2468                position.carry
2469            ),
2470            (
2471                dec!(-0.01),
2472                Some(dec!(50000.0)),
2473                Some(dec!(5)),
2474                Some(dec!(-1.25))
2475            )
2476        );
2477        assert_eq!(
2478            perp_position_report(&perp_position("0.0"), now),
2479            PositionReport::Flat
2480        );
2481        assert_eq!(
2482            perp_position_report(&perp_position("not a number"), now),
2483            PositionReport::Unreported
2484        );
2485    }
2486
2487    #[test]
2488    fn test_fill_to_account_event() {
2489        let fill_json = r#"{
2490            "coin": "BTC",
2491            "side": "B",
2492            "px": "65000.5",
2493            "sz": "0.1",
2494            "time": 1714100000000,
2495            "hash": "0xabc123",
2496            "startPosition": "0",
2497            "dir": "Open Long",
2498            "closedPnl": "0",
2499            "oid": 12345,
2500            "cloid": null,
2501            "crossed": false,
2502            "fee": "0.65",
2503            "feeToken": "USDC",
2504            "tid": 99999
2505        }"#;
2506
2507        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2508        let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2509
2510        assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2511        match event.kind {
2512            AccountEventKind::Trade(trade) => {
2513                assert_eq!(trade.instrument.as_ref(), "BTC-USDC-PERP");
2514                assert_eq!(
2515                    trade.id.0, "99999",
2516                    "the id is the fill's tid, not its hash"
2517                );
2518                assert_eq!(trade.side, Side::Buy);
2519                assert_eq!(trade.price, dec!(65000.5));
2520                assert_eq!(trade.quantity, dec!(0.1));
2521                assert_eq!(trade.fees.fees, dec!(0.65));
2522                assert_eq!(trade.fees.asset.as_ref(), "USDC");
2523                assert_eq!(trade.fees.fees_quote, Some(dec!(0.65)));
2524            }
2525            _ => panic!("Expected Trade event"),
2526        }
2527    }
2528
2529    /// A fill reports its order's cloid as the client order id the order was placed under.
2530    #[tokio::test]
2531    async fn a_fill_carries_the_client_order_id_of_its_cloid() {
2532        let fill_json = r#"{
2533            "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2534            "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2535            "dir": "Open Long", "closedPnl": "0", "oid": 7,
2536            "cloid": "0x0123456789abcdef0123456789abcdef",
2537            "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2538        }"#;
2539        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2540
2541        let dexes = perp_dexes::tests::xyz_and_flx().await;
2542        let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2543        else {
2544            panic!("Expected Trade event");
2545        };
2546        assert_eq!(
2547            trade.client_order_id,
2548            cloid_to_cid("0x0123456789abcdef0123456789abcdef")
2549        );
2550        assert!(trade.client_order_id.is_some());
2551    }
2552
2553    #[tokio::test]
2554    async fn a_builder_deployed_fill_is_named_and_charged_in_its_dex_collateral() {
2555        // A HIP-3 perpetual settles in its deployer's collateral, which is not always USDC.
2556        let fill_json = r#"{
2557            "coin": "flx:TSLA", "side": "B", "px": "250", "sz": "2",
2558            "time": 1714100000000, "hash": "0xhip3", "startPosition": "0",
2559            "dir": "Open Long", "closedPnl": "0", "oid": 7, "cloid": null,
2560            "crossed": true, "fee": "0.2", "feeToken": "USDH", "tid": 77
2561        }"#;
2562        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2563
2564        let dexes = perp_dexes::tests::xyz_and_flx().await;
2565        let AccountEventKind::Trade(trade) = fill_to_account_event(&fill, &dexes).unwrap().kind
2566        else {
2567            panic!("Expected Trade event");
2568        };
2569        assert_eq!(trade.instrument.as_ref(), "flx:TSLA-USDH-PERP");
2570        assert_eq!(trade.client_order_id, None, "placed without a cloid");
2571        assert_eq!(trade.fees.asset.as_ref(), "USDH");
2572        assert_eq!(trade.fees.fees, dec!(0.2));
2573        assert_eq!(
2574            trade.fees.fees_quote,
2575            Some(dec!(0.2)),
2576            "the fee is in the instrument's quote"
2577        );
2578
2579        // Without `flx` configured, its collateral is unknown, so the fill is left out.
2580        assert!(fill_to_account_event(&fill, &default_dex()).is_none());
2581    }
2582
2583    #[test]
2584    fn perp_fill_fees_reads_the_fee_token_and_falls_back_to_the_collateral() {
2585        assert_eq!(
2586            perp_fill_fees(Some("USDT0"), dec!(1.5), "USDC"),
2587            AssetFees::new(AssetNameExchange::from("USDT0"), dec!(1.5), None)
2588        );
2589        assert_eq!(
2590            perp_fill_fees(Some("USDC"), dec!(1.5), "USDC"),
2591            AssetFees::new(AssetNameExchange::from("USDC"), dec!(1.5), Some(dec!(1.5)))
2592        );
2593        assert_eq!(
2594            perp_fill_fees(None, dec!(1.5), "USDH"),
2595            AssetFees::new(AssetNameExchange::from("USDH"), dec!(1.5), Some(dec!(1.5))),
2596            "an absent feeToken is the DEX's collateral"
2597        );
2598    }
2599
2600    #[test]
2601    fn test_fill_to_account_event_sell() {
2602        let fill_json = r#"{
2603            "coin": "ETH",
2604            "side": "A",
2605            "px": "3200",
2606            "sz": "1.5",
2607            "time": 1714100000000,
2608            "hash": "0xdef456",
2609            "startPosition": "1.5",
2610            "dir": "Close Long",
2611            "closedPnl": "150.0",
2612            "oid": 12346,
2613            "cloid": null,
2614            "crossed": true,
2615            "fee": "4.8",
2616            "feeToken": "USDC",
2617            "tid": 100000
2618        }"#;
2619
2620        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
2621        let event = fill_to_account_event(&fill, &default_dex()).unwrap();
2622
2623        match event.kind {
2624            AccountEventKind::Trade(trade) => {
2625                assert_eq!(trade.instrument.as_ref(), "ETH-USDC-PERP");
2626                assert_eq!(trade.side, Side::Sell);
2627                assert_eq!(trade.price, dec!(3200));
2628                assert_eq!(trade.quantity, dec!(1.5));
2629            }
2630            _ => panic!("Expected Trade event"),
2631        }
2632    }
2633
2634    /// Build a perp `TradeInfo` sharing one transaction hash with its siblings.
2635    fn sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
2636        let json = format!(
2637            r#"{{
2638                "coin": "BTC", "side": "B", "px": "{px}", "sz": "0.1",
2639                "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
2640                "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
2641                "crossed": true, "fee": "0.65", "feeToken": "USDC", "tid": {tid}
2642            }}"#
2643        );
2644        serde_json::from_str(&json).unwrap()
2645    }
2646
2647    #[test]
2648    fn a_trade_id_identifies_the_fill_not_the_transaction() {
2649        // One aggressive order sweeping two resting levels: same `hash`, same `oid`, two fills.
2650        // Deriving the id from `hash` gave both the same `TradeId`, so anything reconciling on it
2651        // treated them as one fill and dropped the second -- understating filled quantity and fees.
2652        let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2653        let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2654
2655        let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
2656            (first.kind, second.kind)
2657        else {
2658            panic!("Expected Trade events");
2659        };
2660
2661        assert_eq!(first.order_id, second.order_id, "one order produced both");
2662        assert_ne!(first.id, second.id, "but they are distinct fills");
2663        assert_eq!(first.id.0, "111");
2664        assert_eq!(second.id.0, "222");
2665    }
2666
2667    #[test]
2668    fn a_replayed_fill_is_delivered_once() {
2669        // The `userFills` subscription opens with a snapshot of recent fills, and the SDK
2670        // resubscribes on reconnect -- so the same fill arrives again on every reconnect.
2671        let cache = new_dedup_cache();
2672        let event = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2673        let replay = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2674
2675        assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
2676        assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
2677    }
2678
2679    #[test]
2680    fn both_fills_of_one_sweep_survive_dedup() {
2681        // The guard against the fix and the dedup fighting each other: two fills of one sweep are
2682        // not duplicates of one another, and dedup must not collapse what the id now separates.
2683        let cache = new_dedup_cache();
2684        let first = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2685        let second = fill_to_account_event(&sweep_fill(222, "65001.0"), &default_dex()).unwrap();
2686
2687        assert!(!is_duplicate(&cache, dedup_key_from_event(&first).unwrap()));
2688        assert!(!is_duplicate(
2689            &cache,
2690            dedup_key_from_event(&second).unwrap()
2691        ));
2692    }
2693
2694    #[test]
2695    fn one_tid_on_two_instruments_is_two_fills() {
2696        // Hyperliquid documents `tid` as qualified by coin rather than globally unique, which is
2697        // why the dedup key carries the instrument.
2698        let cache = new_dedup_cache();
2699        let btc = fill_to_account_event(&sweep_fill(111, "65000.5"), &default_dex()).unwrap();
2700
2701        let eth_json = r#"{
2702            "coin": "ETH", "side": "B", "px": "3200", "sz": "1.5",
2703            "time": 1714100000000, "hash": "0xother", "startPosition": "0",
2704            "dir": "Open Long", "closedPnl": "0", "oid": 9, "cloid": null,
2705            "crossed": true, "fee": "4.8", "feeToken": "USDC", "tid": 111
2706        }"#;
2707        let eth: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(eth_json).unwrap();
2708        let eth = fill_to_account_event(&eth, &default_dex()).unwrap();
2709
2710        assert!(!is_duplicate(&cache, dedup_key_from_event(&btc).unwrap()));
2711        assert!(!is_duplicate(&cache, dedup_key_from_event(&eth).unwrap()));
2712    }
2713
2714    #[test]
2715    fn test_order_update_to_account_event_open() {
2716        let update_json = r#"{
2717            "order": {
2718                "coin": "BTC",
2719                "side": "B",
2720                "limitPx": "64000",
2721                "sz": "0.5",
2722                "oid": 12345,
2723                "timestamp": 1714100000000,
2724                "origSz": "0.5",
2725                "cloid": null
2726            },
2727            "status": "open",
2728            "statusTimestamp": 1714100000000
2729        }"#;
2730
2731        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2732        let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2733
2734        assert_eq!(event.exchange, ExchangeId::HyperliquidPerp);
2735        match event.kind {
2736            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2737                assert_eq!(order.key.instrument.as_ref(), "BTC-USDC-PERP");
2738                assert_eq!(order.side, Side::Buy);
2739                assert_eq!(order.price, Some(dec!(64000)));
2740                assert_eq!(order.quantity, dec!(0.5));
2741                assert!(matches!(
2742                    order.state,
2743                    crate::order::state::OrderState::Active(_)
2744                ));
2745            }
2746            _ => panic!("Expected OrderSnapshot event"),
2747        }
2748    }
2749
2750    #[test]
2751    fn test_order_update_to_account_event_filled() {
2752        let update_json = r#"{
2753            "order": {
2754                "coin": "ETH",
2755                "side": "A",
2756                "limitPx": "3250",
2757                "sz": "0",
2758                "oid": 12346,
2759                "timestamp": 1714100000000,
2760                "origSz": "2.0",
2761                "cloid": null
2762            },
2763            "status": "filled",
2764            "statusTimestamp": 1714100001000
2765        }"#;
2766
2767        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2768        let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2769
2770        match event.kind {
2771            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2772                assert_eq!(order.side, Side::Sell);
2773                assert!(matches!(
2774                    order.state,
2775                    crate::order::state::OrderState::Inactive(
2776                        crate::order::state::InactiveOrderState::FullyFilled(_)
2777                    )
2778                ));
2779            }
2780            _ => panic!("Expected OrderSnapshot event"),
2781        }
2782    }
2783
2784    #[test]
2785    fn test_order_update_to_account_event_cancelled() {
2786        let update_json = r#"{
2787            "order": {
2788                "coin": "SOL",
2789                "side": "B",
2790                "limitPx": "150",
2791                "sz": "10",
2792                "oid": 12347,
2793                "timestamp": 1714100000000,
2794                "origSz": "10",
2795                "cloid": null
2796            },
2797            "status": "canceled",
2798            "statusTimestamp": 1714100002000
2799        }"#;
2800
2801        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2802        let event = order_update_to_account_event(&update, &default_dex()).unwrap();
2803
2804        match event.kind {
2805            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2806                assert_eq!(order.key.instrument.as_ref(), "SOL-USDC-PERP");
2807                assert!(matches!(
2808                    order.state,
2809                    crate::order::state::OrderState::Inactive(
2810                        crate::order::state::InactiveOrderState::Cancelled(_)
2811                    )
2812                ));
2813            }
2814            _ => panic!("Expected OrderSnapshot event"),
2815        }
2816    }
2817
2818    #[test]
2819    fn test_order_update_unknown_status_returns_none() {
2820        let update_json = r#"{
2821            "order": {
2822                "coin": "BTC",
2823                "side": "B",
2824                "limitPx": "64000",
2825                "sz": "0.5",
2826                "oid": 12345,
2827                "timestamp": 1714100000000,
2828                "origSz": "0.5",
2829                "cloid": null
2830            },
2831            "status": "unknown_status",
2832            "statusTimestamp": 1714100000000
2833        }"#;
2834
2835        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2836        let event = order_update_to_account_event(&update, &default_dex());
2837        assert!(event.is_none());
2838    }
2839}