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