Skip to main content

rustrade_execution/client/hyperliquid/
mod.rs

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