Skip to main content

rustrade_execution/client/hyperliquid/
spot.rs

1//! Hyperliquid spot trading ExecutionClient implementation.
2//!
3//! Uses the same `hyperliquid_rust_sdk` clients as the perpetuals client, but with
4//! spot-specific endpoints and data handling.
5//!
6//! # Differences from Perpetuals
7//!
8//! - **Coin naming**: Spot pairs are named `@{index}` (`"@107"` is HYPE/USDC), except
9//!   `"PURR/USDC"`; perps by their asset (`"BTC"`). See [Spot coins](#spot-coins).
10//! - **Asset indices**: Spot uses 10000+ (vs 0-9999 for perps)
11//! - **Balances**: Uses `user_token_balances()` instead of `user_state()` margin summary
12//! - **No positions**: Spot has no margin/leverage concepts
13//! - **$10 minimum**: Spot orders require minimum $10 notional value
14//!
15//! # Unified Account Model
16//!
17//! Hyperliquid uses a unified account model:
18//! - Spot balances count as perpetual collateral
19//! - Separate wallets within the unified account (explicit transfer required)
20//! - Perpetual liquidation cannot sweep spot holdings
21//!
22//! # WebSocket Events
23//!
24//! `UserFills` and `OrderUpdates` subscriptions deliver **both** spot and perp events,
25//! intermingled in the same stream. Events are filtered by the shape of the coin name, with
26//! [`CoinKind::of`]: this client keeps only spot coins (`"@107"`, `"PURR/USDC"`).
27//!
28//! If a user has both perp and spot clients for the same wallet, events will be
29//! duplicated. Consumers should use one client type per account, or deduplicate externally.
30//!
31//! # Spot coins
32//!
33//! Hyperliquid names every spot pair but PURR/USDC by its index (`"@107"`), which does not name
34//! its assets. The client reads Hyperliquid's `spotMeta` when it is created, and fails to be
35//! created if it cannot, and resolves each coin through it to the instrument `BASE-QUOTE-SPOT`,
36//! named from the pair's tokens (`"@107"` is `HYPE-USDC-SPOT`, `"@207"` is `HYPE-USDT0-SPOT`).
37//!
38//! A spot coin missing from `spotMeta`, such as a pair listed after the client was created, makes
39//! it read `spotMeta` again, at most once every ten seconds, each read abandoned after five. A coin still missing after that is:
40//! - an error from [`ExecutionClient::account_snapshot`], [`ExecutionClient::fetch_open_orders`]
41//!   and [`ExecutionClient::fetch_trades`], whose lists would otherwise be short with nothing to
42//!   say so. This holds whichever instruments were asked for: the coin's instrument is unknown,
43//!   so it cannot be told apart from them;
44//! - left out of the account stream, logged once per stream and coin, with `error!` for fills
45//!   and `warn!` for order updates. [`ExecutionClient::fetch_trades`] recovers a fill left out
46//!   once the pair resolves.
47//!
48//! Since every report names a pair from its tokens as Hyperliquid spells them, an order's
49//! instrument must spell them the same way: `kPEPE-USDC-SPOT`, not `KPEPE-USDC-SPOT`.
50//! [`ExecutionClient::open_order`] refuses an order under another spelling unsent, with
51//! [`ApiError::InstrumentInvalid`](crate::error::ApiError::InstrumentInvalid) naming the one to
52//! use. An order is then reported under the name it was placed under, and the reads that take
53//! `instruments`, which match names exactly, find it. The perpetuals client matches names
54//! exactly too.
55//!
56//! Neither the reads nor `cancel_order` check the spelling. An `instruments` filter naming a pair
57//! in another spelling, or no listed pair, matches nothing, so the read returns nothing for it
58//! without an error. [`ExecutionClient::cancel_order`] sends the coin as the instrument spells it, so a
59//! cancel under another spelling fails in the SDK.
60//!
61//! Orders are placed through the SDK's `ExchangeClient`, which reads `spotMeta` once, when it is
62//! created, and is never refreshed. So a pair listed after the client was created can be reported
63//! but not traded until the client is created again.
64//!
65//! # Conditional Orders (Stop, TakeProfit)
66//!
67//! See [`super`] module documentation for conditional order support details.
68//! The same constraints apply to spot:
69//! - Supported: `Stop`, `StopLimit`, `TakeProfit`, `TakeProfitLimit`
70//! - Unsupported: `TrailingStop`, `TrailingStopLimit`, `Market`
71//!
72//! # Client order ids
73//!
74//! As for perpetuals, every order must be placed under a client id in
75//! [`ClientOrderId::uuid()`](crate::order::id::ClientOrderId::uuid) form. See the
76//! [`super`] module documentation for why.
77
78use super::common::{
79    CLOID_REQUIRED, CancelOnDropStream, InstrumentFilter, OpenOrder, OpenOrderListing, UserFill,
80    cancel_outcome, cid_to_cloid, cloid_to_cid, instrument_to_spot_coin, map_tif,
81    millis_to_datetime, open_order_to_order, open_orders, parse_decimal, parse_side, span_millis,
82    spot_balances, spot_base_quote, spot_pair_to_instrument, user_fills_by_time,
83};
84use super::config::HyperliquidConfig;
85use super::error::{map_order_error, map_sdk_error, order_rejection};
86use super::order_recovery::{
87    ReconnectWatch, fetch_order_record, listed_cids, lookup_from_record, remember_open,
88    remember_snapshot, send_fills, send_observed, spawn_order_checks,
89};
90use super::precision::{OrderPrecision, WireOrderError, wire_order};
91use super::spot_coins::{SpotCoins, spot_pair};
92use crate::client::dedup::new_dedup_cache;
93use crate::client::order_recovery::{
94    KnownLiveOrders, OrderLookup, SharedKnownLiveOrders, fetch_ended_by_key,
95};
96use crate::{
97    AccountEvent, AccountEventKind, AccountSnapshot, UnindexedAccountEvent,
98    UnindexedAccountSnapshot,
99    balance::AssetBalance,
100    client::{ExecutionClient, OrderStatusClient},
101    emit_stream_terminated,
102    error::{
103        ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
104        UnindexedOrderError,
105    },
106    order::{
107        Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
108        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
109        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
110        state::{Filled, Open, OrderState, UnindexedOrderState},
111    },
112    trade::{AssetFees, Trade, TradeId, TradeOrigin, TradesRead},
113};
114use chrono::{DateTime, Utc};
115use ethers::signers::Signer;
116use fnv::FnvHashSet;
117use futures::{StreamExt, stream::BoxStream};
118use hyperliquid_rust_sdk::{ExchangeClient, InfoClient, Message, Subscription};
119use rust_decimal::Decimal;
120use rustrade_instrument::{
121    Side,
122    asset::name::AssetNameExchange,
123    exchange::ExchangeId,
124    hyperliquid::{CoinKind, SpotPair, SpotPairs},
125    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
126};
127use smol_str::format_smolstr;
128use std::{
129    collections::HashSet,
130    sync::{
131        Arc,
132        atomic::{AtomicBool, Ordering},
133    },
134};
135use tokio::sync::{Notify, mpsc};
136use tokio_util::sync::CancellationToken;
137use tracing::{debug, error, info, warn};
138
139/// Hyperliquid spot trading execution client.
140///
141/// Wraps the official `hyperliquid_rust_sdk` to implement the `ExecutionClient` trait
142/// for spot trading on Hyperliquid DEX.
143#[derive(Debug, Clone)]
144pub struct HyperliquidSpotClient {
145    config: HyperliquidConfig,
146    info_client: Arc<InfoClient>,
147    exchange_client: Arc<ExchangeClient>,
148    spot_coins: SpotCoins,
149    /// The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
150    /// clone and every account stream, since an order placed through one ends on any of them.
151    known_live: SharedKnownLiveOrders,
152}
153
154impl HyperliquidSpotClient {
155    /// Create a new spot client asynchronously.
156    ///
157    /// Use this when calling from an async context (e.g., tokio tests).
158    /// For sync contexts, use `ExecutionClient::new()`.
159    ///
160    /// # Errors
161    ///
162    /// [`ConnectivityError::Socket`] when the SDK clients cannot be created, or Hyperliquid's
163    /// `spotMeta` cannot be read (see [Spot coins](self#spot-coins)).
164    pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
165        let base_url = config.base_url();
166
167        let info_client = InfoClient::new(None, Some(base_url))
168            .await
169            .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
170
171        let wallet = config.wallet.clone();
172        let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
173            .await
174            .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
175
176        let info_client = Arc::new(info_client);
177        let spot_coins = SpotCoins::fetch(Arc::clone(&info_client))
178            .await
179            .map_err(|e| ConnectivityError::Socket(format!("spotMeta: {e}")))?;
180
181        info!(
182            network = ?config.network,
183            wallet = %config.wallet_address_hex(),
184            "Created HyperliquidSpotClient"
185        );
186
187        Ok(Self {
188            config,
189            info_client,
190            exchange_client: Arc::new(exchange_client),
191            spot_coins,
192            known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
193        })
194    }
195
196    /// Returns the wallet address as a hex string (for logging/debugging).
197    pub fn wallet_address(&self) -> String {
198        self.config.wallet_address_hex()
199    }
200
201    /// The precision rules for orders on the spot pair `instrument` (`BASE-QUOTE-SPOT`), from
202    /// its base token's `szDecimals`.
203    ///
204    /// [`open_order`](ExecutionClient::open_order) refuses an order that breaks them with
205    /// [`OrderError::InvalidPrecision`] and sends nothing. Round with these rules first. See
206    /// [`OrderPrecision`].
207    ///
208    /// `None` when `instrument` is not of that form, or Hyperliquid lists no such pair. A pair
209    /// missing from the pairs this client holds is looked for in `spotMeta` again first, at most
210    /// once every 10 seconds, so a pair listed since the client connected is found.
211    ///
212    /// `None` too when `instrument` spells the pair's tokens differently from Hyperliquid, even
213    /// only in ASCII case (`KPEPE-USDC-SPOT` for `kPEPE/USDC`). Every order report names the
214    /// pair as Hyperliquid spells it, so [`open_order`](ExecutionClient::open_order) refuses an
215    /// order under any other spelling, naming the one to use.
216    pub async fn order_precision(
217        &self,
218        instrument: &InstrumentNameExchange,
219    ) -> Option<OrderPrecision> {
220        let (base, quote) = spot_base_quote(instrument)?;
221        let pair = self.traded_pair(base, quote).await.ok()?;
222        Some(OrderPrecision::spot(pair.base_sz_decimals()))
223    }
224
225    /// The pair of `base` quoted in `quote`, spelled exactly as Hyperliquid spells its tokens,
226    /// looked for as [`order_precision`](Self::order_precision) describes.
227    async fn traded_pair(&self, base: &str, quote: &str) -> Result<SpotPair, UntradedPair> {
228        let pair = self
229            .spot_coins
230            .find(base, quote)
231            .await
232            .ok_or(UntradedPair::Unlisted)?;
233        if pair.base() == base && pair.quote() == quote {
234            Ok(pair)
235        } else {
236            Err(UntradedPair::Misspelled(pair))
237        }
238    }
239
240    /// Returns the wallet address as ethers H160.
241    fn wallet_h160(&self) -> ethers::types::H160 {
242        self.config.wallet.address()
243    }
244}
245
246/// Why a spot instrument names no pair the client places orders on.
247#[derive(Debug)]
248enum UntradedPair {
249    /// Hyperliquid lists no such pair, even after `spotMeta` is read again.
250    Unlisted,
251    /// Hyperliquid lists the pair, but spells its tokens differently, if only in ASCII case.
252    /// Every report names the pair as Hyperliquid spells it, so an order placed under this
253    /// spelling would be reported under another name, and left out of reads filtered by this one.
254    Misspelled(SpotPair),
255}
256
257impl ExecutionClient for HyperliquidSpotClient {
258    const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
259
260    // Spot only — the perpetual counterpart is `HyperliquidClient`.
261    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
262        &[InstrumentKindDiscriminant::Spot];
263
264    type Config = HyperliquidConfig;
265    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
266
267    /// Creates a new Hyperliquid spot client synchronously.
268    ///
269    /// # Panics
270    ///
271    /// - If no Tokio runtime is available on the current thread
272    /// - If called from within an async context (e.g., inside `async fn`, `spawn`, or `block_on`)
273    /// - If SDK initialization fails (network error, invalid credentials), or Hyperliquid's
274    ///   `spotMeta` cannot be read
275    ///
276    /// # Recommended Usage
277    ///
278    /// Use [`HyperliquidSpotClient::connect`] instead — it's async-safe and returns `Result`.
279    /// This method exists only for trait compliance; prefer `connect()` in all new code.
280    fn new(config: Self::Config) -> Self {
281        let base_url = config.base_url();
282
283        let handle = tokio::runtime::Handle::current();
284
285        let info_client = handle.block_on(async {
286            InfoClient::new(None, Some(base_url))
287                .await
288                .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
289        });
290
291        let wallet = config.wallet.clone();
292        let exchange_client = handle.block_on(async {
293            ExchangeClient::new(None, wallet, Some(base_url), None, None)
294                .await
295                .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
296        });
297
298        let info_client = Arc::new(info_client);
299        let spot_coins = handle.block_on(async {
300            SpotCoins::fetch(Arc::clone(&info_client))
301                .await
302                .unwrap_or_else(|e| panic!("Failed to read Hyperliquid spotMeta: {e}"))
303        });
304
305        info!(
306            network = ?config.network,
307            wallet = %config.wallet_address_hex(),
308            "Created HyperliquidSpotClient"
309        );
310
311        Self {
312            config,
313            info_client,
314            exchange_client: Arc::new(exchange_client),
315            spot_coins,
316            known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
317        }
318    }
319
320    async fn account_snapshot(
321        &self,
322        _assets: &[AssetNameExchange],
323        instruments: &[InstrumentNameExchange],
324    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
325        let address = self.wallet_h160();
326
327        // Fetch spot token balances and open orders concurrently
328        let (token_balances, open_orders) = tokio::try_join!(
329            async {
330                self.info_client
331                    .user_token_balances(address)
332                    .await
333                    .map_err(map_sdk_error)
334            },
335            open_orders(&self.info_client, address)
336        )?;
337
338        let now = Utc::now();
339        let balances = spot_balances(&token_balances.balances, now).collect();
340
341        let coins = open_orders.iter().map(|order| order.coin.as_str());
342        let pairs = self.spot_coins.covering(coins.clone()).await;
343        for coin in coins {
344            spot_pair(&pairs, coin)?;
345        }
346
347        // Spot has no positions, so the open orders are the whole of each instrument's entry.
348        let instrument_snapshots = OpenOrderListing::new(
349            &open_orders,
350            ExchangeId::HyperliquidSpot,
351            instruments,
352            |coin| pairs.get(coin).map(spot_pair_to_instrument),
353        )
354        .into_snapshots()
355        .collect();
356
357        let snapshot = AccountSnapshot {
358            exchange: ExchangeId::HyperliquidSpot,
359            balances,
360            instruments: instrument_snapshots,
361        };
362        remember_snapshot(&self.known_live, &snapshot);
363        Ok(snapshot)
364    }
365
366    /// Returns a live stream of account events (fills, order updates) for spot orders.
367    ///
368    /// # Instrument filtering
369    ///
370    /// The `instruments` parameter is **ignored** — Hyperliquid's WebSocket API does not
371    /// support per-instrument subscriptions for user events. All fills and order updates
372    /// are delivered, but we filter client-side to only emit spot events (see
373    /// [Spot coins](self#spot-coins)).
374    ///
375    /// # Task lifecycle
376    ///
377    /// Spawns three background tasks (fills, orders, and the reconnect's order check) that are
378    /// automatically cancelled when the returned stream is dropped.
379    ///
380    /// # Orders that ended while disconnected
381    ///
382    /// After each reconnect the stream reports how each order the client holds as live ended,
383    /// where it did, exactly as [`HyperliquidClient`](super::HyperliquidClient)'s does; see its
384    /// `account_stream`. The listing leaves out an order on a spot coin missing from `spotMeta`,
385    /// which cannot be on an instrument the client holds an order on, but a lookup that finds an
386    /// order on one, even after `spotMeta` is read again (see [Spot coins](self#spot-coins)),
387    /// fails, and is retried.
388    async fn account_stream(
389        &self,
390        _assets: &[AssetNameExchange],
391        _instruments: &[InstrumentNameExchange],
392    ) -> Result<Self::AccountStream, UnindexedClientError> {
393        let user = self.wallet_h160();
394        let base_url = self.config.base_url();
395
396        let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
397            .await
398            .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
399
400        let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
401        let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
402
403        ws_client
404            .subscribe(Subscription::UserFills { user }, fills_tx)
405            .await
406            .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
407
408        ws_client
409            .subscribe(Subscription::OrderUpdates { user }, orders_tx)
410            .await
411            .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
412
413        info!(%user, "Subscribed to Hyperliquid spot account stream");
414
415        let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
416        let cancel_token = CancellationToken::new();
417
418        // Both tasks share `event_tx` and both observe `recv() -> None` when the SDK gives up on the
419        // socket, so guard the terminal emit with a shared flag — only the first non-cancellation
420        // terminal sends `StreamTerminated`, avoiding a double-emit.
421        let terminated = Arc::new(AtomicBool::new(false));
422
423        // Scoped to this stream rather than to the client: a fresh `account_stream` opens a fresh
424        // subscription, whose snapshot the new consumer has not seen. It must outlive a reconnect,
425        // which it does -- the SDK reconnects underneath this task, not around it.
426        let fills_dedup = new_dedup_cache();
427
428        // How the orders held as live ended while the socket was down: checked after each
429        // reconnect, once the fills snapshot that opens the resubscription has been sent on. Its
430        // own token also stops it promptly when the stream terminates.
431        let reconnected = Arc::new(Notify::new());
432        let checks_cancel = cancel_token.child_token();
433        let (list_client, list_coins) = (self.info_client.clone(), self.spot_coins.clone());
434        let (lookup_client, lookup_coins) = (self.info_client.clone(), self.spot_coins.clone());
435        spawn_order_checks(
436            ExchangeId::HyperliquidSpot,
437            self.known_live.clone(),
438            reconnected.clone(),
439            checks_cancel.clone(),
440            event_tx.clone(),
441            move |instruments| {
442                spot_listed_cids(list_client.clone(), list_coins.clone(), user, instruments)
443            },
444            move |key| spot_order_lookup(lookup_client.clone(), lookup_coins.clone(), user, key),
445        );
446
447        // Spawn task to process fills (filtered to spot only)
448        let fills_event_tx = event_tx.clone();
449        let fills_cancel = cancel_token.clone();
450        let fills_terminated = terminated.clone();
451        let fills_coins = self.spot_coins.clone();
452        let fills_checks_cancel = checks_cancel.clone();
453        tokio::spawn(async move {
454            // Spot coins already logged as missing from spotMeta, so each is logged once.
455            let mut missing_coins = HashSet::new();
456            let mut reconnects = ReconnectWatch::new(reconnected);
457            loop {
458                tokio::select! {
459                    biased;
460                    () = fills_cancel.cancelled() => {
461                        debug!("Spot fills task cancelled");
462                        return;
463                    }
464                    msg = fills_rx.recv() => {
465                        let Some(msg) = msg else {
466                            debug!("Spot fills receiver closed");
467                            fills_checks_cancel.cancel();
468                            // SDK gave up on the stream (channel closed). Emit a single terminal
469                            // StreamTerminated across both tasks (guarded by the shared flag).
470                            if fills_terminated
471                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
472                                .is_ok()
473                            {
474                                emit_stream_terminated(
475                                    &fills_event_tx,
476                                    ExchangeId::HyperliquidSpot,
477                                    StreamTerminationReason::Error(
478                                        "hyperliquid spot account stream closed".to_string(),
479                                    ),
480                                );
481                            }
482                            return;
483                        };
484                        match msg {
485                            Message::UserFills(fills) => {
486                                let pairs = fills_coins
487                                    .covering(fills.data.fills.iter().map(|fill| fill.coin.as_str()))
488                                    .await;
489                                let convert = |fill: &hyperliquid_rust_sdk::TradeInfo| {
490                                    // Filter: only spot coins
491                                    if CoinKind::of(&fill.coin) != CoinKind::Spot {
492                                        return None;
493                                    }
494                                    let Some(pair) = pairs.get(&fill.coin) else {
495                                        if missing_coins.insert(fill.coin.clone()) {
496                                            error!(
497                                                coin = %fill.coin,
498                                                tid = fill.tid,
499                                                "Hyperliquid spot fills on a coin not in \
500                                                 spotMeta are left out (fetch_trades recovers \
501                                                 them); logged once per stream"
502                                            );
503                                        }
504                                        return None;
505                                    };
506                                    fill_to_account_event(fill, pair)
507                                };
508                                if !send_fills(
509                                    &fills.data,
510                                    convert,
511                                    &fills_dedup,
512                                    &fills_event_tx,
513                                    &mut reconnects,
514                                ) {
515                                    debug!("Spot fills event channel closed");
516                                    return;
517                                }
518                            }
519                            Message::NoData => {
520                                warn!("Spot UserFills WebSocket disconnected");
521                                reconnects.dropped();
522                            }
523                            Message::HyperliquidError(e) => {
524                                // Transient, non-terminal: the loop continues. Log only — no
525                                // in-band event (consumers took no action on the old StreamError).
526                                error!(%e, "Spot UserFills WebSocket error");
527                            }
528                            _ => {}
529                        }
530                    }
531                }
532            }
533        });
534
535        // Spawn task to process order updates (filtered to spot only)
536        // NOTE: ws_client is moved here to keep the WebSocket alive. When this task
537        // exits (via cancellation or channel close), the WebSocket connection closes,
538        // which causes fills_rx to also close.
539        let orders_event_tx = event_tx;
540        let orders_cancel = cancel_token.clone();
541        let orders_terminated = terminated;
542        let orders_coins = self.spot_coins.clone();
543        let orders_known = self.known_live.clone();
544        tokio::spawn(async move {
545            let _ws_client = ws_client;
546            // Spot coins already logged as missing from spotMeta, so each is logged once.
547            let mut missing_coins = HashSet::new();
548
549            loop {
550                tokio::select! {
551                    biased;
552                    () = orders_cancel.cancelled() => {
553                        debug!("Spot orders task cancelled");
554                        return;
555                    }
556                    msg = orders_rx.recv() => {
557                        let Some(msg) = msg else {
558                            debug!("Spot orders receiver closed");
559                            checks_cancel.cancel();
560                            // SDK gave up on the stream (channel closed). Emit a single terminal
561                            // StreamTerminated across both tasks (guarded by the shared flag).
562                            if orders_terminated
563                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
564                                .is_ok()
565                            {
566                                emit_stream_terminated(
567                                    &orders_event_tx,
568                                    ExchangeId::HyperliquidSpot,
569                                    StreamTerminationReason::Error(
570                                        "hyperliquid spot account stream closed".to_string(),
571                                    ),
572                                );
573                            }
574                            return;
575                        };
576                        match msg {
577                            Message::OrderUpdates(updates) => {
578                                let pairs = orders_coins
579                                    .covering(updates.data.iter().map(|update| update.order.coin.as_str()))
580                                    .await;
581                                for update in updates.data {
582                                    // Filter: only spot coins
583                                    let coin = update.order.coin.as_str();
584                                    if CoinKind::of(coin) != CoinKind::Spot {
585                                        continue;
586                                    }
587                                    let Some(pair) = pairs.get(coin) else {
588                                        if missing_coins.insert(coin.to_owned()) {
589                                            warn!(
590                                                %coin,
591                                                oid = update.order.oid,
592                                                "Hyperliquid spot order updates on a coin not in \
593                                                 spotMeta are left out; logged once per stream"
594                                            );
595                                        }
596                                        continue;
597                                    };
598                                    if let Some(event) = order_update_to_account_event(&update, pair)
599                                        && !send_observed(&orders_known, &orders_event_tx, event)
600                                    {
601                                        debug!("Spot orders event channel closed");
602                                        return;
603                                    }
604                                }
605                            }
606                            Message::NoData => {
607                                warn!("Spot OrderUpdates WebSocket disconnected");
608                            }
609                            Message::HyperliquidError(e) => {
610                                // Transient, non-terminal: the loop continues. Log only — no
611                                // in-band event (consumers took no action on the old StreamError).
612                                error!(%e, "Spot OrderUpdates WebSocket error");
613                            }
614                            _ => {}
615                        }
616                    }
617                }
618            }
619        });
620
621        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
622        let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
623        Ok(guarded_stream.boxed())
624    }
625
626    async fn cancel_order(
627        &self,
628        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
629    ) -> UnindexedOrderResponseCancel {
630        use crate::order::{request::OrderResponseCancel, state::Cancelled};
631        use hyperliquid_rust_sdk::{ClientCancelRequest, ClientCancelRequestCloid};
632        use uuid::Uuid;
633
634        // An order is placed, and reported, only under its pair's exact spelling (see
635        // `open_order`), so the instrument of any order this client placed or reported spells
636        // the coin as the SDK keys it. A name in another spelling fails in the SDK.
637        let coin = match instrument_to_spot_coin(request.key.instrument) {
638            Some(c) => c,
639            None => {
640                warn!(
641                    instrument = %request.key.instrument,
642                    "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
643                );
644                return OrderResponseCancel {
645                    key: OrderKey {
646                        exchange: ExchangeId::HyperliquidSpot,
647                        instrument: request.key.instrument.clone(),
648                        strategy: request.key.strategy.clone(),
649                        cid: request.key.cid.clone(),
650                    },
651                    state: Err(UnindexedOrderError::Rejected(
652                        crate::error::ApiError::OrderRejected(format!(
653                            "Invalid instrument format: {}",
654                            request.key.instrument
655                        )),
656                    )),
657                };
658            }
659        };
660
661        enum CancelMethod {
662            ByOid(u64),
663            ByCloid(Uuid),
664        }
665
666        // How the venue addresses the order decides which endpoint cancels it. This reads the
667        // identifier's kind rather than inferring it from the string's shape: an oid and a cloid
668        // are different identifiers that happen to both be text, and telling them apart by parsing
669        // means a cloid which parses as a number gets cancelled as somebody else's oid.
670        let cancel_method = match &request.state.id {
671            Some(VenueOrderId::Assigned(id)) => {
672                id.0.parse::<u64>()
673                    .map(CancelMethod::ByOid)
674                    .map_err(|_| "venue order id is not a numeric Hyperliquid oid")
675            }
676            // The venue accepted the order without assigning an oid, so the only handle on it is
677            // the cloid it was placed with -- which placement derives from the client id the same
678            // way, and refuses the order if it cannot.
679            Some(VenueOrderId::ClientAssigned) => cid_to_cloid(&request.key.cid)
680                .map(CancelMethod::ByCloid)
681                .ok_or("order is addressable only by its client id, which is not a canonical UUID"),
682            None => Err("order is still in flight; the venue has acknowledged nothing to cancel"),
683        };
684
685        let cancel_method = match cancel_method {
686            Ok(method) => method,
687            Err(reason) => {
688                warn!(%reason, cid = %request.key.cid, "Cannot determine how to cancel order");
689                return OrderResponseCancel {
690                    key: OrderKey {
691                        exchange: ExchangeId::HyperliquidSpot,
692                        instrument: request.key.instrument.clone(),
693                        strategy: request.key.strategy.clone(),
694                        cid: request.key.cid.clone(),
695                    },
696                    state: Err(UnindexedOrderError::Rejected(
697                        crate::error::ApiError::OrderRejected(reason.to_string()),
698                    )),
699                };
700            }
701        };
702
703        // Terminal states keep a plain `OrderId`: nothing compares them for identity, so an order
704        // the venue never named records the client id it was addressed by instead.
705        let cancelled_id = match &request.state.id {
706            Some(venue_id) => venue_id.or_client_id(&request.key.cid),
707            None => OrderId(request.key.cid.0.clone()),
708        };
709
710        let response = match cancel_method {
711            CancelMethod::ByOid(oid) => {
712                debug!(oid, "Cancelling spot order by OID");
713                let cancel_request = ClientCancelRequest { asset: coin, oid };
714                self.exchange_client.cancel(cancel_request, None).await
715            }
716            CancelMethod::ByCloid(cloid) => {
717                debug!(%cloid, "Cancelling spot order by cloid (no oid assigned)");
718                let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
719                self.exchange_client
720                    .cancel_by_cloid(cancel_request, None)
721                    .await
722            }
723        };
724
725        let response = match response {
726            Ok(r) => r,
727            Err(e) => {
728                warn!(%e, "Spot cancel order failed (transport)");
729                return OrderResponseCancel {
730                    key: OrderKey {
731                        exchange: ExchangeId::HyperliquidSpot,
732                        instrument: request.key.instrument.clone(),
733                        strategy: request.key.strategy.clone(),
734                        cid: request.key.cid.clone(),
735                    },
736                    state: Err(map_order_error(e, request.key.instrument)),
737                };
738            }
739        };
740
741        match cancel_outcome(response) {
742            Ok(()) => {
743                debug!("Spot cancel order accepted");
744                // Hyperliquid answers a cancel once the order has left the book.
745                self.known_live.lock().ended(&request.key.cid);
746                OrderResponseCancel {
747                    key: OrderKey {
748                        exchange: ExchangeId::HyperliquidSpot,
749                        instrument: request.key.instrument.clone(),
750                        strategy: request.key.strategy.clone(),
751                        cid: request.key.cid.clone(),
752                    },
753                    state: Ok(Cancelled::new(
754                        cancelled_id.clone(),
755                        // SDK cancel response omits server timestamp; use local clock.
756                        Utc::now(),
757                        // Nor does it carry the filled quantity.
758                        None,
759                    )),
760                }
761            }
762            // Not reported ended: if the order did end, the account stream or a lookup says how.
763            Err(reason) => {
764                warn!(%reason, cid = %request.key.cid, "Spot cancel rejected by exchange");
765                OrderResponseCancel {
766                    key: OrderKey {
767                        exchange: ExchangeId::HyperliquidSpot,
768                        instrument: request.key.instrument.clone(),
769                        strategy: request.key.strategy.clone(),
770                        cid: request.key.cid.clone(),
771                    },
772                    state: Err(UnindexedOrderError::Rejected(
773                        crate::error::ApiError::OrderRejected(reason),
774                    )),
775                }
776            }
777        }
778    }
779
780    async fn open_order(
781        &self,
782        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
783    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
784        use hyperliquid_rust_sdk::{
785            ClientOrderRequest, ExchangeDataStatus, ExchangeResponseStatus,
786        };
787
788        let make_inactive =
789            |error: UnindexedOrderError| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
790                Order {
791                    key: OrderKey {
792                        exchange: ExchangeId::HyperliquidSpot,
793                        instrument: request.key.instrument.clone(),
794                        strategy: request.key.strategy.clone(),
795                        cid: request.key.cid.clone(),
796                    },
797                    side: request.state.side,
798                    price: request.state.price,
799                    quantity: request.state.quantity,
800                    kind: request.state.kind,
801                    time_in_force: request.state.time_in_force,
802                    state: OrderState::inactive(error),
803                }
804            };
805        let make_rejected = |msg: String| {
806            make_inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
807                msg,
808            )))
809        };
810        let make_unsupported = |msg: String| make_inactive(OrderError::UnsupportedOrderType(msg));
811
812        let Some((base, quote)) = spot_base_quote(request.key.instrument) else {
813            warn!(
814                instrument = %request.key.instrument,
815                "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
816            );
817            return make_rejected(format!(
818                "Invalid instrument format: {}",
819                request.key.instrument
820            ));
821        };
822        let is_buy = request.state.side == Side::Buy;
823
824        match request.state.kind {
825            OrderKind::Market => {
826                return make_unsupported(
827                    "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
828                        .to_string(),
829                );
830            }
831            OrderKind::TrailingStop { .. }
832            | OrderKind::TrailingStopLimit { .. }
833            | OrderKind::TrailingStopFixedLimit { .. } => {
834                return make_unsupported(
835                    "Hyperliquid does not support trailing stop orders".to_string(),
836                );
837            }
838            OrderKind::Limit
839            | OrderKind::Stop { .. }
840            | OrderKind::StopLimit { .. }
841            | OrderKind::TakeProfit { .. }
842            | OrderKind::TakeProfitLimit { .. } => {}
843        }
844
845        // The venue reports every order under its cloid, so an order placed without one could
846        // not be matched to the id the caller tracks it by. See the module docs.
847        let Some(cloid) = cid_to_cloid(&request.key.cid) else {
848            return make_rejected(CLOID_REQUIRED.to_string());
849        };
850
851        let tif = map_tif(&request.state.time_in_force).to_string();
852
853        let pair = match self.traded_pair(base, quote).await {
854            Ok(pair) => pair,
855            Err(untraded) => {
856                let reason = match untraded {
857                    UntradedPair::Unlisted => {
858                        format!("Hyperliquid's spotMeta lists no spot pair {base}/{quote}")
859                    }
860                    UntradedPair::Misspelled(pair) => format!(
861                        "Hyperliquid spells this spot pair {}/{}, and reports its orders under \
862                         {}; name the instrument that way",
863                        pair.base(),
864                        pair.quote(),
865                        spot_pair_to_instrument(&pair)
866                    ),
867                };
868                warn!(instrument = %request.key.instrument, %reason, "Spot order refused unsent");
869                return make_inactive(OrderError::Rejected(
870                    crate::error::ApiError::InstrumentInvalid(
871                        request.key.instrument.clone(),
872                        reason,
873                    ),
874                ));
875            }
876        };
877        let precision = OrderPrecision::spot(pair.base_sz_decimals());
878        // The SDK addresses a spot pair by `BASE/QUOTE` spelled exactly as `spotMeta` spells its
879        // tokens, which the pair's own names are.
880        let coin = format!("{}/{}", pair.base(), pair.quote());
881        let wire = match wire_order(
882            &precision,
883            request.state.kind,
884            tif,
885            request.state.price,
886            request.state.quantity,
887        ) {
888            Ok(wire) => wire,
889            Err(WireOrderError::Precision(violation)) => {
890                return make_inactive(OrderError::InvalidPrecision(violation));
891            }
892            Err(WireOrderError::MissingPrice) => {
893                return make_rejected(
894                    "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
895                        .to_string(),
896                );
897            }
898            Err(WireOrderError::UnsupportedKind) => {
899                return make_unsupported(format!(
900                    "Hyperliquid does not support {} orders",
901                    request.state.kind
902                ));
903            }
904        };
905
906        // Hyperliquid spot requires minimum $10 notional value
907        // For market triggers, use trigger_price for notional calculation
908        let notional_price = match request.state.kind {
909            OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
910                trigger_price
911            }
912            _ => request.state.price.unwrap_or(Decimal::ZERO),
913        };
914        let notional = notional_price * request.state.quantity;
915        if notional < Decimal::TEN {
916            warn!(
917                instrument = %request.key.instrument,
918                %notional,
919                "Spot order below $10 minimum notional value"
920            );
921            return make_rejected(format!("Spot order notional ${notional} below $10 minimum"));
922        }
923
924        // Logged only for an order about to be sent.
925        if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
926            warn!(
927                instrument = %request.key.instrument,
928                "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
929            );
930        }
931
932        let order_request = ClientOrderRequest {
933            asset: coin,
934            is_buy,
935            reduce_only: request.state.reduce_only,
936            limit_px: wire.limit_px,
937            sz: wire.sz,
938            cloid: Some(cloid),
939            order_type: wire.order_type,
940        };
941
942        let response = match self.exchange_client.order(order_request, None).await {
943            Ok(r) => r,
944            Err(e) => {
945                warn!(%e, "Spot open order failed");
946                return Order {
947                    key: OrderKey {
948                        exchange: ExchangeId::HyperliquidSpot,
949                        instrument: request.key.instrument.clone(),
950                        strategy: request.key.strategy.clone(),
951                        cid: request.key.cid.clone(),
952                    },
953                    side: request.state.side,
954                    price: request.state.price,
955                    quantity: request.state.quantity,
956                    kind: request.state.kind,
957                    time_in_force: request.state.time_in_force,
958                    state: OrderState::inactive(map_order_error(e, request.key.instrument)),
959                };
960            }
961        };
962
963        let state = match response {
964            ExchangeResponseStatus::Ok(exchange_resp) => {
965                let status = exchange_resp
966                    .data
967                    .and_then(|d| d.statuses.into_iter().next());
968
969                match status {
970                    Some(ExchangeDataStatus::Resting(resting)) => {
971                        debug!(oid = resting.oid, "Spot order resting");
972                        OrderState::active(Open {
973                            id: VenueOrderId::Assigned(OrderId(format_smolstr!("{}", resting.oid))),
974                            // SDK does not return exchange timestamp on order placement; use local clock.
975                            time_exchange: Utc::now(),
976                            filled_quantity: Decimal::ZERO,
977                        })
978                    }
979                    Some(ExchangeDataStatus::Filled(filled)) => {
980                        debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
981                        let avg_price = parse_decimal(&filled.avg_px, "avg_px");
982                        OrderState::fully_filled(Filled::new(
983                            OrderId(format_smolstr!("{}", filled.oid)),
984                            // SDK does not return exchange timestamp on order placement; use local clock.
985                            Utc::now(),
986                            parse_decimal(&filled.total_sz, "total_sz")
987                                .unwrap_or(request.state.quantity),
988                            avg_price,
989                        ))
990                    }
991                    Some(ExchangeDataStatus::Error(msg)) => {
992                        warn!(%msg, "Spot order rejected by exchange");
993                        OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
994                    }
995                    Some(
996                        ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
997                    ) => {
998                        // The venue answered without an oid, so the order's only handle is the
999                        // cloid it was placed with. A standalone trigger order is answered
1000                        // `resting` with an oid; these statuses belong to grouped orders.
1001                        debug!(cid = %request.key.cid, "Spot order waiting under its cloid");
1002                        OrderState::active(Open {
1003                            id: VenueOrderId::ClientAssigned,
1004                            time_exchange: Utc::now(),
1005                            filled_quantity: Decimal::ZERO,
1006                        })
1007                    }
1008                    Some(ExchangeDataStatus::Success) | None => {
1009                        warn!("Spot order accepted but no order ID returned");
1010                        OrderState::inactive(OrderError::Rejected(
1011                            crate::error::ApiError::OrderRejected(
1012                                "no order ID in response".to_string(),
1013                            ),
1014                        ))
1015                    }
1016                }
1017            }
1018            ExchangeResponseStatus::Err(msg) => {
1019                warn!(%msg, "Spot order rejected");
1020                OrderState::inactive(OrderError::Rejected(order_rejection(msg)))
1021            }
1022        };
1023
1024        let order = Order {
1025            key: OrderKey {
1026                exchange: ExchangeId::HyperliquidSpot,
1027                instrument: request.key.instrument.clone(),
1028                strategy: request.key.strategy.clone(),
1029                cid: request.key.cid.clone(),
1030            },
1031            side: request.state.side,
1032            price: request.state.price,
1033            quantity: request.state.quantity,
1034            kind: request.state.kind,
1035            time_in_force: request.state.time_in_force,
1036            state,
1037        };
1038        self.known_live
1039            .lock()
1040            .placed(&order.key, order.quantity, order.kind, &order.state);
1041        order
1042    }
1043
1044    async fn fetch_balances(
1045        &self,
1046        _assets: &[AssetNameExchange],
1047    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1048        let address = self.wallet_h160();
1049
1050        let token_balances = self
1051            .info_client
1052            .user_token_balances(address)
1053            .await
1054            .map_err(map_sdk_error)?;
1055
1056        let now = Utc::now();
1057        Ok(spot_balances(&token_balances.balances, now).collect())
1058    }
1059
1060    async fn fetch_open_orders(
1061        &self,
1062        instruments: &[InstrumentNameExchange],
1063    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1064        let address = self.wallet_h160();
1065
1066        let open_orders = open_orders(&self.info_client, address).await?;
1067        let pairs = self
1068            .spot_coins
1069            .covering(open_orders.iter().map(|order| order.coin.as_str()))
1070            .await;
1071
1072        let orders = spot_open_orders(&open_orders, &pairs, instruments)?;
1073        remember_open(&self.known_live, &orders);
1074        Ok(orders)
1075    }
1076
1077    /// Reads the span with `userFillsByTime`, to its end within the call, so the read is always
1078    /// complete (`resume: None`). Hyperliquid keeps only each wallet's 10,000 most recent fills,
1079    /// so a span reaching further back is read only as far as those go.
1080    async fn fetch_trades(
1081        &self,
1082        start: DateTime<Utc>,
1083        end: DateTime<Utc>,
1084        instruments: &[InstrumentNameExchange],
1085    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1086        let Some((start_ms, end_ms)) = span_millis(start, end) else {
1087            return Ok(TradesRead::complete(Vec::new()));
1088        };
1089        let address = self.wallet_h160();
1090
1091        let fills = user_fills_by_time(&self.info_client, address, start_ms, end_ms).await?;
1092        let pairs = self
1093            .spot_coins
1094            .covering(fills.iter().map(|fill| fill.coin.as_str()))
1095            .await;
1096
1097        spot_trades(&fills, &pairs, start, end, instruments).map(TradesRead::complete)
1098    }
1099}
1100
1101/// Looks each order up with `orderStatus` (weight 2), as
1102/// [`HyperliquidClient`](super::HyperliquidClient) does; see its implementation for how each
1103/// status is reported. An order on a spot coin missing from `spotMeta` even after it is read again
1104/// (see [Spot coins](self#spot-coins)) fails the call, since it cannot be told apart from an order
1105/// on the key's instrument.
1106///
1107/// The account stream runs the same lookup itself after a reconnect; see
1108/// [`account_stream`](ExecutionClient::account_stream).
1109impl OrderStatusClient for HyperliquidSpotClient {
1110    async fn fetch_ended_orders(
1111        &self,
1112        orders: &[UnindexedOrderKey],
1113    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1114        let address = self.wallet_h160();
1115        fetch_ended_by_key(orders, |key| {
1116            spot_order_lookup(
1117                self.info_client.clone(),
1118                self.spot_coins.clone(),
1119                address,
1120                key,
1121            )
1122        })
1123        .await
1124    }
1125}
1126
1127/// Look the order under `key` up with `orderStatus`, on the spot pair its coin names.
1128///
1129/// # Errors
1130///
1131/// When the lookup fails, or the order is on a spot coin missing from `spotMeta`.
1132async fn spot_order_lookup(
1133    info_client: Arc<InfoClient>,
1134    spot_coins: SpotCoins,
1135    address: ethers::types::H160,
1136    key: UnindexedOrderKey,
1137) -> Result<OrderLookup, UnindexedClientError> {
1138    let Some(record) = fetch_order_record(&info_client, address, &key.cid).await? else {
1139        return Ok(OrderLookup::Unknown);
1140    };
1141    let coin = record.order.coin.as_str();
1142    let pairs = spot_coins.covering([coin]).await;
1143    let instrument = spot_pair(&pairs, coin)?.map(spot_pair_to_instrument);
1144    Ok(lookup_from_record(key, &record, instrument))
1145}
1146
1147/// The client order ids `openOrders` (weight 20) lists on the spot `instruments`, for a
1148/// reconnect's check of the orders held as live. An order on a spot coin missing from `spotMeta` is
1149/// left out (see `listed_cids`).
1150///
1151/// # Errors
1152///
1153/// When the listing fails.
1154async fn spot_listed_cids(
1155    info_client: Arc<InfoClient>,
1156    spot_coins: SpotCoins,
1157    address: ethers::types::H160,
1158    instruments: Vec<InstrumentNameExchange>,
1159) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
1160    let rows = open_orders(&info_client, address).await?;
1161    let pairs = spot_coins
1162        .covering(rows.iter().map(|row| row.coin.as_str()))
1163        .await;
1164    Ok(listed_cids(&rows, &instruments, |coin| {
1165        pairs.get(coin).map(spot_pair_to_instrument)
1166    }))
1167}
1168
1169/// Convert the open-order rows on spot pairs, keeping those on `instruments` (every one when
1170/// empty). Rows on other markets are left out.
1171///
1172/// # Errors
1173///
1174/// When a row is on a spot coin missing from `pairs` (see [`spot_pair`]).
1175fn spot_open_orders(
1176    rows: &[OpenOrder],
1177    pairs: &SpotPairs,
1178    instruments: &[InstrumentNameExchange],
1179) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1180    let instrument_filter = InstrumentFilter::new(instruments);
1181
1182    let mut orders = Vec::new();
1183    for order in rows {
1184        let Some(pair) = spot_pair(pairs, &order.coin)? else {
1185            continue;
1186        };
1187        let instrument = spot_pair_to_instrument(pair);
1188        if !instrument_filter.matches(&instrument) {
1189            continue;
1190        }
1191        orders.extend(open_order_to_order(
1192            order,
1193            ExchangeId::HyperliquidSpot,
1194            instrument,
1195        ));
1196    }
1197    Ok(orders)
1198}
1199
1200/// Convert the fills on spot pairs within `start..=end`, keeping those on `instruments` (every one
1201/// when empty). Fills on other markets, and fills that do not parse, are left out.
1202///
1203/// # Errors
1204///
1205/// When a fill is on a spot coin missing from `pairs` (see [`spot_pair`]).
1206fn spot_trades(
1207    fills: &[UserFill],
1208    pairs: &SpotPairs,
1209    start: DateTime<Utc>,
1210    end: DateTime<Utc>,
1211    instruments: &[InstrumentNameExchange],
1212) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1213    let instrument_filter = InstrumentFilter::new(instruments);
1214
1215    let mut result = Vec::new();
1216    for fill in fills {
1217        let Some(pair) = spot_pair(pairs, &fill.coin)? else {
1218            continue;
1219        };
1220        let (base_asset, quote_asset) = (pair.base(), pair.quote());
1221        let instrument = spot_pair_to_instrument(pair);
1222
1223        if !instrument_filter.matches(&instrument) {
1224            continue;
1225        }
1226
1227        let Some(side) = parse_side(&fill.side) else {
1228            continue;
1229        };
1230        let Some(price) = parse_decimal(&fill.px, "px") else {
1231            continue;
1232        };
1233        let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1234            continue;
1235        };
1236        let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1237
1238        let Some(time_exchange) = millis_to_datetime(fill.time) else {
1239            warn!(time = fill.time, "Invalid fill timestamp, skipping");
1240            continue;
1241        };
1242        if !(start..=end).contains(&time_exchange) {
1243            continue;
1244        }
1245
1246        // Prefer what the venue says the fee was charged in. The side-based rule below is
1247        // only a guess — spot buys usually pay in base and sells in quote, but a fee can be
1248        // charged in a third asset — and it is reachable only if `feeToken` is absent, which
1249        // has not been observed.
1250        let fee_asset = fill
1251            .fee_token
1252            .as_deref()
1253            .unwrap_or(if matches!(side, Side::Buy) {
1254                base_asset
1255            } else {
1256                quote_asset
1257            });
1258
1259        result.push(Trade {
1260            id: TradeId(format_smolstr!("{}", fill.tid)),
1261            order_id: OrderId(format_smolstr!("{}", fill.oid)),
1262            instrument,
1263            strategy: StrategyId::unknown(),
1264            time_exchange,
1265            side,
1266            price,
1267            quantity,
1268            // `TradeInfo` carries no cumulative filled quantity; Hyperliquid reports order
1269            // state as its own `OrderUpdate` message.
1270            order_filled_quantity: None,
1271            fees: AssetFees {
1272                asset: AssetNameExchange::from(fee_asset),
1273                fees: fee,
1274                // Only set quote-equivalent when the fee is actually denominated in
1275                // the quote asset. The downstream indexer recomputes for base-asset fees.
1276                //
1277                // Case-insensitive, matching the stream path: `fee_asset` is now the venue's
1278                // own `feeToken` rather than a substring of `coin`, so it is no longer equal
1279                // to `quote_asset` byte-for-byte by construction.
1280                fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1281                    Some(fee)
1282                } else {
1283                    None
1284                },
1285            },
1286            // As on the stream, whose copy of a fill usually arrives first; see `TradeOrigin`.
1287            origin: TradeOrigin::Order,
1288            position_side: None,
1289            client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1290        });
1291    }
1292
1293    Ok(result)
1294}
1295
1296/// Convert SDK TradeInfo (fill) on `pair` to AccountEvent::Trade for spot.
1297fn fill_to_account_event(
1298    fill: &hyperliquid_rust_sdk::TradeInfo,
1299    pair: &SpotPair,
1300) -> Option<UnindexedAccountEvent> {
1301    let side = parse_side(&fill.side)?;
1302    let price = parse_decimal(&fill.px, "fill.px")?;
1303    let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1304    let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1305    let time_exchange = millis_to_datetime(fill.time)?;
1306
1307    let quote_asset = pair.quote();
1308    let instrument = spot_pair_to_instrument(pair);
1309    let order_id = OrderId(format_smolstr!("{}", fill.oid));
1310
1311    // SDK's `TradeInfo` exposes `fee_token` directly, which is the asset the fee is
1312    // denominated in (typically base asset for buys, quote asset for sells).
1313    let fee_asset = fill.fee_token.as_str();
1314
1315    let trade = Trade {
1316        id: TradeId(format_smolstr!("{}", fill.tid)),
1317        order_id,
1318        instrument,
1319        strategy: StrategyId::unknown(),
1320        time_exchange,
1321        side,
1322        price,
1323        quantity,
1324        // `TradeInfo` carries no cumulative filled quantity, so the order's state must be learned
1325        // from an `OrderUpdate` -- which Hyperliquid sends as its own message.
1326        order_filled_quantity: None,
1327        fees: AssetFees {
1328            asset: AssetNameExchange::from(fee_asset),
1329            fees: fee,
1330            // Only set quote-equivalent when the fee is actually denominated in
1331            // the quote asset. The downstream indexer recomputes for base-asset fees.
1332            fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1333                Some(fee)
1334            } else {
1335                None
1336            },
1337        },
1338        // The SDK's `TradeInfo` drops the fill's liquidation marker, so every fill is reported
1339        // as `Order`; see `TradeOrigin`.
1340        origin: TradeOrigin::Order,
1341        position_side: None,
1342        client_order_id: fill.cloid.as_deref().and_then(cloid_to_cid),
1343    };
1344
1345    Some(AccountEvent::new(
1346        ExchangeId::HyperliquidSpot,
1347        AccountEventKind::Trade(trade),
1348    ))
1349}
1350
1351/// Convert SDK OrderUpdate on `pair` to AccountEvent::OrderSnapshot for spot.
1352fn order_update_to_account_event(
1353    update: &hyperliquid_rust_sdk::OrderUpdate,
1354    pair: &SpotPair,
1355) -> Option<UnindexedAccountEvent> {
1356    super::common::order_update_to_account_event(
1357        update,
1358        ExchangeId::HyperliquidSpot,
1359        spot_pair_to_instrument(pair),
1360    )
1361}
1362
1363#[cfg(test)]
1364// Test code: panics on bad input are acceptable
1365#[allow(clippy::unwrap_used)]
1366mod tests {
1367    use super::super::spot_coins::test_spot_pairs;
1368    use crate::client::dedup::{dedup_key_from_event, is_duplicate};
1369
1370    mod order_recovery {
1371        use super::super::super::common::info_tests::info_client_against;
1372        use super::super::super::order_recovery::tests::{CID, CLOID, record_json, row};
1373        use super::super::super::spot_coins::TEST_SPOT_META;
1374        use super::*;
1375        use crate::order::state::InactiveOrderState;
1376        use wiremock::matchers::{body_string_contains, method, path};
1377        use wiremock::{Mock, MockServer, ResponseTemplate};
1378
1379        /// A server answering `spotMeta` with [`TEST_SPOT_META`] and every other info request
1380        /// with `body`, and the spot coins read from it.
1381        async fn serve(body: serde_json::Value) -> (MockServer, Arc<InfoClient>, SpotCoins) {
1382            let server = MockServer::start().await;
1383            Mock::given(method("POST"))
1384                .and(path("/info"))
1385                .and(body_string_contains("spotMeta"))
1386                .respond_with(
1387                    ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1388                )
1389                .with_priority(1)
1390                .mount(&server)
1391                .await;
1392            Mock::given(method("POST"))
1393                .and(path("/info"))
1394                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1395                .with_priority(2)
1396                .mount(&server)
1397                .await;
1398            let client = Arc::new(info_client_against(server.uri()).await);
1399            let coins = SpotCoins::fetch(client.clone()).await.unwrap();
1400            (server, client, coins)
1401        }
1402
1403        fn key() -> UnindexedOrderKey {
1404            OrderKey {
1405                exchange: ExchangeId::HyperliquidSpot,
1406                instrument: InstrumentNameExchange::new("HYPE-USDC-SPOT"),
1407                strategy: StrategyId::new("strategy"),
1408                cid: ClientOrderId::new(CID),
1409            }
1410        }
1411
1412        fn order_status(coin: &str) -> serde_json::Value {
1413            serde_json::json!({
1414                "status": "order",
1415                "order": record_json(coin, "canceled", "0.01", Some(CLOID)),
1416            })
1417        }
1418
1419        #[tokio::test]
1420        async fn a_spot_lookup_names_the_pair_from_spot_meta() {
1421            let (_server, client, coins) = serve(order_status("@107")).await;
1422
1423            let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key())
1424                .await
1425                .unwrap();
1426
1427            let OrderLookup::Ended(order) = lookup else {
1428                panic!("expected an ended order, got {lookup:?}");
1429            };
1430            assert_eq!(order.key, key());
1431            assert!(matches!(order.state, InactiveOrderState::Cancelled(_)));
1432        }
1433
1434        #[tokio::test]
1435        async fn a_spot_lookup_of_a_perp_order_is_unknown() {
1436            let (_server, client, coins) = serve(order_status("BTC")).await;
1437
1438            let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key())
1439                .await
1440                .unwrap();
1441
1442            assert!(matches!(lookup, OrderLookup::Unknown));
1443        }
1444
1445        #[tokio::test]
1446        async fn a_spot_lookup_fails_on_a_coin_missing_from_spot_meta() {
1447            let (_server, client, coins) = serve(order_status("@999")).await;
1448
1449            let lookup = spot_order_lookup(client, coins, ethers::types::H160::zero(), key()).await;
1450
1451            assert!(lookup.is_err());
1452        }
1453
1454        #[tokio::test]
1455        async fn a_spot_listing_names_only_the_orders_on_the_pairs_asked_about() {
1456            let rows = serde_json::json!([
1457                row("@107", 1, Some(CLOID)),
1458                row("@207", 2, None),
1459                row("BTC", 3, None),
1460            ]);
1461            let (_server, client, coins) = serve(rows).await;
1462
1463            let listed = spot_listed_cids(
1464                client,
1465                coins,
1466                ethers::types::H160::zero(),
1467                vec![InstrumentNameExchange::new("HYPE-USDC-SPOT")],
1468            )
1469            .await
1470            .unwrap();
1471
1472            assert_eq!(
1473                listed.into_iter().collect::<Vec<_>>(),
1474                [ClientOrderId::new(CID)]
1475            );
1476        }
1477
1478        #[tokio::test]
1479        async fn a_spot_listing_leaves_out_a_coin_missing_from_spot_meta() {
1480            let rows = serde_json::json!([row("@999", 1, None), row("@107", 2, Some(CLOID))]);
1481            let (_server, client, coins) = serve(rows).await;
1482
1483            let listed = spot_listed_cids(
1484                client,
1485                coins,
1486                ethers::types::H160::zero(),
1487                vec![InstrumentNameExchange::new("HYPE-USDC-SPOT")],
1488            )
1489            .await
1490            .unwrap();
1491
1492            assert_eq!(
1493                listed.into_iter().collect::<Vec<_>>(),
1494                [ClientOrderId::new(CID)]
1495            );
1496        }
1497    }
1498
1499    mod open_order {
1500        use super::super::super::common::info_tests::{
1501            exchange_client_against, info_client_against, test_wallet,
1502        };
1503        use super::super::super::config::Network;
1504        use super::super::super::spot_coins::TEST_SPOT_META;
1505        use super::*;
1506        use crate::error::{ApiError, OrderField, PrecisionLimit, PrecisionViolation};
1507        use crate::order::{OrderEvent, request::RequestOpen, state::InactiveOrderState};
1508        use std::collections::HashMap;
1509        use wiremock::matchers::{method, path};
1510        use wiremock::{Mock, MockServer, ResponseTemplate};
1511
1512        /// A spot client on [`TEST_SPOT_META`]'s pairs whose exchange endpoint rests every order.
1513        async fn resting_client() -> (MockServer, HyperliquidSpotClient) {
1514            client_responding(serde_json::json!({
1515                "status": "ok",
1516                "response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 77}}]}},
1517            }))
1518            .await
1519        }
1520
1521        /// A spot client on [`TEST_SPOT_META`]'s pairs whose exchange endpoint answers every
1522        /// request with `body`.
1523        async fn client_responding(body: serde_json::Value) -> (MockServer, HyperliquidSpotClient) {
1524            let server = MockServer::start().await;
1525            Mock::given(method("POST"))
1526                .and(path("/info"))
1527                .respond_with(
1528                    ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1529                )
1530                .mount(&server)
1531                .await;
1532            Mock::given(method("POST"))
1533                .and(path("/exchange"))
1534                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1535                .mount(&server)
1536                .await;
1537            let info_client = Arc::new(info_client_against(server.uri()).await);
1538            let client = HyperliquidSpotClient {
1539                config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1540                spot_coins: SpotCoins::fetch(info_client.clone()).await.unwrap(),
1541                info_client,
1542                exchange_client: Arc::new(
1543                    exchange_client_against(
1544                        server.uri(),
1545                        HashMap::from([("HYPE/USDC".to_owned(), 10_107)]),
1546                    )
1547                    .await,
1548                ),
1549                known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
1550            };
1551            (server, client)
1552        }
1553
1554        /// A spot order refused for want of balance is a balance shortfall, as on other venues;
1555        /// any other refusal stays a rejection. Hyperliquid names no asset. The refusal is read
1556        /// both as an order's status and as the error of the whole response.
1557        #[tokio::test]
1558        async fn a_spot_order_refused_for_balance_is_balance_insufficient() {
1559            const BALANCE: &str = "Order has insufficient spot balance to trade";
1560            const MINIMUM: &str = "Order must have minimum value of 10 USDC.";
1561            for (body, expected) in [
1562                (
1563                    serde_json::json!({
1564                        "status": "ok",
1565                        "response": {"type": "order", "data": {"statuses": [{"error": BALANCE}]}},
1566                    }),
1567                    ApiError::BalanceInsufficient(None, BALANCE.to_owned()),
1568                ),
1569                (
1570                    serde_json::json!({"status": "err", "response": BALANCE}),
1571                    ApiError::BalanceInsufficient(None, BALANCE.to_owned()),
1572                ),
1573                (
1574                    serde_json::json!({
1575                        "status": "ok",
1576                        "response": {"type": "order", "data": {"statuses": [{"error": MINIMUM}]}},
1577                    }),
1578                    ApiError::OrderRejected(MINIMUM.to_owned()),
1579                ),
1580            ] {
1581                let (_server, client) = client_responding(body.clone()).await;
1582                let order = place_hype(&client, dec!(10), dec!(1)).await;
1583                let OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1584                    reported,
1585                ))) = &order.state
1586                else {
1587                    panic!("{body}: expected a rejection, got {:?}", order.state);
1588                };
1589                assert_eq!(reported, &expected, "{body}");
1590            }
1591        }
1592
1593        async fn place_hype(
1594            client: &HyperliquidSpotClient,
1595            price: Decimal,
1596            quantity: Decimal,
1597        ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1598            place(client, "HYPE-USDC-SPOT", price, quantity).await
1599        }
1600
1601        async fn place(
1602            client: &HyperliquidSpotClient,
1603            instrument: &str,
1604            price: Decimal,
1605            quantity: Decimal,
1606        ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1607            let instrument = InstrumentNameExchange::new(instrument);
1608            client
1609                .open_order(OrderEvent {
1610                    key: OrderKey {
1611                        exchange: ExchangeId::HyperliquidSpot,
1612                        instrument: &instrument,
1613                        strategy: StrategyId::new("strategy"),
1614                        cid: ClientOrderId::uuid(),
1615                    },
1616                    state: RequestOpen {
1617                        side: Side::Buy,
1618                        price: Some(price),
1619                        quantity,
1620                        kind: OrderKind::Limit,
1621                        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1622                        position_id: None,
1623                        reduce_only: false,
1624                        market: None,
1625                    },
1626                })
1627                .await
1628        }
1629
1630        /// The orders the server was sent, as the SDK wrote them.
1631        async fn sent_orders(server: &MockServer) -> Vec<serde_json::Value> {
1632            server
1633                .received_requests()
1634                .await
1635                .unwrap_or_default()
1636                .into_iter()
1637                .filter(|request| request.url.path() == "/exchange")
1638                .map(|request| {
1639                    let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1640                    body["action"]["orders"][0].clone()
1641                })
1642                .collect()
1643        }
1644
1645        #[tokio::test]
1646        async fn a_spot_pairs_precision_is_its_base_tokens() {
1647            let (_server, client) = resting_client().await;
1648
1649            assert_eq!(
1650                client
1651                    .order_precision(&InstrumentNameExchange::new("HYPE-USDC-SPOT"))
1652                    .await,
1653                Some(OrderPrecision::spot(2))
1654            );
1655            assert_eq!(
1656                client
1657                    .order_precision(&InstrumentNameExchange::new("PURR-USDC-SPOT"))
1658                    .await,
1659                Some(OrderPrecision::spot(0))
1660            );
1661            assert_eq!(
1662                client
1663                    .order_precision(&InstrumentNameExchange::new("BTC-USDC-PERP"))
1664                    .await,
1665                None
1666            );
1667        }
1668
1669        #[tokio::test]
1670        async fn a_spot_order_spelling_the_pair_differently_is_refused_unsent() {
1671            let (server, client) = resting_client().await;
1672
1673            // Hyperliquid spells the pair HYPE/USDC, and reports its orders under HYPE-USDC-SPOT.
1674            for instrument in ["hype-usdc-SPOT", "Hype-USDC-SPOT"] {
1675                let order = place(&client, instrument, dec!(25), dec!(1)).await;
1676                assert_eq!(
1677                    order.state,
1678                    OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1679                        ApiError::InstrumentInvalid(
1680                            InstrumentNameExchange::new(instrument),
1681                            "Hyperliquid spells this spot pair HYPE/USDC, and reports its orders \
1682                             under HYPE-USDC-SPOT; name the instrument that way"
1683                                .to_owned(),
1684                        )
1685                    )))
1686                );
1687                assert_eq!(
1688                    client
1689                        .order_precision(&InstrumentNameExchange::new(instrument))
1690                        .await,
1691                    None
1692                );
1693            }
1694            assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1695        }
1696
1697        #[tokio::test]
1698        async fn a_spot_order_on_an_unlisted_pair_is_refused_unsent() {
1699            let (server, client) = resting_client().await;
1700
1701            let order = place(&client, "HYPE-PURR-SPOT", dec!(25), dec!(1)).await;
1702            assert_eq!(
1703                order.state,
1704                OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::Rejected(
1705                    ApiError::InstrumentInvalid(
1706                        InstrumentNameExchange::new("HYPE-PURR-SPOT"),
1707                        "Hyperliquid's spotMeta lists no spot pair HYPE/PURR".to_owned(),
1708                    )
1709                )))
1710            );
1711            assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1712        }
1713
1714        #[tokio::test]
1715        async fn a_spot_order_breaking_the_precision_rules_is_refused_unsent() {
1716            let (server, client) = resting_client().await;
1717
1718            // HYPE's szDecimals is 2, so a size has at most 2 decimal places and a price 6.
1719            for (price, quantity, violation) in [
1720                (
1721                    dec!(25),
1722                    dec!(1.234),
1723                    PrecisionViolation {
1724                        field: OrderField::Quantity,
1725                        value: dec!(1.234),
1726                        limit: PrecisionLimit::DecimalPlaces { max: 2 },
1727                    },
1728                ),
1729                (
1730                    dec!(25.1234567),
1731                    dec!(1),
1732                    PrecisionViolation {
1733                        field: OrderField::Price,
1734                        value: dec!(25.1234567),
1735                        limit: PrecisionLimit::DecimalPlaces { max: 6 },
1736                    },
1737                ),
1738            ] {
1739                let order = place_hype(&client, price, quantity).await;
1740                assert_eq!(
1741                    order.state,
1742                    OrderState::Inactive(InactiveOrderState::OpenFailed(
1743                        OrderError::InvalidPrecision(violation)
1744                    ))
1745                );
1746            }
1747            assert_eq!(sent_orders(&server).await, Vec::<serde_json::Value>::new());
1748        }
1749
1750        #[tokio::test]
1751        async fn a_valid_spot_order_is_sent_unchanged() {
1752            let (server, client) = resting_client().await;
1753
1754            let order = place_hype(&client, dec!(25.123), dec!(1.25)).await;
1755            assert!(
1756                matches!(order.state, OrderState::Active(_)),
1757                "{:?}",
1758                order.state
1759            );
1760
1761            let sent = sent_orders(&server).await;
1762            assert_eq!(sent.len(), 1);
1763            assert_eq!(
1764                (&sent[0]["p"], &sent[0]["s"]),
1765                (&serde_json::json!("25.123"), &serde_json::json!("1.25"))
1766            );
1767        }
1768    }
1769
1770    mod cancel {
1771        use super::super::super::common::info_tests::{
1772            CANCEL_NOT_APPLIED, cancel_answer, exchange_client_against, info_client_against,
1773            test_wallet,
1774        };
1775        use super::super::super::config::Network;
1776        use super::super::super::spot_coins::TEST_SPOT_META;
1777        use super::*;
1778        use crate::error::ApiError;
1779        use crate::order::{OrderEvent, request::RequestCancel};
1780        use std::collections::HashMap;
1781        use wiremock::matchers::{method, path};
1782        use wiremock::{Mock, MockServer, ResponseTemplate};
1783
1784        /// Cancel a live `HYPE-USDC-SPOT` order through a client whose exchange endpoint answers
1785        /// `answer`. Returns the response, and whether the order is still held live afterwards.
1786        async fn cancel_answered(
1787            answer: serde_json::Value,
1788        ) -> (UnindexedOrderResponseCancel, bool) {
1789            let server = MockServer::start().await;
1790            Mock::given(method("POST"))
1791                .and(path("/info"))
1792                .respond_with(
1793                    ResponseTemplate::new(200).set_body_raw(TEST_SPOT_META, "application/json"),
1794                )
1795                .mount(&server)
1796                .await;
1797            Mock::given(method("POST"))
1798                .and(path("/exchange"))
1799                .respond_with(ResponseTemplate::new(200).set_body_json(answer))
1800                .mount(&server)
1801                .await;
1802            let info_client = Arc::new(info_client_against(server.uri()).await);
1803            let client = HyperliquidSpotClient {
1804                config: HyperliquidConfig::new(test_wallet(), Network::Testnet),
1805                spot_coins: SpotCoins::fetch(info_client.clone()).await.unwrap(),
1806                info_client,
1807                exchange_client: Arc::new(
1808                    exchange_client_against(
1809                        server.uri(),
1810                        HashMap::from([("HYPE/USDC".to_owned(), 10_107)]),
1811                    )
1812                    .await,
1813                ),
1814                known_live: KnownLiveOrders::shared(ExchangeId::HyperliquidSpot),
1815            };
1816            let instrument = InstrumentNameExchange::new("HYPE-USDC-SPOT");
1817            let key = OrderKey {
1818                exchange: ExchangeId::HyperliquidSpot,
1819                instrument: instrument.clone(),
1820                strategy: StrategyId::new("strategy"),
1821                cid: ClientOrderId::uuid(),
1822            };
1823            let id = VenueOrderId::Assigned(OrderId::new("7"));
1824            let open = Open {
1825                id: id.clone(),
1826                time_exchange: Utc::now(),
1827                filled_quantity: Decimal::ZERO,
1828            };
1829            client
1830                .known_live
1831                .lock()
1832                .live(&key, dec!(1), OrderKind::Limit, &open);
1833
1834            let response = client
1835                .cancel_order(OrderEvent {
1836                    key: OrderKey {
1837                        exchange: key.exchange,
1838                        instrument: &instrument,
1839                        strategy: key.strategy.clone(),
1840                        cid: key.cid.clone(),
1841                    },
1842                    state: RequestCancel { id: Some(id) },
1843                })
1844                .await;
1845            let live = client.known_live.lock().contains(&key.cid);
1846            (response, live)
1847        }
1848
1849        #[tokio::test]
1850        async fn a_success_status_cancels_the_spot_order() {
1851            let (response, live) =
1852                cancel_answered(cancel_answer(serde_json::json!(["success"]))).await;
1853            assert!(response.state.is_ok(), "{:?}", response.state);
1854            assert!(!live, "a cancelled order is no longer held live");
1855        }
1856
1857        /// An order that filled before the cancel arrived is answered this way, so it must not be
1858        /// reported cancelled, and stays held live until the account stream or a lookup ends it.
1859        #[tokio::test]
1860        async fn an_error_status_is_a_rejected_spot_cancel_and_the_order_stays_live() {
1861            let (response, live) = cancel_answered(cancel_answer(serde_json::json!([
1862                {"error": CANCEL_NOT_APPLIED}
1863            ])))
1864            .await;
1865            let Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(reason))) =
1866                &response.state
1867            else {
1868                panic!("expected a rejected cancel, got {:?}", response.state);
1869            };
1870            assert_eq!(reason, CANCEL_NOT_APPLIED);
1871            assert!(live, "the order is still held live");
1872        }
1873
1874        #[tokio::test]
1875        async fn an_answer_without_a_status_is_a_rejected_spot_cancel() {
1876            let (response, live) = cancel_answered(serde_json::json!({
1877                "status": "ok",
1878                "response": {"type": "cancel"},
1879            }))
1880            .await;
1881            assert!(
1882                matches!(
1883                    response.state,
1884                    Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(_)))
1885                ),
1886                "{:?}",
1887                response.state
1888            );
1889            assert!(live, "the order is still held live");
1890        }
1891    }
1892
1893    use super::*;
1894    use rust_decimal_macros::dec;
1895    use rustrade_integration::collection::snapshot::Snapshot;
1896
1897    /// The pair `coin` names in [`test_spot_pairs`].
1898    fn pair(coin: &str) -> SpotPair {
1899        test_spot_pairs().get(coin).unwrap().clone()
1900    }
1901
1902    #[test]
1903    fn test_fill_to_account_event_spot() {
1904        let fill_json = r#"{
1905            "coin": "PURR/USDC",
1906            "side": "B",
1907            "px": "0.05",
1908            "sz": "1000",
1909            "time": 1714100000000,
1910            "hash": "0xspot123",
1911            "startPosition": "0",
1912            "dir": "Open Long",
1913            "closedPnl": "0",
1914            "oid": 12345,
1915            "cloid": null,
1916            "crossed": false,
1917            "fee": "0.025",
1918            "feeToken": "USDC",
1919            "tid": 99999
1920        }"#;
1921
1922        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1923        let event = fill_to_account_event(&fill, &pair("PURR/USDC")).unwrap();
1924
1925        assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1926        match event.kind {
1927            AccountEventKind::Trade(trade) => {
1928                assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1929                assert_eq!(
1930                    trade.id.0, "99999",
1931                    "the id is the fill's tid, not its hash"
1932                );
1933                assert_eq!(trade.side, Side::Buy);
1934                assert_eq!(trade.price, dec!(0.05));
1935                assert_eq!(trade.quantity, dec!(1000));
1936                assert_eq!(trade.fees.fees, dec!(0.025));
1937                assert_eq!(trade.fees.asset.as_ref(), "USDC");
1938            }
1939            _ => panic!("Expected Trade event"),
1940        }
1941    }
1942
1943    /// Build a spot `TradeInfo` sharing one transaction hash with its siblings.
1944    fn spot_sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1945        let json = format!(
1946            r#"{{
1947                "coin": "PURR/USDC", "side": "B", "px": "{px}", "sz": "1000",
1948                "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1949                "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1950                "crossed": true, "fee": "0.025", "feeToken": "USDC", "tid": {tid}
1951            }}"#
1952        );
1953        serde_json::from_str(&json).unwrap()
1954    }
1955
1956    #[test]
1957    fn a_spot_trade_id_identifies_the_fill_not_the_transaction() {
1958        let first =
1959            fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1960        let second =
1961            fill_to_account_event(&spot_sweep_fill(222, "0.051"), &pair("PURR/USDC")).unwrap();
1962
1963        let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1964            (first.kind, second.kind)
1965        else {
1966            panic!("Expected Trade events");
1967        };
1968
1969        assert_eq!(first.order_id, second.order_id, "one order produced both");
1970        assert_ne!(first.id, second.id, "but they are distinct fills");
1971    }
1972
1973    #[test]
1974    fn a_replayed_spot_fill_is_delivered_once() {
1975        let cache = new_dedup_cache();
1976        let event =
1977            fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1978        let replay =
1979            fill_to_account_event(&spot_sweep_fill(111, "0.05"), &pair("PURR/USDC")).unwrap();
1980
1981        assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1982        assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1983        // And the sibling fill of the same sweep still gets through.
1984        let sibling =
1985            fill_to_account_event(&spot_sweep_fill(222, "0.051"), &pair("PURR/USDC")).unwrap();
1986        assert!(!is_duplicate(
1987            &cache,
1988            dedup_key_from_event(&sibling).unwrap()
1989        ));
1990    }
1991
1992    #[test]
1993    fn test_order_update_to_account_event_spot() {
1994        let update_json = r#"{
1995            "order": {
1996                "coin": "@107",
1997                "side": "A",
1998                "limitPx": "25.5",
1999                "sz": "10",
2000                "oid": 12346,
2001                "timestamp": 1714100000000,
2002                "origSz": "10",
2003                "cloid": null
2004            },
2005            "status": "open",
2006            "statusTimestamp": 1714100000000
2007        }"#;
2008
2009        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
2010        let event = order_update_to_account_event(&update, &pair("@107")).unwrap();
2011
2012        assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
2013        match event.kind {
2014            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
2015                assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
2016                assert_eq!(order.side, Side::Sell);
2017                assert_eq!(order.price, Some(dec!(25.5)));
2018                assert_eq!(order.quantity, dec!(10));
2019                assert!(matches!(
2020                    order.state,
2021                    crate::order::state::OrderState::Active(_)
2022                ));
2023            }
2024            _ => panic!("Expected OrderSnapshot event"),
2025        }
2026    }
2027    /// A stream fill on `coin`, bought, with its fee in `fee_token`.
2028    fn stream_fill(coin: &str, fee_token: &str) -> hyperliquid_rust_sdk::TradeInfo {
2029        let json = format!(
2030            r#"{{
2031                "coin": "{coin}", "side": "B", "px": "25.5", "sz": "2",
2032                "time": 1714100000000, "hash": "0xindexed", "startPosition": "0",
2033                "dir": "Buy", "closedPnl": "0", "oid": 4343, "cloid": null,
2034                "crossed": true, "fee": "0.01", "feeToken": "{fee_token}", "tid": 333
2035            }}"#
2036        );
2037        serde_json::from_str(&json).unwrap()
2038    }
2039
2040    #[test]
2041    fn a_fill_on_an_indexed_coin_is_named_from_its_tokens() {
2042        let event = fill_to_account_event(&stream_fill("@107", "HYPE"), &pair("@107")).unwrap();
2043        let AccountEventKind::Trade(trade) = event.kind else {
2044            panic!("Expected Trade event");
2045        };
2046        assert_eq!(trade.instrument.as_ref(), "HYPE-USDC-SPOT");
2047        assert_eq!(trade.fees.asset.as_ref(), "HYPE");
2048        assert_eq!(
2049            trade.fees.fees_quote, None,
2050            "a fee in the base asset is not in quote"
2051        );
2052    }
2053
2054    #[test]
2055    fn a_fill_quoted_in_another_token_takes_its_quote_from_the_pair() {
2056        let event = fill_to_account_event(&stream_fill("@207", "USDT0"), &pair("@207")).unwrap();
2057        let AccountEventKind::Trade(trade) = event.kind else {
2058            panic!("Expected Trade event");
2059        };
2060        assert_eq!(trade.instrument.as_ref(), "HYPE-USDT0-SPOT");
2061        assert_eq!(
2062            trade.fees.fees_quote,
2063            Some(dec!(0.01)),
2064            "USDT0 is this pair's quote, not USDC"
2065        );
2066    }
2067
2068    /// Open-order rows on a perpetual, an indexed spot coin, PURR/USDC and an outcome coin, in
2069    /// the `openOrders` response's shape. Values are synthetic.
2070    fn open_order_rows(spot_coin: &str) -> Vec<OpenOrder> {
2071        serde_json::from_value(serde_json::json!([
2072            {"coin": "BTC", "side": "B", "limitPx": "50000", "sz": "0.1", "oid": 1,
2073             "timestamp": 1714100000000u64},
2074            {"coin": spot_coin, "side": "B", "limitPx": "25.5", "sz": "2", "oid": 2,
2075             "timestamp": 1714100000000u64},
2076            {"coin": "PURR/USDC", "side": "A", "limitPx": "0.05", "sz": "100", "oid": 3,
2077             "timestamp": 1714100000000u64},
2078            {"coin": "#12", "side": "B", "limitPx": "0.5", "sz": "10", "oid": 4,
2079             "timestamp": 1714100000000u64}
2080        ]))
2081        .unwrap()
2082    }
2083
2084    #[test]
2085    fn open_orders_on_indexed_coins_are_listed_and_other_markets_left_out() {
2086        let orders = spot_open_orders(&open_order_rows("@107"), &test_spot_pairs(), &[]).unwrap();
2087
2088        let instruments: Vec<_> = orders.iter().map(|o| o.key.instrument.as_ref()).collect();
2089        assert_eq!(instruments, ["HYPE-USDC-SPOT", "PURR-USDC-SPOT"]);
2090
2091        let hype_only = [InstrumentNameExchange::from("HYPE-USDC-SPOT")];
2092        let orders =
2093            spot_open_orders(&open_order_rows("@107"), &test_spot_pairs(), &hype_only).unwrap();
2094        assert_eq!(orders.len(), 1);
2095    }
2096
2097    #[test]
2098    fn an_open_order_on_a_spot_coin_missing_from_spot_meta_fails_the_list() {
2099        let result = spot_open_orders(&open_order_rows("@999"), &test_spot_pairs(), &[]);
2100        assert!(
2101            matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("@999")),
2102            "a list missing an order must not read as complete: {result:?}"
2103        );
2104    }
2105
2106    /// `userFillsByTime` rows on a perpetual, an indexed spot coin and PURR/USDC, at
2107    /// [`fill_time`].
2108    fn user_fills(spot_coin: &str) -> Vec<UserFill> {
2109        serde_json::from_value(serde_json::json!([
2110            {"coin": "BTC", "side": "B", "px": "50000", "sz": "0.1", "time": 1714100000000u64,
2111             "oid": 1, "fee": "0.5", "feeToken": "USDC", "tid": 11},
2112            {"coin": spot_coin, "side": "B", "px": "25.5", "sz": "2", "time": 1714100000000u64,
2113             "oid": 2, "fee": "0.01", "feeToken": "HYPE", "tid": 12},
2114            {"coin": "PURR/USDC", "side": "A", "px": "0.05", "sz": "100",
2115             "time": 1714100000000u64, "oid": 3, "fee": "0.002", "tid": 13}
2116        ]))
2117        .unwrap()
2118    }
2119
2120    fn fill_time() -> DateTime<Utc> {
2121        millis_to_datetime(1714100000000).unwrap()
2122    }
2123
2124    #[test]
2125    fn trades_on_indexed_coins_are_read_with_assets_from_spot_meta() {
2126        let trades = spot_trades(
2127            &user_fills("@107"),
2128            &test_spot_pairs(),
2129            fill_time(),
2130            fill_time(),
2131            &[],
2132        )
2133        .unwrap();
2134
2135        let read: Vec<_> = trades
2136            .iter()
2137            .map(|t| (t.instrument.as_ref(), t.fees.asset.as_ref()))
2138            .collect();
2139        assert_eq!(
2140            read,
2141            [("HYPE-USDC-SPOT", "HYPE"), ("PURR-USDC-SPOT", "USDC")],
2142            "the perpetual is left out; PURR/USDC's missing feeToken falls back to the quote, \
2143             since it sold"
2144        );
2145    }
2146
2147    #[test]
2148    fn a_trade_on_a_spot_coin_missing_from_spot_meta_fails_the_read() {
2149        let result = spot_trades(
2150            &user_fills("@999"),
2151            &test_spot_pairs(),
2152            fill_time(),
2153            fill_time(),
2154            &[],
2155        );
2156        assert!(
2157            matches!(&result, Err(UnindexedClientError::Internal(msg)) if msg.contains("@999")),
2158            "a read missing a fill must not read as complete: {result:?}"
2159        );
2160    }
2161}