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