Skip to main content

rustrade_execution/client/hyperliquid/
spot.rs

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