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