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