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 uses pair format `"PURR/USDC"` vs symbol-only `"BTC"` for perps
9//! - **Asset indices**: Spot uses 10000+ (vs 0-9999 for perps)
10//! - **Balances**: Uses `user_token_balances()` instead of `user_state()` margin summary
11//! - **No positions**: Spot has no margin/leverage concepts
12//! - **$10 minimum**: Spot orders require minimum $10 notional value
13//!
14//! # Unified Account Model
15//!
16//! Hyperliquid uses a unified account model:
17//! - Spot balances count as perpetual collateral
18//! - Separate wallets within the unified account (explicit transfer required)
19//! - Perpetual liquidation cannot sweep spot holdings
20//!
21//! # WebSocket Events
22//!
23//! `UserFills` and `OrderUpdates` subscriptions deliver **both** spot and perp events,
24//! intermingled in the same stream. Events are filtered by coin name format:
25//! - Spot coins contain `/` (e.g., `"PURR/USDC"`)
26//! - Perp coins are single symbols (e.g., `"BTC"`)
27//!
28//! If a user has both perp and spot clients for the same wallet, events will be
29//! duplicated. Consumers should use one client type per account, or deduplicate externally.
30//!
31//! # Conditional Orders (Stop, TakeProfit)
32//!
33//! See [`super`] module documentation for conditional order support details.
34//! The same constraints apply to spot:
35//! - Supported: `Stop`, `StopLimit`, `TakeProfit`, `TakeProfitLimit`
36//! - Unsupported: `TrailingStop`, `TrailingStopLimit`, `Market`
37//! - UUID requirement: Trigger orders MUST use [`crate::order::id::ClientOrderId::uuid()`]
38
39use super::common::{
40    CancelOnDropStream, cid_to_cloid, instrument_to_spot_coin, is_spot_coin, map_tif,
41    millis_to_datetime, parse_decimal, parse_side, round_to_5_sig_figs, spot_coin_to_instrument,
42    user_fills,
43};
44use super::config::HyperliquidConfig;
45use super::error::{map_order_error, map_sdk_error};
46use crate::client::dedup::{dedup_key_from_event, is_duplicate, new_dedup_cache};
47use crate::{
48    AccountEvent, AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot,
49    UnindexedAccountEvent, UnindexedAccountSnapshot,
50    balance::{AssetBalance, Balance},
51    client::ExecutionClient,
52    emit_stream_terminated,
53    error::{
54        ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
55        UnindexedOrderError,
56    },
57    order::{
58        Order, OrderKey, OrderKind, TimeInForce,
59        id::{ClientOrderId, OrderId, StrategyId},
60        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
61        state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
62    },
63    trade::{AssetFees, Trade, TradeId},
64};
65use chrono::{DateTime, Utc};
66use ethers::signers::Signer;
67use futures::{StreamExt, stream::BoxStream};
68use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient, Message, Subscription};
69use rust_decimal::Decimal;
70use rustrade_instrument::{
71    Side,
72    asset::name::AssetNameExchange,
73    exchange::ExchangeId,
74    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
75};
76use rustrade_integration::collection::snapshot::Snapshot;
77use smol_str::{SmolStr, format_smolstr};
78use std::{
79    collections::HashSet,
80    sync::{
81        Arc,
82        atomic::{AtomicBool, Ordering},
83    },
84};
85use tokio::sync::mpsc;
86use tokio_util::sync::CancellationToken;
87use tracing::{debug, error, info, trace, warn};
88
89/// Hyperliquid spot trading execution client.
90///
91/// Wraps the official `hyperliquid_rust_sdk` to implement the `ExecutionClient` trait
92/// for spot trading on Hyperliquid DEX.
93#[derive(Debug, Clone)]
94pub struct HyperliquidSpotClient {
95    config: HyperliquidConfig,
96    info_client: Arc<InfoClient>,
97    exchange_client: Arc<ExchangeClient>,
98}
99
100impl HyperliquidSpotClient {
101    /// Create a new spot client asynchronously.
102    ///
103    /// Use this when calling from an async context (e.g., tokio tests).
104    /// For sync contexts, use `ExecutionClient::new()`.
105    pub async fn connect(config: HyperliquidConfig) -> Result<Self, ConnectivityError> {
106        let base_url = if config.testnet {
107            BaseUrl::Testnet
108        } else {
109            BaseUrl::Mainnet
110        };
111
112        let info_client = InfoClient::new(None, Some(base_url))
113            .await
114            .map_err(|e| ConnectivityError::Socket(format!("InfoClient: {e}")))?;
115
116        let wallet = config.wallet.clone();
117        let exchange_client = ExchangeClient::new(None, wallet, Some(base_url), None, None)
118            .await
119            .map_err(|e| ConnectivityError::Socket(format!("ExchangeClient: {e}")))?;
120
121        info!(
122            testnet = config.testnet,
123            wallet = %config.wallet_address_hex(),
124            "Created HyperliquidSpotClient"
125        );
126
127        Ok(Self {
128            config,
129            info_client: Arc::new(info_client),
130            exchange_client: Arc::new(exchange_client),
131        })
132    }
133
134    /// Returns the base URL for the configured network (mainnet or testnet).
135    fn base_url(&self) -> BaseUrl {
136        if self.config.testnet {
137            BaseUrl::Testnet
138        } else {
139            BaseUrl::Mainnet
140        }
141    }
142
143    /// Returns the wallet address as a hex string (for logging/debugging).
144    pub fn wallet_address(&self) -> String {
145        self.config.wallet_address_hex()
146    }
147
148    /// Returns the wallet address as ethers H160.
149    fn wallet_h160(&self) -> ethers::types::H160 {
150        self.config.wallet.address()
151    }
152}
153
154impl ExecutionClient for HyperliquidSpotClient {
155    const EXCHANGE: ExchangeId = ExchangeId::HyperliquidSpot;
156
157    // Spot only — the perpetual counterpart is `HyperliquidClient`.
158    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
159        &[InstrumentKindDiscriminant::Spot];
160
161    type Config = HyperliquidConfig;
162    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
163
164    /// Creates a new Hyperliquid spot client synchronously.
165    ///
166    /// # Panics
167    ///
168    /// - If no Tokio runtime is available on the current thread
169    /// - If called from within an async context (e.g., inside `async fn`, `spawn`, or `block_on`)
170    /// - If SDK initialization fails (network error, invalid credentials)
171    ///
172    /// # Recommended Usage
173    ///
174    /// Use [`HyperliquidSpotClient::connect`] instead — it's async-safe and returns `Result`.
175    /// This method exists only for trait compliance; prefer `connect()` in all new code.
176    fn new(config: Self::Config) -> Self {
177        let base_url = if config.testnet {
178            BaseUrl::Testnet
179        } else {
180            BaseUrl::Mainnet
181        };
182
183        let handle = tokio::runtime::Handle::current();
184
185        let info_client = handle.block_on(async {
186            InfoClient::new(None, Some(base_url))
187                .await
188                .unwrap_or_else(|e| panic!("Failed to create Hyperliquid InfoClient: {e}"))
189        });
190
191        let wallet = config.wallet.clone();
192        let exchange_client = handle.block_on(async {
193            ExchangeClient::new(None, wallet, Some(base_url), None, None)
194                .await
195                .unwrap_or_else(|e| panic!("Failed to create Hyperliquid ExchangeClient: {e}"))
196        });
197
198        info!(
199            testnet = config.testnet,
200            wallet = %config.wallet_address_hex(),
201            "Created HyperliquidSpotClient"
202        );
203
204        Self {
205            config,
206            info_client: Arc::new(info_client),
207            exchange_client: Arc::new(exchange_client),
208        }
209    }
210
211    async fn account_snapshot(
212        &self,
213        _assets: &[AssetNameExchange],
214        instruments: &[InstrumentNameExchange],
215    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
216        let address = self.wallet_h160();
217
218        // Fetch spot token balances and open orders concurrently
219        let (token_balances, open_orders) = tokio::try_join!(
220            async {
221                self.info_client
222                    .user_token_balances(address)
223                    .await
224                    .map_err(map_sdk_error)
225            },
226            async {
227                self.info_client
228                    .open_orders(address)
229                    .await
230                    .map_err(map_sdk_error)
231            }
232        )?;
233
234        let now = Utc::now();
235        let balances = parse_token_balances(&token_balances.balances, now);
236
237        // Build instrument filter if provided
238        let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
239            None
240        } else {
241            let mut set = HashSet::with_capacity(instruments.len());
242            set.extend(instruments.iter().cloned());
243            Some(set)
244        };
245
246        // Group open orders by instrument (filter to spot orders only)
247        let mut orders_by_instrument: std::collections::HashMap<InstrumentNameExchange, Vec<_>> =
248            std::collections::HashMap::new();
249
250        for order in &open_orders {
251            // Filter: only spot coins (contain '/')
252            if !is_spot_coin(&order.coin) {
253                continue;
254            }
255
256            let instrument = spot_coin_to_instrument(&order.coin);
257            if instrument_filter
258                .as_ref()
259                .is_some_and(|f| !f.contains(&instrument))
260            {
261                continue;
262            }
263
264            let Some(side) = parse_side(&order.side) else {
265                continue;
266            };
267            let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
268                continue;
269            };
270            let Some(quantity) = parse_decimal(&order.sz, "sz") else {
271                continue;
272            };
273            let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
274                warn!(
275                    oid = order.oid,
276                    timestamp = order.timestamp,
277                    "Invalid order timestamp, skipping"
278                );
279                continue;
280            };
281
282            let order_id = format_smolstr!("{}", order.oid);
283            let order_snapshot = Order {
284                key: OrderKey {
285                    exchange: ExchangeId::HyperliquidSpot,
286                    instrument: instrument.clone(),
287                    strategy: StrategyId::unknown(),
288                    cid: ClientOrderId::new(order_id.clone()),
289                },
290                side,
291                price: Some(price),
292                quantity,
293                kind: OrderKind::Limit,
294                time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
295                state: crate::order::state::OrderState::active(Open {
296                    id: OrderId(order_id),
297                    time_exchange,
298                    filled_quantity: Decimal::ZERO,
299                }),
300            };
301
302            orders_by_instrument
303                .entry(instrument)
304                .or_default()
305                .push(order_snapshot);
306        }
307
308        // Build instrument snapshots from orders (spot has no positions)
309        let instrument_snapshots: Vec<_> = orders_by_instrument
310            .into_iter()
311            .map(|(instrument, orders)| InstrumentAccountSnapshot {
312                instrument,
313                orders,
314                position: None, // Spot has no positions
315                isolated: None,
316            })
317            .collect();
318
319        Ok(AccountSnapshot {
320            exchange: ExchangeId::HyperliquidSpot,
321            balances,
322            instruments: instrument_snapshots,
323        })
324    }
325
326    /// Returns a live stream of account events (fills, order updates) for spot orders.
327    ///
328    /// # Instrument filtering
329    ///
330    /// The `instruments` parameter is **ignored** — Hyperliquid's WebSocket API does not
331    /// support per-instrument subscriptions for user events. All fills and order updates
332    /// are delivered, but we filter client-side to only emit spot events (coins with '/').
333    ///
334    /// # Task lifecycle
335    ///
336    /// Spawns two background tasks (fills, orders) that are automatically cancelled
337    /// when the returned stream is dropped.
338    async fn account_stream(
339        &self,
340        _assets: &[AssetNameExchange],
341        _instruments: &[InstrumentNameExchange],
342    ) -> Result<Self::AccountStream, UnindexedClientError> {
343        let user = self.wallet_h160();
344        let base_url = self.base_url();
345
346        let mut ws_client = InfoClient::with_reconnect(None, Some(base_url))
347            .await
348            .map_err(|e| ConnectivityError::Socket(e.to_string()))?;
349
350        let (fills_tx, mut fills_rx) = mpsc::unbounded_channel::<Message>();
351        let (orders_tx, mut orders_rx) = mpsc::unbounded_channel::<Message>();
352
353        ws_client
354            .subscribe(Subscription::UserFills { user }, fills_tx)
355            .await
356            .map_err(|e| ConnectivityError::Socket(format!("UserFills subscribe: {e}")))?;
357
358        ws_client
359            .subscribe(Subscription::OrderUpdates { user }, orders_tx)
360            .await
361            .map_err(|e| ConnectivityError::Socket(format!("OrderUpdates subscribe: {e}")))?;
362
363        info!(%user, "Subscribed to Hyperliquid spot account stream");
364
365        let (event_tx, event_rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
366        let cancel_token = CancellationToken::new();
367
368        // Both tasks share `event_tx` and both observe `recv() -> None` when the SDK gives up on the
369        // socket, so guard the terminal emit with a shared flag — only the first non-cancellation
370        // terminal sends `StreamTerminated`, avoiding a double-emit.
371        let terminated = Arc::new(AtomicBool::new(false));
372
373        // Scoped to this stream rather than to the client: a fresh `account_stream` opens a fresh
374        // subscription, whose snapshot the new consumer has not seen. It must outlive a reconnect,
375        // which it does -- the SDK reconnects underneath this task, not around it.
376        let fills_dedup = new_dedup_cache();
377
378        // Spawn task to process fills (filtered to spot only)
379        let fills_event_tx = event_tx.clone();
380        let fills_cancel = cancel_token.clone();
381        let fills_terminated = terminated.clone();
382        tokio::spawn(async move {
383            loop {
384                tokio::select! {
385                    biased;
386                    () = fills_cancel.cancelled() => {
387                        debug!("Spot fills task cancelled");
388                        return;
389                    }
390                    msg = fills_rx.recv() => {
391                        let Some(msg) = msg else {
392                            debug!("Spot fills receiver closed");
393                            // SDK gave up on the stream (channel closed). Emit a single terminal
394                            // StreamTerminated across both tasks (guarded by the shared flag).
395                            if fills_terminated
396                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
397                                .is_ok()
398                            {
399                                emit_stream_terminated(
400                                    &fills_event_tx,
401                                    ExchangeId::HyperliquidSpot,
402                                    StreamTerminationReason::Error(
403                                        "hyperliquid spot account stream closed".to_string(),
404                                    ),
405                                );
406                            }
407                            return;
408                        };
409                        match msg {
410                            Message::UserFills(fills) => {
411                                // Hyperliquid opens a `userFills` subscription with a snapshot of
412                                // recent fills, and the SDK resubscribes on every reconnect. Each
413                                // reconnect therefore redelivers fills already sent. A trade is a
414                                // delta the consumer accumulates, so redelivering one double-counts
415                                // filled quantity and fees -- hence the dedup cache.
416                                //
417                                // Order updates on the sibling task are deliberately NOT deduped:
418                                // they are absolute state, so a replayed one is idempotent, while
419                                // dropping one could strand the consumer on stale state.
420                                for fill in fills.data.fills {
421                                    // Filter: only spot coins
422                                    if !is_spot_coin(&fill.coin) {
423                                        continue;
424                                    }
425                                    let Some(event) = fill_to_account_event(&fill) else {
426                                        continue;
427                                    };
428                                    if let Some(key) = dedup_key_from_event(&event)
429                                        && is_duplicate(&fills_dedup, key)
430                                    {
431                                        trace!(
432                                            tid = fill.tid,
433                                            "Hyperliquid spot dedup: skipping fill already delivered"
434                                        );
435                                        continue;
436                                    }
437                                    if fills_event_tx.send(event).is_err() {
438                                        debug!("Spot fills event channel closed");
439                                        return;
440                                    }
441                                }
442                            }
443                            Message::NoData => {
444                                warn!("Spot UserFills WebSocket disconnected");
445                            }
446                            Message::HyperliquidError(e) => {
447                                // Transient, non-terminal: the loop continues. Log only — no
448                                // in-band event (consumers took no action on the old StreamError).
449                                error!(%e, "Spot UserFills WebSocket error");
450                            }
451                            _ => {}
452                        }
453                    }
454                }
455            }
456        });
457
458        // Spawn task to process order updates (filtered to spot only)
459        // NOTE: ws_client is moved here to keep the WebSocket alive. When this task
460        // exits (via cancellation or channel close), the WebSocket connection closes,
461        // which causes fills_rx to also close.
462        let orders_event_tx = event_tx;
463        let orders_cancel = cancel_token.clone();
464        let orders_terminated = terminated;
465        tokio::spawn(async move {
466            let _ws_client = ws_client;
467
468            loop {
469                tokio::select! {
470                    biased;
471                    () = orders_cancel.cancelled() => {
472                        debug!("Spot orders task cancelled");
473                        return;
474                    }
475                    msg = orders_rx.recv() => {
476                        let Some(msg) = msg else {
477                            debug!("Spot orders receiver closed");
478                            // SDK gave up on the stream (channel closed). Emit a single terminal
479                            // StreamTerminated across both tasks (guarded by the shared flag).
480                            if orders_terminated
481                                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
482                                .is_ok()
483                            {
484                                emit_stream_terminated(
485                                    &orders_event_tx,
486                                    ExchangeId::HyperliquidSpot,
487                                    StreamTerminationReason::Error(
488                                        "hyperliquid spot account stream closed".to_string(),
489                                    ),
490                                );
491                            }
492                            return;
493                        };
494                        match msg {
495                            Message::OrderUpdates(updates) => {
496                                for update in updates.data {
497                                    // Filter: only spot coins
498                                    if !is_spot_coin(&update.order.coin) {
499                                        continue;
500                                    }
501                                    if let Some(event) = order_update_to_account_event(&update)
502                                        && orders_event_tx.send(event).is_err()
503                                    {
504                                        debug!("Spot orders event channel closed");
505                                        return;
506                                    }
507                                }
508                            }
509                            Message::NoData => {
510                                warn!("Spot OrderUpdates WebSocket disconnected");
511                            }
512                            Message::HyperliquidError(e) => {
513                                // Transient, non-terminal: the loop continues. Log only — no
514                                // in-band event (consumers took no action on the old StreamError).
515                                error!(%e, "Spot OrderUpdates WebSocket error");
516                            }
517                            _ => {}
518                        }
519                    }
520                }
521            }
522        });
523
524        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
525        let guarded_stream = CancelOnDropStream::new(stream, cancel_token);
526        Ok(guarded_stream.boxed())
527    }
528
529    async fn cancel_order(
530        &self,
531        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
532    ) -> Option<UnindexedOrderResponseCancel> {
533        use crate::order::{request::OrderResponseCancel, state::Cancelled};
534        use hyperliquid_rust_sdk::{
535            ClientCancelRequest, ClientCancelRequestCloid, ExchangeResponseStatus,
536        };
537        use uuid::Uuid;
538
539        let coin = match instrument_to_spot_coin(request.key.instrument) {
540            Some(c) => c,
541            None => {
542                warn!(
543                    instrument = %request.key.instrument,
544                    "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
545                );
546                return Some(OrderResponseCancel {
547                    key: OrderKey {
548                        exchange: ExchangeId::HyperliquidSpot,
549                        instrument: request.key.instrument.clone(),
550                        strategy: request.key.strategy.clone(),
551                        cid: request.key.cid.clone(),
552                    },
553                    state: Err(UnindexedOrderError::Rejected(
554                        crate::error::ApiError::OrderRejected(format!(
555                            "Invalid instrument format: {}",
556                            request.key.instrument
557                        )),
558                    )),
559                });
560            }
561        };
562
563        let order_id = match &request.state.id {
564            Some(id) => id,
565            None => {
566                warn!("Cancel request missing order ID");
567                return Some(OrderResponseCancel {
568                    key: OrderKey {
569                        exchange: ExchangeId::HyperliquidSpot,
570                        instrument: request.key.instrument.clone(),
571                        strategy: request.key.strategy.clone(),
572                        cid: request.key.cid.clone(),
573                    },
574                    state: Err(UnindexedOrderError::Rejected(
575                        crate::error::ApiError::OrderRejected("Missing order ID".to_string()),
576                    )),
577                });
578            }
579        };
580
581        // Try to determine cancel method:
582        // 1. If order_id parses as u64, use cancel() with OID (regular limit orders)
583        // 2. If order_id parses as UUID, use cancel_by_cloid() (trigger orders)
584        // 3. Otherwise, reject with clear error
585        enum CancelMethod {
586            ByOid(u64),
587            ByCloid(Uuid),
588        }
589
590        let cancel_method = if let Ok(oid) = order_id.0.parse::<u64>() {
591            CancelMethod::ByOid(oid)
592        } else if let Ok(uuid) = Uuid::parse_str(&order_id.0) {
593            CancelMethod::ByCloid(uuid)
594        } else {
595            warn!(?order_id, "Order ID is neither u64 (OID) nor UUID (cloid)");
596            return Some(OrderResponseCancel {
597                key: OrderKey {
598                    exchange: ExchangeId::HyperliquidSpot,
599                    instrument: request.key.instrument.clone(),
600                    strategy: request.key.strategy.clone(),
601                    cid: request.key.cid.clone(),
602                },
603                state: Err(UnindexedOrderError::Rejected(
604                    crate::error::ApiError::OrderRejected(
605                        "Invalid order ID: must be numeric OID or UUID (for trigger orders)"
606                            .to_string(),
607                    ),
608                )),
609            });
610        };
611
612        let response = match cancel_method {
613            CancelMethod::ByOid(oid) => {
614                debug!(oid, "Cancelling spot order by OID");
615                let cancel_request = ClientCancelRequest { asset: coin, oid };
616                self.exchange_client.cancel(cancel_request, None).await
617            }
618            CancelMethod::ByCloid(cloid) => {
619                debug!(%cloid, "Cancelling spot order by cloid (trigger order)");
620                let cancel_request = ClientCancelRequestCloid { asset: coin, cloid };
621                self.exchange_client
622                    .cancel_by_cloid(cancel_request, None)
623                    .await
624            }
625        };
626
627        let response = match response {
628            Ok(r) => r,
629            Err(e) => {
630                warn!(%e, "Spot cancel order failed (transport)");
631                return Some(OrderResponseCancel {
632                    key: OrderKey {
633                        exchange: ExchangeId::HyperliquidSpot,
634                        instrument: request.key.instrument.clone(),
635                        strategy: request.key.strategy.clone(),
636                        cid: request.key.cid.clone(),
637                    },
638                    state: Err(map_order_error(e, request.key.instrument)),
639                });
640            }
641        };
642
643        match response {
644            ExchangeResponseStatus::Ok(_) => {
645                debug!("Spot cancel order accepted");
646                Some(OrderResponseCancel {
647                    key: OrderKey {
648                        exchange: ExchangeId::HyperliquidSpot,
649                        instrument: request.key.instrument.clone(),
650                        strategy: request.key.strategy.clone(),
651                        cid: request.key.cid.clone(),
652                    },
653                    state: Ok(Cancelled::new(
654                        order_id.clone(),
655                        // SDK cancel response omits server timestamp; use local clock.
656                        Utc::now(),
657                        Decimal::ZERO,
658                    )),
659                })
660            }
661            ExchangeResponseStatus::Err(msg) => {
662                warn!(%msg, "Spot cancel rejected by exchange");
663                Some(OrderResponseCancel {
664                    key: OrderKey {
665                        exchange: ExchangeId::HyperliquidSpot,
666                        instrument: request.key.instrument.clone(),
667                        strategy: request.key.strategy.clone(),
668                        cid: request.key.cid.clone(),
669                    },
670                    state: Err(UnindexedOrderError::Rejected(
671                        crate::error::ApiError::OrderRejected(msg),
672                    )),
673                })
674            }
675        }
676    }
677
678    async fn open_order(
679        &self,
680        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
681    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
682        use hyperliquid_rust_sdk::{
683            ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, ExchangeDataStatus,
684            ExchangeResponseStatus,
685        };
686
687        let make_rejected =
688            |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
689                Order {
690                    key: OrderKey {
691                        exchange: ExchangeId::HyperliquidSpot,
692                        instrument: request.key.instrument.clone(),
693                        strategy: request.key.strategy.clone(),
694                        cid: request.key.cid.clone(),
695                    },
696                    side: request.state.side,
697                    price: request.state.price,
698                    quantity: request.state.quantity,
699                    kind: request.state.kind,
700                    time_in_force: request.state.time_in_force,
701                    state: OrderState::inactive(OrderError::Rejected(
702                        crate::error::ApiError::OrderRejected(msg),
703                    )),
704                }
705            };
706
707        let make_unsupported =
708            |msg: String| -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
709                Order {
710                    key: OrderKey {
711                        exchange: ExchangeId::HyperliquidSpot,
712                        instrument: request.key.instrument.clone(),
713                        strategy: request.key.strategy.clone(),
714                        cid: request.key.cid.clone(),
715                    },
716                    side: request.state.side,
717                    price: request.state.price,
718                    quantity: request.state.quantity,
719                    kind: request.state.kind,
720                    time_in_force: request.state.time_in_force,
721                    state: OrderState::inactive(OrderError::UnsupportedOrderType(msg)),
722                }
723            };
724
725        let coin = match instrument_to_spot_coin(request.key.instrument) {
726            Some(c) => c,
727            None => {
728                warn!(
729                    instrument = %request.key.instrument,
730                    "Invalid spot instrument format (expected BASE-QUOTE-SPOT)"
731                );
732                return Some(make_rejected(format!(
733                    "Invalid instrument format: {}",
734                    request.key.instrument
735                )));
736            }
737        };
738        let is_buy = request.state.side == Side::Buy;
739
740        match request.state.kind {
741            OrderKind::Market => {
742                return Some(make_unsupported(
743                    "Hyperliquid does not support market orders; use Limit with IOC time-in-force"
744                        .to_string(),
745                ));
746            }
747            OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. } => {
748                return Some(make_unsupported(
749                    "Hyperliquid does not support trailing stop orders".to_string(),
750                ));
751            }
752            OrderKind::Limit
753            | OrderKind::Stop { .. }
754            | OrderKind::StopLimit { .. }
755            | OrderKind::TakeProfit { .. }
756            | OrderKind::TakeProfitLimit { .. } => {}
757        }
758
759        let cloid = cid_to_cloid(&request.key.cid);
760        let cloid_is_some = cloid.is_some();
761        let is_trigger_order = matches!(
762            request.state.kind,
763            OrderKind::Stop { .. }
764                | OrderKind::StopLimit { .. }
765                | OrderKind::TakeProfit { .. }
766                | OrderKind::TakeProfitLimit { .. }
767        );
768        if is_trigger_order && cloid.is_none() {
769            return Some(make_rejected(
770                "Trigger orders require UUID-format client order ID (use ClientOrderId::uuid()). \
771                 Non-UUID IDs cannot be cancelled via cancel_by_cloid()."
772                    .to_string(),
773            ));
774        }
775
776        let limit_px = match request.state.kind {
777            // Market triggers: SDK uses trigger_px as limit_px
778            OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
779                round_to_5_sig_figs(trigger_price)
780            }
781            _ => match request.state.price {
782                Some(p) => round_to_5_sig_figs(p),
783                None => {
784                    return Some(make_rejected(
785                        "Hyperliquid requires limit price for Limit/StopLimit/TakeProfitLimit orders"
786                            .to_string(),
787                    ));
788                }
789            },
790        };
791
792        let sz = round_to_5_sig_figs(request.state.quantity);
793
794        // Hyperliquid spot requires minimum $10 notional value
795        // For market triggers, use trigger_price for notional calculation
796        let notional_price = match request.state.kind {
797            OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
798                trigger_price
799            }
800            _ => request.state.price.unwrap_or(Decimal::ZERO),
801        };
802        let notional = notional_price * request.state.quantity;
803        if notional < Decimal::TEN {
804            warn!(
805                instrument = %request.key.instrument,
806                %notional,
807                "Spot order below $10 minimum notional value"
808            );
809            return Some(make_rejected(format!(
810                "Spot order notional ${notional} below $10 minimum"
811            )));
812        }
813
814        if matches!(request.state.time_in_force, TimeInForce::FillOrKill) {
815            warn!(
816                instrument = %request.key.instrument,
817                "FillOrKill not supported by Hyperliquid, using ImmediateOrCancel (may result in partial fills)"
818            );
819        }
820        let tif = map_tif(&request.state.time_in_force).to_string();
821
822        // Build order_type based on OrderKind
823        let order_type = match request.state.kind {
824            OrderKind::Limit => ClientOrder::Limit(ClientLimit { tif }),
825            OrderKind::Stop { trigger_price } => ClientOrder::Trigger(ClientTrigger {
826                is_market: true,
827                trigger_px: round_to_5_sig_figs(trigger_price),
828                tpsl: "sl".to_string(),
829            }),
830            OrderKind::StopLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
831                is_market: false,
832                trigger_px: round_to_5_sig_figs(trigger_price),
833                tpsl: "sl".to_string(),
834            }),
835            OrderKind::TakeProfit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
836                is_market: true,
837                trigger_px: round_to_5_sig_figs(trigger_price),
838                tpsl: "tp".to_string(),
839            }),
840            OrderKind::TakeProfitLimit { trigger_price } => ClientOrder::Trigger(ClientTrigger {
841                is_market: false,
842                trigger_px: round_to_5_sig_figs(trigger_price),
843                tpsl: "tp".to_string(),
844            }),
845            // Already rejected above
846            OrderKind::Market
847            | OrderKind::TrailingStop { .. }
848            | OrderKind::TrailingStopLimit { .. } => {
849                unreachable!("unsupported order kinds rejected earlier")
850            }
851        };
852
853        let order_request = ClientOrderRequest {
854            asset: coin,
855            is_buy,
856            reduce_only: request.state.reduce_only,
857            limit_px,
858            sz,
859            cloid,
860            order_type,
861        };
862
863        let response = match self.exchange_client.order(order_request, None).await {
864            Ok(r) => r,
865            Err(e) => {
866                warn!(%e, "Spot open order failed");
867                return Some(Order {
868                    key: OrderKey {
869                        exchange: ExchangeId::HyperliquidSpot,
870                        instrument: request.key.instrument.clone(),
871                        strategy: request.key.strategy.clone(),
872                        cid: request.key.cid.clone(),
873                    },
874                    side: request.state.side,
875                    price: request.state.price,
876                    quantity: request.state.quantity,
877                    kind: request.state.kind,
878                    time_in_force: request.state.time_in_force,
879                    state: OrderState::inactive(map_order_error(e, request.key.instrument)),
880                });
881            }
882        };
883
884        let state = match response {
885            ExchangeResponseStatus::Ok(exchange_resp) => {
886                let status = exchange_resp
887                    .data
888                    .and_then(|d| d.statuses.into_iter().next());
889
890                match status {
891                    Some(ExchangeDataStatus::Resting(resting)) => {
892                        debug!(oid = resting.oid, "Spot order resting");
893                        OrderState::active(Open {
894                            id: OrderId(format_smolstr!("{}", resting.oid)),
895                            // SDK does not return exchange timestamp on order placement; use local clock.
896                            time_exchange: Utc::now(),
897                            filled_quantity: Decimal::ZERO,
898                        })
899                    }
900                    Some(ExchangeDataStatus::Filled(filled)) => {
901                        debug!(oid = filled.oid, avg_px = %filled.avg_px, "Spot order filled");
902                        let avg_price = parse_decimal(&filled.avg_px, "avg_px");
903                        OrderState::fully_filled(Filled::new(
904                            OrderId(format_smolstr!("{}", filled.oid)),
905                            // SDK does not return exchange timestamp on order placement; use local clock.
906                            Utc::now(),
907                            parse_decimal(&filled.total_sz, "total_sz")
908                                .unwrap_or(request.state.quantity),
909                            avg_price,
910                        ))
911                    }
912                    Some(ExchangeDataStatus::Error(msg)) => {
913                        warn!(%msg, "Spot order rejected by exchange");
914                        OrderState::inactive(OrderError::Rejected(
915                            crate::error::ApiError::OrderRejected(msg),
916                        ))
917                    }
918                    Some(
919                        ExchangeDataStatus::WaitingForFill | ExchangeDataStatus::WaitingForTrigger,
920                    ) => {
921                        // Use cid as OrderId only if it's a valid UUID (required for
922                        // cancel_by_cloid). Trigger orders pass the UUID guard above;
923                        // limit IOC/FOK orders may have non-UUID cids — those become
924                        // untrackable, so reject with an observable failure rather than
925                        // returning an OrderId that cancel cannot parse.
926                        if cloid_is_some {
927                            debug!(cloid = %request.key.cid.0, "Spot order waiting (cloid trackable)");
928                            OrderState::active(Open {
929                                id: OrderId(request.key.cid.0.clone()),
930                                time_exchange: Utc::now(),
931                                filled_quantity: Decimal::ZERO,
932                            })
933                        } else {
934                            warn!("Spot order accepted but cid is not UUID — untrackable");
935                            OrderState::inactive(OrderError::Rejected(
936                                crate::error::ApiError::OrderRejected(
937                                    "order accepted but cid is not UUID; cannot track for cancel"
938                                        .to_string(),
939                                ),
940                            ))
941                        }
942                    }
943                    Some(ExchangeDataStatus::Success) | None => {
944                        warn!("Spot order accepted but no order ID returned");
945                        OrderState::inactive(OrderError::Rejected(
946                            crate::error::ApiError::OrderRejected(
947                                "no order ID in response".to_string(),
948                            ),
949                        ))
950                    }
951                }
952            }
953            ExchangeResponseStatus::Err(msg) => {
954                warn!(%msg, "Spot order rejected");
955                OrderState::inactive(OrderError::Rejected(crate::error::ApiError::OrderRejected(
956                    msg,
957                )))
958            }
959        };
960
961        Some(Order {
962            key: OrderKey {
963                exchange: ExchangeId::HyperliquidSpot,
964                instrument: request.key.instrument.clone(),
965                strategy: request.key.strategy.clone(),
966                cid: request.key.cid.clone(),
967            },
968            side: request.state.side,
969            price: request.state.price,
970            quantity: request.state.quantity,
971            kind: request.state.kind,
972            time_in_force: request.state.time_in_force,
973            state,
974        })
975    }
976
977    async fn fetch_balances(
978        &self,
979        _assets: &[AssetNameExchange],
980    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
981        let address = self.wallet_h160();
982
983        let token_balances = self
984            .info_client
985            .user_token_balances(address)
986            .await
987            .map_err(map_sdk_error)?;
988
989        let now = Utc::now();
990        Ok(parse_token_balances(&token_balances.balances, now))
991    }
992
993    async fn fetch_open_orders(
994        &self,
995        instruments: &[InstrumentNameExchange],
996    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
997        let address = self.wallet_h160();
998
999        let open_orders = self
1000            .info_client
1001            .open_orders(address)
1002            .await
1003            .map_err(map_sdk_error)?;
1004
1005        let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1006            None
1007        } else {
1008            let mut set = HashSet::with_capacity(instruments.len());
1009            set.extend(instruments.iter().cloned());
1010            Some(set)
1011        };
1012
1013        let mut result = Vec::new();
1014        for order in open_orders {
1015            // Filter: only spot coins
1016            if !is_spot_coin(&order.coin) {
1017                continue;
1018            }
1019
1020            let instrument = spot_coin_to_instrument(&order.coin);
1021
1022            if instrument_filter
1023                .as_ref()
1024                .is_some_and(|f| !f.contains(&instrument))
1025            {
1026                continue;
1027            }
1028
1029            let Some(side) = parse_side(&order.side) else {
1030                continue;
1031            };
1032            let Some(price) = parse_decimal(&order.limit_px, "limit_px") else {
1033                continue;
1034            };
1035            let Some(quantity) = parse_decimal(&order.sz, "sz") else {
1036                continue;
1037            };
1038            let Some(time_exchange) = millis_to_datetime(order.timestamp) else {
1039                warn!(
1040                    oid = order.oid,
1041                    timestamp = order.timestamp,
1042                    "Invalid order timestamp, skipping"
1043                );
1044                continue;
1045            };
1046
1047            let order_id = format_smolstr!("{}", order.oid);
1048            result.push(Order {
1049                key: OrderKey {
1050                    exchange: ExchangeId::HyperliquidSpot,
1051                    instrument,
1052                    strategy: StrategyId::unknown(),
1053                    cid: ClientOrderId::new(order_id.clone()),
1054                },
1055                side,
1056                price: Some(price),
1057                quantity,
1058                kind: OrderKind::Limit,
1059                time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1060                state: Open {
1061                    id: OrderId(order_id),
1062                    time_exchange,
1063                    filled_quantity: Decimal::ZERO,
1064                },
1065            });
1066        }
1067
1068        Ok(result)
1069    }
1070
1071    async fn fetch_trades(
1072        &self,
1073        time_since: DateTime<Utc>,
1074        instruments: &[InstrumentNameExchange],
1075    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1076        let address = self.wallet_h160();
1077
1078        let fills = user_fills(&self.info_client, address).await?;
1079
1080        // Safety: .max(0) ensures the value is non-negative before casting to u64.
1081        #[allow(clippy::cast_sign_loss)]
1082        let time_since_ms = time_since.timestamp_millis().max(0) as u64;
1083
1084        let instrument_filter: Option<HashSet<_>> = if instruments.is_empty() {
1085            None
1086        } else {
1087            let mut set = HashSet::with_capacity(instruments.len());
1088            set.extend(instruments.iter().cloned());
1089            Some(set)
1090        };
1091
1092        let mut result = Vec::new();
1093        for fill in fills {
1094            // Filter by time
1095            if fill.time < time_since_ms {
1096                continue;
1097            }
1098
1099            // Filter: only spot coins (must contain '/') and parse base/quote in one pass
1100            let Some((base_asset, quote_asset)) = fill.coin.split_once('/') else {
1101                continue;
1102            };
1103
1104            let instrument = spot_coin_to_instrument(&fill.coin);
1105
1106            if instrument_filter
1107                .as_ref()
1108                .is_some_and(|f| !f.contains(&instrument))
1109            {
1110                continue;
1111            }
1112
1113            let Some(side) = parse_side(&fill.side) else {
1114                continue;
1115            };
1116            let Some(price) = parse_decimal(&fill.px, "px") else {
1117                continue;
1118            };
1119            let Some(quantity) = parse_decimal(&fill.sz, "sz") else {
1120                continue;
1121            };
1122            let fee = parse_decimal(&fill.fee, "fee").unwrap_or(Decimal::ZERO);
1123
1124            let Some(time_exchange) = millis_to_datetime(fill.time) else {
1125                warn!(time = fill.time, "Invalid fill timestamp, skipping");
1126                continue;
1127            };
1128
1129            // Prefer what the venue says the fee was charged in. The side-based rule below is
1130            // only a guess — spot buys usually pay in base and sells in quote, but a fee can be
1131            // charged in a third asset — and it is reachable only if `feeToken` is absent, which
1132            // has not been observed.
1133            let fee_asset = fill
1134                .fee_token
1135                .as_deref()
1136                .unwrap_or(if matches!(side, Side::Buy) {
1137                    base_asset
1138                } else {
1139                    quote_asset
1140                });
1141
1142            result.push(Trade {
1143                id: TradeId(format_smolstr!("{}", fill.tid)),
1144                order_id: OrderId(format_smolstr!("{}", fill.oid)),
1145                instrument,
1146                strategy: StrategyId::unknown(),
1147                time_exchange,
1148                side,
1149                price,
1150                quantity,
1151                // `TradeInfo` carries no cumulative filled quantity; Hyperliquid reports order
1152                // state as its own `OrderUpdate` message.
1153                order_filled_quantity: None,
1154                fees: AssetFees {
1155                    asset: AssetNameExchange::from(fee_asset),
1156                    fees: fee,
1157                    // Only set quote-equivalent when the fee is actually denominated in
1158                    // the quote asset. The downstream indexer recomputes for base-asset fees.
1159                    //
1160                    // Case-insensitive, matching the stream path: `fee_asset` is now the venue's
1161                    // own `feeToken` rather than a substring of `coin`, so it is no longer equal
1162                    // to `quote_asset` byte-for-byte by construction.
1163                    fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1164                        Some(fee)
1165                    } else {
1166                        None
1167                    },
1168                },
1169            });
1170        }
1171
1172        Ok(result)
1173    }
1174}
1175
1176/// Parse spot token balances from SDK response.
1177fn parse_token_balances(
1178    balances: &[hyperliquid_rust_sdk::UserTokenBalance],
1179    now: DateTime<Utc>,
1180) -> Vec<AssetBalance<AssetNameExchange>> {
1181    balances
1182        .iter()
1183        .map(|bal| {
1184            let total = parse_decimal(&bal.total, "total").unwrap_or(Decimal::ZERO);
1185            let hold = parse_decimal(&bal.hold, "hold").unwrap_or(Decimal::ZERO);
1186            let free = (total - hold).max(Decimal::ZERO);
1187
1188            AssetBalance::new(
1189                AssetNameExchange::from(bal.coin.as_str()),
1190                Balance::new(total, free),
1191                now,
1192            )
1193        })
1194        .collect()
1195}
1196
1197/// Convert SDK TradeInfo (fill) to AccountEvent::Trade for spot.
1198fn fill_to_account_event(fill: &hyperliquid_rust_sdk::TradeInfo) -> Option<UnindexedAccountEvent> {
1199    let side = parse_side(&fill.side)?;
1200    let price = parse_decimal(&fill.px, "fill.px")?;
1201    let quantity = parse_decimal(&fill.sz, "fill.sz")?;
1202    let fee = parse_decimal(&fill.fee, "fill.fee").unwrap_or(Decimal::ZERO);
1203    let time_exchange = millis_to_datetime(fill.time)?;
1204
1205    // Parse base/quote in one pass to avoid redundant string scan
1206    let (_base, quote_asset) = fill.coin.split_once('/').unwrap_or(("", "USDC"));
1207    let instrument = spot_coin_to_instrument(&fill.coin);
1208    let order_id = OrderId(format_smolstr!("{}", fill.oid));
1209
1210    // SDK's `TradeInfo` exposes `fee_token` directly, which is the asset the fee is
1211    // denominated in (typically base asset for buys, quote asset for sells).
1212    let fee_asset = fill.fee_token.as_str();
1213
1214    let trade = Trade {
1215        id: TradeId(format_smolstr!("{}", fill.tid)),
1216        order_id,
1217        instrument,
1218        strategy: StrategyId::unknown(),
1219        time_exchange,
1220        side,
1221        price,
1222        quantity,
1223        // `TradeInfo` carries no cumulative filled quantity, so the order's state must be learned
1224        // from an `OrderUpdate` -- which Hyperliquid sends as its own message.
1225        order_filled_quantity: None,
1226        fees: AssetFees {
1227            asset: AssetNameExchange::from(fee_asset),
1228            fees: fee,
1229            // Only set quote-equivalent when the fee is actually denominated in
1230            // the quote asset. The downstream indexer recomputes for base-asset fees.
1231            fees_quote: if fee_asset.eq_ignore_ascii_case(quote_asset) {
1232                Some(fee)
1233            } else {
1234                None
1235            },
1236        },
1237    };
1238
1239    Some(AccountEvent::new(
1240        ExchangeId::HyperliquidSpot,
1241        AccountEventKind::Trade(trade),
1242    ))
1243}
1244
1245/// Convert SDK OrderUpdate to AccountEvent::OrderSnapshot for spot.
1246fn order_update_to_account_event(
1247    update: &hyperliquid_rust_sdk::OrderUpdate,
1248) -> Option<UnindexedAccountEvent> {
1249    let order = &update.order;
1250    let side = parse_side(&order.side)?;
1251    let price = parse_decimal(&order.limit_px, "order.limit_px")?;
1252    let orig_sz = parse_decimal(&order.orig_sz, "order.orig_sz")?;
1253    let time_exchange = millis_to_datetime(update.status_timestamp)?;
1254    let instrument = spot_coin_to_instrument(&order.coin);
1255
1256    let order_id_smol = format_smolstr!("{}", order.oid);
1257    let cid = order
1258        .cloid
1259        .as_deref()
1260        .map(|c| ClientOrderId::new(SmolStr::new(c)))
1261        .unwrap_or_else(|| ClientOrderId::new(order_id_smol.clone()));
1262
1263    let state = match update.status.as_str() {
1264        "open" | "resting" => {
1265            let current_sz = parse_decimal(&order.sz, "order.sz")?;
1266            let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1267            crate::order::state::OrderState::active(Open {
1268                id: OrderId(order_id_smol),
1269                time_exchange,
1270                filled_quantity,
1271            })
1272        }
1273        "filled" => crate::order::state::OrderState::fully_filled(Filled::new(
1274            OrderId(order_id_smol),
1275            time_exchange,
1276            orig_sz,
1277            None,
1278        )),
1279        "canceled" | "cancelled" => {
1280            let current_sz = parse_decimal(&order.sz, "order.sz")?;
1281            let filled_quantity = (orig_sz - current_sz).max(Decimal::ZERO);
1282            crate::order::state::OrderState::inactive(Cancelled::new(
1283                OrderId(order_id_smol),
1284                time_exchange,
1285                filled_quantity,
1286            ))
1287        }
1288        status => {
1289            warn!(%status, "Unknown order status");
1290            return None;
1291        }
1292    };
1293
1294    let order_snapshot = Order {
1295        key: OrderKey {
1296            exchange: ExchangeId::HyperliquidSpot,
1297            instrument,
1298            strategy: StrategyId::unknown(),
1299            cid,
1300        },
1301        side,
1302        price: Some(price),
1303        quantity: orig_sz,
1304        kind: OrderKind::Limit,
1305        time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1306        state,
1307    };
1308
1309    Some(AccountEvent::new(
1310        ExchangeId::HyperliquidSpot,
1311        AccountEventKind::OrderSnapshot(Snapshot(order_snapshot)),
1312    ))
1313}
1314
1315#[cfg(test)]
1316// Test code: panics on bad input are acceptable
1317#[allow(clippy::unwrap_used)]
1318mod tests {
1319    use super::*;
1320    use rust_decimal_macros::dec;
1321
1322    #[test]
1323    fn test_fill_to_account_event_spot() {
1324        let fill_json = r#"{
1325            "coin": "PURR/USDC",
1326            "side": "B",
1327            "px": "0.05",
1328            "sz": "1000",
1329            "time": 1714100000000,
1330            "hash": "0xspot123",
1331            "startPosition": "0",
1332            "dir": "Open Long",
1333            "closedPnl": "0",
1334            "oid": 12345,
1335            "cloid": null,
1336            "crossed": false,
1337            "fee": "0.025",
1338            "feeToken": "USDC",
1339            "tid": 99999
1340        }"#;
1341
1342        let fill: hyperliquid_rust_sdk::TradeInfo = serde_json::from_str(fill_json).unwrap();
1343        let event = fill_to_account_event(&fill).unwrap();
1344
1345        assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1346        match event.kind {
1347            AccountEventKind::Trade(trade) => {
1348                assert_eq!(trade.instrument.as_ref(), "PURR-USDC-SPOT");
1349                assert_eq!(
1350                    trade.id.0, "99999",
1351                    "the id is the fill's tid, not its hash"
1352                );
1353                assert_eq!(trade.side, Side::Buy);
1354                assert_eq!(trade.price, dec!(0.05));
1355                assert_eq!(trade.quantity, dec!(1000));
1356                assert_eq!(trade.fees.fees, dec!(0.025));
1357                assert_eq!(trade.fees.asset.as_ref(), "USDC");
1358            }
1359            _ => panic!("Expected Trade event"),
1360        }
1361    }
1362
1363    /// Build a spot `TradeInfo` sharing one transaction hash with its siblings.
1364    fn spot_sweep_fill(tid: u64, px: &str) -> hyperliquid_rust_sdk::TradeInfo {
1365        let json = format!(
1366            r#"{{
1367                "coin": "PURR/USDC", "side": "B", "px": "{px}", "sz": "1000",
1368                "time": 1714100000000, "hash": "0xonesweep", "startPosition": "0",
1369                "dir": "Open Long", "closedPnl": "0", "oid": 4242, "cloid": null,
1370                "crossed": true, "fee": "0.025", "feeToken": "USDC", "tid": {tid}
1371            }}"#
1372        );
1373        serde_json::from_str(&json).unwrap()
1374    }
1375
1376    #[test]
1377    fn a_spot_trade_id_identifies_the_fill_not_the_transaction() {
1378        let first = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1379        let second = fill_to_account_event(&spot_sweep_fill(222, "0.051")).unwrap();
1380
1381        let (AccountEventKind::Trade(first), AccountEventKind::Trade(second)) =
1382            (first.kind, second.kind)
1383        else {
1384            panic!("Expected Trade events");
1385        };
1386
1387        assert_eq!(first.order_id, second.order_id, "one order produced both");
1388        assert_ne!(first.id, second.id, "but they are distinct fills");
1389    }
1390
1391    #[test]
1392    fn a_replayed_spot_fill_is_delivered_once() {
1393        let cache = new_dedup_cache();
1394        let event = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1395        let replay = fill_to_account_event(&spot_sweep_fill(111, "0.05")).unwrap();
1396
1397        assert!(!is_duplicate(&cache, dedup_key_from_event(&event).unwrap()));
1398        assert!(is_duplicate(&cache, dedup_key_from_event(&replay).unwrap()));
1399        // And the sibling fill of the same sweep still gets through.
1400        let sibling = fill_to_account_event(&spot_sweep_fill(222, "0.051")).unwrap();
1401        assert!(!is_duplicate(
1402            &cache,
1403            dedup_key_from_event(&sibling).unwrap()
1404        ));
1405    }
1406
1407    #[test]
1408    fn test_order_update_to_account_event_spot() {
1409        let update_json = r#"{
1410            "order": {
1411                "coin": "HYPE/USDC",
1412                "side": "A",
1413                "limitPx": "25.5",
1414                "sz": "10",
1415                "oid": 12346,
1416                "timestamp": 1714100000000,
1417                "origSz": "10",
1418                "cloid": null
1419            },
1420            "status": "open",
1421            "statusTimestamp": 1714100000000
1422        }"#;
1423
1424        let update: hyperliquid_rust_sdk::OrderUpdate = serde_json::from_str(update_json).unwrap();
1425        let event = order_update_to_account_event(&update).unwrap();
1426
1427        assert_eq!(event.exchange, ExchangeId::HyperliquidSpot);
1428        match event.kind {
1429            AccountEventKind::OrderSnapshot(Snapshot(order)) => {
1430                assert_eq!(order.key.instrument.as_ref(), "HYPE-USDC-SPOT");
1431                assert_eq!(order.side, Side::Sell);
1432                assert_eq!(order.price, Some(dec!(25.5)));
1433                assert_eq!(order.quantity, dec!(10));
1434                assert!(matches!(
1435                    order.state,
1436                    crate::order::state::OrderState::Active(_)
1437                ));
1438            }
1439            _ => panic!("Expected OrderSnapshot event"),
1440        }
1441    }
1442}