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