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