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