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