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